From c96a77c60b54cf58b162c93f9ffea19fde288168 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 22:15:38 -0400 Subject: [PATCH 01/98] test(pty): migrate session tests to Effect runner (#27222) --- .../opencode/test/pty/pty-session.test.ts | 175 +++++++++--------- 1 file changed, 84 insertions(+), 91 deletions(-) diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index 8c5d804b7..9c58ab935 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -1,103 +1,96 @@ -import { describe, expect, test } from "bun:test" -import { AppRuntime } from "../../src/effect/app-runtime" +import { describe, expect } from "bun:test" import { Bus } from "../../src/bus" -import { Effect } from "effect" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" +import { Config } from "../../src/config/config" +import { Plugin } from "../../src/plugin" import { Pty } from "../../src/pty" import type { PtyID } from "../../src/pty/schema" -import { tmpdir } from "../fixture/fixture" -import { setTimeout as sleep } from "node:timers/promises" +import { Effect, Layer, Queue } from "effect" +import { testEffect } from "../lib/effect" -const wait = async (fn: () => boolean, ms = 5000) => { - const end = Date.now() + ms - while (Date.now() < end) { - if (fn()) return - await sleep(25) - } - throw new Error("timeout waiting for pty events") -} +type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID } -const pick = (log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }>, id: PtyID) => { - return log.filter((evt) => evt.id === id).map((evt) => evt.type) +const it = testEffect( + Pty.layer.pipe( + Layer.provideMerge(Bus.layer), + Layer.provideMerge(Config.defaultLayer), + Layer.provideMerge(Plugin.defaultLayer), + ), +) +const ptyTest = process.platform === "win32" ? it.instance.skip : it.instance + +const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () { + const bus = yield* Bus.Service + const events = yield* Queue.unbounded() + + const subscribe = (effect: Effect.Effect<() => void, never, A>) => + Effect.acquireRelease(effect, (off) => Effect.sync(off)) + + yield* subscribe( + bus.subscribeCallback(Pty.Event.Created, (evt) => { + Queue.offerUnsafe(events, { type: "created", id: evt.properties.info.id }) + }), + ) + yield* subscribe( + bus.subscribeCallback(Pty.Event.Exited, (evt) => { + Queue.offerUnsafe(events, { type: "exited", id: evt.properties.id }) + }), + ) + yield* subscribe( + bus.subscribeCallback(Pty.Event.Deleted, (evt) => { + Queue.offerUnsafe(events, { type: "deleted", id: evt.properties.id }) + }), + ) + + return events +}) + +const createPty = Effect.fn("PtySessionTest.createPty")(function* (input: Pty.CreateInput) { + const pty = yield* Pty.Service + return yield* Effect.acquireRelease(pty.create(input), (info) => pty.remove(info.id).pipe(Effect.ignore)) +}) + +const waitForEvents = (events: Queue.Queue, id: PtyID, count: number) => { + return Effect.gen(function* () { + const picked: Array = [] + while (picked.length < count) { + const evt = yield* Queue.take(events) + if (evt.id === id) picked.push(evt.type) + } + return picked + }).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => Effect.fail(new Error("timeout waiting for pty events")), + }), + ) } describe("pty", () => { - test("publishes created, exited, deleted in order for a short-lived process", async () => { - if (process.platform === "win32") return + ptyTest("publishes created, exited, deleted in order for a short-lived process", () => + Effect.gen(function* () { + const events = yield* subscribePtyEvents() + const info = yield* createPty({ + command: "/usr/bin/env", + args: ["sh", "-c", "sleep 0.1"], + title: "sleep", + }) - await using dir = await tmpdir({ git: true }) + expect(yield* waitForEvents(events, info.id, 3)).toEqual(["created", "exited", "deleted"]) + }), + { git: true }, + ) - await WithInstance.provide({ - directory: dir.path, - fn: () => - AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = [] - const off = [ - Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })), - Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })), - Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })), - ] + ptyTest("publishes created, exited, deleted in order for /bin/sh + remove", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const events = yield* subscribePtyEvents() + const info = yield* createPty({ command: "/bin/sh", title: "sh" }) - let id: PtyID | undefined - try { - const info = yield* pty.create({ - command: "/usr/bin/env", - args: ["sh", "-c", "sleep 0.1"], - title: "sleep", - }) - id = info.id - - yield* Effect.promise(() => wait(() => pick(log, id!).includes("exited"))) - - yield* pty.remove(id) - yield* Effect.promise(() => wait(() => pick(log, id!).length >= 3)) - expect(pick(log, id!)).toEqual(["created", "exited", "deleted"]) - } finally { - off.forEach((x) => x()) - if (id) yield* pty.remove(id) - } - }), - ), - }) - }) - - test("publishes created, exited, deleted in order for /bin/sh + remove", async () => { - if (process.platform === "win32") return - - await using dir = await tmpdir({ git: true }) - - await WithInstance.provide({ - directory: dir.path, - fn: () => - AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = [] - const off = [ - Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })), - Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })), - Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })), - ] - - let id: PtyID | undefined - try { - const info = yield* pty.create({ command: "/bin/sh", title: "sh" }) - id = info.id - - yield* Effect.promise(() => sleep(100)) - - yield* pty.remove(id) - yield* Effect.promise(() => wait(() => pick(log, id!).length >= 3)) - expect(pick(log, id!)).toEqual(["created", "exited", "deleted"]) - } finally { - off.forEach((x) => x()) - if (id) yield* pty.remove(id) - } - }), - ), - }) - }) + expect(yield* waitForEvents(events, info.id, 1)).toEqual(["created"]) + yield* pty.write(info.id, "exit\n") + expect(yield* waitForEvents(events, info.id, 2)).toEqual(["exited", "deleted"]) + yield* pty.remove(info.id) + }), + { git: true }, + ) }) From b43147440c2dcd8c37e7a00017e6d92060097117 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 13 May 2026 02:16:48 +0000 Subject: [PATCH 02/98] chore: generate --- .../opencode/test/pty/pty-session.test.ts | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index 9c58ab935..12784baf3 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -66,31 +66,35 @@ const waitForEvents = (events: Queue.Queue, id: PtyID, count: number) } describe("pty", () => { - ptyTest("publishes created, exited, deleted in order for a short-lived process", () => - Effect.gen(function* () { - const events = yield* subscribePtyEvents() - const info = yield* createPty({ - command: "/usr/bin/env", - args: ["sh", "-c", "sleep 0.1"], - title: "sleep", - }) + ptyTest( + "publishes created, exited, deleted in order for a short-lived process", + () => + Effect.gen(function* () { + const events = yield* subscribePtyEvents() + const info = yield* createPty({ + command: "/usr/bin/env", + args: ["sh", "-c", "sleep 0.1"], + title: "sleep", + }) - expect(yield* waitForEvents(events, info.id, 3)).toEqual(["created", "exited", "deleted"]) - }), + expect(yield* waitForEvents(events, info.id, 3)).toEqual(["created", "exited", "deleted"]) + }), { git: true }, ) - ptyTest("publishes created, exited, deleted in order for /bin/sh + remove", () => - Effect.gen(function* () { - const pty = yield* Pty.Service - const events = yield* subscribePtyEvents() - const info = yield* createPty({ command: "/bin/sh", title: "sh" }) + ptyTest( + "publishes created, exited, deleted in order for /bin/sh + remove", + () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const events = yield* subscribePtyEvents() + const info = yield* createPty({ command: "/bin/sh", title: "sh" }) - expect(yield* waitForEvents(events, info.id, 1)).toEqual(["created"]) - yield* pty.write(info.id, "exit\n") - expect(yield* waitForEvents(events, info.id, 2)).toEqual(["exited", "deleted"]) - yield* pty.remove(info.id) - }), + expect(yield* waitForEvents(events, info.id, 1)).toEqual(["created"]) + yield* pty.write(info.id, "exit\n") + expect(yield* waitForEvents(events, info.id, 2)).toEqual(["exited", "deleted"]) + yield* pty.remove(info.id) + }), { git: true }, ) }) From 2f4dce789f3376e253308458e51286d00dcb2097 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 13 May 2026 10:27:05 +0800 Subject: [PATCH 03/98] app: use session_working helper to simplify loading states (#27212) --- packages/app/src/components/prompt-input.tsx | 8 +------- packages/app/src/context/global-sync/child-store.ts | 3 +++ packages/app/src/context/global-sync/types.ts | 1 + packages/app/src/pages/layout/sidebar-items.tsx | 4 +--- packages/app/src/pages/layout/sidebar-project.tsx | 2 +- packages/app/src/pages/session.tsx | 5 +---- .../src/pages/session/composer/session-composer-state.ts | 9 +-------- packages/app/src/pages/session/use-session-commands.tsx | 4 +--- 8 files changed, 10 insertions(+), 26 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index eaeedf087..1e1be28b5 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -240,13 +240,7 @@ export const PromptInput: Component = (props) => { return paths }) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const status = createMemo( - () => - sync.data.session_status[params.id ?? ""] ?? { - type: "idle", - }, - ) - const working = createMemo(() => status()?.type !== "idle") + const working = createMemo(() => sync.data.session_working(params.id ?? "")) const imageAttachments = createMemo(() => prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index e8ca597d1..af90fab6b 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -208,6 +208,9 @@ export function createChildStoreManager(input: { session: [], sessionTotal: 0, session_status: {}, + session_working(id: string) { + return this.session_status[id].type !== "idle" + }, session_diff: {}, todo: {}, permission: {}, diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 6bf42a073..43837ac97 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -46,6 +46,7 @@ export type State = { session_status: { [sessionID: string]: SessionStatus } + session_working(id: string): boolean session_diff: { [sessionID: string]: SnapshotFileDiff[] } diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 3aac5a613..77d9a03d9 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -166,9 +166,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { }) const isWorking = createMemo(() => { if (hasPermissions()) return false - // This matches how the TUI does it - const status = sessionStore.session_status[props.session.id] - return status?.type === "busy" || status?.type === "retry" + return sessionStore.session_working(props.session.id) }) const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)) diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 58595c25b..b910dd209 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -305,7 +305,7 @@ export const SortableProject = (props: { const isWorking = createMemo(() => dirs().some((directory) => { const [store] = globalSync.child(directory, { bootstrap: false }) - return Object.values(store.session_status).some((status) => status?.type === "busy" || status?.type === "retry") + return Object.keys(store.session_status).some((id) => store.session_working(id)) }), ) const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 8bc7e6a5c..1e73ed590 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1496,10 +1496,7 @@ export default function Page() { return out }) - const busy = (sessionID: string) => { - // This matches how the TUI does it - return (sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle" - } + const busy = (sessionID: string) => sync.data.session_working(sessionID) const queuedFollowups = createMemo(() => { const id = params.id diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 525766dcf..a7213c4a7 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -57,14 +57,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"), ) - 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 live = createMemo(() => sync.data.session_working(params.id ?? "") || blocked()) const [store, setStore] = createStore({ responding: undefined as string | undefined, diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 922299bec..b45d110b9 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -75,8 +75,6 @@ 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 [] @@ -290,7 +288,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return - if (status().type !== "idle") { + if (sync.data.session_working(params.id ?? "")) { await sdk.client.session.abort({ sessionID }).catch(() => {}) } From 91a9514ed0d492c4ede303779aa419c22980a16f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 22:32:30 -0400 Subject: [PATCH 04/98] test(server): use AppFileSystem in provider tests (#27227) --- .../test/server/httpapi-provider.test.ts | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 6ea664d63..cb47e5bbd 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" -import { Effect, FileSystem, Layer, Path } from "effect" -import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Effect, Layer } from "effect" +import path from "path" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -16,7 +17,7 @@ const testStateLayer = Layer.effectDiscard( ), ) -const it = testEffect(Layer.mergeAll(testStateLayer, NodeFileSystem.layer, NodePath.layer)) +const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer)) const projectOptions = { config: { formatter: false, lsp: false } } const providerID = "test-oauth-parity" const oauthURL = "https://example.com/oauth" @@ -95,11 +96,9 @@ function requestAuthorize(input: { function writeProviderAuthPlugin(dir: string) { return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path + const fs = yield* AppFileSystem.Service - yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true }) - yield* fs.writeFileString( + yield* fs.writeWithDirs( path.join(dir, ".opencode", "plugin", "provider-oauth-parity.ts"), [ "export default {", @@ -131,11 +130,9 @@ function writeProviderAuthPlugin(dir: string) { function writeFunctionOptionsPlugin(dir: string) { return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path + const fs = yield* AppFileSystem.Service - yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true }) - yield* fs.writeFileString( + yield* fs.writeWithDirs( path.join(dir, ".opencode", "plugin", "provider-function-options.ts"), [ "export default {", @@ -164,11 +161,9 @@ function writeFunctionOptionsPlugin(dir: string) { function writeProviderModelsMutationPlugin(dir: string) { return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path + const fs = yield* AppFileSystem.Service - yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true }) - yield* fs.writeFileString( + yield* fs.writeWithDirs( path.join(dir, ".opencode", "plugin", "provider-models-mutation.ts"), [ "export default {", From ff16eb8dea2cd9e5d5c680d491da407c2b75f470 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 22:32:50 -0400 Subject: [PATCH 05/98] test(project): use Deferred for dispose handoff (#27225) --- packages/opencode/test/project/instance.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 167c7680c..9c0f9150e 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Deferred, Effect, Fiber, Layer, Queue } from "effect" +import { Deferred, Effect, Fiber, Layer } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" import { InstanceBootstrap } from "../../src/project/bootstrap-service" @@ -203,20 +203,20 @@ describe("InstanceStore", () => { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service const disposing = yield* Deferred.make() - const releaseDispose = yield* Queue.unbounded<() => void>() + const releaseDispose = yield* Deferred.make<() => void>() const disposed: Array = [] yield* registerDisposerScoped((directory) => { disposed.push(directory) Deferred.doneUnsafe(disposing, Effect.void) return new Promise((resolve) => { - Queue.offerUnsafe(releaseDispose, resolve) + Deferred.doneUnsafe(releaseDispose, Effect.succeed(resolve)) }) }) yield* store.load({ directory: dir }) const first = yield* store.disposeAll().pipe(Effect.forkScoped) yield* Deferred.await(disposing) - const release = yield* Queue.take(releaseDispose) + const release = yield* Deferred.await(releaseDispose) const second = yield* store.disposeAll().pipe(Effect.forkScoped) expect(disposed).toEqual([dir]) From 80543fb5dc1e22d922c03e2057ef50869e8cb263 Mon Sep 17 00:00:00 2001 From: qunqin <146629280+qwq202@users.noreply.github.com> Date: Wed, 13 May 2026 10:40:36 +0800 Subject: [PATCH 06/98] fix(desktop): resolve login shell when loading env (#26449) Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> --- packages/desktop/src/main/shell-env.test.ts | 9 ++++++++- packages/desktop/src/main/shell-env.ts | 12 +++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src/main/shell-env.test.ts b/packages/desktop/src/main/shell-env.test.ts index cfe88277e..e71708ad0 100644 --- a/packages/desktop/src/main/shell-env.test.ts +++ b/packages/desktop/src/main/shell-env.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { isNushell, mergeShellEnv, parseShellEnv } from "./shell-env" +import { isNushell, mergeShellEnv, parseShellEnv, resolveUserShell } from "./shell-env" describe("shell env", () => { test("parseShellEnv supports null-delimited pairs", () => { @@ -34,6 +34,13 @@ describe("shell env", () => { expect(env.OPENCODE_CLIENT).toBe("desktop") }) + test("resolveUserShell falls back to the login shell before /bin/sh", () => { + expect(resolveUserShell("/custom/env-shell", "/bin/zsh")).toBe("/custom/env-shell") + expect(resolveUserShell(undefined, "/bin/zsh")).toBe("/bin/zsh") + expect(resolveUserShell(undefined, "unknown")).toBe("/bin/sh") + expect(resolveUserShell(undefined, undefined)).toBe("/bin/sh") + }) + test("isNushell handles path and binary name", () => { expect(isNushell("nu")).toBe(true) expect(isNushell("/opt/homebrew/bin/nu")).toBe(true) diff --git a/packages/desktop/src/main/shell-env.ts b/packages/desktop/src/main/shell-env.ts index 4a65fbf0f..deb43033a 100644 --- a/packages/desktop/src/main/shell-env.ts +++ b/packages/desktop/src/main/shell-env.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process" +import { userInfo } from "node:os" import { basename } from "node:path" import { getLogger } from "./logging" @@ -6,8 +7,17 @@ const TIMEOUT = 5_000 type Probe = { type: "Loaded"; value: Record } | { type: "Timeout" } | { type: "Unavailable" } +export function resolveUserShell(envShell: string | undefined, loginShell: string | null | undefined) { + const resolvedLoginShell = loginShell && loginShell !== "unknown" ? loginShell : undefined + return envShell || resolvedLoginShell || "/bin/sh" +} + export function getUserShell() { - return process.env.SHELL || "/bin/sh" + try { + return resolveUserShell(process.env.SHELL, userInfo().shell) + } catch { + return resolveUserShell(process.env.SHELL, undefined) + } } export function parseShellEnv(out: Buffer) { From 588b5240d04c917eb39d6f4e1865b42c01e05eec Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 22:43:41 -0400 Subject: [PATCH 07/98] test(server): migrate worktree endpoint repro to effect runner (#27220) --- .../server/worktree-endpoint-repro.test.ts | 162 ++++++++++++++---- 1 file changed, 132 insertions(+), 30 deletions(-) diff --git a/packages/opencode/test/server/worktree-endpoint-repro.test.ts b/packages/opencode/test/server/worktree-endpoint-repro.test.ts index e95d706d5..a1f6bf45a 100644 --- a/packages/opencode/test/server/worktree-endpoint-repro.test.ts +++ b/packages/opencode/test/server/worktree-endpoint-repro.test.ts @@ -1,13 +1,14 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect, Layer, Queue } from "effect" import { HttpRouter } from "effect/unstable/http" import { Flag } from "@opencode-ai/core/flag/flag" +import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Worktree } from "@/worktree" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" -import { withTimeout } from "../../src/util/timeout" import { resetDatabase } from "../fixture/db" -import { TestInstance } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const stateLayer = Layer.effectDiscard( @@ -28,7 +29,10 @@ const stateLayer = Layer.effectDiscard( ) const it = testEffect(stateLayer) +const worktreeTest = process.platform === "win32" ? it.instance.skip : it.instance type TestServer = ReturnType +type CreatedWorktree = { directory: string } +type ScopedWorktree = { directory: string; body: CreatedWorktree; ready: Effect.Effect } function serverScoped() { return Effect.acquireRelease( @@ -44,14 +48,106 @@ function request(server: TestServer, input: string, init?: RequestInit) { } function withRequestTimeout(effect: Effect.Effect, label: string, ms = 5_000) { - return Effect.promise(() => withTimeout(Effect.runPromise(effect), ms, label)) + return effect.pipe( + Effect.timeoutOrElse({ + duration: `${ms} millis`, + orElse: () => Effect.fail(new Error(`${label} timed out after ${ms}ms`)), + }), + ) +} + +function json(response: Response) { + return Effect.promise(() => response.json() as Promise) +} + +function readyWatcher() { + return Effect.gen(function* () { + const events = yield* Queue.bounded(1) + const on = (event: GlobalEvent) => { + if (event.payload.type === Worktree.Event.Ready.type) Queue.offerUnsafe(events, event) + } + + GlobalBus.on("event", on) + yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) + + return (directory: string) => + Effect.gen(function* () { + while (true) { + const event = yield* Queue.take(events) + if (event.directory === directory) return + } + }).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.fail(new Error(`timed out waiting for worktree.ready: ${directory}`)), + }), + ) + }) +} + +function removeCreatedWorktree(input: { + server: TestServer + rootDirectory: string + worktreeDirectory: string + ready: Effect.Effect +}) { + return Effect.gen(function* () { + yield* input.ready.pipe(Effect.timeout("1 second"), Effect.ignore) + yield* Effect.promise(() => disposeAllInstances()).pipe(Effect.ignore) + + const removed = yield* request( + input.server, + `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(input.rootDirectory)}`, + { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ directory: input.worktreeDirectory }), + }, + ) + if (removed.status !== 200) { + const message = yield* Effect.promise(() => removed.text()) + throw new Error(`failed to remove worktree: ${removed.status} ${message}`) + } + const ok = yield* json(removed) + if (!ok) throw new Error(`failed to remove worktree ${input.worktreeDirectory}`) + }) +} + +function createWorktreeScoped(input: { + server: TestServer + directory: string + path: string + init: RequestInit + timeoutLabel: string + timeoutMs?: number +}) { + return Effect.acquireRelease( + Effect.gen(function* () { + const waitReady = yield* readyWatcher() + const response = yield* withRequestTimeout( + request(input.server, input.path, input.init), + input.timeoutLabel, + input.timeoutMs, + ) + expect(response.status).toBe(200) + const body = yield* json(response) + return { directory: body.directory, body, ready: waitReady(body.directory) } satisfies ScopedWorktree + }), + (created) => + removeCreatedWorktree({ + server: input.server, + rootDirectory: input.directory, + worktreeDirectory: created.directory, + ready: created.ready, + }).pipe(Effect.orDie), + ).pipe(Effect.map((created) => created.body)) } function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) { return Effect.gen(function* () { const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`) expect(current.status).toBe(200) - const project = (yield* Effect.promise(() => current.json())) as { id: string } + const project = yield* json<{ id: string }>(current) const updated = yield* request( input.server, `/project/${project.id}?directory=${encodeURIComponent(input.directory)}`, @@ -66,47 +162,51 @@ function setProjectStartCommand(input: { server: TestServer; directory: string; } describe("worktree endpoint reproduction", () => { - it.instance( + worktreeTest( "direct HttpApi worktree create returns without waiting for boot", () => Effect.gen(function* () { const test = yield* TestInstance const server = yield* serverScoped() - const response = yield* withRequestTimeout( - request(server, `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, { + const response = yield* createWorktreeScoped({ + server, + directory: test.directory, + path: `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, + init: { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}), - }), - "direct worktree create", - ) + }, + timeoutLabel: "direct worktree create", + }) - expect(response.status).toBe(200) - expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: expect.any(String) }) + expect(response).toMatchObject({ directory: expect.any(String) }) }), { git: true }, ) - it.instance( + worktreeTest( "workspace worktree create does not hang", () => Effect.gen(function* () { const test = yield* TestInstance const server = yield* serverScoped() - const response = yield* withRequestTimeout( - request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, { + const response = yield* createWorktreeScoped({ + server, + directory: test.directory, + path: `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, + init: { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ type: "worktree", branch: null }), - }), - "workspace worktree create", - 8_000, - ) + }, + timeoutLabel: "workspace worktree create", + timeoutMs: 8_000, + }) - expect(response.status).toBe(200) - expect(yield* Effect.promise(() => response.json())).toMatchObject({ + expect(response).toMatchObject({ type: "worktree", directory: expect.any(String), }) @@ -114,7 +214,7 @@ describe("worktree endpoint reproduction", () => { { git: true }, ) - it.instance( + worktreeTest( "workspace worktree create returns without waiting for project start command", () => Effect.gen(function* () { @@ -127,17 +227,19 @@ describe("worktree endpoint reproduction", () => { }) const started = Date.now() - const response = yield* withRequestTimeout( - request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, { + yield* createWorktreeScoped({ + server, + directory: test.directory, + path: `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, + init: { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ type: "worktree", branch: null }), - }), - "workspace worktree create with project start command", - 6_000, - ) + }, + timeoutLabel: "workspace worktree create with project start command", + timeoutMs: 6_000, + }) - expect(response.status).toBe(200) expect(Date.now() - started).toBeLessThan(1_500) }), { git: true }, From 13fbc9acfc0c664525006294f50674ff7d1cd969 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 22:45:26 -0400 Subject: [PATCH 08/98] docs(effect): add cleanup roadmap (#27228) --- packages/opencode/specs/effect/errors.md | 366 ++++-------------- packages/opencode/specs/effect/guide.md | 251 ++++++++++++ packages/opencode/specs/effect/migration.md | 321 +++------------ packages/opencode/specs/effect/routes.md | 88 +++-- packages/opencode/specs/effect/schema.md | 361 +++-------------- packages/opencode/specs/effect/todo.md | 237 ++++++++++++ .../opencode/test/EFFECT_TEST_MIGRATION.md | 252 +++++------- 7 files changed, 804 insertions(+), 1072 deletions(-) create mode 100644 packages/opencode/specs/effect/guide.md create mode 100644 packages/opencode/specs/effect/todo.md diff --git a/packages/opencode/specs/effect/errors.md b/packages/opencode/specs/effect/errors.md index e19199ef4..5266ca510 100644 --- a/packages/opencode/specs/effect/errors.md +++ b/packages/opencode/specs/effect/errors.md @@ -1,41 +1,22 @@ -# Typed error migration +# Typed Error Migration -Plan for moving `packages/opencode` from temporary defect/`NamedError` -compatibility toward typed Effect service errors and explicit HTTP error -contracts. +This note expands the `ERR`, `RENDER`, and `HTTP` tracks from +[`todo.md`](./todo.md). It is the current reference for expected failures, +typed service errors, and HTTP error boundaries. ## Goal - Expected service failures live on the Effect error channel. - Service interfaces expose those failures in their return types. -- Domain errors are authored with Effect Schema so they are reusable by services, - tests, HTTP routes, tools, and OpenAPI generation. -- HTTP status codes and wire compatibility are handled at the HTTP boundary, not - inside service modules. -- `Effect.die`, `throw`, `catchDefect`, and global cause inspection are reserved - for defects, compatibility bridges, or final fallback behavior. +- Domain errors are authored with `Schema.TaggedErrorClass`. +- `Effect.die(...)` is reserved for defects: bugs, impossible states, + violated invariants, and final unknown-boundary fallbacks. +- HTTP status codes and public wire bodies are handled at HTTP route + boundaries, not inside service modules. +- User-facing boundaries render useful structured error details instead of + opaque `Error: SomeName` strings. -## Current State - -- Many migrated services use Effect internally, but expected failures are still a - mix of `NamedError.create(...)`, `namedSchemaError(...)`, `class extends Error`, - `throw`, and `Effect.die(...)`. -- Some services already use `Schema.TaggedErrorClass`, for example `Account`, - `Auth`, `Permission`, `Question`, `Installation`, and parts of - `Workspace`. -- The temporary HttpApi compatibility middleware recognizes `NamedError`, - `Session.BusyError`, and a few name-based cases, then emits the legacy - `{ name, data }` JSON body. -- Effect `HttpApi` only knows how to encode errors that are declared on the - endpoint, group, or middleware. Undeclared expected errors become defects and - eventually fall through to generic HTTP handling. -- The temporary HttpApi error middleware catches defect-wrapped legacy errors to - preserve runtime behavior, but it is intentionally a bridge rather than the - final model. - -## End State - -Service modules own domain failures. +## Service Error Shape ```ts export class SessionBusyError extends Schema.TaggedErrorClass()("SessionBusyError", { @@ -50,281 +31,90 @@ export interface Interface { } ``` -HTTP modules own transport mapping. +Rules: + +- Use `Schema.TaggedErrorClass` for expected domain failures. +- Export a domain-level `Error` union from each service module. +- Put expected errors in service method signatures. +- Use `yield* new DomainError(...)` for direct early failures in + `Effect.gen` / `Effect.fn`. +- Use `Schema.Defect` for unknown cause fields when preserving the cause is + useful for logs or callers. +- Use `Effect.try(...)`, `Effect.tryPromise(...)`, `Effect.mapError`, + `Effect.catchTag`, and `Effect.catchTags` to translate external + failures into domain errors. +- Do not use `throw`, `Effect.die(...)`, or `catchDefect` for expected + user, IO, validation, missing-resource, auth, provider, worktree, or + busy-state failures. + +## HTTP Boundary Shape + +Service modules stay transport-agnostic. They should not import HTTP +status codes, `HttpApiError`, `HttpServerResponse`, or route-specific +error schemas. + +HTTP handlers translate service errors into public endpoint errors: ```ts const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) { return yield* session .get(ctx.params.sessionID) - .pipe( - Effect.catchTag("StorageNotFoundError", () => new SessionNotFoundHttpError({ sessionID: ctx.params.sessionID })), - ) + .pipe(Effect.catchTag("StorageNotFoundError", () => notFound("Session not found"))) }) ``` -HTTP-visible error schemas carry their own response status through Effect -HttpApi's `httpApiStatus` annotation. Prefer `HttpApiSchema.status(...)`, or the -equivalent declaration annotation, instead of maintaining a parallel status map. +Endpoint definitions declare which public errors can be emitted. Public +HTTP error schemas carry their response status with `httpApiStatus` or the +equivalent HttpApi schema annotation. -```ts -export class SessionNotFoundHttpError extends Schema.TaggedErrorClass()( - "SessionNotFoundHttpError", - { - sessionID: SessionID, - message: Schema.String, - }, - { httpApiStatus: 404 }, -) {} -``` +The service error and HTTP error may be the same class only when the wire +shape is intentionally public. Use separate HTTP error schemas when the +service error contains internals, low-level causes, retry hints, or data +that should not be exposed to API clients. -Endpoint definitions still declare which HTTP-visible error schemas can be -emitted. The status annotation is only used if the error is part of the endpoint, -group, or middleware error schema and the handler fails with that error on the -typed error channel. +## Mapping Guidance -```ts -HttpApiEndpoint.get("get", SessionPaths.get, { - success: Session.Info, - error: [SessionNotFoundHttpError, SessionBusyHttpError], -}) -``` +- Keep one-off translations inline in the handler. +- Extract tiny shared helpers when the same translation repeats across a + route group. +- Do not create one giant `unknown -> status` mapper. +- Do not grow generic HTTP middleware into a registry of domain errors. +- Preserve existing public `{ name, data }` bodies until a deliberate + breaking API change. +- Use built-in `HttpApiError.*` only when its generated body and SDK + surface are intentionally the public contract. -The service error and HTTP error may be the same class when the wire shape is a -deliberate public contract. They should be different classes when the service -error contains internals, low-level causes, retry hints, or anything that should -not be exposed to API clients. +## Middleware Guidance -## Rules +HTTP middleware should be cross-cutting: auth, context, schema decode +formatting, routing, and final unknown-defect fallback. -- Use `Schema.TaggedErrorClass` for new expected domain errors. -- Include `cause: Schema.optional(Schema.Defect)` only when preserving an - underlying unknown failure is useful for logs or callers. -- Export a domain-level error union from each service module, for example - `export type Error = NotFoundError | BusyError | Storage.Error`. -- Put expected errors in service method signatures, for example - `Effect.Effect`. -- Use `yield* new DomainError(...)` for direct early failures inside - `Effect.gen` / `Effect.fn`. -- Use `Effect.try({ try, catch })`, `Effect.mapError`, or `Effect.catchTag` to - convert external exceptions into domain errors. -- Use `HttpApiSchema.status(...)` or `{ httpApiStatus: code }` on HTTP-visible - error schemas so Effect `HttpApiBuilder` and OpenAPI generation get the status - from the schema itself. -- Do not use `Effect.die(...)` for user, IO, validation, missing-resource, auth, - provider, worktree, or busy-state failures. -- Do not use `catchDefect` to recover expected domain errors. If recovery is - needed, the upstream effect should fail with a typed error instead. -- Do not make service modules import `HttpApiError`, `HttpServerResponse`, HTTP - status codes, or route-specific error schemas. -- Keep raw `HttpRouter` routes free to use `HttpServerRespondable` when that is - the right transport abstraction, but prefer declared `HttpApi` errors for - normal JSON API endpoints. +The current compatibility middleware still knows about some legacy domain +errors. As route groups declare expected errors and handlers map them, that +middleware should shrink. It should not gain new name checks. -## HTTP Boundary Shape +Unknown `500` responses should log full details server-side with +`Cause.pretty(cause)` and return a safe public body. -Create an HttpApi-local error module, likely -`src/server/routes/instance/httpapi/errors.ts`. +## Migration Order -That module should provide: +Prefer small vertical slices: -- Legacy-compatible public schemas for `{ name, data }` error bodies that must - remain SDK-compatible while route groups declare typed errors. -- Small constructors or mapping helpers for common API errors such as not found, - bad request, conflict, and unknown internal errors. -- Route-group-specific adapters only when they encode domain-specific public - data. -- A single place to document which public error shape is legacy-compatible and - which shape is new Effect-native API surface. +1. Fix rendering at one user-visible boundary. +2. Convert one service domain to `Schema.TaggedErrorClass` errors. +3. Map those errors at the affected HTTP handlers. +4. Remove the corresponding name-based middleware branch if possible. +5. Add or update focused tests for both service error tags and HTTP wire + bodies. -Avoid one giant `unknown -> status` mapper. Prefer small, explicit mappers close -to the handler or route group. +Good early domains are storage not-found, worktree errors, and provider +auth validation errors because they currently drive HTTP behavior. -```ts -const mapSessionError = (effect: Effect.Effect) => - effect.pipe( - Effect.catchTag("StorageNotFoundError", (error) => new SessionNotFoundHttpError({ message: error.message })), - Effect.catchTag("SessionBusyError", (error) => new SessionBusyHttpError({ message: error.message })), - ) -``` +## Checklist For A PR -Use built-in `HttpApiError.BadRequest`, `HttpApiError.NotFound`, and related -types only when their generated response body and SDK surface are intentionally -acceptable. Use a custom schema-backed error when clients need the legacy -`{ name, data }` body or a domain-specific error payload. - -## Migration Phases - -### 1. Stabilize The Bridge - -Keep the temporary HttpApi error middleware only as a compatibility bridge while -typed errors are introduced. - -- Add tests that prove the bridge catches legacy `NamedError` defects. -- Add tests that prove declared HttpApi errors still use the declared endpoint - contract. -- Stop returning stack traces in unknown HTTP `500` responses; log the full - `Cause.pretty(cause)` server-side instead. -- Add a comment or TODO that names this plan and states the bridge must shrink - as route groups migrate. - -### 2. Define The Shared HTTP Error Helpers - -Add the `httpapi/errors.ts` module before converting route groups. - -- Define a legacy `{ name, data }` body helper for SDK-compatible errors. -- Define `UnknownError` for generic internal failures with a safe public message. -- Define `BadRequestError` and `NotFoundError` equivalents only if the actual - wire body must match the existing SDK surface. -- Put the HTTP status on the public schema with `HttpApiSchema.status(...)` or - `{ httpApiStatus: code }`; do not keep a separate name-to-status table. -- Keep conversion helpers pure and small. They should not inspect `Cause` or - accept `unknown` unless they are final fallback helpers. - -### 3. Convert One Vertical Slice - -Start with session read routes because they already have local `mapNotFound` -logic and are heavily covered by existing HttpApi tests. - -- Convert `Session.BusyError` from a plain `Error` to a typed service error, or - add a typed wrapper while preserving the old constructor until callers are - migrated. -- Replace `catchDefect` in `httpapi/handlers/session.ts` with typed error - mapping. -- Add endpoint error schemas for the affected session endpoints. -- Prove behavior with focused tests in `test/server/httpapi-session.test.ts`. -- Remove the migrated cases from the global compatibility middleware. - -### 4. Convert Legacy NamedError Domains - -Move legacy `NamedError.create(...)` services to Effect Schema-backed errors in -small domain PRs. - -Priority order: - -1. `storage/storage.ts` and `storage/db.ts` not-found errors. -2. `worktree/index.ts` `Worktree*` errors. -3. `provider/auth.ts` validation failures and `provider/provider.ts` model-not-found errors. -4. `mcp/index.ts`, `skill/index.ts`, `lsp/client.ts`, and `ide/index.ts` service errors. -5. Config and CLI-only errors after HTTP-facing domains are stable. - -For each domain: - -- Replace `NamedError.create(...)` with `Schema.TaggedErrorClass` when the error - is primarily a service error. -- Keep or add a separate HTTP error schema when the legacy `{ name, data }` wire - shape must remain stable. -- Update service interface return types to include the new error union. -- Replace `throw new X(...)` inside `Effect.fn` with `yield* new X(...)`. -- Replace async exceptions with `Effect.try({ catch })` or explicit `mapError`. -- Add service-level tests that assert the error tag and data, not just the HTTP - status. - -### 5. Declare HttpApi Errors Group By Group - -For each HttpApi group: - -- Inventory every service call and the typed errors it can return. -- Add only the public error schemas that endpoint can actually emit. -- Map service errors to HTTP errors in the handler file. -- Keep built-in `HttpApiError` only for generic request/validation failures where - the generated contract is accepted. -- Update `httpapi/public.ts` compatibility transforms only when the generated - spec cannot represent the desired source shape directly. -- Regenerate the SDK after OpenAPI-visible changes and verify the diff is - intentional. - -Suggested route order: - -1. `session` not-found and busy-state reads. -2. `experimental` worktree mutations. -3. `provider` auth and model selection errors. -4. `mcp` OAuth and connection errors. -5. Remaining route groups as typed error contracts are declared. - -### 6. Remove Defect Recovery - -After enough route groups declare their expected errors: - -- Delete `catchDefect` recovery for domain errors. -- Delete name-prefix checks such as `error.name.startsWith("Worktree")` from - HTTP middleware. -- Delete `NamedError` branches from the Effect HttpApi compatibility middleware - once no Effect route depends on them. -- Leave one final unknown-defect fallback that logs server-side and returns a - safe generic `500` body. - -## Inventory Checklist - -Use this checklist when touching a service or route group. - -- [ ] Does the service interface expose every expected failure in the Effect - error type? -- [ ] Are user-caused, provider-caused, IO, auth, missing-resource, and busy-state - failures modeled as typed errors instead of defects? -- [ ] Does the service avoid importing HTTP status, `HttpApiError`, or response - classes? -- [ ] Does the handler map each service error into a declared endpoint error? -- [ ] Does the endpoint `error` field include every public error the handler can - emit? -- [ ] Does OpenAPI/SDK output either stay byte-identical or have an explicitly - reviewed diff? -- [ ] Do tests cover both service-level error typing and HTTP-level status/body? -- [ ] Did the PR remove any now-unneeded case from the temporary compatibility - middleware? - -## Testing Requirements - -For service conversions: - -- Test the service method directly with `testEffect(...)`. -- Assert on `_tag` or class identity and the structured fields. -- Avoid testing by string-matching `Cause.pretty(...)`. - -For HttpApi conversions: - -- Add or update the focused `test/server/httpapi-*.test.ts` file. -- Assert status code, content type, and exact JSON body for declared public - errors. -- Add a regression test that the temporary middleware is no longer needed for the - migrated route. -- Keep compatibility tests aligned with the existing SDK contract until the - public error shape intentionally changes. - -## Verification Commands - -Run from `packages/opencode` unless noted otherwise. - -```bash -bun run prettier --write -bunx oxlint -bun typecheck -bun run test -- test/server/httpapi-session.test.ts -``` - -Run SDK generation from the repo root when schemas or OpenAPI-visible errors -change. - -```bash -./packages/sdk/js/script/build.ts -``` - -## Open Questions - -- Should legacy V1 routes keep `{ name, data }` forever while V2 routes expose a - more Effect-native tagged error body? -- Should storage not-found remain generic, or should callers map it to - domain-specific not-found errors before crossing service boundaries? -- Should `namedSchemaError(...)` stay as a long-term public-wire helper, or only - as a migration bridge for old `NamedError` contracts? -- Which SDK version boundary lets us stop remapping built-in Effect HttpApi error - schemas in `httpapi/public.ts`? - -## Success Criteria - -- New service code no longer uses `die` for expected failures. -- A route reviewer can read an endpoint definition and see every public error it - can return. -- The temporary HttpApi error middleware shrinks over time instead of gaining new - name-based cases. -- Service tests prove domain error types without going through HTTP. -- HTTP tests prove status/body contracts without relying on defect recovery. +- [ ] Expected failures are typed errors, not defects. +- [ ] Service method signatures expose the expected error union. +- [ ] HTTP handlers translate domain errors at the boundary. +- [ ] Public HTTP error bodies preserve existing wire contracts. +- [ ] Generic middleware gets smaller or stays unchanged. +- [ ] Focused tests cover the service error and any public HTTP response. diff --git a/packages/opencode/specs/effect/guide.md b/packages/opencode/specs/effect/guide.md new file mode 100644 index 000000000..5df029344 --- /dev/null +++ b/packages/opencode/specs/effect/guide.md @@ -0,0 +1,251 @@ +# Effect Guide + +How we write Effect code in `packages/opencode`. The companion roadmap is +[`todo.md`](./todo.md). + +This guide describes the preferred shape for new work and migrations. If a +legacy file differs, migrate it only when it is already in scope. + +## Service Shape + +Use one module per service: flat top-level exports, traced Effect methods, +explicit layers, and a self-reexport at the bottom. + +```ts +export interface Interface { + readonly get: (id: FooID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Foo") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make(Effect.fn("Foo.state")(() => Effect.succeed({}))) + + const get = Effect.fn("Foo.get")(function* (id: FooID) { + const s = yield* InstanceState.get(state) + return yield* loadFoo(s, id) + }) + + return Service.of({ get }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(FooDep.defaultLayer)) + +export * as Foo from "./foo" +``` + +Rules: + +- Do not use `export namespace Foo { ... }`. +- Use `Effect.fn("Foo.method")` for public service methods. +- Use `Effect.fnUntraced` for small internal helpers that do not need a + span. +- Keep helpers as non-exported top-level declarations in the same file. +- Self-reexport with `export * as Foo from "."` for `index.ts`, otherwise + `export * as Foo from "./foo"`. +- In `src/config`, keep the existing top-of-file self-export pattern. + +## Runtime Boundaries + +Most code should run through [`AppRuntime`](../../src/effect/app-runtime.ts). +It hosts `AppLayer`, shares the global `memoMap`, and restores the current +instance/workspace refs when crossing from non-Effect code. + +Use `AppRuntime.runPromise(effect)` at app boundaries such as CLI commands, +HTTP handlers, or plain async adapters. + +`makeRuntime(...)` still exists for a few intentional service-local +boundaries and migration leftovers. Do not add a new service-local runtime +unless the service truly cannot live in `AppLayer`. + +## Runtime Flags + +Read opencode runtime flags through +[`RuntimeFlags.Service`](../../src/effect/runtime-flags.ts), not through +mutable `Flag` or late `process.env` reads. + +Tests should vary behavior with explicit layer variants: + +```ts +const it = testEffect(MyService.defaultLayer.pipe(Layer.provide(RuntimeFlags.layer({ experimentalScout: true })))) +``` + +Do not mutate `process.env` or `Flag` after services/layers are built. + +## Per-Instance State + +Use [`InstanceState`](../../src/effect/instance-state.ts) when two open +directories should not share one copy of a service's state. It is backed by +a `ScopedCache`, keyed by directory, and disposed automatically when an +instance is unloaded. + +Put subscriptions, finalizers, and scoped background work inside the +`InstanceState.make(...)` initializer: + +```ts +const cache = + yield * + InstanceState.make( + Effect.fn("Foo.state")(function* () { + const bus = yield* Bus.Service + + yield* bus.subscribeAll().pipe( + Stream.runForEach((event) => handleEvent(event)), + Effect.forkScoped, + ) + + yield* Effect.acquireRelease(openResource, closeResource) + + return yield* loadInitialState() + }), + ) +``` + +Do not add separate `started` flags on top of `InstanceState`. Let +`ScopedCache` handle run-once and deduplication. + +To make `init()` non-blocking, fork at the caller/bootstrap boundary. Do +not fork inside `InstanceState.make(...)` just to return early with +partially initialized state. + +## Errors + +Expected domain failures belong on the Effect error channel. Defects are +for bugs, impossible states, and final unknown-boundary fallbacks. + +```ts +export class SessionBusyError extends Schema.TaggedErrorClass()("SessionBusyError", { + sessionID: SessionID, + message: Schema.String, +}) {} + +export type Error = Storage.Error | SessionBusyError + +export interface Interface { + readonly get: (id: SessionID) => Effect.Effect +} +``` + +Rules: + +- Use `Schema.TaggedErrorClass` for new expected domain errors. +- Export a domain-level `Error` union from service modules. +- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` for + direct expected failures. +- Use `Schema.Defect` for unknown cause fields. +- Use `Effect.try(...)`, `Effect.tryPromise(...)`, `Effect.mapError`, + `Effect.catchTag`, and `Effect.catchTags` to translate external + failures into domain errors. +- Do not use `Effect.die(...)` for user, IO, validation, missing-resource, + auth, provider, or busy-state failures. + +## HTTP Error Boundaries + +Service modules stay HTTP-agnostic. They should not import HTTP status +codes, `HttpApiError`, `HttpServerResponse`, or route-specific error +schemas. + +HTTP handlers translate service errors into endpoint-declared public error +schemas. Keep mappings inline when they are one-off; extract tiny shared +helpers only when the same translation repeats. + +Do not turn generic middleware into a registry of domain errors. Middleware +should handle cross-cutting concerns and the final unknown-defect fallback. + +Preserve legacy public wire shapes, such as `{ name, data }`, until a +deliberate breaking API change. + +## Schemas + +Use Effect Schema as the source of truth. + +- Use `Schema.Class` for exported data objects with a clear identity. +- Use `Schema.Struct` for local shapes and simple nested objects. +- Use `Schema.brand` for single-value IDs. +- Reuse named refinements instead of re-spelling constraints. +- Prefer narrow boundary helpers over generic Schema-to-Zod bridges. + +Intentional boundaries: + +- Public plugin tools still expose Zod through `tool.schema = z`. +- Tool parameter JSON Schema is generated through tool-specific helpers. +- Public config and TUI schemas are generated through the schema script. + +## Preferred Services + +In effectified code, yield existing services instead of dropping to ad hoc +platform APIs. + +- Use `AppFileSystem.Service` instead of raw `fs/promises` for app file IO. +- Use `AppProcess.Service` instead of direct `ChildProcessSpawner.spawn` or + legacy process helpers. +- Use `HttpClient.HttpClient` instead of raw `fetch` inside Effect code. +- Use `Path.Path`, `Config`, `Clock`, and `DateTime` when already inside + Effect. +- Use `Effect.callback` for callback-based APIs. +- Use `Effect.void` instead of `Effect.succeed(undefined)`. +- Use `Effect.cached` when concurrent callers should share one in-flight + computation. + +For background loops, use `Effect.repeat` or `Effect.schedule` with +`Effect.forkScoped` in the owning layer/state scope. + +## Promise And ALS Bridges + +[`EffectBridge`](../../src/effect/bridge.ts) is the sanctioned helper for +Promise/callback interop that needs to preserve instance/workspace context. +Keep it, but reduce its dependency on legacy `Instance.current` / +`Instance.restore` over time. + +`Instance.bind` / `Instance.restore` are transitional legacy tools. Use +them only for native callbacks that still require legacy ALS context. Do +not use them for `setTimeout`, `Promise.then`, `EventEmitter.on`, or +Effect fibers. + +## Testing + +Detailed test migration rules live in +[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md). + +Core pattern: + +```ts +const it = testEffect(Layer.mergeAll(MyService.defaultLayer)) + +describe("my service", () => { + it.instance("does the thing", () => + Effect.gen(function* () { + const svc = yield* MyService.Service + expect(yield* svc.run()).toEqual("ok") + }), + ) +}) +``` + +Rules: + +- Use `it.effect(...)` for TestClock/TestConsole tests. +- Use `it.live(...)` for real timers, filesystem mtimes, child processes, + git, locks, or other live integration behavior. +- Use `it.instance(...)` for service tests that need a scoped instance. +- Prefer Effect-aware fixtures from `test/fixture/fixture.ts`. +- Avoid sleeps; wait for real events or deterministic state transitions. +- Avoid mutable `process.env`, `Flag`, or module-global changes after + layers are built. +- Use `Layer.mock` for partial service stubs. +- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` test + wrappers. + +## Verification + +From `packages/opencode`: + +```bash +bun run typecheck +bun run test -- path/to/test.ts +``` + +Do not run tests from the repo root; the repo has a guard for that. diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md index 13838e833..5355feccc 100644 --- a/packages/opencode/specs/effect/migration.md +++ b/packages/opencode/specs/effect/migration.md @@ -1,291 +1,62 @@ -# Effect patterns +# Effect Migration Patterns -Practical reference for new and migrated Effect code in `packages/opencode`. +This is the compact reference for moving code toward the current Effect +shape. The high-level roadmap is [`todo.md`](./todo.md); examples and +rules are in [`guide.md`](./guide.md). -## Choose scope +## Default Shape -Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal. +- Service methods return `Effect`. +- Service methods are named with `Effect.fn("Domain.method")`. +- Expected failures are typed errors on the error channel. +- Dependencies are yielded once at layer construction and closed over by + methods. +- `defaultLayer` wires production dependencies; tests can use open layers + when replacing dependencies. -Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`. +## Instance State -- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree -- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs +Use `InstanceState` for per-directory state, subscriptions, scoped +background work, and per-instance cleanup. -Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`. +Do not add ad hoc `started` flags on top of `InstanceState`; the scoped +cache handles run-once and concurrent deduplication. -## Instance context transition +## Runtime Boundaries -See `instance-context.md` for the phased plan to remove the legacy ALS / promise-backed `Instance` helper and move request / CLI / tool boundaries onto Effect-provided instance scope. +Prefer `AppRuntime` for crossing from non-Effect code into the shared app +layer. -## Service shape +`makeRuntime(...)` exists for intentional service-local boundaries and +legacy facades. Do not add new service-local runtimes unless the service is +genuinely outside `AppLayer`. -Every service follows the same pattern: one module, flat top-level exports, traced Effect methods, and a self-reexport at the bottom when the file is the public module. +## Platform Edges -```ts -export interface Interface { - readonly get: (id: FooID) => Effect.Effect -} +- Use `AppFileSystem.Service` instead of raw filesystem APIs in + effectified services. +- Use `AppProcess.Service` instead of raw process wrappers. +- Use `HttpClient.HttpClient` instead of raw `fetch` in Effect code. +- Use `Effect.cached` for shared in-flight work. +- Use `Effect.callback` for callback APIs. -export class Service extends Context.Service()("@opencode/Foo") {} +## Tests During Migration -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const state = yield* InstanceState.make( - Effect.fn("Foo.state")(() => Effect.succeed({ ... })), - ) +When migrating code, migrate touched tests toward +[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md): - const get = Effect.fn("Foo.get")(function* (id: FooID) { - const s = yield* InstanceState.get(state) - // ... - }) +- `testEffect(...)` +- `it.effect`, `it.live`, or `it.instance` +- explicit layers for behavior changes +- deterministic waits instead of sleeps +- no mutable env/global flags after layers are built - return Service.of({ get }) - }), -) +## Migration Checklist -export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer)) - -export * as Foo from "." -``` - -Rules: - -- Keep the service surface in one module; prefer flat top-level exports over `export namespace Foo { ... }` -- Use `Effect.fn("Foo.method")` for Effect methods -- Use a self-reexport (`export * as Foo from "."` or `"./foo"`) for the public namespace projection -- Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase -- No `Layer.fresh` for normal per-directory isolation; use `InstanceState` - -## Schema boundaries - -Use Effect Schema directly at HTTP, tool, and AI SDK boundaries. For provider-facing JSON Schema, use a boundary-specific helper such as `ToolJsonSchema.fromSchema(...)`; do not reintroduce generic Effect Schema → Zod conversion. - -## InstanceState init patterns - -The `InstanceState.make` init callback receives a `Scope`, so you can use `Effect.acquireRelease`, `Effect.addFinalizer`, and `Effect.forkScoped` inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by `ScopedCache`. This makes it the right place for: - -- **Subscriptions**: Yield `Bus.Service` at the layer level, then use `Stream` + `forkScoped` inside the init closure. The fiber is automatically interrupted when the instance scope closes: - -```ts -const bus = yield * Bus.Service - -const cache = - yield * - InstanceState.make( - Effect.fn("Foo.state")(function* (ctx) { - // ... load state ... - - yield* bus.subscribeAll().pipe( - Stream.runForEach((event) => - Effect.sync(() => { - /* handle */ - }), - ), - Effect.forkScoped, - ) - - return { - /* state */ - } - }), - ) -``` - -- **Resource cleanup**: Use `Effect.acquireRelease` or `Effect.addFinalizer` for resources that need teardown (native watchers, process handles, etc.): - -```ts -yield * - Effect.acquireRelease( - Effect.sync(() => nativeAddon.watch(dir)), - (watcher) => Effect.sync(() => watcher.close()), - ) -``` - -- **Background fibers**: Use `Effect.forkScoped` — the fiber is interrupted on disposal. -- **Side effects at init**: Config notification, event wiring, etc. all belong in the init closure. Callers just do `InstanceState.get(cache)` to trigger everything, and `ScopedCache` deduplicates automatically. - -The key insight: don't split init into a separate method with a `started` flag. Put everything in the `InstanceState.make` closure and let `ScopedCache` handle the run-once semantics. - -## Effect.cached for deduplication - -Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation. It memoizes the result and deduplicates concurrent fibers — second caller joins the first caller's fiber instead of starting a new one. - -```ts -// Inside the layer — yield* to initialize the memo -let cached = yield * Effect.cached(loadExpensive()) - -const get = Effect.fn("Foo.get")(function* () { - return yield* cached // concurrent callers share the same fiber -}) - -// To invalidate: swap in a fresh memo -const invalidate = Effect.fn("Foo.invalidate")(function* () { - cached = yield* Effect.cached(loadExpensive()) -}) -``` - -Prefer `Effect.cached` over these patterns: - -- Storing a `Fiber.Fiber | undefined` with manual check-and-fork (e.g. `file/index.ts` `ensure`) -- Storing a `Promise` task for deduplication (e.g. `skill/index.ts` `ensure`) -- `let cached: X | undefined` with check-and-load (races when two callers see `undefined` before either resolves) - -`Effect.cached` handles the run-once + concurrent-join semantics automatically. For invalidatable caches, reassign with `yield* Effect.cached(...)` — the old memo is discarded. - -## Scheduled Tasks - -For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition. - -## Preferred Effect services - -In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs. - -Prefer these first: - -- `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O -- `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers -- `HttpClient.HttpClient` instead of raw `fetch` -- `Path.Path` instead of mixing path helpers into service code when you already need a path service -- `Config` for effect-native configuration reads -- `Clock` / `DateTime` for time reads inside effects - -## Child processes - -For child process work in services, yield `ChildProcessSpawner.ChildProcessSpawner` in the layer and use `ChildProcess.make(...)`. - -Keep shelling-out code inside the service, not in callers. - -## Shared leaf models - -Shared schema or model files can stay outside the service namespace when lower layers also depend on them. - -That is fine for leaf files like `schema.ts`. Keep the service surface in the owning namespace. - -## Migration checklist - -Service-shape migrated (single namespace, traced methods, `InstanceState` where needed). - -This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in `facades.md`. - -- [x] `Account` — `account/index.ts` -- [x] `Agent` — `agent/agent.ts` -- [x] `AppFileSystem` — `filesystem/index.ts` -- [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop) -- [x] `Bus` — `bus/index.ts` -- [x] `Command` — `command/index.ts` -- [x] `Config` — `config/config.ts` -- [x] `Discovery` — `skill/discovery.ts` (dependency-only layer, no standalone runtime) -- [x] `File` — `file/index.ts` -- [x] `FileWatcher` — `file/watcher.ts` -- [x] `Format` — `format/index.ts` -- [x] `Installation` — `installation/index.ts` -- [x] `LSP` — `lsp/index.ts` -- [x] `MCP` — `mcp/index.ts` -- [x] `McpAuth` — `mcp/auth.ts` -- [x] `Permission` — `permission/index.ts` -- [x] `Plugin` — `plugin/index.ts` -- [x] `Project` — `project/project.ts` -- [x] `ProviderAuth` — `provider/auth.ts` -- [x] `Pty` — `pty/index.ts` -- [x] `Question` — `question/index.ts` -- [x] `SessionStatus` — `session/status.ts` -- [x] `Skill` — `skill/index.ts` -- [x] `Snapshot` — `snapshot/index.ts` -- [x] `ToolRegistry` — `tool/registry.ts` -- [x] `Truncate` — `tool/truncate.ts` -- [x] `Vcs` — `project/vcs.ts` -- [x] `Worktree` — `worktree/index.ts` - -- [x] `Session` — `session/index.ts` -- [x] `SessionProcessor` — `session/processor.ts` -- [x] `SessionPrompt` — `session/prompt.ts` -- [x] `SessionCompaction` — `session/compaction.ts` -- [x] `SessionSummary` — `session/summary.ts` -- [x] `SessionRevert` — `session/revert.ts` -- [x] `Instruction` — `session/instruction.ts` -- [x] `SystemPrompt` — `session/system.ts` -- [x] `Provider` — `provider/provider.ts` -- [x] `Storage` — `storage/storage.ts` -- [x] `ShareNext` — `share/share-next.ts` -- [x] `SessionTodo` — `session/todo.ts` - -Still open at the service-shape level: - -- [ ] `SyncEvent` — `sync/index.ts` (deferred pending sync with James) -- [ ] `Workspace` — `control-plane/workspace.ts` (deferred pending sync with James) - -## Tool migration - -Tool-specific migration guidance and checklist live in `tools.md`. - -## Effect service adoption in already-migrated code - -Some already-effectified areas still use raw `Filesystem.*` or `Process.spawn` in their implementation or helper modules. These are low-hanging fruit — the layers already exist, they just need the dependency swap. - -### `Filesystem.*` → `AppFileSystem.Service` (yield in layer) - -- [x] `config/config.ts` — `installDependencies()` now uses `AppFileSystem` -- [x] `provider/provider.ts` — recent model state now reads via `AppFileSystem.Service` - -### `Process.spawn` → `ChildProcessSpawner` (yield in layer) - -- [x] `format/formatter.ts` — direct `Process.spawn()` checks removed (`air`, `uv`) -- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers - -## Filesystem consolidation - -`util/filesystem.ts` is still used widely across `src/`, and raw `fs` / `fs/promises` imports still exist in multiple tooling and infrastructure files. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` where possible — this should happen naturally during each migration, not as a separate sweep. - -Tool-specific filesystem cleanup notes live in `tools.md`. - -## Primitives & utilities - -- [ ] `util/lock.ts` — reader-writer lock → Effect Semaphore/Permit -- [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer -- [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise -- [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code - -## Destroying the facades - -This phase is no longer broadly open. There are 5 `makeRuntime(...)` call sites under `src/`, and only a small subset are still ordinary facade-removal targets. The live checklist now lives in `facades.md`. - -These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them. - -### Process - -For each service, the migration is roughly: - -1. **Find callers.** `grep -n "Namespace\.(methodA|methodB|...)"` across `src/` and `test/`. Skip the service file itself. -2. **Migrate production callers.** For each effectful caller that does `Effect.tryPromise(() => Namespace.method(...))`: - - Add the service to the caller's layer R type (`Layer.Layer`) - - Yield it at the top of the layer: `const ns = yield* Namespace.Service` - - Replace `Effect.tryPromise(() => Namespace.method(...))` with `yield* ns.method(...)` (or `ns.method(...).pipe(Effect.orElseSucceed(...))` for the common fallback case) - - Add `Layer.provide(Namespace.defaultLayer)` to the caller's own `defaultLayer` chain -3. **Fix tests that used the caller's raw `.layer`.** Any test that composes `Caller.layer` (not `defaultLayer`) needs to also provide the newly-required service tag. The fastest fix is usually switching to `Caller.defaultLayer` since it now pulls in the new dependency. -4. **Migrate test callers of the facade.** Tests calling `Namespace.method(...)` directly get converted to full effectful style using `testEffect(Namespace.defaultLayer)` + `it.live` / `it.effect` + `yield* svc.method(...)`. Don't wrap the test body in `Effect.promise(async () => {...})` — do the whole thing in `Effect.gen` and use `AppFileSystem.Service` / `tmpdirScoped` / `Effect.addFinalizer` for what used to be raw `fs` / `Bun.write` / `try/finally`. -5. **Delete the facades.** Once `grep` shows zero callers, remove the `export async function` block AND the `makeRuntime(...)` line from the service namespace. Also remove the now-unused `import { makeRuntime }`. - -### Pitfalls - -- **Layer caching inside tests.** `testEffect(layer)` constructs the Storage (or whatever) service once and memoizes it. If a test then tries `inner.pipe(Effect.provide(customStorage))` to swap in a differently-configured Storage, the outer cached one wins and the inner provision is a no-op. Fix: wrap the overriding layer in `Layer.fresh(...)`, which forces a new instance to be built instead of hitting the memoMap cache. This lets a single `testEffect(...)` serve both simple and per-test-customized cases. -- **`Effect.tryPromise` → `yield*` drops the Promise layer.** The old code was `Effect.tryPromise(() => Storage.read(...))` — a `tryPromise` wrapper because the facade returned a Promise. The new code is `yield* storage.read(...)` directly — the service method already returns an Effect, so no wrapper is needed. Don't reach for `Effect.promise` or `Effect.tryPromise` during migration; if you're using them on a service method call, you're doing it wrong. -- **Raw `.layer` test callers break silently in the type checker.** When you add a new R requirement to a service's `.layer`, any test that composes it raw (not `defaultLayer`) becomes under-specified. `tsgo` will flag this — the error looks like `Type 'Storage.Service' is not assignable to type '... | Service | TestConsole'`. Usually the fix is to switch that composition to `defaultLayer`, or add `Layer.provide(NewDep.defaultLayer)` to the custom composition. -- **Tests that do async setup with `fs`, `Bun.write`, `tmpdir`.** Convert these to `AppFileSystem.Service` calls inside `Effect.gen`, and use `tmpdirScoped()` instead of `tmpdir()` so cleanup happens via the scope finalizer. For file operations on the actual filesystem (not via a service), a small helper like `const writeJson = Effect.fnUntraced(function* (file, value) { const fs = yield* AppFileSystem.Service; yield* fs.makeDirectory(path.dirname(file), { recursive: true }); yield* fs.writeFileString(file, JSON.stringify(value, null, 2)) })` keeps the migration tests clean. - -### Migration log - -- `SessionStatus` — migrated 2026-04-11. Replaced the last route and retry-policy callers with `AppRuntime.runPromise(SessionStatus.Service.use(...))` and removed the `makeRuntime(...)` facade. -- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime. -- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration. -- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed. -- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/instance/session.ts` converted; facade removed. -- `Account` — migrated 2026-04-11. Callers in `server/routes/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed. -- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed. -- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed. -- `Question` — migrated 2026-04-11. Callers in `server/routes/instance/question.ts` and test converted; facade removed. -- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed. - -## Route handler effectification - -Route-handler migration guidance and checklist live in `routes.md`. +- [ ] The code has a single Effect body instead of Promise wrappers around + service calls. +- [ ] Expected failures are typed errors, not thrown exceptions or defects. +- [ ] Layer requirements are explicit. +- [ ] Tests use Effect-aware fixtures and focused layers. +- [ ] Public behavior and wire shapes are preserved unless intentionally + changed. diff --git a/packages/opencode/specs/effect/routes.md b/packages/opencode/specs/effect/routes.md index 8066bda34..7dcd80ce9 100644 --- a/packages/opencode/specs/effect/routes.md +++ b/packages/opencode/specs/effect/routes.md @@ -1,57 +1,61 @@ -# Route handler effectification +# HTTP Route Patterns -Practical reference for converting server route handlers in `packages/opencode` to a single `AppRuntime.runPromise(Effect.gen(...))` body. +Current guidance for `packages/opencode/src/server/routes/instance/httpapi`. -## Goal +## Handler Shape -Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one. - -This eliminates multiple `runPromise` round-trips and lets handlers compose naturally. +Use `HttpApiBuilder.group(...)` for normal JSON and streaming HTTP API +endpoints. Yield stable services once while building the handler layer, +then close over those services in endpoint implementations. ```ts -// Before - one facade call per service -;async (c) => { - await SessionRunState.assertNotBusy(id) - await Session.removeMessage({ sessionID: id, messageID }) - return c.json(true) -} +export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => + Effect.gen(function* () { + const session = yield* Session.Service -// After - one Effect.gen, yield services from context -;async (c) => { - await AppRuntime.runPromise( - Effect.gen(function* () { - const state = yield* SessionRunState.Service - const session = yield* Session.Service - yield* state.assertNotBusy(id) - yield* session.removeMessage({ sessionID: id, messageID }) - }), - ) - return c.json(true) -} + return handlers.handle("list", () => session.list()) + }), +) ``` -## Rules +Use raw `HttpRouter` only for routes that do not fit the request/response +HttpApi model, such as WebSocket upgrades or catch-all fallback routes. -- Wrap the whole handler body in one `AppRuntime.runPromise(Effect.gen(...))` call when the handler is service-heavy. -- Yield services from context instead of calling async facades repeatedly. -- When independent service calls can run in parallel, use `Effect.all(..., { concurrency: "unbounded" })`. -- Prefer one composed Effect body over multiple separate `runPromise(...)` calls in the same handler. +Do not rebuild stable layers inside request handlers. Provide stable +services at the route/layer boundary and use request-level provisioning +only for request-derived context. -## Current route files +## Error Boundaries -Current instance route files live under `src/server/routes/instance/httpapi`. -Most handlers already yield stable services at route-layer construction and then -close over those services in endpoint implementations. +Expected service errors should be mapped at the handler boundary to +endpoint-declared public HTTP errors. Keep one-off mappings inline. Extract +small helpers when the same mapping repeats. -Files still worth tracking here: +Generic middleware should not become a domain-error mapper. It should +handle cross-cutting concerns and final unknown-defect fallback. -- [ ] `handlers/session.ts` — still the heaviest mixed file; some paths keep compatibility translations and direct event publication -- [ ] `handlers/experimental.ts` — mixed state; some handlers still rely on request-local context reads -- [ ] `middleware/*` — still contains compatibility policy for auth, compression, errors, instance context, and workspace routing -- [ ] `public.ts` — still owns SDK/OpenAPI compatibility translation shims -- [ ] raw route modules — WebSocket and catch-all routes should stay explicit and avoid rebuilding stable layers per request +Public JSON errors should be explicit schema contracts declared on each +endpoint or group. Built-in `HttpApiError.*` is fine only when its generated +body is intentionally the public wire shape. -## Notes +Preserve existing `{ name, data }` error bodies until a deliberate breaking +API change. -- Route conversion is now less about backend migration and more about removing the remaining direct `Instance.*` reads, request-local service plumbing, and OpenAPI compatibility shims. -- Prefer route-layer service capture over rebuilding or providing stable layers inside individual handlers. +## OpenAPI Compatibility + +`public.ts` still owns SDK/OpenAPI compatibility transforms. Shrink those +transforms by tightening source schemas one workaround at a time. + +When an OpenAPI-visible source schema changes: + +- verify the generated SDK diff is intentional +- preserve legacy compatibility unless the PR explicitly changes it +- prefer source-schema fixes over new post-processing rules + +## Checklist For Route PRs + +- [ ] Stable services are yielded at handler-layer construction. +- [ ] Expected domain errors are translated at the route boundary. +- [ ] Endpoint/group error schemas describe the public body and status. +- [ ] Middleware does not gain new domain-specific name checks. +- [ ] Raw routes are used only when HttpApi is the wrong abstraction. diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index 1fc6a4478..12ac26704 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -1,22 +1,15 @@ -# Schema migration +# Schema Migration -Practical reference for migrating data types in `packages/opencode` from -Zod-first definitions to Effect Schema. +Use Effect Schema as the source of truth for domain models, DTOs, IDs, +inputs, outputs, and typed errors. -## Goal +This is guidance, not an inventory. Do not use this file to track which +schema modules are complete; verify current state with `git grep` before +starting a migration. -Use Effect Schema as the source of truth for domain models, IDs, inputs, -outputs, and typed errors. Prefer native Effect Schema, Standard Schema, and -native JSON Schema generation at HTTP, tool, and AI SDK boundaries. +## Preferred Shapes -The long-term driver is `specs/effect/http-api.md`: Schema-first DTOs should -flow through `HttpApi` / `HttpRouter` without a Zod translation layer. - -## Preferred shapes - -### Data objects - -Use `Schema.Class` for structured data. +Use `Schema.Class` for exported data objects with a clear domain identity: ```ts export class Info extends Schema.Class("Foo.Info")({ @@ -26,18 +19,16 @@ export class Info extends Schema.Class("Foo.Info")({ }) {} ``` -If a schema needs local static helpers, use the two-step `withStatics` pattern: +Use `Schema.Struct` for local shapes and simple nested objects: ```ts -export const Info = Schema.Struct({ +const Payload = Schema.Struct({ id: FooID, - name: Schema.String, -}).pipe(withStatics((s) => ({ decode: Schema.decodeUnknownOption(s) }))) + value: Schema.String, +}) ``` -### Errors - -Use `Schema.TaggedErrorClass` for domain errors. +Use `Schema.TaggedErrorClass` for expected domain errors: ```ts export class NotFoundError extends Schema.TaggedErrorClass()("FooNotFoundError", { @@ -45,309 +36,53 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Foo }) {} ``` -### IDs and branded leaf types +Use branded schema-backed IDs for single-value domain identifiers. -Keep branded/schema-backed IDs as Effect schemas. +## Boundary Rule -### Refinements +Effect Schema should own the type. Boundaries should consume Effect Schema +directly or use narrow boundary-specific helpers. Avoid reintroducing a +generic Effect Schema -> Zod bridge. -Reuse named refinements instead of re-spelling numeric or string constraints in -every schema. Boundary JSON Schema helpers should normalize native Effect JSON -Schema output only where a provider requires it. +Current intentional boundaries: + +- Public plugin tools still expose Zod through `tool.schema = z`. +- Tool parameters use tool-specific JSON Schema helpers. +- Public config and TUI schema generation goes through the schema script. +- AI SDK object generation uses Standard Schema / JSON Schema helpers. + +When Zod must stay temporarily, leave a short note explaining the boundary +or compatibility reason. + +## Refinements + +Reuse named refinements instead of re-spelling constraints: ```ts const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)) const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) -const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)) ``` -## Compatibility rule +Prefer domain-named leaf schemas when the name improves callers or error +messages. Avoid adding brands purely for novelty. -During migration, route validators, tool parameters, and AI SDK schemas should -consume Effect schemas directly or use a narrow boundary helper. Avoid -maintaining a second hand-written Zod schema. +## Migration Order -The default should be: +For a domain that still has mixed schemas: -- Effect Schema owns the type -- new domain models should not start Zod-first unless there is a concrete - boundary-specific need +1. Shared leaf models and branded IDs. +2. Exported `Info`, `Input`, `Output`, and event payload types. +3. Expected domain errors. +4. Service-local internal models. +5. HTTP/tool/AI boundary validators. -## When Zod can stay +Keep public wire shapes stable unless the PR is explicitly a breaking API +change. -It is fine to keep a Zod-native schema temporarily when: +## Checklist For A PR -- the type is only used at an HTTP or tool boundary and is not reused elsewhere -- the validator is part of an existing public API that explicitly accepts Zod -- the migration would force unrelated churn across a large call graph - -When this happens, prefer leaving a short note or TODO rather than silently -creating a parallel schema source of truth. - -## Boundary helpers - -Use narrow helpers at concrete boundaries instead of a generic Schema → Zod bridge. - -- Tool parameters: `ToolJsonSchema.fromSchema(...)` and `ToolJsonSchema.fromTool(...)` -- Public config/TUI schemas: `packages/opencode/script/schema.ts` -- AI SDK object generation: `Schema.toStandardSchemaV1(...)` plus `Schema.toStandardJSONSchemaV1(...)` - -Plugin tools are the main remaining intentional Zod boundary because the public -plugin API exposes `tool.schema = z` and `args: z.ZodRawShape`. - -### Local `DeepMutable` in `config/config.ts` - -`Schema.Struct` produces `readonly` types. Some consumer code (notably the -`Config` service) mutates `Info` objects directly, so a readonly-stripping -utility is needed when casting the derived zod schema's output type. - -`Types.DeepMutable` from effect-smol would be a drop-in, but it widens -`unknown` to `{}` in the fallback branch — a bug that affects any schema -using `Schema.Record(String, Schema.Unknown)`. - -Tracked upstream as `effect:core/x228my`: "Types.DeepMutable widens unknown -to `{}`." Once that lands, the local `DeepMutable` copy can be deleted and -`Types.DeepMutable` used directly. - -## Ordering - -Migrate in this order: - -1. Shared leaf models and `schema.ts` files -2. Exported `Info`, `Input`, `Output`, and DTO types -3. Tagged domain errors -4. Service-local internal models -5. Route and tool boundary validators that can switch to native Effect Schema helpers - -This keeps shared types canonical first and makes boundary updates mostly -mechanical. - -## Progress tracker - -### `src/config/` ✅ complete - -All of `packages/opencode/src/config/` has been migrated. The `export const -` values are all Effect Schema at source. - -A file is considered "done" when: - -- its exported schema values (`Info`, `Input`, `Event`, `Definition`, etc.) - are authored as Effect Schema -- any remaining Zod is an explicit boundary compatibility choice, not a - hand-written parallel source of truth - -Files that meet this bar but still carry a compatibility boundary are checked -off with an inline note describing the boundary and what unblocks its removal. - -- [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider -- [x] server, layout -- [x] keybinds -- [x] permission#Info -- [x] agent -- [x] config.ts root - -### `src/*/schema.ts` leaf modules - -These are the highest-priority next targets. Each is a small, self-contained -schema module with a clear domain. - -- [x] `src/account/schema.ts` -- [x] `src/control-plane/schema.ts` -- [x] `src/permission/schema.ts` -- [x] `src/project/schema.ts` -- [x] `src/provider/schema.ts` -- [x] `src/pty/schema.ts` -- [x] `src/question/schema.ts` -- [x] `src/session/schema.ts` -- [x] `src/storage/schema.ts` -- [x] `src/sync/schema.ts` -- [x] `src/tool/schema.ts` -- [x] `src/util/schema.ts` - -### Session domain - -Major cluster. Message + event types flow through the SSE API and every SDK -output, so byte-identical SDK surface is critical. - -Suggested order for this cluster, starting from the leaves that `session.ts` -and the SSE/event surface depend on: - -1. `src/session/schema.ts` ✅ already migrated -2. `src/provider/schema.ts` if `message-v2.ts` still relies on zod-first IDs -3. `src/lsp/*` schema leaves needed by `LSP.Range` -4. `src/snapshot/*` leaves used by `Snapshot.FileDiff` -5. `src/session/message-v2.ts` -6. `src/session/message.ts` -7. `src/session/prompt.ts` -8. `src/session/revert.ts` -9. `src/session/summary.ts` -10. `src/session/status.ts` -11. `src/session/todo.ts` -12. `src/session/session.ts` -13. `src/session/compaction.ts` - -Dependency sketch: - -```text -session.ts -|- project/schema.ts -|- control-plane/schema.ts -|- permission/schema.ts -|- snapshot/* -|- message-v2.ts -| |- provider/schema.ts -| |- lsp/* -| |- snapshot/* -| |- sync/index.ts -| `- bus/bus-event.ts -|- sync/index.ts -|- bus/bus-event.ts -`- util/update-schema.ts -``` - -Working rule for this cluster: - -- migrate reusable leaf schemas and nested payload objects first -- migrate aggregate DTOs like `Session.Info` after their nested pieces exist as - named Schema values -- leave zod-only event/update helpers in place temporarily when converting - them would force unrelated churn across sync/bus boundaries - -`message-v2.ts` first-pass outline: - -1. Schema-backed imports already available - - `SessionID`, `MessageID`, `PartID` - - `ProviderID`, `ModelID` -2. Local leaf objects to extract and migrate first - - output format payloads - - common part bases like `PartBase` - - timestamp/range helper objects like `time.start/end` - - file/source helper objects - - token/cost/model helper objects -3. Part variants built from those leaves - - `SnapshotPart`, `PatchPart`, `TextPart`, `ReasoningPart` - - `FilePart`, `AgentPart`, `CompactionPart`, `SubtaskPart` - - retry/step/tool related parts -4. Higher-level unions and DTOs - - `FilePartSource` - - part unions - - message unions and assistant/user payloads -5. Errors and event payloads last - - `NamedError.create(...)` shapes can stay temporarily if converting them to - `Schema.TaggedErrorClass` would force unrelated churn - - `SyncEvent.define(...)` and `BusEvent.define(...)` payloads can use - derived `.zod` at remaining zod-based HTTP/OpenAPI boundaries - -Possible later tightening after the Schema-first migration is stable: - -- promote repeated opaque strings and timestamp numbers into branded/newtype - leaf schemas where that adds domain value without changing the wire format - -- [x] `src/session/compaction.ts` -- [x] `src/session/message-v2.ts` -- [x] `src/session/message.ts` -- [x] `src/session/prompt.ts` -- [x] `src/session/revert.ts` -- [x] `src/session/session.ts` -- [x] `src/session/status.ts` -- [x] `src/session/summary.ts` -- [x] `src/session/todo.ts` - -### Provider domain - -- [x] `src/provider/auth.ts` -- [x] `src/provider/models.ts` -- [x] `src/provider/provider.ts` - -### Tool schemas - -Each tool declares its parameters via a zod schema. Tools are consumed by -both the in-process runtime and the AI SDK's tool-calling layer, so the -emitted JSON Schema must stay byte-identical. - -- [x] `src/tool/apply_patch.ts` -- [x] `src/tool/bash.ts` -- [x] `src/tool/edit.ts` -- [x] `src/tool/glob.ts` -- [x] `src/tool/grep.ts` -- [x] `src/tool/invalid.ts` -- [x] `src/tool/lsp.ts` -- [x] `src/tool/plan.ts` -- [x] `src/tool/question.ts` -- [x] `src/tool/read.ts` -- [x] `src/tool/registry.ts` -- [x] `src/tool/skill.ts` -- [x] `src/tool/task.ts` -- [x] `src/tool/todo.ts` -- [x] `src/tool/tool.ts` -- [x] `src/tool/webfetch.ts` -- [x] `src/tool/websearch.ts` -- [x] `src/tool/write.ts` - -### HTTP route boundaries - -The server route tree now lives under `src/server/routes/instance/httpapi` and -uses Effect HttpApi contracts for request and response schemas. Remaining schema -work is no longer a Hono route migration; it is compatibility cleanup around -derived `.zod` statics, OpenAPI translation shims, and route groups that still -need explicit SDK-visible error contracts. - -Good follow-up targets: - -- shrink `public.ts` legacy OpenAPI translation shims one SDK-compatible slice at a time -- replace production `.zod.safeParse(...)` call sites with Effect Schema decoders -- remove derived `.zod` statics after their production consumers are gone -- declare route-group errors directly instead of relying on compatibility middleware - -### Everything else - -Small / shared / control-plane / CLI. Mostly independent; can be done -piecewise. - -- [ ] `src/acp/agent.ts` -- [ ] `src/agent/agent.ts` -- [x] `src/bus/bus-event.ts` -- [ ] `src/bus/index.ts` -- [ ] `src/cli/cmd/tui/config/tui-migrate.ts` -- [ ] `src/cli/cmd/tui/config/tui-schema.ts` -- [ ] `src/cli/cmd/tui/config/tui.ts` -- [ ] `src/cli/cmd/tui/event.ts` -- [ ] `src/cli/ui.ts` -- [ ] `src/command/index.ts` -- [x] `src/control-plane/adapters/worktree.ts` -- [x] `src/control-plane/types.ts` -- [x] `src/control-plane/workspace.ts` -- [ ] `src/file/index.ts` -- [ ] `src/file/ripgrep.ts` -- [ ] `src/file/watcher.ts` -- [ ] `src/format/index.ts` -- [ ] `src/id/id.ts` -- [ ] `src/ide/index.ts` -- [ ] `src/installation/index.ts` -- [ ] `src/lsp/client.ts` -- [ ] `src/lsp/lsp.ts` -- [ ] `src/mcp/auth.ts` -- [ ] `src/patch/index.ts` -- [ ] `src/plugin/github-copilot/models.ts` -- [ ] `src/project/project.ts` -- [ ] `src/project/vcs.ts` -- [ ] `src/pty/index.ts` -- [ ] `src/skill/index.ts` -- [ ] `src/snapshot/index.ts` -- [ ] `src/storage/db.ts` -- [ ] `src/storage/storage.ts` -- [x] `src/sync/index.ts` — public API (`SyncEvent.define`) is Schema-first; `payloads()` still derives zod for the remaining HTTP/OpenAPI boundary -- [ ] `src/util/fn.ts` -- [ ] `src/util/log.ts` -- [ ] `src/util/update-schema.ts` -- [ ] `src/worktree/index.ts` - -## Notes - -- Prefer one canonical schema definition. Avoid maintaining parallel Zod and - Effect definitions for the same domain type. -- Keep the migration incremental. Converting the domain model first is more - valuable than converting every boundary in the same change. -- Every migrated file should leave the generated SDK output (`packages/sdk/ -openapi.json` and `packages/sdk/js/src/v2/gen/types.gen.ts`) byte-identical - unless the change is deliberately user-visible. +- [ ] There is one schema source of truth for each migrated type. +- [ ] Remaining Zod is an intentional boundary choice. +- [ ] Public JSON/OpenAPI output is unchanged or intentionally updated. +- [ ] Derived helpers are narrow and boundary-specific. +- [ ] Tests assert behavior, not duplicated schema implementation details. diff --git a/packages/opencode/specs/effect/todo.md b/packages/opencode/specs/effect/todo.md new file mode 100644 index 000000000..9261811c3 --- /dev/null +++ b/packages/opencode/specs/effect/todo.md @@ -0,0 +1,237 @@ +# Effect TODO + +Short roadmap for Effect cleanup in `packages/opencode`. + +Current patterns and examples live in [`guide.md`](./guide.md). Test +migration rules live in +[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md). +Older deep-dive notes in this directory may still be useful, but treat +this roadmap and the guide as the current entry points. + +This is a planning map, not a verified inventory. Before starting a task, +re-run a targeted `git grep` from current `dev` and update this file if +the inventory changed. + +## Priorities + +```text +P0 ERR + RENDER + HTTP + Make expected failures typed, render them well, and stop relying on + generic HTTP error guesswork. + +P1 TEST + Convert touched tests to the ideal Effect test patterns from the guide. + +P2 RF + Move mutable runtime flags into typed runtime/config services. + +P3 GLOBAL + Make global paths explicit and remove import-time side effects. + +P4 INST + BRIDGE + Remove ambient Instance coupling while keeping Promise/callback interop. + +P5 PROC + FS + Replace raw process/filesystem edges with typed Effect services. + +P6 OA + Shrink OpenAPI compatibility shims as source schemas improve. +``` + +## Work Paths + +- `ERR` Typed errors — replace legacy `NamedError.create(...)` and + `Effect.die(...)` for expected service failures with + `Schema.TaggedErrorClass` errors on the Effect error channel. + Shrinks: [`NamedError`](../../../core/src/util/error.ts) usage. +- `RENDER` User-visible error rendering — preserve structured typed-error + details at CLI, HTTP, and tool boundaries. + Shrinks: opaque `Error: Name` rendering. +- `HTTP` HTTP route cleanup — make route errors explicit instead of + relying on generic middleware to guess status/body from error names. + Shrinks: [`middleware/error.ts`](../../src/server/routes/instance/httpapi/middleware/error.ts) + and route-level compatibility shims. +- `TEST` Effect test migration — use `testEffect`, `it.live`, and + `it.instance` with explicit layers. + Shrinks: Promise-style tests, sleeps, mutable global test flags. +- `RF` RuntimeFlags / Flag deletion — move mutable + [`Flag`](../../../core/src/flag/flag.ts) reads into typed runtime/config + services. + Shrinks: [`flag.ts`](../../../core/src/flag/flag.ts), + [`test/fixture/flag.ts`](../../test/fixture/flag.ts). +- `GLOBAL` Global paths / import side effects — make global path state + explicit and testable instead of mutable module state. + Shrinks: [`global.ts`](../../../core/src/global.ts) import-time side + effects, mutable `Global.Path` overrides, and its `Flag` dependency. +- `INST` Instance shim — remove ambient `Instance` usage and old ALS + access patterns. + Shrinks: [`src/project/instance.ts`](../../src/project/instance.ts). +- `BRIDGE` Promise/callback interop — keep bridge helpers, but reduce + legacy ALS coupling. + Shrinks: [`src/effect/bridge.ts`](../../src/effect/bridge.ts) + dependency on [`project/instance.ts`](../../src/project/instance.ts). +- `PROC` AppProcess migration — prefer `AppProcess.Service` over raw + process wrappers. + Shrinks: direct spawn callsites and legacy process helpers. +- `FS` AppFileSystem migration — prefer `AppFileSystem.Service` over raw + filesystem APIs. + Shrinks: direct `fs` / `Bun.file` service callsites where inappropriate. +- `RT` Runtime/facade cleanup — remove service-local `makeRuntime` + facades when not intentional. + Shrinks: async facade exports around services and + [`run-service.ts`](../../src/effect/run-service.ts) usage. +- `OA` OpenAPI compatibility — tighten source schemas instead of + post-processing generated OpenAPI. + Shrinks: schema workaround blocks in + [`public.ts`](../../src/server/routes/instance/httpapi/public.ts). + +## P0: Errors, Rendering, And HTTP + +This should be the next big cleanup theme. The codebase is moving toward +typed Effect failures, but the user-facing boundaries still leak old +shapes and sometimes collapse rich errors into opaque strings. + +### Problems + +- Some expected service failures still use `NamedError.create(...)`. +- Some expected service failures still become `Effect.die(...)`, which + makes them defects instead of typed, recoverable failures. +- CLI and HTTP boundaries can render structured errors as generic + `Error: SomeName` output. +- HTTP error middleware still guesses status codes from error names like + `Worktree*` or `ProviderAuthValidationFailed`. +- Route handlers and route groups do not consistently declare the public + error body they intend to expose. +- Repeated route error translations do not yet have a clear home: some + should stay inline, some deserve tiny shared mapper helpers. +- Unknown 500s should log full detail server-side while returning a safe + public body. + +### Target Shape + +- Services define expected failures with `Schema.TaggedErrorClass`. +- Services export an `Error` union and include it in method return types. +- Expected failures stay on the Effect error channel. +- `Effect.die(...)` is reserved for defects: bugs, impossible states, + violated invariants, or final unknown-boundary fallbacks. +- Inside `Effect.gen` / `Effect.fn`, use `yield* new MyError(...)` for + direct expected failures. +- Domain services do not import HTTP status codes, `HttpApiError`, or + route-specific error schemas. +- HTTP route groups make their public error contracts obvious. +- Handlers map service errors to declared HTTP errors at the boundary. +- Shared mapper helpers are only for repeated translations, not a giant + central registry of every domain error. +- Generic HTTP middleware should shrink; it should not accumulate more + name-based domain knowledge. + +### First PR Candidates + +- [ ] `RENDER-1` Fix CLI top-level rendering for typed config errors. +- [ ] `ERR-1` Convert [`storage/storage.ts`](../../src/storage/storage.ts) + not-found errors. +- [ ] `ERR-2` Convert [`worktree/index.ts`](../../src/worktree/index.ts) + errors and remove matching HTTP name checks where possible. +- [ ] `ERR-3` Convert [`provider/auth.ts`](../../src/provider/auth.ts) + validation errors. +- [ ] `HTTP-1` Remove the unknown-500 stack leak from + [`middleware/error.ts`](../../src/server/routes/instance/httpapi/middleware/error.ts). +- [ ] `HTTP-2` Audit one route group for explicit error contracts and + decide which mappings stay inline vs. shared helper. + +## P1: Tests + +When touching tests, migrate them toward the ideal patterns in +[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md): + +- Use `testEffect(...)` with explicit layers. +- Prefer `it.instance(...)` for service tests that need an instance. +- Prefer `it.live(...)` for real timers, filesystem mtimes, child + processes, git, locks, or other live integration behavior. +- Avoid sleeps; wait on real events or deterministic state transitions. +- Do not mutate `process.env` or mutable globals after layers are built. +- Use explicit layer variants, such as `RuntimeFlags.layer(...)`, for + behavior changes. + +## P2: RuntimeFlags / Flag Deletion + +Recently completed: + +- [x] Plugin/pure-mode flags moved to RuntimeFlags. +- [x] Tool visibility flags moved to RuntimeFlags. +- [x] Built-in websearch provider selection uses the same runtime flags as + tool visibility. +- [x] Removed global default-plugin disabling from test preload. + +Recommended next PRs: + +```text +RF-1 scout consumers ─┐ + ├─ can run in parallel +RF-2 plan-mode prompt ┘ + └─ RF-3 event-system cluster, stacked only if RF-2 still touches prompt.ts + +RF-4 workspaces cluster: later, after mutable Flag tests are cleaned up +``` + +- [ ] `RF-1` Move scout reads in [`agent.ts`](../../src/agent/agent.ts) + and [`reference.ts`](../../src/reference/reference.ts). +- [ ] `RF-2` Move plan-mode prompt read in + [`session/prompt.ts`](../../src/session/prompt.ts). +- [ ] `RF-3` Move event-system reads in session prompt/processor/ + compaction and TUI debug plugin. +- [ ] `RF-4` Move workspaces reads in session/sync/control-plane after + tests stop relying on mutable `Flag` timing. +- [ ] Delete [`test/fixture/flag.ts`](../../test/fixture/flag.ts) once + tests no longer mutate `Flag`. +- [ ] Delete [`flag.ts`](../../../core/src/flag/flag.ts) once no packages + import it. + +## P3: Global Paths + +[`global.ts`](../../../core/src/global.ts) is real connective tissue, not +just cosmetic ugliness. It currently mixes path calculation, import-time +directory creation, `Flock` setup, mutable exported `Path` state, and a +`Flag` dependency. + +Problems to reduce: + +- Importing the module creates directories. +- Tests override `Global.Path` by mutating exported module state. +- Most callers use `Global.Path` directly instead of the Effect service. +- `Global.make()` still reads mutable `Flag.OPENCODE_CONFIG_DIR`. + +Next PR candidates: + +- [ ] Replace mutable `Global.Path` test overrides with explicit test + layers or scoped helpers. +- [ ] Move directory creation and `Flock` setup behind an explicit init + boundary where possible. +- [ ] Remove the `Flag` dependency from global path resolution. + +## P4: Instance And Bridge + +[`project/instance.ts`](../../src/project/instance.ts) is the deletion +target. [`effect/bridge.ts`](../../src/effect/bridge.ts) is not a near-term +deletion target; Promise/callback interop will continue to exist. + +Goal: + +- Keep a sanctioned bridge for Promise/callback boundaries. +- Reduce bridge dependence on legacy `Instance.restore` / `Instance.current`. +- Move callers toward `InstanceRef`, `WorkspaceRef`, `InstanceState`, or + explicit context where practical. +- Delete `project/instance.ts` only after ambient Instance coupling is gone. + +## Lower Priority Tracks + +- `PROC` / `FS` — continue AppProcess and AppFileSystem migrations as + focused PRs when touching relevant files. +- `RT` — remove service-local runtime facades only when they are not an + intentional boundary. +- `OA` — shrink [`public.ts`](../../src/server/routes/instance/httpapi/public.ts) + by tightening source schemas one workaround at a time. +- `fetch` → `HttpClient` — migrate raw fetch callsites when the caller is + already effectful or being effectified. +- `Tools` — remaining tool cleanup is narrow: `webfetch` HTML extraction + and `shell` raw stream/promise edges. diff --git a/packages/opencode/test/EFFECT_TEST_MIGRATION.md b/packages/opencode/test/EFFECT_TEST_MIGRATION.md index 2c160b993..73acea914 100644 --- a/packages/opencode/test/EFFECT_TEST_MIGRATION.md +++ b/packages/opencode/test/EFFECT_TEST_MIGRATION.md @@ -1,16 +1,21 @@ -# Effect Test Migration Plan +# Effect Test Migration -This document describes how to move opencode tests out of Promise-land and into the shared `testEffect` pattern. +Move tests that exercise Effect services out of Promise-land and into the +shared `testEffect` pattern. + +This file is guidance, not a live inventory. Before claiming a migration, +search current `dev` for the exact anti-pattern and update any PR notes +with what you actually changed. ## Target Pattern -Every test file that exercises Effect services should have one local runner near the top: +Every Effect service test should have one local runner near the top: ```ts const it = testEffect(layer) ``` -Then each test should use one of the runner methods: +Use the runner method that matches the behavior: ```ts it.effect("pure service behavior", () => @@ -23,7 +28,7 @@ it.effect("pure service behavior", () => it.instance("instance-local behavior", () => Effect.gen(function* () { const test = yield* TestInstance - // test.directory is a scoped temp opencode instance + expect(test.directory).toContain("opencode-test-") }), ) @@ -35,40 +40,22 @@ it.live("live filesystem or process behavior", () => ) ``` -Use `it.effect` for pure Effect code that should run with `TestClock` and `TestConsole`. -Use `it.instance` when the test needs one scoped opencode instance. -Use `it.live` when the test depends on real time, filesystem mtimes, git, child processes, servers, file watchers, or OS behavior. +## Choosing The Runner -## Anti-Patterns To Remove +- `it.effect(...)` — pure Effect behavior with `TestClock` and + `TestConsole`. +- `it.instance(...)` — service behavior that needs one scoped opencode + instance. +- `it.live(...)` — real time, filesystem mtimes, child processes, git, + locks, servers, watchers, or OS behavior. -Avoid these in tests that already target Effect services: - -- `test(..., async () => Effect.runPromise(...))` -- local `run(...)`, `load(...)`, `svc(...)`, or `runtime.runPromise(...)` wrappers that only provide a layer -- `tmpdir()` plus `WithInstance.provide(...)` in Promise test bodies -- custom `ManagedRuntime.make(...)` in test files -- Promise `try/catch` around Effect failures -- `Promise.withResolvers`, `Bun.sleep`, or `setTimeout` for synchronization when `Deferred`, `Fiber`, or `Effect.sleep` can express the same behavior - -Promise helpers are acceptable at the boundary for non-Effect APIs, but they should be yielded from an Effect body with `Effect.promise(...)` rather than becoming the test harness. +Most integration-style tests use `it.live(...)` or `it.instance(...)`. ## Layer Rules -Compose tests from open service layers, not closed `defaultLayer` graphs when a dependency needs replacing. - -Good: - -```ts -const layer = Config.layer.pipe( - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(AuthTest.empty), - Layer.provide(AccountTest.empty), - Layer.provide(NpmTest.noop), -) -``` - -Avoid using a fully closed layer and hoping to override an inner dependency later. Once `Agent.defaultLayer` has already provided `Config.defaultLayer`, tests cannot cleanly swap the `Npm.Service` used by that config layer. +Compose tests from open service layers when a dependency needs replacing. +Do not use a closed `defaultLayer` and then try to override an inner +dependency after it has already been provided. Prefer small reusable fake boundary layers in `test/fake/*`: @@ -80,146 +67,103 @@ SkillTest.empty ProviderTest.fake().layer ``` -Do not add generic test-layer builders until repeated local compositions prove the need. Shared fake boundary services are the first reusable unit. Pre-composed subtrees such as `AgentTest.withPlugins` should come later, only after the same graph appears in multiple files. +Use `Layer.mock` for partial service stubs. Missing methods should fail +loudly if the test accidentally calls them. + +Do not add generic test-layer builders until repeated local compositions +prove the need. ## Fixture Rules Use Effect-aware fixtures from `test/fixture/fixture.ts`: -- `TestInstance` inside `it.instance(...)` for the current temp instance path -- `tmpdirScoped(...)` inside `Effect.gen` for additional temp directories -- `provideInstance(dir)(effect)` when one test needs to switch instance context -- `provideTmpdirInstance((dir) => effect, options)` when a live test needs custom instance setup or multiple instance scopes -- `disposeAllInstances()` in `afterEach` only for integration tests that intentionally touch shared instance registries +- `TestInstance` inside `it.instance(...)` for the current temp instance. +- `tmpdirScoped(...)` inside `Effect.gen` for extra temp directories. +- `provideInstance(dir)(effect)` when one test needs to switch instance + context. +- `provideTmpdirInstance((dir) => effect, options)` when a live test needs + custom instance setup or multiple instance scopes. +- `disposeAllInstances()` in `afterEach` only for integration tests that + intentionally touch shared instance registries. -Use finalizers only as a temporary bridge for existing global mutations: +Avoid mutable global setup. If a global mutation is unavoidable during a +migration, scope it with acquire/release and treat it as temporary. -```ts -yield * - Effect.acquireUseRelease( - Effect.sync(() => { - const previous = process.env.MY_FLAG - process.env.MY_FLAG = "1" - return previous - }), - () => testBody, - (previous) => - Effect.sync(() => { - if (previous === undefined) delete process.env.MY_FLAG - else process.env.MY_FLAG = previous - }), - ) -``` +Long term, tests should not toggle `process.env`, `Global.Path`, or +mutable flags when behavior can be modeled with services. Prefer layers +such as `RuntimeFlags.layer(...)` or focused fake services. -TODO: eliminate this pattern over time. Tests should not toggle process-global flags or env vars when the behavior can be modeled with services. Prefer moving flag/env reads behind injectable services such as `Config.Service`, `Env.Service`, or focused test layers, then provide the desired test value through the layer graph instead of mutating `process.env` or `Global.Path`. +## Anti-Patterns To Remove + +- `test(..., async () => Effect.runPromise(...))` +- local `run(...)`, `load(...)`, `svc(...)`, or `runtime.runPromise(...)` + wrappers that only provide a layer +- `tmpdir()` plus legacy instance provision in Promise test bodies +- custom `ManagedRuntime.make(...)` in test files +- Promise `try/catch` around Effect failures +- `Promise.withResolvers`, `Bun.sleep`, or `setTimeout` for synchronization + when events, `Deferred`, fibers, or deterministic state checks fit +- mutable env/global/flag changes after layers are built + +Promise helpers are acceptable at non-Effect boundaries, but yield them from +inside an Effect body with `Effect.promise(...)` rather than making them the +test harness. ## Conversion Recipe -1. Identify the real service under test and its open `*.layer`. -2. Build one top-level `layer` with real dependencies where they are relevant and `test/fake/*` layers at slow or external boundaries. -3. Replace local Promise wrappers with Effect helpers: - -```ts -const run = Effect.fn("MyTest.run")(function* (input: Input) { - const service = yield* MyService.Service - return yield* service.run(input) -}) -``` - -4. Convert `test(..., async () => { ... })` to `it.effect`, `it.instance`, or `it.live`. +1. Identify the real service under test and whether its open `layer` or + closed `defaultLayer` is appropriate. +2. Build one top-level `layer` with real dependencies where relevant and + fake layers at slow or external boundaries. +3. Replace local Promise wrappers with Effect helpers. +4. Convert `test(..., async () => { ... })` to `it.effect`, `it.instance`, + or `it.live`. 5. Move `await` calls inside `Effect.gen` as `yield*` calls. -6. Replace `await using tmp = await tmpdir(...)` with `yield* tmpdirScoped(...)` when the temp directory is inside an Effect test. -7. Replace `WithInstance.provide({ directory, fn })` with `it.instance(...)`, `provideInstance(directory)(effect)`, or `provideTmpdirInstance(...)`. -8. Replace Promise failure assertions with Effect assertions: - -```ts -const exit = yield * run(input).pipe(Effect.exit) -expect(Exit.isFailure(exit)).toBe(true) -``` - -This is correct but still verbose. Track repeated assertion shapes during migration so we can add small test assertion helpers later instead of copying low-level `Exit` plumbing everywhere. - -9. Keep concurrency concurrent by using `Effect.forkScoped`, `Fiber.join`, `Deferred`, or `Effect.all(..., { concurrency: "unbounded" })` instead of serializing formerly parallel Promise work. -10. Run the focused test file and `bun typecheck` from `packages/opencode`. +6. Replace `await using tmp = await tmpdir(...)` with + `yield* tmpdirScoped(...)` when the temp directory lives inside the + Effect test. +7. Replace Promise failure assertions with `Effect.exit`, `Effect.flip`, or + focused assertion helpers. +8. Preserve concurrency with fibers, `Deferred`, and + `Effect.all(..., { concurrency: "unbounded" })`; do not accidentally + serialize formerly parallel behavior. +9. Run the focused test file and `bun typecheck` from `packages/opencode`. ## Good Examples -Use these files as models: +Use current examples as patterns, but re-check them before copying because +test migrations are active: -- `test/tool/write.test.ts`: strong `it.instance` tests, top-level `testEffect(...)`, and Effect-native test helpers. -- `test/effect/instance-state.test.ts`: good `it.live` use for scoped directories, instance switching, reload/disposal, and concurrency. -- `test/bus/bus-effect.test.ts`: good `Deferred`, streams, and scoped fibers. -- `test/tool/truncation.test.ts`: good configured runners and concise live service tests. -- `test/tool/repo_clone.test.ts`: good live git integration while staying inside Effect fixtures. -- `test/server/httpapi-instance.test.ts`: good scoped integration layer setup and live HTTP assertions. -- `test/account/service.test.ts`: good service-level live tests, `Effect.flip`, typed errors, and fake HTTP clients. -- `test/agent/plugin-agent-regression.test.ts`: good example of open real service layers plus reusable fake boundary layers. +- `test/effect/instance-state.test.ts` — scoped directories, instance + switching, disposal, and concurrency. +- `test/bus/bus-effect.test.ts` — `Deferred`, streams, scoped fibers. +- `test/agent/plugin-agent-regression.test.ts` — real service layers plus + fake boundary layers. +- `test/account/service.test.ts` — service-level live tests, typed errors, + fake HTTP clients. -## Current Promise-Land Hotspots +## Migration Queue Policy -Start with files that already exercise Effect services but still manually run Promises: +Do not maintain a long file checklist here. It goes stale quickly. -- `test/config/config.test.ts`: many `Effect.runPromise`, `tmpdir()`, and `WithInstance.provide(...)` patterns despite already having `const it = testEffect(layer)`. -- `test/tool/shell.test.ts`: custom `ManagedRuntime`, Promise test helpers, and instance setup around shell execution. -- `test/tool/edit.test.ts`: manual runtime helpers and Promise concurrency patterns that should become fibers/deferreds. -- `test/session/messages-pagination.test.ts`: local Promise service facade over `Session.defaultLayer`. -- `test/snapshot/snapshot.test.ts`: Promise helper with `provideInstance` around snapshot operations. -- `test/file/index.test.ts`: Promise wrappers for `File.Service` plus repeated temp instance setup. -- `test/provider/provider.test.ts`: `AppRuntime.runPromise` helpers and mutable env/config setup. -- `test/project/vcs.test.ts`: Promise event waiting and `AppRuntime.runPromise` around VCS service calls. +When looking for the next target, search for current anti-patterns: -## Migration Order +```bash +git grep -n "Effect.runPromise\|ManagedRuntime\|Promise.withResolvers\|Bun.sleep\|WithInstance" -- packages/opencode/test +``` -1. Convert one small file with straightforward service calls and no race behavior. -2. Convert `config.test.ts` incrementally by cluster, not in one PR. -3. Extract additional `test/fake/*` boundary layers only when a second test needs the same fake. -4. Convert files with concurrency or watchers after the simple files, preserving timing semantics with `Deferred` and fibers. -5. Leave pure non-Effect utility tests alone unless converting the underlying code to Effect. +Then choose one file or one small cluster, keep the PR focused, and mention +the focused verification in the PR body. -## Claimable Checklist +## Rough Edges To Watch -Use this as a migration queue. Each checkbox should be safe for one agent or one PR unless the notes say otherwise. Agents should claim one item, convert only that file or cluster, run the focused test file, run `bun typecheck`, and update this checklist in the PR description or follow-up note. - -- [ ] `test/file/index.test.ts`: straightforward service wrapper cleanup. Replace local Promise helpers with Effect helpers and use `it.instance` / `it.live` around existing temp instance cases. -- [ ] `test/session/messages-pagination.test.ts`: convert the local `run(...)` / `svc(...)` facade to `testEffect(Session.defaultLayer...)` and direct service yields. Good early target. -- [ ] `test/snapshot/snapshot.test.ts`: convert snapshot operations to `it.live` with `tmpdirScoped` / `provideInstance`. Keep git/filesystem behavior live. -- [ ] `test/project/vcs.test.ts`: convert `AppRuntime.runPromise` service calls first. Leave event/watcher timing intact until the first Effect version is stable. -- [ ] `test/provider/provider.test.ts` cluster 1: convert provider service tests that only read config/env and do not mutate global state heavily. -- [ ] `test/provider/provider.test.ts` cluster 2: convert tests with env/config mutation after introducing or reusing service-backed test seams. -- [ ] `test/tool/shell.test.ts`: replace custom `ManagedRuntime` with `testEffect`, keep as `it.live`, and preserve process behavior. -- [ ] `test/tool/edit.test.ts` cluster 1: convert straightforward edit/read/write cases and remove manual runtime helpers. -- [ ] `test/tool/edit.test.ts` cluster 2: convert concurrency/race tests using `Deferred`, fibers, and `Effect.all` without serializing behavior. -- [ ] `test/config/config.test.ts` setup pass: replace inline fake layers with shared `test/fake/*` layers where possible and turn Promise helpers into Effect helpers. -- [ ] `test/config/config.test.ts` cluster 1: convert simple config load/merge tests that only need one instance. -- [ ] `test/config/config.test.ts` cluster 2: convert managed/global config tests that mutate `Global.Path` or managed config directories. Prefer service seams; use finalizers only as a bridge. -- [ ] `test/config/config.test.ts` cluster 3: convert plugin/dependency tests after ensuring `NpmTest.noop` or explicit fake NPM layers are used. -- [ ] `test/config/config.test.ts` cluster 4: convert remote/account/provider config tests after isolating auth/account/env dependencies through layers. -- [ ] Audit remaining `Effect.runPromise` in `packages/opencode/test/**/*.ts` and create follow-up checklist entries for any missed files. -- [ ] Audit remaining `WithInstance.provide` in `packages/opencode/test/**/*.ts` and convert cases that can use `it.instance` or `provideInstance` inside Effect. -- [ ] Audit repeated `Exit` / `Cause` assertion shapes and propose `test/lib/effect-assert.ts` helpers if at least three files repeat the same pattern. - -Parallelization notes: - -- The first four items are mostly independent and good for separate worktrees. -- `provider.test.ts`, `tool/edit.test.ts`, and `config.test.ts` should be split by cluster so agents do not edit the same file concurrently. -- Any new fake boundary layer under `test/fake/*` should be small and independently useful. Do not add a fake just for one assertion unless it removes a real external dependency. -- Do not combine assertion-helper design with file migrations. First collect repeated shapes, then add helpers in a separate pass. - -Orchestration rules: - -- Prefer supervised foreground agents for implementation. Background agents are acceptable for research-only surveys, but code migrations need a returned diff, focused test output, and local commit before moving on. -- Create one worktree per claim and verify the branch/worktree path before edits. A status check should include `git status --short --branch` from the claimed worktree. -- After an agent reports completion, the coordinator must independently inspect `git status`, run the focused test, run `bun typecheck`, and review the diff before pushing. -- If an agent edits the wrong worktree, move the patch deliberately with `git diff` / `git apply`, then clean the accidental worktree before opening a PR. -- Keep dependency setup boring. Prefer reusing existing installed dependencies via worktrees or symlinks over running a fresh `bun install` in a temporary path unless the native build path is known to work. -- Do not delete worktrees with unpushed commits or uncommitted changes. Once a migration PR branch is pushed and clean, the local worktree can be removed while leaving the branch on the fork. - -## Effectified Test Rough Edges - -Track patterns that are technically Effect-native but still too noisy. These should become a second cleanup pass after the Promise-land migration is underway. - -- Failure assertions against `Exit` / `Cause` are often verbose. Consider helpers such as `expectEffectFailure(effect)`, `expectTaggedError(effect, Tag)`, or custom Bun matchers if the same shapes repeat. -- Some tests still need `Effect.promise(...)` around Node/Bun filesystem helpers. Prefer Effect platform services when the surrounding code already uses them, but do not block migrations on perfect filesystem abstraction. -- Scoped global mutation with `process.env`, `Global.Path`, or flags should disappear behind injectable services over time. -- Layer composition can be noisy when a test needs a real service subtree plus fake boundaries. Keep extracting small `test/fake/*` boundary layers before inventing larger builders. -- Concurrency tests can become harder to read after replacing Promise resolvers with `Deferred` and fibers. Look for repeated patterns that deserve named helpers. +- Failure assertions against `Exit` / `Cause` can get verbose. Add helpers + only after the same shape repeats across multiple files. +- Some tests still need `Effect.promise(...)` around Node/Bun APIs. Prefer + Effect platform services when the surrounding code already uses them, but + do not block useful migrations on perfect abstraction. +- Layer composition can be noisy when a test needs real service subtrees plus + fake boundaries. Extract small `test/fake/*` layers before inventing + larger builders. +- Concurrency tests can get harder to read after replacing Promise + resolvers. Look for repeated patterns that deserve named helpers. From e3684f36f9e0e011c93455f77c6ac2226b09413e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 22:46:41 -0400 Subject: [PATCH 09/98] chore: delete unused util/abort module + orphaned leak test (#27230) --- packages/opencode/src/util/abort.ts | 35 ----- .../test/memory/abort-leak-webfetch.ts | 49 ------- .../opencode/test/memory/abort-leak.test.ts | 127 ------------------ 3 files changed, 211 deletions(-) delete mode 100644 packages/opencode/src/util/abort.ts delete mode 100644 packages/opencode/test/memory/abort-leak-webfetch.ts delete mode 100644 packages/opencode/test/memory/abort-leak.test.ts diff --git a/packages/opencode/src/util/abort.ts b/packages/opencode/src/util/abort.ts deleted file mode 100644 index 3e7cfd8b2..000000000 --- a/packages/opencode/src/util/abort.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Creates an AbortController that automatically aborts after a timeout. - * - * Uses bind() instead of arrow functions to avoid capturing the surrounding - * scope in closures. Arrow functions like `() => controller.abort()` capture - * request bodies and other large objects, preventing GC for the timer lifetime. - * - * @param ms Timeout in milliseconds - * @returns Object with controller, signal, and clearTimeout function - */ -export function abortAfter(ms: number) { - const controller = new AbortController() - const id = setTimeout(controller.abort.bind(controller), ms) - return { - controller, - signal: controller.signal, - clearTimeout: () => globalThis.clearTimeout(id), - } -} - -/** - * Combines multiple AbortSignals with a timeout. - * - * @param ms Timeout in milliseconds - * @param signals Additional signals to combine - * @returns Combined signal that aborts on timeout or when any input signal aborts - */ -export function abortAfterAny(ms: number, ...signals: AbortSignal[]) { - const timeout = abortAfter(ms) - const signal = AbortSignal.any([timeout.signal, ...signals]) - return { - signal, - clearTimeout: timeout.clearTimeout, - } -} diff --git a/packages/opencode/test/memory/abort-leak-webfetch.ts b/packages/opencode/test/memory/abort-leak-webfetch.ts deleted file mode 100644 index c3197f8dd..000000000 --- a/packages/opencode/test/memory/abort-leak-webfetch.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { abortAfterAny } from "../../src/util/abort" - -const MB = 1024 * 1024 -const ITERATIONS = 50 - -const heap = () => { - Bun.gc(true) - return process.memoryUsage().heapUsed / MB -} - -const server = Bun.serve({ - port: 0, - fetch() { - return new Response("hello from local", { - headers: { - "content-type": "text/plain", - }, - }) - }, -}) - -const url = `http://127.0.0.1:${server.port}` - -async function run() { - const { signal, clearTimeout } = abortAfterAny(30000, new AbortController().signal) - try { - const response = await fetch(url, { signal }) - await response.text() - } finally { - clearTimeout() - } -} - -try { - await run() - Bun.sleepSync(100) - const baseline = heap() - - for (let i = 0; i < ITERATIONS; i++) { - await run() - } - - Bun.sleepSync(100) - const after = heap() - process.stdout.write(JSON.stringify({ baseline, after, growth: after - baseline })) -} finally { - void server.stop(true) - process.exit(0) -} diff --git a/packages/opencode/test/memory/abort-leak.test.ts b/packages/opencode/test/memory/abort-leak.test.ts deleted file mode 100644 index d30ad45e4..000000000 --- a/packages/opencode/test/memory/abort-leak.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, test, expect } from "bun:test" -import path from "path" - -const projectRoot = path.join(import.meta.dir, "../..") -const worker = path.join(import.meta.dir, "abort-leak-webfetch.ts") - -const MB = 1024 * 1024 -const ITERATIONS = 50 - -const getHeapMB = () => { - Bun.gc(true) - return process.memoryUsage().heapUsed / MB -} - -describe("memory: abort controller leak", () => { - test("webfetch does not leak memory over many invocations", async () => { - // Measure the abort-timed fetch path in a fresh process so shared tool - // runtime state does not dominate the heap signal. - const proc = Bun.spawn({ - cmd: [process.execPath, worker], - cwd: projectRoot, - stdout: "pipe", - stderr: "pipe", - env: process.env, - }) - - const [code, stdout, stderr] = await Promise.all([ - proc.exited, - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]) - - if (code !== 0) { - throw new Error(stderr.trim() || stdout.trim() || `worker exited with code ${code}`) - } - - const result = JSON.parse(stdout.trim()) as { - baseline: number - after: number - growth: number - } - - console.log(`Baseline: ${result.baseline.toFixed(2)} MB`) - console.log(`After ${ITERATIONS} fetches: ${result.after.toFixed(2)} MB`) - console.log(`Growth: ${result.growth.toFixed(2)} MB`) - - // Memory growth should be minimal - less than 1MB per 10 requests. - expect(result.growth).toBeLessThan(ITERATIONS / 10) - }, 60000) - - test("compare closure vs bind pattern directly", async () => { - const ITERATIONS = 500 - - // Test OLD pattern: arrow function closure - // Store closures in a map keyed by content to force retention - const closureMap = new Map void>() - const timers: Timer[] = [] - const controllers: AbortController[] = [] - - Bun.gc(true) - Bun.sleepSync(100) - const baseline = getHeapMB() - - for (let i = 0; i < ITERATIONS; i++) { - // Simulate large response body like webfetch would have - const content = `${i}:${"x".repeat(50 * 1024)}` // 50KB unique per iteration - const controller = new AbortController() - controllers.push(controller) - - // OLD pattern - closure captures `content` - const handler = () => { - // Actually use content so it can't be optimized away - if (content.length > 1000000000) controller.abort() - } - closureMap.set(content, handler) - const timeoutId = setTimeout(handler, 30000) - timers.push(timeoutId) - } - - Bun.gc(true) - Bun.sleepSync(100) - const after = getHeapMB() - const oldGrowth = after - baseline - - console.log(`OLD pattern (closure): ${oldGrowth.toFixed(2)} MB growth (${closureMap.size} closures)`) - - // Cleanup after measuring - timers.forEach(clearTimeout) - controllers.forEach((c) => c.abort()) - closureMap.clear() - - // Test NEW pattern: bind - Bun.gc(true) - Bun.sleepSync(100) - const baseline2 = getHeapMB() - const handlers2: (() => void)[] = [] - const timers2: Timer[] = [] - const controllers2: AbortController[] = [] - - for (let i = 0; i < ITERATIONS; i++) { - const _content = `${i}:${"x".repeat(50 * 1024)}` // 50KB - won't be captured - const controller = new AbortController() - controllers2.push(controller) - - // NEW pattern - bind doesn't capture surrounding scope - const handler = controller.abort.bind(controller) - handlers2.push(handler) - const timeoutId = setTimeout(handler, 30000) - timers2.push(timeoutId) - } - - Bun.gc(true) - Bun.sleepSync(100) - const after2 = getHeapMB() - const newGrowth = after2 - baseline2 - - // Cleanup after measuring - timers2.forEach(clearTimeout) - controllers2.forEach((c) => c.abort()) - handlers2.length = 0 - - console.log(`NEW pattern (bind): ${newGrowth.toFixed(2)} MB growth`) - console.log(`Improvement: ${(oldGrowth - newGrowth).toFixed(2)} MB saved`) - - expect(newGrowth).toBeLessThanOrEqual(oldGrowth) - }) -}) From 03cf83323643dfbfb1abd452ca54b2b10273b295 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 22:51:11 -0400 Subject: [PATCH 10/98] test(provider): migrate DigitalOcean provider test to Effect runner (#27232) --- .../test/provider/digitalocean.test.ts | 230 +++++++++--------- 1 file changed, 110 insertions(+), 120 deletions(-) diff --git a/packages/opencode/test/provider/digitalocean.test.ts b/packages/opencode/test/provider/digitalocean.test.ts index 6fc49a6ef..665c792de 100644 --- a/packages/opencode/test/provider/digitalocean.test.ts +++ b/packages/opencode/test/provider/digitalocean.test.ts @@ -1,132 +1,122 @@ -import { test, expect, afterEach } from "bun:test" -import path from "path" - -import { tmpdir } from "../fixture/fixture" -import { WithInstance } from "../../src/project/with-instance" +import { expect } from "bun:test" 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() - }), - ) -} +import { testEffect } from "../lib/effect" const DIGITALOCEAN = ProviderID.make("digitalocean") +const it = testEffect(Provider.defaultLayer) -const originalAuthContent = process.env.OPENCODE_AUTH_CONTENT -afterEach(() => { - if (originalAuthContent === undefined) delete process.env.OPENCODE_AUTH_CONTENT - else process.env.OPENCODE_AUTH_CONTENT = originalAuthContent -}) +const withEnv = (values: Record, effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]] as const)) + Object.assign(process.env, values) + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }), + ) -function injectAuth(metadata: Record | undefined) { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - digitalocean: { - type: "api", - key: "sk_do_test", - ...(metadata ? { metadata } : {}), +const withAuth = (metadata: Record | undefined, effect: Effect.Effect) => + withEnv( + { + OPENCODE_AUTH_CONTENT: JSON.stringify({ + digitalocean: { + type: "api", + key: "sk_do_test", + ...(metadata ? { metadata } : {}), + }, + }), }, - }) -} + effect, + ) -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) - }, - }) -}) +it.instance( + "digitalocean provider autoloads from DIGITALOCEAN_ACCESS_TOKEN", + () => + withEnv( + { DIGITALOCEAN_ACCESS_TOKEN: "test-token" }, + Effect.gen(function* () { + const provider = yield* Provider.Service + const providers = yield* provider.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) + }), + ), + { config: {} }, +) -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() - }, - }) -}) +it.instance( + "digitalocean provider.models surfaces cached routers from auth metadata", + () => + withAuth( + { + 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), + }, + Effect.gen(function* () { + const provider = yield* Provider.Service + const providers = yield* provider.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() + }), + ), + { config: {} }, +) -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() - }, - }) -}) +it.instance( + "digitalocean provider.models skips refresh when oauth bearer is expired", + () => + withAuth( + { + routers: JSON.stringify([{ name: "stale-router", uuid: "stale" }]), + routers_fetched_at: "0", + oauth_access: "doo_v1_expired", + oauth_expires: "1", + }, + Effect.gen(function* () { + const provider = yield* Provider.Service + const providers = yield* provider.list() + const models = providers[DIGITALOCEAN].models + expect(models["router:stale-router"]).toBeDefined() + }), + ), + { config: {} }, +) -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) - }, - }) -}) +it.instance( + "digitalocean provider.models passes through base models when no auth metadata", + () => + withEnv( + { DIGITALOCEAN_ACCESS_TOKEN: "test-token" }, + Effect.gen(function* () { + const provider = yield* Provider.Service + const providers = yield* provider.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) + }), + ), + { config: {} }, +) From 485ecbde18f5b164273cf086057a062be9efe3e6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 22:52:59 -0400 Subject: [PATCH 11/98] test(server): migrate global session list to effect runner (#27233) --- .../test/server/global-session-list.test.ts | 154 +++++++++--------- 1 file changed, 77 insertions(+), 77 deletions(-) diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index 04348e5c0..0fdba1f66 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -1,104 +1,104 @@ -import { describe, expect, test } from "bun:test" -import { Effect } from "effect" -import { WithInstance } from "../../src/project/with-instance" +import { describe, expect } from "bun:test" +import { Deferred, Effect, Layer } from "effect" import { Project } from "@/project/project" import { Session as SessionNs } from "@/session/session" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" void Log.init({ print: false }) -function run(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) -} +const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, Project.defaultLayer, CrossSpawnSpawner.defaultLayer)) -const svc = { - ...SessionNs, - create(input?: SessionNs.CreateInput) { - return run(SessionNs.Service.use((svc) => svc.create(input))) - }, - setArchived(input: typeof SessionNs.SetArchivedInput.Type) { - return run(SessionNs.Service.use((svc) => svc.setArchived(input))) - }, -} +const withSession = (input?: Parameters[0]) => + Effect.acquireRelease( + SessionNs.Service.use((session) => session.create(input)), + (created) => SessionNs.Service.use((session) => session.remove(created.id).pipe(Effect.ignore)), + ) describe("session.listGlobal", () => { - test("lists sessions across projects with project metadata", async () => { - await using first = await tmpdir({ git: true }) - await using second = await tmpdir({ git: true }) + it.instance( + "lists sessions across projects with project metadata", + () => + Effect.gen(function* () { + const first = yield* TestInstance + const second = yield* tmpdirScoped({ git: true }) - const firstSession = await WithInstance.provide({ - directory: first.path, - fn: async () => svc.create({ title: "first-session" }), - }) - const secondSession = await WithInstance.provide({ - directory: second.path, - fn: async () => svc.create({ title: "second-session" }), - }) + const firstSession = yield* withSession({ title: "first-session" }) + const secondSession = yield* withSession({ title: "second-session" }).pipe(provideInstance(second)) - const sessions = [...svc.listGlobal({ limit: 200 })] - const ids = sessions.map((session) => session.id) + const sessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200 })]) + const ids = sessions.map((session) => session.id) - expect(ids).toContain(firstSession.id) - expect(ids).toContain(secondSession.id) + expect(ids).toContain(firstSession.id) + expect(ids).toContain(secondSession.id) - const firstProject = Project.get(firstSession.projectID) - const secondProject = Project.get(secondSession.projectID) + const firstProject = yield* Project.Service.use((project) => project.get(firstSession.projectID)) + const secondProject = yield* Project.Service.use((project) => project.get(secondSession.projectID)) - const firstItem = sessions.find((session) => session.id === firstSession.id) - const secondItem = sessions.find((session) => session.id === secondSession.id) + const firstItem = sessions.find((session) => session.id === firstSession.id) + const secondItem = sessions.find((session) => session.id === secondSession.id) - expect(firstItem?.project?.id).toBe(firstProject?.id) - expect(firstItem?.project?.worktree).toBe(firstProject?.worktree) - expect(secondItem?.project?.id).toBe(secondProject?.id) - expect(secondItem?.project?.worktree).toBe(secondProject?.worktree) - }) + expect(firstItem?.project?.id).toBe(firstProject?.id) + expect(firstItem?.project?.worktree).toBe(firstProject?.worktree) + expect(secondItem?.project?.id).toBe(secondProject?.id) + expect(secondItem?.project?.worktree).toBe(secondProject?.worktree) + expect(first.directory).not.toBe(second) + }), + { git: true }, + ) - test("excludes archived sessions by default", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance( + "excludes archived sessions by default", + () => + Effect.gen(function* () { + const archived = yield* withSession({ title: "archived-session" }) - const archived = await WithInstance.provide({ - directory: tmp.path, - fn: async () => svc.create({ title: "archived-session" }), - }) + yield* SessionNs.Service.use((session) => session.setArchived({ sessionID: archived.id, time: Date.now() })) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => svc.setArchived({ sessionID: archived.id, time: Date.now() }), - }) + const sessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200 })]) + const ids = sessions.map((session) => session.id) - const sessions = [...svc.listGlobal({ limit: 200 })] - const ids = sessions.map((session) => session.id) + expect(ids).not.toContain(archived.id) - expect(ids).not.toContain(archived.id) + const allSessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200, archived: true })]) + const allIds = allSessions.map((session) => session.id) - const allSessions = [...svc.listGlobal({ limit: 200, archived: true })] - const allIds = allSessions.map((session) => session.id) + expect(allIds).toContain(archived.id) + }), + { git: true }, + ) - expect(allIds).toContain(archived.id) - }) + it.instance( + "supports cursor pagination", + () => + Effect.gen(function* () { + const test = yield* TestInstance - test("supports cursor pagination", async () => { - await using tmp = await tmpdir({ git: true }) + const first = yield* withSession({ title: "page-one" }) + const ready = yield* Deferred.make() + yield* Deferred.succeed(ready, undefined).pipe(Effect.delay("5 millis"), Effect.forkScoped) + yield* Deferred.await(ready).pipe( + Effect.timeoutOrElse({ + duration: "1 second", + orElse: () => Effect.fail(new Error("timed out waiting between session creates")), + }), + ) + const second = yield* withSession({ title: "page-two" }) - const first = await WithInstance.provide({ - directory: tmp.path, - fn: async () => svc.create({ title: "page-one" }), - }) - await new Promise((resolve) => setTimeout(resolve, 5)) - const second = await WithInstance.provide({ - directory: tmp.path, - fn: async () => svc.create({ title: "page-two" }), - }) + const page = yield* Effect.sync(() => [...SessionNs.listGlobal({ directory: test.directory, limit: 1 })]) + expect(page.length).toBe(1) + expect(page[0].id).toBe(second.id) - const page = [...svc.listGlobal({ directory: tmp.path, limit: 1 })] - expect(page.length).toBe(1) - expect(page[0].id).toBe(second.id) + const next = yield* Effect.sync(() => [ + ...SessionNs.listGlobal({ directory: test.directory, limit: 10, cursor: page[0].time.updated }), + ]) + const ids = next.map((session) => session.id) - const next = [...svc.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })] - const ids = next.map((session) => session.id) - - expect(ids).toContain(first.id) - expect(ids).not.toContain(second.id) - }) + expect(ids).toContain(first.id) + expect(ids).not.toContain(second.id) + }), + { git: true }, + ) }) From 0b67e1a046b62177889b2e3a02a85239fd504ce0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 22:55:33 -0400 Subject: [PATCH 12/98] test(server): migrate session messages to Effect runner (#27234) --- .../test/server/session-messages.test.ts | 294 +++++++++--------- 1 file changed, 141 insertions(+), 153 deletions(-) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index f5ee5bdcb..e603accbb 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,191 +1,179 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect } from "bun:test" import { Effect } from "effect" -import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" +import { ModelID, ProviderID } from "../../src/provider/schema" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" void Log.init({ print: false }) -function run(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) -} +const it = testEffect(SessionNs.defaultLayer) -const svc = { - ...SessionNs, - create(input?: SessionNs.CreateInput) { - return run(SessionNs.Service.use((svc) => svc.create(input))) - }, - remove(id: SessionID) { - return run(SessionNs.Service.use((svc) => svc.remove(id))) - }, - updateMessage(msg: T) { - return run(SessionNs.Service.use((svc) => svc.updateMessage(msg))) - }, - updatePart(part: T) { - return run(SessionNs.Service.use((svc) => svc.updatePart(part))) - }, +const model = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test"), } afterEach(async () => { await disposeAllInstances() }) -async function withoutWatcher(fn: () => Promise) { - if (process.platform !== "win32") return fn() - const prev = process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER - process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = "true" - try { - return await fn() - } finally { - if (prev === undefined) delete process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER - else process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = prev - } +const withoutWatcher = (effect: Effect.Effect) => { + if (process.platform !== "win32") return effect + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER + process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = "true" + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER + else process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = previous + }), + ) } -async function fill(sessionID: SessionID, count: number, time = (i: number) => Date.now() + i) { - const ids = [] as MessageID[] - for (let i = 0; i < count; i++) { - const id = MessageID.ascending() - ids.push(id) - await svc.updateMessage({ - id, - sessionID, - role: "user", - time: { created: time(i) }, - agent: "test", - model: { providerID: "test", modelID: "test" }, - tools: {}, - mode: "", - } as unknown as MessageV2.Info) - await svc.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: id, - type: "text", - text: `m${i}`, - }) - } - return ids +const sessionScoped = Effect.acquireRelease( + SessionNs.Service.use((svc) => svc.create({})), + (session) => SessionNs.Service.use((svc) => svc.remove(session.id)).pipe(Effect.ignore), +) + +const fill = Effect.fn("SessionMessagesTest.fill")(function* ( + sessionID: SessionID, + count: number, + time = (i: number) => Date.now() + i, +) { + const session = yield* SessionNs.Service + return yield* Effect.forEach( + Array.from({ length: count }, (_, i) => i), + (i) => + Effect.gen(function* () { + const id = MessageID.ascending() + yield* session.updateMessage({ + id, + sessionID, + role: "user", + time: { created: time(i) }, + agent: "test", + model, + tools: {}, + } satisfies MessageV2.User) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: `m${i}`, + } satisfies MessageV2.TextPart) + return id + }), + ) +}) + +function request(path: string) { + return Effect.promise(() => Promise.resolve(Server.Default().app.request(path))) +} + +function json(response: Response) { + return Effect.promise(() => response.json() as Promise) } describe("session messages endpoint", () => { - test("returns cursor headers for older pages", async () => { - await using tmp = await tmpdir({ git: true }) - await withoutWatcher(() => - WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 5) - const app = Server.Default().app + it.instance( + "returns cursor headers for older pages", + withoutWatcher( + Effect.gen(function* () { + const session = yield* sessionScoped + const ids = yield* fill(session.id, 5) - const a = await app.request(`/session/${session.id}/message?limit=2`) - expect(a.status).toBe(200) - const aBody = (await a.json()) as MessageV2.WithParts[] - expect(aBody.map((item) => item.info.id)).toEqual(ids.slice(-2)) - const cursor = a.headers.get("x-next-cursor") - expect(cursor).toBeTruthy() - expect(a.headers.get("link")).toContain('rel="next"') + const a = yield* request(`/session/${session.id}/message?limit=2`) + expect(a.status).toBe(200) + const aBody = yield* json(a) + expect(aBody.map((item) => item.info.id)).toEqual(ids.slice(-2)) + const cursor = a.headers.get("x-next-cursor") + expect(cursor).toBeTruthy() + expect(a.headers.get("link")).toContain('rel="next"') - const b = await app.request(`/session/${session.id}/message?limit=2&before=${encodeURIComponent(cursor!)}`) - expect(b.status).toBe(200) - const bBody = (await b.json()) as MessageV2.WithParts[] - expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) - - await svc.remove(session.id) - }, + const b = yield* request(`/session/${session.id}/message?limit=2&before=${encodeURIComponent(cursor!)}`) + expect(b.status).toBe(200) + const bBody = yield* json(b) + expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) }), - ) - }) + ), + { git: true }, + ) - test("keeps full-history responses when limit is omitted", async () => { - await using tmp = await tmpdir({ git: true }) - await withoutWatcher(() => - WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 3) - const app = Server.Default().app + it.instance( + "keeps full-history responses when limit is omitted", + withoutWatcher( + Effect.gen(function* () { + const session = yield* sessionScoped + const ids = yield* fill(session.id, 3) - const res = await app.request(`/session/${session.id}/message`) - expect(res.status).toBe(200) - const body = (await res.json()) as MessageV2.WithParts[] - expect(body.map((item) => item.info.id)).toEqual(ids) - - await svc.remove(session.id) - }, + const res = yield* request(`/session/${session.id}/message`) + expect(res.status).toBe(200) + const body = yield* json(res) + expect(body.map((item) => item.info.id)).toEqual(ids) }), - ) - }) + ), + { git: true }, + ) - test("rejects invalid cursors and missing sessions", async () => { - await using tmp = await tmpdir({ git: true }) - await withoutWatcher(() => - WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const app = Server.Default().app + it.instance( + "rejects invalid cursors and missing sessions", + withoutWatcher( + Effect.gen(function* () { + const session = yield* sessionScoped - const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`) - expect(bad.status).toBe(400) + const bad = yield* request(`/session/${session.id}/message?limit=2&before=bad`) + expect(bad.status).toBe(400) - const miss = await app.request(`/session/ses_missing/message?limit=2`) - expect(miss.status).toBe(404) - - await svc.remove(session.id) - }, + const miss = yield* request(`/session/ses_missing/message?limit=2`) + expect(miss.status).toBe(404) }), - ) - }) + ), + { git: true }, + ) - test("does not truncate large legacy limit requests", async () => { - await using tmp = await tmpdir({ git: true }) - await withoutWatcher(() => - WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await fill(session.id, 520) - const app = Server.Default().app + it.instance( + "does not truncate large legacy limit requests", + withoutWatcher( + Effect.gen(function* () { + const session = yield* sessionScoped + yield* fill(session.id, 520) - const res = await app.request(`/session/${session.id}/message?limit=510`) - expect(res.status).toBe(200) - const body = (await res.json()) as MessageV2.WithParts[] - expect(body).toHaveLength(510) - - await svc.remove(session.id) - }, + const res = yield* request(`/session/${session.id}/message?limit=510`) + expect(res.status).toBe(200) + const body = yield* json(res) + expect(body).toHaveLength(510) }), - ) - }) + ), + { git: true }, + ) - test("accepts directory query used by workspace routing", async () => { - await using tmp = await tmpdir({ git: true }) - await withoutWatcher(() => - WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await fill(session.id, 1) - const app = Server.Default().app + it.instance( + "accepts directory query used by workspace routing", + withoutWatcher( + Effect.gen(function* () { + const tmp = yield* TestInstance + const session = yield* sessionScoped + yield* fill(session.id, 1) - const res = await app.request( - `/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.path)}`, - ) - expect(res.status).toBe(200) - const body = await res.json() - expect(Array.isArray(body)).toBe(true) - expect(body).toHaveLength(1) - - await svc.remove(session.id) - }, + const res = yield* request( + `/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.directory)}`, + ) + expect(res.status).toBe(200) + const body = yield* json(res) + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(1) }), - ) - }) + ), + { git: true }, + ) }) From 8370d0cef4c3362674f480b7fff0b42f03ed84fd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 22:55:36 -0400 Subject: [PATCH 13/98] test(server): migrate httpapi ui tests to effect runner (#27236) --- .../opencode/test/server/httpapi-ui.test.ts | 419 ++++++++++-------- 1 file changed, 222 insertions(+), 197 deletions(-) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 256c45019..74fc23004 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto" -import { afterEach, describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" import { ConfigProvider, Effect, Layer } from "effect" @@ -18,24 +18,33 @@ import { authorizationRouterMiddleware } from "../../src/server/routes/instance/ import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui" import { Server } from "../../src/server/server" +import { testEffect } from "../lib/effect" void Log.init({ print: false }) -const original = { - OPENCODE_DISABLE_EMBEDDED_WEB_UI: Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI, - OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, - OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, - envPassword: process.env.OPENCODE_SERVER_PASSWORD, - envUsername: process.env.OPENCODE_SERVER_USERNAME, -} +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const original = { + OPENCODE_DISABLE_EMBEDDED_WEB_UI: Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI, + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, + envPassword: process.env.OPENCODE_SERVER_PASSWORD, + envUsername: process.env.OPENCODE_SERVER_USERNAME, + } -afterEach(() => { - Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = original.OPENCODE_DISABLE_EMBEDDED_WEB_UI - Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD - Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME - restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword) - restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername) -}) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = original.OPENCODE_DISABLE_EMBEDDED_WEB_UI + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword) + restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername) + }), + ) + }), +) + +const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer)) function restoreEnv(key: string, value: string | undefined) { if (value === undefined) { @@ -61,9 +70,13 @@ function app(input?: { password?: string; username?: string }) { ).handler return { request(input: string | URL | Request, init?: RequestInit) { - return handler( - input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), - ExperimentalHttpApiServer.context, + return Effect.promise(() => + Promise.resolve( + handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + ExperimentalHttpApiServer.context, + ), + ), ) }, } @@ -95,9 +108,13 @@ function uiApp(input?: { password?: string; username?: string; client?: Layer.La ).handler return { request(input: string | URL | Request, init?: RequestInit) { - return handler( - input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), - ExperimentalHttpApiServer.context, + return Effect.promise(() => + Promise.resolve( + handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + ExperimentalHttpApiServer.context, + ), + ), ) }, } @@ -113,32 +130,38 @@ function httpClient(response: Response, onRequest?: (request: HttpClientRequest. ) } +function responseText(response: Response) { + return Effect.promise(() => response.text()) +} + describe("HttpApi UI fallback", () => { - test("serves the web UI through the experimental backend", async () => { - Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true - let proxiedUrl: string | undefined + it.live("serves the web UI through the experimental backend", () => + Effect.gen(function* () { + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + let proxiedUrl: string | undefined - const response = await uiApp({ - client: httpClient( - new Response("opencode", { headers: { "content-type": "text/html" } }), - (request) => { - proxiedUrl = request.url - }, - ), - }).request("/") + const response = yield* uiApp({ + client: httpClient( + new Response("opencode", { headers: { "content-type": "text/html" } }), + (request) => { + proxiedUrl = request.url + }, + ), + }).request("/") - expect(response.status).toBe(200) - expect(response.headers.get("content-type")).toContain("text/html") - expect(await response.text()).toBe("opencode") - expect(proxiedUrl).toBe("https://app.opencode.ai/") - }) + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toContain("text/html") + expect(yield* responseText(response)).toBe("opencode") + expect(proxiedUrl).toBe("https://app.opencode.ai/") + }), + ) - test("strips upstream transfer encoding headers from proxied assets", async () => { - Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true - let proxiedUrl: string | undefined + it.live("strips upstream transfer encoding headers from proxied assets", () => + Effect.gen(function* () { + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + let proxiedUrl: string | undefined - const response = await Effect.runPromise( - Effect.gen(function* () { + const response = yield* Effect.gen(function* () { const fs = yield* AppFileSystem.Service const client = yield* HttpClient.HttpClient return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), { @@ -147,48 +170,45 @@ describe("HttpApi UI fallback", () => { }) }).pipe( Effect.provide( - Layer.mergeAll( - AppFileSystem.defaultLayer, - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => { - proxiedUrl = request.url - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response("console.log('ok')", { - headers: { - "content-encoding": "br", - "content-length": "999", - "content-type": "text/javascript", - }, - }), - ), - ) - }), - ), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + proxiedUrl = request.url + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("console.log('ok')", { + headers: { + "content-encoding": "br", + "content-length": "999", + "content-type": "text/javascript", + }, + }), + ), + ) + }), ), ), Effect.map(HttpServerResponse.toWeb), - ), - ) + ) - expect(response.status).toBe(200) - expect(proxiedUrl).toBe("https://app.opencode.ai/assets/app.js") - expect(response.headers.get("content-encoding")).toBeNull() - expect(response.headers.get("content-length")).not.toBe("999") - expect(response.headers.get("content-type")).toContain("text/javascript") - expect(await response.text()).toBe("console.log('ok')") - }) + expect(response.status).toBe(200) + expect(proxiedUrl).toBe("https://app.opencode.ai/assets/app.js") + expect(response.headers.get("content-encoding")).toBeNull() + expect(response.headers.get("content-length")).not.toBe("999") + expect(response.headers.get("content-type")).toContain("text/javascript") + expect(yield* responseText(response)).toBe("console.log('ok')") + }), + ) // Regression for #25698 (Ope): upstream `transfer-encoding: chunked` was // forwarded through the proxy while the proxy itself re-frames the body, // causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`. - test("strips upstream transfer-encoding header from proxied assets", async () => { - Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + it.live("strips upstream transfer-encoding header from proxied assets", () => + Effect.gen(function* () { + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true - const response = await Effect.runPromise( - Effect.gen(function* () { + const response = yield* Effect.gen(function* () { const fs = yield* AppFileSystem.Service const client = yield* HttpClient.HttpClient return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/")), { @@ -197,161 +217,166 @@ describe("HttpApi UI fallback", () => { }) }).pipe( Effect.provide( - Layer.mergeAll( - AppFileSystem.defaultLayer, - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response("opencode", { - headers: { - "transfer-encoding": "chunked", - "content-type": "text/html", - }, - }), - ), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("opencode", { + headers: { + "transfer-encoding": "chunked", + "content-type": "text/html", + }, + }), ), ), ), ), ), Effect.map(HttpServerResponse.toWeb), - ), - ) + ) - expect(response.status).toBe(200) - expect(response.headers.get("transfer-encoding")).toBeNull() - expect(await response.text()).toBe("opencode") - }) + expect(response.status).toBe(200) + expect(response.headers.get("transfer-encoding")).toBeNull() + expect(yield* responseText(response)).toBe("opencode") + }), + ) - test("serves embedded UI assets when Bun can read them but access reports missing", async () => { - let readPath: string | undefined + it.live("serves embedded UI assets when Bun can read them but access reports missing", () => + Effect.gen(function* () { + let readPath: string | undefined - const response = await Effect.runPromise( - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - return yield* serveEmbeddedUIEffect( - "/assets/app.js", - { - ...fs, - existsSafe: () => Effect.die("embedded UI should not rely on filesystem access checks"), - readFile: (path) => { - readPath = path - return path === "/$bunfs/root/assets/app.js" - ? Effect.succeed(new TextEncoder().encode("console.log('embedded')")) - : Effect.die(`unexpected embedded UI path: ${path}`) - }, + const fs = yield* AppFileSystem.Service + const response = yield* serveEmbeddedUIEffect( + "/assets/app.js", + { + ...fs, + existsSafe: () => Effect.die("embedded UI should not rely on filesystem access checks"), + readFile: (path) => { + readPath = path + return path === "/$bunfs/root/assets/app.js" + ? Effect.succeed(new TextEncoder().encode("console.log('embedded')")) + : Effect.die(`unexpected embedded UI path: ${path}`) }, - { "assets/app.js": "/$bunfs/root/assets/app.js" }, - ) - }).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.map(HttpServerResponse.toWeb)), - ) + }, + { "assets/app.js": "/$bunfs/root/assets/app.js" }, + ).pipe(Effect.map(HttpServerResponse.toWeb)) - expect(response.status).toBe(200) - expect(readPath).toBe("/$bunfs/root/assets/app.js") - expect(response.headers.get("content-type")).toContain("text/javascript") - expect(await response.text()).toBe("console.log('embedded')") - }) + expect(response.status).toBe(200) + expect(readPath).toBe("/$bunfs/root/assets/app.js") + expect(response.headers.get("content-type")).toContain("text/javascript") + expect(yield* responseText(response)).toBe("console.log('embedded')") + }), + ) - test("allows embedded UI terminal wasm and theme preload CSP", async () => { - const script = 'document.documentElement.dataset.theme = "dark"' + it.live("allows embedded UI terminal wasm and theme preload CSP", () => + Effect.gen(function* () { + const script = 'document.documentElement.dataset.theme = "dark"' - const response = await Effect.runPromise( - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - return yield* serveEmbeddedUIEffect( - "/", - { - ...fs, - readFile: (path) => { - return path === "/$bunfs/root/index.html" - ? Effect.succeed( - new TextEncoder().encode( - ``, - ), - ) - : Effect.die(`unexpected embedded UI path: ${path}`) - }, + const fs = yield* AppFileSystem.Service + const response = yield* serveEmbeddedUIEffect( + "/", + { + ...fs, + readFile: (path) => { + return path === "/$bunfs/root/index.html" + ? Effect.succeed( + new TextEncoder().encode( + ``, + ), + ) + : Effect.die(`unexpected embedded UI path: ${path}`) }, - { "index.html": "/$bunfs/root/index.html" }, - ) - }).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.map(HttpServerResponse.toWeb)), - ) + }, + { "index.html": "/$bunfs/root/index.html" }, + ).pipe(Effect.map(HttpServerResponse.toWeb)) - const csp = response.headers.get("content-security-policy") ?? "" - expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'") - expect(csp).toContain(`'sha256-${createHash("sha256").update(script).digest("base64")}'`) - expect(csp).toContain("connect-src * data:") - }) + const csp = response.headers.get("content-security-policy") ?? "" + expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'") + expect(csp).toContain(`'sha256-${createHash("sha256").update(script).digest("base64")}'`) + expect(csp).toContain("connect-src * data:") + }), + ) - test("keeps matched API routes ahead of the UI fallback", async () => { - const response = await Server.Default().app.request("/session/ses_nope") + it.live("keeps matched API routes ahead of the UI fallback", () => + Effect.gen(function* () { + const response = yield* Effect.promise(() => Promise.resolve(Server.Default().app.request("/session/ses_nope"))) - expect(response.status).toBe(404) - }) + expect(response.status).toBe(404) + }), + ) - test("requires server password for the web UI", async () => { - Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + it.live("requires server password for the web UI", () => + Effect.gen(function* () { + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true - const response = await uiApp({ password: "secret", username: "opencode" }).request("/") + const response = yield* uiApp({ password: "secret", username: "opencode" }).request("/") - expect(response.status).toBe(401) - expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"') - }) + expect(response.status).toBe(401) + expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"') + }), + ) - test("accepts auth token for the web UI", async () => { - Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + it.live("accepts auth token for the web UI", () => + Effect.gen(function* () { + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true - const response = await uiApp({ - password: "secret", - username: "opencode", - client: httpClient(new Response("opencode", { headers: { "content-type": "text/html" } })), - }).request(`/?auth_token=${btoa("opencode:secret")}`) + const response = yield* uiApp({ + password: "secret", + username: "opencode", + client: httpClient(new Response("opencode", { headers: { "content-type": "text/html" } })), + }).request(`/?auth_token=${btoa("opencode:secret")}`) - expect(response.status).toBe(200) - expect(await response.text()).toBe("opencode") - }) + expect(response.status).toBe(200) + expect(yield* responseText(response)).toBe("opencode") + }), + ) - test("accepts basic auth for the web UI", async () => { - Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + it.live("accepts basic auth for the web UI", () => + Effect.gen(function* () { + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true - const response = await uiApp({ password: "secret", username: "opencode" }).request("/", { - headers: { authorization: `Basic ${btoa("opencode:secret")}` }, - }) + const response = yield* uiApp({ password: "secret", username: "opencode" }).request("/", { + headers: { authorization: `Basic ${btoa("opencode:secret")}` }, + }) - expect(response.status).toBe(200) - }) + expect(response.status).toBe(200) + }), + ) // Regression for #25698 (Ope): the browser fetches the PWA manifest and // its icons via flows that don't carry app-managed credentials (the // `` request is not under page-auth control), so the // server returning 401 breaks PWA install. These specific public assets // should bypass auth. - test("serves the PWA manifest without auth even when a server password is set", async () => { - Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + it.live("serves the PWA manifest without auth even when a server password is set", () => + Effect.gen(function* () { + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true - for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) { - const response = await uiApp({ - password: "secret", - username: "opencode", - client: httpClient(new Response("ok")), - }).request(path) - expect(response.status).not.toBe(401) - } - }) + for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) { + const response = yield* uiApp({ + password: "secret", + username: "opencode", + client: httpClient(new Response("ok")), + }).request(path) + expect(response.status).not.toBe(401) + } + }), + ) - test("allows web UI preflight without auth", async () => { - const response = await app({ password: "secret", username: "opencode" }).request("/", { - method: "OPTIONS", - headers: { - origin: "http://localhost:3000", - "access-control-request-method": "GET", - }, - }) + it.live("allows web UI preflight without auth", () => + Effect.gen(function* () { + const response = yield* app({ password: "secret", username: "opencode" }).request("/", { + method: "OPTIONS", + headers: { + origin: "http://localhost:3000", + "access-control-request-method": "GET", + }, + }) - expect(response.status).toBe(204) - expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") - }) + expect(response.status).toBe(204) + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") + }), + ) }) From d88cef6adabf63f4ae628de4795afb76b3c46866 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 22:56:29 -0400 Subject: [PATCH 14/98] test(mcp): migrate headers tests to Effect runner (#27237) --- packages/opencode/test/mcp/headers.test.ts | 141 +++++++-------------- 1 file changed, 44 insertions(+), 97 deletions(-) diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index 5bc8f803d..c51ed00d3 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -1,6 +1,6 @@ -import { test, expect, mock, beforeEach } from "bun:test" +import { describe, expect, mock, beforeEach } from "bun:test" import { Effect } from "effect" -import type { MCP as MCPNS } from "../../src/mcp/index" +import { testEffect } from "../lib/effect" // Track what options were passed to each transport constructor const transportCalls: Array<{ @@ -46,53 +46,22 @@ beforeEach(() => { // Import MCP after mocking const { MCP } = await import("../../src/mcp/index") -const { AppRuntime } = await import("../../src/effect/app-runtime") -const { Instance } = await import("../../src/project/instance") -const { WithInstance } = await import("../../src/project/with-instance") -const { tmpdir } = await import("../fixture/fixture") -const service = MCP.Service as unknown as Effect.Effect +const it = testEffect(MCP.defaultLayer) -test("headers are passed to transports when oauth is enabled (default)", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - `${dir}/opencode.json`, - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - "test-server": { - type: "remote", - url: "https://example.com/mcp", - headers: { - Authorization: "Bearer test-token", - "X-Custom-Header": "custom-value", - }, - }, +describe("mcp.headers", () => { + it.instance("headers are passed to transports when oauth is enabled (default)", () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + yield* mcp + .add("test-server", { + type: "remote", + url: "https://example.com/mcp", + headers: { + Authorization: "Bearer test-token", + "X-Custom-Header": "custom-value", }, - }), - ) - }, - }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // Trigger MCP initialization - it will fail to connect but we can check the transport options - await AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* service - yield* mcp - .add("test-server", { - type: "remote", - url: "https://example.com/mcp", - headers: { - Authorization: "Bearer test-token", - "X-Custom-Header": "custom-value", - }, - }) - .pipe(Effect.catch(() => Effect.void)) - }), - ) + }) + .pipe(Effect.catch(() => Effect.void)) // Both transports should have been created with headers expect(transportCalls.length).toBeGreaterThanOrEqual(1) @@ -106,33 +75,22 @@ test("headers are passed to transports when oauth is enabled (default)", async ( // OAuth should be enabled by default, so authProvider should exist expect(call.options.authProvider).toBeDefined() } - }, - }) -}) + }), + ) -test("headers are passed to transports when oauth is explicitly disabled", async () => { - await using tmp = await tmpdir() - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - transportCalls.length = 0 - - await AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* service - yield* mcp - .add("test-server-no-oauth", { - type: "remote", - url: "https://example.com/mcp", - oauth: false, - headers: { - Authorization: "Bearer test-token", - }, - }) - .pipe(Effect.catch(() => Effect.void)) - }), - ) + it.instance("headers are passed to transports when oauth is explicitly disabled", () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + yield* mcp + .add("test-server-no-oauth", { + type: "remote", + url: "https://example.com/mcp", + oauth: false, + headers: { + Authorization: "Bearer test-token", + }, + }) + .pipe(Effect.catch(() => Effect.void)) expect(transportCalls.length).toBeGreaterThanOrEqual(1) @@ -144,29 +102,18 @@ test("headers are passed to transports when oauth is explicitly disabled", async // OAuth is disabled, so no authProvider expect(call.options.authProvider).toBeUndefined() } - }, - }) -}) + }), + ) -test("no requestInit when headers are not provided", async () => { - await using tmp = await tmpdir() - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - transportCalls.length = 0 - - await AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* service - yield* mcp - .add("test-server-no-headers", { - type: "remote", - url: "https://example.com/mcp", - }) - .pipe(Effect.catch(() => Effect.void)) - }), - ) + it.instance("no requestInit when headers are not provided", () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + yield* mcp + .add("test-server-no-headers", { + type: "remote", + url: "https://example.com/mcp", + }) + .pipe(Effect.catch(() => Effect.void)) expect(transportCalls.length).toBeGreaterThanOrEqual(1) @@ -174,6 +121,6 @@ test("no requestInit when headers are not provided", async () => { // No headers means requestInit should be undefined expect(call.options.requestInit).toBeUndefined() } - }, - }) + }), + ) }) From 6d3b2fe08bdb17fc265c44d542a9fc6ca38e150d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 23:01:29 -0400 Subject: [PATCH 15/98] test(server): stabilize SDK project skill prompt test (#27239) --- packages/opencode/test/server/httpapi-sdk.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 2f5d81342..a76877a0b 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -743,7 +743,7 @@ describe("HttpApi SDK", () => { ) httpapi( - "includes project skills in REST API async prompt context", + "includes project skills in REST API prompt context", withFakeLlmProject("default", { setup: writeProjectSkill }, ({ sdk, llm }) => Effect.gen(function* () { yield* llm.text("skill context ok", { usage: { input: 11, output: 7 } }) @@ -755,18 +755,17 @@ describe("HttpApi SDK", () => { ) const sessionID = String(record(session.data).id) const prompt = yield* capture(() => - sdk.session.promptAsync({ + sdk.session.prompt({ sessionID, agent: "build", model: { providerID: "test", modelID: "test-model" }, parts: [{ type: "text", text: "hello skill context" }], }), ) - yield* llm.wait(1) const inputs = yield* llm.inputs expect(session.status).toBe(200) - expect(prompt.status).toBe(204) + expect(prompt.status).toBe(200) expect(JSON.stringify(inputs[0])).toContain("project-rest-skill") }), ), From 68c4951318b3ace5943eb525e225d316aa3803ed Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 23:05:30 -0400 Subject: [PATCH 16/98] test(mcp): migrate lifecycle tests to Effect runner (#27205) --- packages/opencode/test/mcp/lifecycle.test.ts | 722 ++++++++++--------- 1 file changed, 375 insertions(+), 347 deletions(-) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 5afc85e3b..185086fe6 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,7 +1,7 @@ -import { test, expect, mock, beforeEach } from "bun:test" -import { InstanceRuntime } from "../../src/project/instance-runtime" -import { Effect } from "effect" +import { expect, mock, beforeEach } from "bun:test" +import { Effect, Exit } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" +import { testEffect } from "../lib/effect" // --- Mock infrastructure --- @@ -179,39 +179,9 @@ beforeEach(() => { // Import after mocks const { MCP } = await import("../../src/mcp/index") -const { Instance } = await import("../../src/project/instance") -const { WithInstance } = await import("../../src/project/with-instance") -const { tmpdir } = await import("../fixture/fixture") +const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") -// --- Helper --- - -function withInstance( - config: Record, - fn: (mcp: MCPNS.Interface) => Effect.Effect, -) { - return async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - `${dir}/opencode.json`, - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: config, - }), - ) - }, - }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer))) - // dispose instance to clean up state between tests - await InstanceRuntime.disposeInstance(Instance.current) - }, - }) - } -} +const it = testEffect(MCP.defaultLayer) function statusName(status: Record | MCPNS.Status, server: string) { if ("status" in status) return status.status @@ -222,82 +192,82 @@ function statusName(status: Record | MCPNS.Status, server: // Test: tools() are cached after connect // ======================================================================== -test( +it.instance( "tools() reuses cached tool definitions after connect", - withInstance({}, (mcp) => - Effect.gen(function* () { - lastCreatedClientName = "my-server" - const serverState = getOrCreateClientState("my-server") - serverState.tools = [ - { name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } }, - ] + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "my-server" + const serverState = getOrCreateClientState("my-server") + serverState.tools = [ + { name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } }, + ] - // First: add the server successfully - const addResult = yield* mcp.add("my-server", { - type: "local", - command: ["echo", "test"], - }) - expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected") + // First: add the server successfully + const addResult = yield* mcp.add("my-server", { + type: "local", + command: ["echo", "test"], + }) + expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected") - expect(serverState.listToolsCalls).toBe(1) + expect(serverState.listToolsCalls).toBe(1) - const toolsA = yield* mcp.tools() - const toolsB = yield* mcp.tools() - expect(Object.keys(toolsA).length).toBeGreaterThan(0) - expect(Object.keys(toolsB).length).toBeGreaterThan(0) - expect(serverState.listToolsCalls).toBe(1) - }), - ), + const toolsA = yield* mcp.tools() + const toolsB = yield* mcp.tools() + expect(Object.keys(toolsA).length).toBeGreaterThan(0) + expect(Object.keys(toolsB).length).toBeGreaterThan(0) + expect(serverState.listToolsCalls).toBe(1) + }), + ), + { config: { mcp: {} } }, ) // ======================================================================== // Test: tool change notifications refresh the cache // ======================================================================== -test( +it.instance( "tool change notifications refresh cached tool definitions", - withInstance({}, (mcp) => - Effect.gen(function* () { - lastCreatedClientName = "status-server" - const serverState = getOrCreateClientState("status-server") + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "status-server" + const serverState = getOrCreateClientState("status-server") - yield* mcp.add("status-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("status-server", { + type: "local", + command: ["echo", "test"], + }) - const before = yield* mcp.tools() - expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true) - expect(serverState.listToolsCalls).toBe(1) + const before = yield* mcp.tools() + expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true) + expect(serverState.listToolsCalls).toBe(1) - serverState.tools = [{ name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }] + serverState.tools = [ + { name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }, + ] - const handler = Array.from(serverState.notificationHandlers.values())[0] - expect(handler).toBeDefined() - yield* Effect.promise(() => handler?.()) + const handler = Array.from(serverState.notificationHandlers.values())[0] + expect(handler).toBeDefined() + yield* Effect.promise(() => handler?.()) - const after = yield* mcp.tools() - expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true) - expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false) - expect(serverState.listToolsCalls).toBe(2) - }), - ), + const after = yield* mcp.tools() + expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true) + expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false) + expect(serverState.listToolsCalls).toBe(2) + }), + ), + { config: { mcp: {} } }, ) // ======================================================================== // Test: connect() / disconnect() lifecycle // ======================================================================== -test( +it.instance( "disconnect sets status to disabled and removes client", - withInstance( - { - "disc-server": { - type: "local", - command: ["echo", "test"], - }, - }, - (mcp) => + () => + MCP.Service.use((mcp: MCPNS.Interface) => Effect.gen(function* () { lastCreatedClientName = "disc-server" getOrCreateClientState("disc-server") @@ -315,24 +285,27 @@ test( const statusAfter = yield* mcp.status() expect(statusAfter["disc-server"]?.status).toBe("disabled") - // Tools should be empty after disconnect const tools = yield* mcp.tools() const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server")) expect(serverTools.length).toBe(0) }), - ), -) - -test( - "connect() after disconnect() re-establishes the server", - withInstance( - { - "reconn-server": { - type: "local", - command: ["echo", "test"], + ), + { + config: { + mcp: { + "disc-server": { + type: "local", + command: ["echo", "test"], + }, }, }, - (mcp) => + }, +) + +it.instance( + "connect() after disconnect() re-establishes the server", + () => + MCP.Service.use((mcp: MCPNS.Interface) => Effect.gen(function* () { lastCreatedClientName = "reconn-server" const serverState = getOrCreateClientState("reconn-server") @@ -348,70 +321,71 @@ test( yield* mcp.disconnect("reconn-server") expect((yield* mcp.status())["reconn-server"]?.status).toBe("disabled") - // Reconnect yield* mcp.connect("reconn-server") expect((yield* mcp.status())["reconn-server"]?.status).toBe("connected") const tools = yield* mcp.tools() expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true) }), - ), + ), + { + config: { + mcp: { + "reconn-server": { + type: "local", + command: ["echo", "test"], + }, + }, + }, + }, ) // ======================================================================== // Test: add() closes existing client before replacing // ======================================================================== -test( +it.instance( "add() closes the old client when replacing a server", // Don't put the server in config — add it dynamically so we control // exactly which client instance is "first" vs "second". - withInstance({}, (mcp) => - Effect.gen(function* () { - lastCreatedClientName = "replace-server" - const firstState = getOrCreateClientState("replace-server") + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "replace-server" + const firstState = getOrCreateClientState("replace-server") - yield* mcp.add("replace-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("replace-server", { + type: "local", + command: ["echo", "test"], + }) - expect(firstState.closed).toBe(false) + expect(firstState.closed).toBe(false) - // Create new state for second client - clientStates.delete("replace-server") - const secondState = getOrCreateClientState("replace-server") + // Create new state for second client + clientStates.delete("replace-server") + const secondState = getOrCreateClientState("replace-server") - // Re-add should close the first client - yield* mcp.add("replace-server", { - type: "local", - command: ["echo", "test"], - }) + // Re-add should close the first client + yield* mcp.add("replace-server", { + type: "local", + command: ["echo", "test"], + }) - expect(firstState.closed).toBe(true) - expect(secondState.closed).toBe(false) - }), - ), + expect(firstState.closed).toBe(true) + expect(secondState.closed).toBe(false) + }), + ), + { config: { mcp: {} } }, ) // ======================================================================== // Test: state init with mixed success/failure // ======================================================================== -test( +it.instance( "init connects available servers even when one fails", - withInstance( - { - "good-server": { - type: "local", - command: ["echo", "good"], - }, - "bad-server": { - type: "local", - command: ["echo", "bad"], - }, - }, - (mcp) => + () => + MCP.Service.use((mcp: MCPNS.Interface) => Effect.gen(function* () { // Set up good server const goodState = getOrCreateClientState("good-server") @@ -443,77 +417,88 @@ test( const tools = yield* mcp.tools() expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true) }), - ), -) - -test( - "falls back when MCP output schema refs fail SDK tool discovery", - withInstance({}, (mcp) => - Effect.gen(function* () { - lastCreatedClientName = "stitch-like-server" - const serverState = getOrCreateClientState("stitch-like-server") - serverState.listToolsShouldFail = true - serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #" - serverState.tools = [ - { - name: "render_screen", - description: "renders a screen", - inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] }, - outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } }, + ), + { + config: { + mcp: { + "good-server": { + type: "local", + command: ["echo", "good"], }, - ] - - const addResult = yield* mcp.add("stitch-like-server", { - type: "local", - command: ["echo", "test"], - }) - - expect(statusName(addResult.status, "stitch-like-server")).toBe("connected") - - const tools = yield* mcp.tools() - expect(Object.keys(tools).some((key) => key.includes("render_screen"))).toBe(true) - expect(serverState.listToolsCalls).toBe(1) - expect(serverState.requestCalls).toBe(1) - }), - ), + "bad-server": { + type: "local", + command: ["echo", "bad"], + }, + }, + }, + }, ) -test( +it.instance( + "falls back when MCP output schema refs fail SDK tool discovery", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "stitch-like-server" + const serverState = getOrCreateClientState("stitch-like-server") + serverState.listToolsShouldFail = true + serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #" + serverState.tools = [ + { + name: "render_screen", + description: "renders a screen", + inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] }, + outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } }, + }, + ] + + const addResult = yield* mcp.add("stitch-like-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(statusName(addResult.status, "stitch-like-server")).toBe("connected") + + const tools = yield* mcp.tools() + expect(Object.keys(tools).some((key) => key.includes("render_screen"))).toBe(true) + expect(serverState.listToolsCalls).toBe(1) + expect(serverState.requestCalls).toBe(1) + }), + ), + { config: { mcp: {} } }, +) + +it.instance( "does not fall back for non-schema MCP tool discovery errors", - withInstance({}, (mcp) => - Effect.gen(function* () { - lastCreatedClientName = "broken-server" - const serverState = getOrCreateClientState("broken-server") - serverState.listToolsShouldFail = true - serverState.listToolsError = "transport closed" + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "broken-server" + const serverState = getOrCreateClientState("broken-server") + serverState.listToolsShouldFail = true + serverState.listToolsError = "transport closed" - const addResult = yield* mcp.add("broken-server", { - type: "local", - command: ["echo", "test"], - }) + const addResult = yield* mcp.add("broken-server", { + type: "local", + command: ["echo", "test"], + }) - expect(statusName(addResult.status, "broken-server")).toBe("failed") - expect(serverState.listToolsCalls).toBe(1) - expect(serverState.requestCalls).toBe(0) - }), - ), + expect(statusName(addResult.status, "broken-server")).toBe("failed") + expect(serverState.listToolsCalls).toBe(1) + expect(serverState.requestCalls).toBe(0) + }), + ), + { config: { mcp: {} } }, ) // ======================================================================== // Test: disabled server via config // ======================================================================== -test( +it.instance( "disabled server is marked as disabled without attempting connection", - withInstance( - { - "disabled-server": { - type: "local", - command: ["echo", "test"], - enabled: false, - }, - }, - (mcp) => + () => + MCP.Service.use((mcp: MCPNS.Interface) => Effect.gen(function* () { const countBefore = clientCreateCount @@ -529,23 +514,28 @@ test( const status = yield* mcp.status() expect(status["disabled-server"]?.status).toBe("disabled") }), - ), + ), + { + config: { + mcp: { + "disabled-server": { + type: "local", + command: ["echo", "test"], + enabled: false, + }, + }, + }, + }, ) // ======================================================================== // Test: prompts() and resources() // ======================================================================== -test( +it.instance( "prompts() returns prompts from connected servers", - withInstance( - { - "prompt-server": { - type: "local", - command: ["echo", "test"], - }, - }, - (mcp) => + () => + MCP.Service.use((mcp: MCPNS.Interface) => Effect.gen(function* () { lastCreatedClientName = "prompt-server" const serverState = getOrCreateClientState("prompt-server") @@ -562,19 +552,23 @@ test( expect(key).toContain("prompt-server") expect(key).toContain("my-prompt") }), - ), -) - -test( - "resources() returns resources from connected servers", - withInstance( - { - "resource-server": { - type: "local", - command: ["echo", "test"], + ), + { + config: { + mcp: { + "prompt-server": { + type: "local", + command: ["echo", "test"], + }, }, }, - (mcp) => + }, +) + +it.instance( + "resources() returns resources from connected servers", + () => + MCP.Service.use((mcp: MCPNS.Interface) => Effect.gen(function* () { lastCreatedClientName = "resource-server" const serverState = getOrCreateClientState("resource-server") @@ -591,19 +585,23 @@ test( expect(key).toContain("resource-server") expect(key).toContain("my-resource") }), - ), -) - -test( - "prompts() skips disconnected servers", - withInstance( - { - "prompt-disc-server": { - type: "local", - command: ["echo", "test"], + ), + { + config: { + mcp: { + "resource-server": { + type: "local", + command: ["echo", "test"], + }, }, }, - (mcp) => + }, +) + +it.instance( + "prompts() skips disconnected servers", + () => + MCP.Service.use((mcp: MCPNS.Interface) => Effect.gen(function* () { lastCreatedClientName = "prompt-disc-server" const serverState = getOrCreateClientState("prompt-disc-server") @@ -619,67 +617,77 @@ test( const prompts = yield* mcp.prompts() expect(Object.keys(prompts).length).toBe(0) }), - ), + ), + { + config: { + mcp: { + "prompt-disc-server": { + type: "local", + command: ["echo", "test"], + }, + }, + }, + }, ) // ======================================================================== // Test: connect() on nonexistent server // ======================================================================== -test( +it.instance( "connect() on nonexistent server does not throw", - withInstance({}, (mcp) => - Effect.gen(function* () { - // Should not throw - yield* mcp.connect("nonexistent") - const status = yield* mcp.status() - expect(status["nonexistent"]).toBeUndefined() - }), - ), + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + // Should not throw + yield* mcp.connect("nonexistent") + const status = yield* mcp.status() + expect(status["nonexistent"]).toBeUndefined() + }), + ), + { config: { mcp: {} } }, ) // ======================================================================== // Test: disconnect() on nonexistent server // ======================================================================== -test( +it.instance( "disconnect() on nonexistent server does not throw", - withInstance({}, (mcp) => - Effect.gen(function* () { - yield* mcp.disconnect("nonexistent") - // Should complete without error - }), - ), + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + yield* mcp.disconnect("nonexistent") + // Should complete without error + }), + ), + { config: { mcp: {} } }, ) // ======================================================================== // Test: tools() with no MCP servers configured // ======================================================================== -test( +it.instance( "tools() returns empty when no MCP servers are configured", - withInstance({}, (mcp) => - Effect.gen(function* () { - const tools = yield* mcp.tools() - expect(Object.keys(tools).length).toBe(0) - }), - ), + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + const tools = yield* mcp.tools() + expect(Object.keys(tools).length).toBe(0) + }), + ), + { config: { mcp: {} } }, ) // ======================================================================== // Test: connect failure during create() // ======================================================================== -test( +it.instance( "server that fails to connect is marked as failed", - withInstance( - { - "fail-connect": { - type: "local", - command: ["echo", "test"], - }, - }, - (mcp) => + () => + MCP.Service.use((mcp: MCPNS.Interface) => Effect.gen(function* () { lastCreatedClientName = "fail-connect" getOrCreateClientState("fail-connect") @@ -701,51 +709,55 @@ test( const tools = yield* mcp.tools() expect(Object.keys(tools).length).toBe(0) }), - ), + ), + { + config: { + mcp: { + "fail-connect": { + type: "local", + command: ["echo", "test"], + }, + }, + }, + }, ) // ======================================================================== // Bug #5: McpOAuthCallback.cancelPending uses wrong key // ======================================================================== -test("McpOAuthCallback.cancelPending is keyed by mcpName but pendingAuths uses oauthState", async () => { - const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") +it.live("McpOAuthCallback.cancelPending is keyed by mcpName but pendingAuths uses oauthState", () => + Effect.acquireUseRelease( + Effect.sync(() => McpOAuthCallback.waitForCallback("abc123hexstate", "my-mcp-server")), + (callback) => + Effect.gen(function* () { + McpOAuthCallback.cancelPending("my-mcp-server") - // Register a pending auth with an oauthState key, associated to an mcpName - const oauthState = "abc123hexstate" - const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, "my-mcp-server") + const exit = yield* Effect.tryPromise({ + try: () => callback, + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe( + Effect.timeoutOrElse({ + duration: "1 second", + orElse: () => Effect.fail(new Error("timed out waiting for OAuth cancellation")), + }), + Effect.exit, + ) - // cancelPending is called with mcpName — should find the entry via reverse index - McpOAuthCallback.cancelPending("my-mcp-server") - - // The callback should still be pending because cancelPending looked up - // "my-mcp-server" in a map keyed by "abc123hexstate" - let rejected = false - callbackPromise.then(() => {}).catch(() => (rejected = true)) - - // Give it a tick - await new Promise((r) => setTimeout(r, 50)) - - // cancelPending("my-mcp-server") should have rejected the pending callback - expect(rejected).toBe(true) - - await McpOAuthCallback.stop() -}) + expect(Exit.isFailure(exit)).toBe(true) + }), + () => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore), + ), +) // ======================================================================== // Test: multiple tools from same server get correct name prefixes // ======================================================================== -test( +it.instance( "tools() prefixes tool names with sanitized server name", - withInstance( - { - "my.special-server": { - type: "local", - command: ["echo", "test"], - }, - }, - (mcp) => + () => + MCP.Service.use((mcp: MCPNS.Interface) => Effect.gen(function* () { lastCreatedClientName = "my.special-server" const serverState = getOrCreateClientState("my.special-server") @@ -768,87 +780,103 @@ test( expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true) expect(keys.length).toBe(2) }), - ), + ), + { + config: { + mcp: { + "my.special-server": { + type: "local", + command: ["echo", "test"], + }, + }, + }, + }, ) // ======================================================================== // Test: transport leak — local stdio timeout (#19168) // ======================================================================== -test( +it.instance( "local stdio transport is closed when connect times out (no process leak)", - withInstance({}, (mcp) => - Effect.gen(function* () { - lastCreatedClientName = "hanging-server" - getOrCreateClientState("hanging-server") - connectShouldHang = true + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "hanging-server" + getOrCreateClientState("hanging-server") + connectShouldHang = true - const addResult = yield* mcp.add("hanging-server", { - type: "local", - command: ["node", "fake.js"], - timeout: 100, - }) + const addResult = yield* mcp.add("hanging-server", { + type: "local", + command: ["node", "fake.js"], + timeout: 100, + }) - const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status - expect(serverStatus.status).toBe("failed") - expect(serverStatus.error).toContain("timed out") - // Transport must be closed to avoid orphaned child process - expect(transportCloseCount).toBeGreaterThanOrEqual(1) - }), - ), + const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status + expect(serverStatus.status).toBe("failed") + expect(serverStatus.error).toContain("timed out") + // Transport must be closed to avoid orphaned child process + expect(transportCloseCount).toBeGreaterThanOrEqual(1) + }), + ), + { config: { mcp: {} } }, ) // ======================================================================== // Test: transport leak — remote timeout (#19168) // ======================================================================== -test( +it.instance( "remote transport is closed when connect times out", - withInstance({}, (mcp) => - Effect.gen(function* () { - lastCreatedClientName = "hanging-remote" - getOrCreateClientState("hanging-remote") - connectShouldHang = true + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "hanging-remote" + getOrCreateClientState("hanging-remote") + connectShouldHang = true - const addResult = yield* mcp.add("hanging-remote", { - type: "remote", - url: "http://localhost:9999/mcp", - timeout: 100, - oauth: false, - }) + const addResult = yield* mcp.add("hanging-remote", { + type: "remote", + url: "http://localhost:9999/mcp", + timeout: 100, + oauth: false, + }) - const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status - expect(serverStatus.status).toBe("failed") - // Transport must be closed to avoid leaked HTTP connections - expect(transportCloseCount).toBeGreaterThanOrEqual(1) - }), - ), + const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status + expect(serverStatus.status).toBe("failed") + // Transport must be closed to avoid leaked HTTP connections + expect(transportCloseCount).toBeGreaterThanOrEqual(1) + }), + ), + { config: { mcp: {} } }, ) // ======================================================================== // Test: transport leak — failed remote transports not closed (#19168) // ======================================================================== -test( +it.instance( "failed remote transport is closed before trying next transport", - withInstance({}, (mcp) => - Effect.gen(function* () { - lastCreatedClientName = "fail-remote" - getOrCreateClientState("fail-remote") - connectShouldFail = true - connectError = "Connection refused" + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "fail-remote" + getOrCreateClientState("fail-remote") + connectShouldFail = true + connectError = "Connection refused" - const addResult = yield* mcp.add("fail-remote", { - type: "remote", - url: "http://localhost:9999/mcp", - timeout: 5000, - oauth: false, - }) + const addResult = yield* mcp.add("fail-remote", { + type: "remote", + url: "http://localhost:9999/mcp", + timeout: 5000, + oauth: false, + }) - const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status - expect(serverStatus.status).toBe("failed") - // Both StreamableHTTP and SSE transports should be closed - expect(transportCloseCount).toBeGreaterThanOrEqual(2) - }), - ), + const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status + expect(serverStatus.status).toBe("failed") + // Both StreamableHTTP and SSE transports should be closed + expect(transportCloseCount).toBeGreaterThanOrEqual(2) + }), + ), + { config: { mcp: {} } }, ) From 913ea36ae61617152ae28a092e0c4f9446dc09a2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 23:13:05 -0400 Subject: [PATCH 17/98] test(server): scope MCP HttpApi handler (#27226) --- .../opencode/test/server/httpapi-mcp.test.ts | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index 136a17d40..be11a431e 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -23,13 +23,19 @@ function app() { return Server.Default().app } type TestApp = ReturnType +type TestHandler = ReturnType -const request = Effect.fnUntraced(function* (route: string, directory: string, init?: RequestInit) { +const handlerScoped = Effect.acquireRelease( + Effect.sync(() => ExperimentalHttpApiServer.webHandler()), + (handler) => Effect.promise(() => handler.dispose()).pipe(Effect.ignore), +) + +const request = Effect.fnUntraced(function* (handler: TestHandler, route: string, directory: string, init?: RequestInit) { const headers = new Headers(init?.headers) headers.set("x-opencode-directory", directory) return yield* Effect.promise(() => Promise.resolve( - ExperimentalHttpApiServer.webHandler().handler( + handler.handler( new Request(`http://localhost${route}`, { ...init, headers, @@ -58,7 +64,8 @@ describe("mcp HttpApi", () => { () => Effect.gen(function* () { const tmp = yield* TestInstance - const response = yield* request(McpPaths.status, tmp.directory) + const handler = yield* handlerScoped + const response = yield* request(handler, McpPaths.status, tmp.directory) expect(response.status).toBe(200) expect(yield* json(response)).toEqual({ demo: { status: "disabled" } }) @@ -81,7 +88,8 @@ describe("mcp HttpApi", () => { () => Effect.gen(function* () { const tmp = yield* TestInstance - const added = yield* request(McpPaths.status, tmp.directory, { + const handler = yield* handlerScoped + const added = yield* request(handler, McpPaths.status, tmp.directory, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -96,11 +104,11 @@ describe("mcp HttpApi", () => { expect(added.status).toBe(200) expect(yield* json(added)).toMatchObject({ added: { status: "disabled" } }) - const connected = yield* request("/mcp/demo/connect", tmp.directory, { method: "POST" }) + const connected = yield* request(handler, "/mcp/demo/connect", tmp.directory, { method: "POST" }) expect(connected.status).toBe(200) expect(yield* json(connected)).toBe(true) - const disconnected = yield* request("/mcp/demo/disconnect", tmp.directory, { method: "POST" }) + const disconnected = yield* request(handler, "/mcp/demo/disconnect", tmp.directory, { method: "POST" }) expect(disconnected.status).toBe(200) expect(yield* json(disconnected)).toBe(true) }), @@ -122,13 +130,14 @@ describe("mcp HttpApi", () => { () => Effect.gen(function* () { const tmp = yield* TestInstance - const start = yield* request("/mcp/demo/auth", tmp.directory, { method: "POST" }) + const handler = yield* handlerScoped + const start = yield* request(handler, "/mcp/demo/auth", tmp.directory, { method: "POST" }) expect(start.status).toBe(400) - const authenticate = yield* request("/mcp/demo/auth/authenticate", tmp.directory, { method: "POST" }) + const authenticate = yield* request(handler, "/mcp/demo/auth/authenticate", tmp.directory, { method: "POST" }) expect(authenticate.status).toBe(400) - const removed = yield* request("/mcp/demo/auth", tmp.directory, { method: "DELETE" }) + const removed = yield* request(handler, "/mcp/demo/auth", tmp.directory, { method: "DELETE" }) expect(removed.status).toBe(200) expect(yield* json(removed)).toEqual({ success: true }) }), From 784796e27a03d1afd8231f2c6f717ca4862ca4f1 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 13 May 2026 03:14:19 +0000 Subject: [PATCH 18/98] chore: generate --- packages/opencode/test/server/httpapi-mcp.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index be11a431e..21cb0cfd2 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -30,7 +30,12 @@ const handlerScoped = Effect.acquireRelease( (handler) => Effect.promise(() => handler.dispose()).pipe(Effect.ignore), ) -const request = Effect.fnUntraced(function* (handler: TestHandler, route: string, directory: string, init?: RequestInit) { +const request = Effect.fnUntraced(function* ( + handler: TestHandler, + route: string, + directory: string, + init?: RequestInit, +) { const headers = new Headers(init?.headers) headers.set("x-opencode-directory", directory) return yield* Effect.promise(() => From 77e51b0a32cf5458d6c50a4fb487b085142b748d Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 12 May 2026 23:19:23 -0400 Subject: [PATCH 19/98] zen: stat worker --- infra/console.ts | 1 + .../app/src/routes/zen/util/handler.ts | 3 ++- .../src/routes/zen/util/modelTpsLimiter.ts | 16 +++++++++++----- packages/console/function/src/stat.ts | 19 ++++++++++--------- sst-env.d.ts | 1 + sst.config.ts | 2 +- 6 files changed, 26 insertions(+), 16 deletions(-) diff --git a/infra/console.ts b/infra/console.ts index c247dfcdb..56befe626 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -301,4 +301,5 @@ new sst.cloudflare.x.SolidStart("Console", { export const stat = new sst.cloudflare.Worker("Stat", { handler: "packages/console/function/src/stat.ts", link: [database], + url: true, }) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 540dfe7e8..a550a6e4c 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -320,6 +320,7 @@ export async function handler( await modelTpsLimiter?.track( providerInfo.id, providerInfo.model, + providerInfo.tpsGoal, timestampFirstByte, timestampLastByte, usageInfo, @@ -525,7 +526,7 @@ export async function handler( }) .filter((provider) => { if (!provider.tpsGoal) return true - const isLowTps = modelTpsLimits?.[`${provider.id}/${provider.model}`] ?? false + const isLowTps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? false return !isLowTps }) .map((provider) => { diff --git a/packages/console/app/src/routes/zen/util/modelTpsLimiter.ts b/packages/console/app/src/routes/zen/util/modelTpsLimiter.ts index 428272eec..477d08ce6 100644 --- a/packages/console/app/src/routes/zen/util/modelTpsLimiter.ts +++ b/packages/console/app/src/routes/zen/util/modelTpsLimiter.ts @@ -5,7 +5,7 @@ import { UsageInfo } from "./provider/provider" export function createModelTpsLimiter(providers: { id: string; model: string; tpsGoal?: number }[]) { const tpsGoals = Object.fromEntries( providers.flatMap((p) => { - return p.tpsGoal ? [[`${p.id}/${p.model}`, p.tpsGoal]] : [] + return p.tpsGoal ? [[`${p.id}/${p.model}/${p.tpsGoal}`, p.tpsGoal]] : [] }), ) const ids = Object.keys(tpsGoals) @@ -56,11 +56,17 @@ export function createModelTpsLimiter(providers: { id: string; model: string; tp }), ) }, - track: async (provider: string, model: string, tsFirstByte: number, tsLastByte: number, usageInfo: UsageInfo) => { - const id = `${provider}/${model}` - if (!ids.includes(id)) return - const tpsGoal = tpsGoals[id] + track: async ( + provider: string, + model: string, + tpsGoal: number | undefined, + tsFirstByte: number, + tsLastByte: number, + usageInfo: UsageInfo, + ) => { if (!tpsGoal) return + const id = `${provider}/${model}/${tpsGoal}` + if (!ids.includes(id)) return if (tsFirstByte <= 0 || tsLastByte <= 0) return const tokens = usageInfo.outputTokens if (tokens <= 10) return diff --git a/packages/console/function/src/stat.ts b/packages/console/function/src/stat.ts index 078aabb51..9a1a1cc14 100644 --- a/packages/console/function/src/stat.ts +++ b/packages/console/function/src/stat.ts @@ -1,16 +1,17 @@ -import { WorkerEntrypoint } from "cloudflare:workers" import { and, Database, inArray } from "@opencode-ai/console-core/drizzle/index.js" import { ModelTpsRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js" +type Entry = { provider: string; model: string; tps: number } type Result = Record -export default class Stat extends WorkerEntrypoint { - async fetch() { - return new Response("Not Found", { status: 404 }) - } +export default { + async fetch(request: Request) { + if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 }) - async getStats(ids: string[]): Promise { - if (ids.length === 0) return {} + const entries = (await request.json()) as Entry[] + if (!Array.isArray(entries) || entries.length === 0) return Response.json({} satisfies Result) + + const ids = entries.map((e) => `${e.provider}/${e.model}/${e.tps}`) const toInterval = (date: Date) => parseInt( @@ -34,6 +35,6 @@ export default class Stat extends WorkerEntrypoint { result[row.id].qualify += row.qualify result[row.id].unqualify += row.unqualify } - return result - } + return Response.json(result) + }, } diff --git a/sst-env.d.ts b/sst-env.d.ts index a55dd4bb4..b75b4bd6e 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -155,6 +155,7 @@ declare module "sst" { } "Stat": { "type": "sst.cloudflare.Worker" + "url": string } "Teams": { "type": "sst.cloudflare.SolidStart" diff --git a/sst.config.ts b/sst.config.ts index 2feb3ac24..4bf7cc6a8 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -26,7 +26,7 @@ export default $config({ } return { - STAT_WORKER_NAME: stat.nodes.worker.scriptName, + StatWorkerUrl: stat.url, } }, }) From 8249baeb4ecded9f1f2ef6bd5c5d1f0907e984ed Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 23:20:20 -0400 Subject: [PATCH 20/98] test(pty): migrate shell tests to Effect runner (#27238) --- packages/opencode/test/pty/pty-shell.test.ts | 114 +++++++------------ 1 file changed, 42 insertions(+), 72 deletions(-) diff --git a/packages/opencode/test/pty/pty-shell.test.ts b/packages/opencode/test/pty/pty-shell.test.ts index 00e965d25..e8132dec7 100644 --- a/packages/opencode/test/pty/pty-shell.test.ts +++ b/packages/opencode/test/pty/pty-shell.test.ts @@ -1,39 +1,35 @@ -import { describe, expect, test } from "bun:test" -import { AppRuntime } from "../../src/effect/app-runtime" +import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { Pty } from "../../src/pty" import { Shell } from "../../src/shell/shell" -import { tmpdir } from "../fixture/fixture" +import { testEffect } from "../lib/effect" Shell.preferred.reset() +const it = testEffect(Pty.defaultLayer) + +const createPty = (input: Pty.CreateInput) => + Effect.acquireRelease( + Effect.gen(function* () { + const pty = yield* Pty.Service + const info = yield* pty.create(input) + return { pty, info } + }), + ({ pty, info }) => pty.remove(info.id).pipe(Effect.ignore), + ).pipe(Effect.map(({ info }) => info)) + describe("pty shell args", () => { if (process.platform !== "win32") return const ps = Bun.which("pwsh") || Bun.which("powershell") if (ps) { - test( + it.instance( "does not add login args to pwsh", - async () => { - await using dir = await tmpdir() - await WithInstance.provide({ - directory: dir.path, - fn: () => - AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - const info = yield* pty.create({ command: ps, title: "pwsh" }) - try { - expect(info.args).toEqual([]) - } finally { - yield* pty.remove(info.id) - } - }), - ), - }) - }, + () => + Effect.gen(function* () { + const info = yield* createPty({ command: ps, title: "pwsh" }) + expect(info.args).toEqual([]) + }), { timeout: 30000 }, ) } @@ -44,62 +40,36 @@ describe("pty shell args", () => { return Shell.gitbash() })() if (bash) { - test( + it.instance( "adds login args to bash", - async () => { - await using dir = await tmpdir() - await WithInstance.provide({ - directory: dir.path, - fn: () => - AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - const info = yield* pty.create({ command: bash, title: "bash" }) - try { - expect(info.args).toEqual(["-l"]) - } finally { - yield* pty.remove(info.id) - } - }), - ), - }) - }, + () => + Effect.gen(function* () { + const info = yield* createPty({ command: bash, title: "bash" }) + expect(info.args).toEqual(["-l"]) + }), { timeout: 30000 }, ) } }) describe("pty configured shell", () => { - test( - "uses configured shell for default PTY command", - async () => { - const configured = process.platform === "win32" ? Bun.which("pwsh") || Bun.which("powershell") : Bun.which("bash") - if (!configured) return + const configured = process.platform === "win32" ? Bun.which("pwsh") || Bun.which("powershell") : Bun.which("bash") - await using dir = await tmpdir({ - config: { shell: Shell.name(configured) }, - }) - await WithInstance.provide({ - directory: dir.path, - fn: () => - AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - const info = yield* pty.create({ title: "configured" }) - try { - if (process.platform === "win32") { - expect(info.command.toLowerCase()).toBe(configured.toLowerCase()) - } else { - expect(info.command).toBe(configured) - } - expect(info.args).toEqual(process.platform === "win32" ? [] : ["-l"]) - } finally { - yield* pty.remove(info.id) - } - }), - ), - }) - }, + it.instance( + "uses configured shell for default PTY command", + () => + Effect.gen(function* () { + if (!configured) return + + const info = yield* createPty({ title: "configured" }) + if (process.platform === "win32") { + expect(info.command.toLowerCase()).toBe(configured.toLowerCase()) + } else { + expect(info.command).toBe(configured) + } + expect(info.args).toEqual(process.platform === "win32" ? [] : ["-l"]) + }), + configured ? { config: { shell: Shell.name(configured) } } : undefined, { timeout: 30000 }, ) }) From 46daede10c55288220dab68e61e90b7ca56f1eb7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 23:25:52 -0400 Subject: [PATCH 21/98] test(pty): migrate output isolation to Effect runner (#27235) --- .../test/pty/pty-output-isolation.test.ts | 271 +++++++++--------- 1 file changed, 143 insertions(+), 128 deletions(-) diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts index 662042b64..0fa710f02 100644 --- a/packages/opencode/test/pty/pty-output-isolation.test.ts +++ b/packages/opencode/test/pty/pty-output-isolation.test.ts @@ -1,147 +1,162 @@ -import { describe, expect, test } from "bun:test" -import { AppRuntime } from "../../src/effect/app-runtime" -import { Effect } from "effect" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" +import { describe, expect } from "bun:test" +import { Bus } from "../../src/bus" +import { Config } from "../../src/config/config" +import { Plugin } from "../../src/plugin" import { Pty } from "../../src/pty" -import { tmpdir } from "../fixture/fixture" -import { setTimeout as sleep } from "node:timers/promises" +import { Duration, Effect, Layer, Queue } from "effect" +import { testEffect } from "../lib/effect" + +type Socket = Parameters[1] + +const it = testEffect( + Pty.layer.pipe( + Layer.provideMerge(Bus.layer), + Layer.provideMerge(Config.defaultLayer), + Layer.provideMerge(Plugin.defaultLayer), + ), +) +const ptyTest = process.platform === "win32" ? it.instance.skip : it.instance + +const createPty = Effect.fn("PtyOutputIsolationTest.createPty")(function* (input: Pty.CreateInput) { + const pty = yield* Pty.Service + return yield* Effect.acquireRelease(pty.create(input), (info) => pty.remove(info.id).pipe(Effect.ignore)) +}) + +const decodeOutput = (data: string | Uint8Array | ArrayBuffer) => + typeof data === "string" + ? data + : Buffer.from(data instanceof Uint8Array ? data : new Uint8Array(data)).toString("utf8") + +const makeSocket = Effect.fn("PtyOutputIsolationTest.makeSocket")(function* (data: unknown) { + const output = yield* Queue.unbounded() + const chunks: string[] = [] + const socket: Socket = { + readyState: 1, + data, + send: (data) => { + const text = decodeOutput(data) + chunks.push(text) + Queue.offerUnsafe(output, text) + }, + close: () => { + // no-op (simulate abrupt drop) + }, + } + + return { socket, output, chunks } +}) + +const waitForOutput = (output: Queue.Queue, text: string, duration: Duration.Input = "5 seconds") => + Effect.gen(function* () { + let received = "" + while (!received.includes(text)) { + received += yield* Queue.take(output) + } + return received + }).pipe( + Effect.timeoutOrElse({ + duration, + orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)), + }), + ) + +const waitForLeakedOutput = (output: Queue.Queue, text: string) => + Effect.gen(function* () { + let received = "" + while (!received.includes(text)) { + received += yield* Queue.take(output) + } + return received + }).pipe( + Effect.timeoutOrElse({ + duration: "100 millis", + orElse: () => Effect.succeed(undefined), + }), + ) describe("pty", () => { - test("does not leak output when websocket objects are reused", async () => { - await using dir = await tmpdir({ git: true }) + ptyTest( + "does not leak output when websocket objects are reused", + () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const a = yield* createPty({ command: "cat", title: "a" }) + const b = yield* createPty({ command: "cat", title: "b" }) + const connectionA = yield* makeSocket({ events: { connection: "a" } }) + const connectionB = { events: { connection: "b" } } - await WithInstance.provide({ - directory: dir.path, - fn: () => - AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - const a = yield* pty.create({ command: "cat", title: "a" }) - const b = yield* pty.create({ command: "cat", title: "b" }) - try { - const outA: string[] = [] - const outB: string[] = [] + yield* pty.connect(a.id, connectionA.socket) - const ws = { - readyState: 1, - data: { events: { connection: "a" } }, - send: (data: unknown) => { - outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) - }, - close: () => { - // no-op (simulate abrupt drop) - }, - } + const outBQueue = yield* Queue.unbounded() + const outB: string[] = [] + connectionA.socket.data = connectionB + connectionA.socket.send = (data) => { + const text = decodeOutput(data) + outB.push(text) + Queue.offerUnsafe(outBQueue, text) + } + yield* pty.connect(b.id, connectionA.socket) - yield* pty.connect(a.id, ws as any) + connectionA.chunks.length = 0 + outB.length = 0 - ws.data = { events: { connection: "b" } } - ws.send = (data: unknown) => { - outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) - } - yield* pty.connect(b.id, ws as any) + yield* pty.write(a.id, "AAA\n") + const verifyA = yield* makeSocket({ events: { connection: "verify-a" } }) + yield* pty.connect(a.id, verifyA.socket) + yield* waitForOutput(verifyA.output, "AAA") - outA.length = 0 - outB.length = 0 + expect(outB.join("")).not.toContain("AAA") + expect(yield* waitForLeakedOutput(outBQueue, "AAA")).toBeUndefined() + }), + { git: true }, + ) - yield* pty.write(a.id, "AAA\n") - yield* Effect.promise(() => sleep(100)) + ptyTest( + "does not leak output when Bun recycles websocket objects before re-connect", + () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const a = yield* createPty({ command: "cat", title: "a" }) + const outA = yield* makeSocket({ events: { connection: "a" } }) + const outB = yield* Queue.unbounded() - expect(outB.join("")).not.toContain("AAA") - } finally { - yield* pty.remove(a.id) - yield* pty.remove(b.id) - } - }), - ), - }) - }) + yield* pty.connect(a.id, outA.socket) + outA.chunks.length = 0 - test("does not leak output when Bun recycles websocket objects before re-connect", async () => { - await using dir = await tmpdir({ git: true }) + const connectionB = { events: { connection: "b" } } + outA.socket.data = connectionB + outA.socket.send = (data) => { + Queue.offerUnsafe(outB, decodeOutput(data)) + } - await WithInstance.provide({ - directory: dir.path, - fn: () => - AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - const a = yield* pty.create({ command: "cat", title: "a" }) - try { - const outA: string[] = [] - const outB: string[] = [] + yield* pty.write(a.id, "AAA\n") + const verifyA = yield* makeSocket({ events: { connection: "verify-a" } }) + yield* pty.connect(a.id, verifyA.socket) + yield* waitForOutput(verifyA.output, "AAA") - const ws = { - readyState: 1, - data: { events: { connection: "a" } }, - send: (data: unknown) => { - outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) - }, - close: () => { - // no-op (simulate abrupt drop) - }, - } + expect(yield* waitForLeakedOutput(outB, "AAA")).toBeUndefined() + }), + { git: true }, + ) - yield* pty.connect(a.id, ws as any) - outA.length = 0 + ptyTest( + "treats in-place socket data mutation as the same connection", + () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const a = yield* createPty({ command: "cat", title: "a" }) + const ctx = { connId: 1 } + const out = yield* makeSocket(ctx) - ws.data = { events: { connection: "b" } } - ws.send = (data: unknown) => { - outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) - } + yield* pty.connect(a.id, out.socket) + out.chunks.length = 0 - yield* pty.write(a.id, "AAA\n") - yield* Effect.promise(() => sleep(100)) + ctx.connId = 2 - expect(outB.join("")).not.toContain("AAA") - } finally { - yield* pty.remove(a.id) - } - }), - ), - }) - }) + yield* pty.write(a.id, "AAA\n") - test("treats in-place socket data mutation as the same connection", async () => { - await using dir = await tmpdir({ git: true }) - - await WithInstance.provide({ - directory: dir.path, - fn: () => - AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - const a = yield* pty.create({ command: "cat", title: "a" }) - try { - const out: string[] = [] - - const ctx = { connId: 1 } - const ws = { - readyState: 1, - data: ctx, - send: (data: unknown) => { - out.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) - }, - close: () => { - // no-op - }, - } - - yield* pty.connect(a.id, ws as any) - out.length = 0 - - ctx.connId = 2 - - yield* pty.write(a.id, "AAA\n") - yield* Effect.promise(() => sleep(100)) - - expect(out.join("")).toContain("AAA") - } finally { - yield* pty.remove(a.id) - } - }), - ), - }) - }) + expect(yield* waitForOutput(out.output, "AAA")).toContain("AAA") + }), + { git: true }, + ) }) From ad6a8a185045d691b3f293b6a02629bcb9e61740 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 13 May 2026 11:28:08 +0800 Subject: [PATCH 22/98] fix: use htmlrewriter2 instead of HTMLRewriter for node compat (#26309) --- bun.lock | 1 + packages/opencode/package.json | 1 + packages/opencode/src/tool/webfetch.ts | 48 ++++++++------------ packages/opencode/test/tool/webfetch.test.ts | 19 ++++++++ 4 files changed, 41 insertions(+), 28 deletions(-) diff --git a/bun.lock b/bun.lock index 76602e885..e8ff7adaf 100644 --- a/bun.lock +++ b/bun.lock @@ -430,6 +430,7 @@ "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", + "htmlparser2": "8.0.2", "ignore": "7.0.5", "immer": "11.1.4", "jsonc-parser": "3.3.1", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index b0427f231..48cb7b345 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -141,6 +141,7 @@ "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", + "htmlparser2": "8.0.2", "ignore": "7.0.5", "immer": "11.1.4", "jsonc-parser": "3.3.1", diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 8c2be44e9..f8a4b6233 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -1,5 +1,6 @@ import { Effect, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { Parser } from "htmlparser2" import * as Tool from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" @@ -139,8 +140,7 @@ export const WebFetchTool = Tool.define( case "text": if (contentType.includes("text/html")) { - const text = yield* Effect.promise(() => extractTextFromHTML(content)) - return { output: text, title, metadata: {} } + return { output: extractTextFromHTML(content), title, metadata: {} } } return { output: content, title, metadata: {} } @@ -155,35 +155,27 @@ export const WebFetchTool = Tool.define( }), ) -async function extractTextFromHTML(html: string) { +function extractTextFromHTML(html: string) { let text = "" - let skipContent = false + let skipDepth = 0 - const rewriter = new HTMLRewriter() - .on("script, style, noscript, iframe, object, embed", { - element() { - skipContent = true - }, - text() { - // Skip text content inside these elements - }, - }) - .on("*", { - element(element) { - // Reset skip flag when entering other elements - if (!["script", "style", "noscript", "iframe", "object", "embed"].includes(element.tagName)) { - skipContent = false - } - }, - text(input) { - if (!skipContent) { - text += input.text - } - }, - }) - .transform(new Response(html)) + const parser = new Parser({ + onopentag(name) { + if (skipDepth > 0 || ["script", "style", "noscript", "iframe", "object", "embed"].includes(name)) { + skipDepth++ + } + }, + ontext(input) { + if (skipDepth === 0) text += input + }, + onclosetag() { + if (skipDepth > 0) skipDepth-- + }, + }) + + parser.write(html) + parser.end() - await rewriter.text() return text.trim() } diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index 804c6bde2..fdf5210b9 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -91,4 +91,23 @@ describe("tool.webfetch", () => { }), ), ) + + it.instance("extracts text from html without scripts or styles", () => + withFetch( + () => + new Response( + "Hello world", + { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }, + ), + (url) => + Effect.gen(function* () { + const result = yield* exec({ url: new URL("/page.html", url).toString(), format: "text" }) + expect(result.output).toBe("Hello world") + expect(result.attachments).toBeUndefined() + }), + ), + ) }) From c5961205ef2e0d10b77cf829271711e5e7cc83c6 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 13 May 2026 03:44:43 +0000 Subject: [PATCH 23/98] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 70372b820..0bba38a2c 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-CF+ecq3PqUuA7ta09oYkDaFz+FxZqQragu9xvtw++RE=", - "aarch64-linux": "sha256-QVdgdCncl+XEIx569oNNRXyxRh5mkEMJSTSO1I3QfzM=", - "aarch64-darwin": "sha256-dfP1SEpgQ63DEfG4eRD7BWGoMPFmjGSWiW4M0J4Asfg=", - "x86_64-darwin": "sha256-JgXg+BwZTWroChnxE6akoEFfdb7vVH0qBMZSZ7TJhDU=" + "x86_64-linux": "sha256-xZyIgqow1wVh0Kfpb5GLUUHsE3jyfqJfrZ9Qykml008=", + "aarch64-linux": "sha256-tbbne63KImq4EQrPi45l9YG1dY/SO7b1ZKkLjDfZhWg=", + "aarch64-darwin": "sha256-PYsiSMkASbcZxqMXb7UfbkRTiQae6xzseMNhDP+/y5g=", + "x86_64-darwin": "sha256-Qnj9FAgXWyiB6U5NyIsRw7aNVNexAagETr07Jwde908=" } } From 3be65dff4622f4bb6a0a64d26d16cdbe33fb1ee3 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 13 May 2026 12:09:36 +0800 Subject: [PATCH 24/98] fix: add optional chaining to session_status access (#27247) --- packages/app/src/context/global-sync/child-store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index af90fab6b..0c4c68b7f 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -209,7 +209,7 @@ export function createChildStoreManager(input: { sessionTotal: 0, session_status: {}, session_working(id: string) { - return this.session_status[id].type !== "idle" + return this.session_status[id]?.type !== "idle" }, session_diff: {}, todo: {}, From 67e6408ceff89ec3ed3f56c8e96df539607e8504 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 12 May 2026 23:21:31 -0500 Subject: [PATCH 25/98] fix: disable image module for now (#27248) --- packages/opencode/src/session/processor.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 579c4cc42..506ec0c40 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -402,10 +402,14 @@ export const layer: Layer.Layer< typeof attachment.mime === "string" && typeof attachment.url === "string", ) + // temporarily disabled + // const normalized = yield* Effect.forEach(toolAttachments, (attachment) => + // attachment.mime.startsWith("image/") + // ? image.normalize(attachment).pipe(Effect.exit) + // : Effect.succeed(Exit.succeed(attachment)), + // ) const normalized = yield* Effect.forEach(toolAttachments, (attachment) => - attachment.mime.startsWith("image/") - ? image.normalize(attachment).pipe(Effect.exit) - : Effect.succeed(Exit.succeed(attachment)), + Effect.succeed(Exit.succeed(attachment)), ) const omitted = normalized.filter(Exit.isFailure).length const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value) @@ -414,7 +418,7 @@ export const layer: Layer.Layer< output: omitted === 0 ? value.output.output - : `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the inline image size limit.]`, + : `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`, attachments: attachments?.length ? attachments : undefined, } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. From 90c13d93f3a2fb2df80e798668dccb3b973e5423 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 10:15:28 +0530 Subject: [PATCH 26/98] fix(server): hide unknown 500 details (#27251) --- .../instance/httpapi/middleware/error.ts | 2 +- .../server/httpapi-error-middleware.test.ts | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/server/httpapi-error-middleware.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index 6f3c33a64..585f4bd18 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -48,7 +48,7 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) return Effect.succeed( HttpServerResponse.jsonUnsafe( new NamedError.Unknown({ - message: error instanceof Error && error.stack ? error.stack : String(error), + message: "Unexpected server error. Check server logs for details.", }).toObject(), { status: 500 }, ), diff --git a/packages/opencode/test/server/httpapi-error-middleware.test.ts b/packages/opencode/test/server/httpapi-error-middleware.test.ts new file mode 100644 index 000000000..a07e31382 --- /dev/null +++ b/packages/opencode/test/server/httpapi-error-middleware.test.ts @@ -0,0 +1,30 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" +import { errorLayer } from "../../src/server/routes/instance/httpapi/middleware/error" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(NodeHttpServer.layerTest, NodeServices.layer)) + +describe("HttpApi error middleware", () => { + it.live("returns a safe body for unknown 500 defects", () => + Effect.gen(function* () { + yield* HttpRouter.add("GET", "/boom", Effect.die(new Error("secret stack marker"))).pipe( + Layer.provide(errorLayer), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClientRequest.get("/boom").pipe(HttpClient.execute) + const body = yield* response.json + + expect(response.status).toBe(500) + expect(body).toEqual({ + name: "UnknownError", + data: { message: "Unexpected server error. Check server logs for details." }, + }) + expect(JSON.stringify(body)).not.toContain("secret stack marker") + }), + ) +}) From 10b99b2f5a3b5c8bf69a1542d21b1ee14145d1d3 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 13 May 2026 12:52:18 +0800 Subject: [PATCH 27/98] build(ci): use native arm64 runner for desktop linux arm64 builds (#27250) --- .github/workflows/publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bef1e7029..5f8bac973 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -244,9 +244,9 @@ jobs: - host: "blacksmith-4vcpu-ubuntu-2404" target: x86_64-unknown-linux-gnu platform_flag: --linux - - host: "blacksmith-4vcpu-ubuntu-2404" + - host: "blacksmith-4vcpu-ubuntu-2404-arm" target: aarch64-unknown-linux-gnu - platform_flag: --linux + platform_flag: --linux --arm64 runs-on: ${{ matrix.settings.host }} steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 From 28204720dcb6f3d42d1015432fff0acecef10b3a Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 12 May 2026 23:55:45 -0500 Subject: [PATCH 28/98] temporarily revert: preserve permission ordering by accepting a layered array (#27258) --- packages/opencode/src/agent/agent.ts | 6 +- packages/opencode/src/config/config.ts | 25 +-- packages/opencode/src/config/permission.ts | 8 - packages/opencode/test/config/config.test.ts | 178 +----------------- .../opencode/test/permission-task.test.ts | 25 +-- 5 files changed, 18 insertions(+), 224 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 74ca1a402..423a51318 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -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 = { build: { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 78bb83ed8..545e48e64 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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", @@ -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 = {} 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 diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index c780d2443..1092ae2b7 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -56,11 +56,3 @@ export const Info = InputSchema.pipe( ).annotate({ identifier: "PermissionConfig" }) type _Info = Schema.Schema.Type 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] -} diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index a2e439177..90e78efcd 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -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 + 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 expect(bash?.["rm -rf *"]).toBe("deny") expect(bash?.["curl *"]).toBe("deny") }) diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index 77ceda8a4..64b93bb8b 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -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") From bed88ce50ebcb11a3f4fc801280b380b6aa65208 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 13 May 2026 04:56:52 +0000 Subject: [PATCH 29/98] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 5 +---- packages/sdk/openapi.json | 13 +------------ 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9789d3099..72b2a9f16 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1263,10 +1263,7 @@ export type Config = { } instructions?: Array 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 + permission?: PermissionConfig tools?: { [key: string]: boolean } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b6f421dd0..fbbe237ec 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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", From 4aaece29d9a37a225597021d9df2edb72f017410 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 13 May 2026 13:14:39 +0800 Subject: [PATCH 30/98] feat(desktop): reintroduce AppStream MetaInfo for Linux desktop builds (#27253) --- packages/desktop/.gitignore | 1 + packages/desktop/scripts/copy-metainfo.ts | 47 +++++++++++++++++++++++ packages/desktop/scripts/prebuild.ts | 1 + 3 files changed, 49 insertions(+) create mode 100644 packages/desktop/scripts/copy-metainfo.ts diff --git a/packages/desktop/.gitignore b/packages/desktop/.gitignore index ac9d8db96..6923045cd 100644 --- a/packages/desktop/.gitignore +++ b/packages/desktop/.gitignore @@ -26,3 +26,4 @@ out/ resources/opencode-cli* resources/icons +resources/*.metainfo.xml diff --git a/packages/desktop/scripts/copy-metainfo.ts b/packages/desktop/scripts/copy-metainfo.ts new file mode 100644 index 000000000..e7585ccaf --- /dev/null +++ b/packages/desktop/scripts/copy-metainfo.ts @@ -0,0 +1,47 @@ +import { resolveChannel } from "./utils" + +const arg = process.argv[2] +const channel = arg === "dev" || arg === "beta" || arg === "prod" ? arg : resolveChannel() + +const appId = channel === "prod" ? "ai.opencode.desktop" : `ai.opencode.desktop.${channel}` +const productName = channel === "prod" ? "OpenCode" : `OpenCode ${channel.charAt(0).toUpperCase() + channel.slice(1)}` +const summary = `Open source AI coding agent${channel !== "prod" ? ` (${channel})` : ""}` + +const xml = ` + + ${appId} + + CC0-1.0 + MIT + + ${productName} + ${summary} + + + Anomaly Innovations Inc. + + + +

+ OpenCode is an open source agent that helps you write and run code with any AI model. +

+
+ + ${appId}.desktop + + + + https://github.com/anomalyco/opencode/issues + https://opencode.ai + https://github.com/anomalyco/opencode + + + + https://raw.githubusercontent.com/anomalyco/opencode/b75d4d1c5ec449585d515c756fc81f080a157a9a/packages/web/src/assets/lander/screenshot.png + + +
+` + +await Bun.write(`resources/${appId}.metainfo.xml`, xml) +console.log(`Generated metainfo for ${channel} at resources/${appId}.metainfo.xml`) diff --git a/packages/desktop/scripts/prebuild.ts b/packages/desktop/scripts/prebuild.ts index 46a2475ea..79b0e30af 100644 --- a/packages/desktop/scripts/prebuild.ts +++ b/packages/desktop/scripts/prebuild.ts @@ -5,5 +5,6 @@ import { resolveChannel } from "./utils" const channel = resolveChannel() await $`bun ./scripts/copy-icons.ts ${channel}` +await $`bun ./scripts/copy-metainfo.ts ${channel}` await $`cd ../opencode && bun script/build-node.ts` From 367665dba23f967202a3b4ab6f01c63ca5e83440 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 11:01:18 +0530 Subject: [PATCH 31/98] fix(cli): render tagged config errors (#27256) --- packages/opencode/src/cli/error.ts | 62 +++++++++++++++++------- packages/opencode/test/cli/error.test.ts | 35 +++++++++++++ 2 files changed, 79 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 6fd7b573e..9551fac1e 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -5,13 +5,37 @@ interface ErrorLike { name?: string _tag?: string message?: string - data?: Record + data?: Record +} + +type ConfigIssue = { message: string; path: string[] } + +function isRecord(input: unknown): input is Record { + return typeof input === "object" && input !== null } function isTaggedError(error: unknown, tag: string): boolean { - return ( - typeof error === "object" && error !== null && "_tag" in error && (error as Record)._tag === tag - ) + return isRecord(error) && error._tag === tag +} + +function configData(input: unknown, tag: string): Record | undefined { + if (!isRecord(input)) return undefined + if (input.name === tag && isRecord(input.data)) return input.data + if (input._tag === tag) return input + return undefined +} + +function stringField(input: Record, key: string): string | undefined { + return typeof input[key] === "string" ? input[key] : undefined +} + +function configIssues(input: Record): ConfigIssue[] { + return Array.isArray(input.issues) + ? input.issues.filter((issue): issue is ConfigIssue => { + if (!isRecord(issue)) return false + return typeof issue.message === "string" && Array.isArray(issue.path) && issue.path.every((x) => typeof x === "string") + }) + : [] } export function FormatError(input: unknown) { @@ -35,7 +59,7 @@ export function FormatError(input: unknown) { // ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] } if (NamedError.hasName(input, "ProviderModelNotFoundError")) { const data = (input as ErrorLike).data - const suggestions: string[] = Array.isArray(data?.suggestions) ? data.suggestions : [] + const suggestions = Array.isArray(data?.suggestions) ? data.suggestions.filter((x) => typeof x === "string") : [] return [ `Model not found: ${data?.providerID}/${data?.modelID}`, ...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), @@ -50,28 +74,30 @@ export function FormatError(input: unknown) { } // ConfigJsonError: { path: string, message?: string } - if (NamedError.hasName(input, "ConfigJsonError")) { - const data = (input as ErrorLike).data - return `Config file at ${data?.path} is not valid JSON(C)` + (data?.message ? `: ${data.message}` : "") + const configJson = configData(input, "ConfigJsonError") + if (configJson) { + const message = stringField(configJson, "message") + return `Config file at ${stringField(configJson, "path")} is not valid JSON(C)` + (message ? `: ${message}` : "") } // ConfigDirectoryTypoError: { dir: string, path: string, suggestion: string } - if (NamedError.hasName(input, "ConfigDirectoryTypoError")) { - const data = (input as ErrorLike).data - return `Directory "${data?.dir}" in ${data?.path} is not valid. Rename the directory to "${data?.suggestion}" or remove it. This is a common typo.` + const configDirectoryTypo = configData(input, "ConfigDirectoryTypoError") + if (configDirectoryTypo) { + return `Directory "${stringField(configDirectoryTypo, "dir")}" in ${stringField(configDirectoryTypo, "path")} is not valid. Rename the directory to "${stringField(configDirectoryTypo, "suggestion")}" or remove it. This is a common typo.` } // ConfigFrontmatterError: { message: string } - if (NamedError.hasName(input, "ConfigFrontmatterError")) { - return (input as ErrorLike).data?.message ?? "" + const configFrontmatter = configData(input, "ConfigFrontmatterError") + if (configFrontmatter) { + return stringField(configFrontmatter, "message") ?? "" } // ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> } - if (NamedError.hasName(input, "ConfigInvalidError")) { - const data = (input as ErrorLike).data - const path = data?.path - const message = data?.message - const issues: Array<{ message: string; path: string[] }> = Array.isArray(data?.issues) ? data.issues : [] + const configInvalid = configData(input, "ConfigInvalidError") + if (configInvalid) { + const path = stringField(configInvalid, "path") + const message = stringField(configInvalid, "message") + const issues = configIssues(configInvalid) return [ `Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""), ...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")), diff --git a/packages/opencode/test/cli/error.test.ts b/packages/opencode/test/cli/error.test.ts index b4d1dbeda..e9ede9cdb 100644 --- a/packages/opencode/test/cli/error.test.ts +++ b/packages/opencode/test/cli/error.test.ts @@ -4,6 +4,41 @@ import { FormatError } from "../../src/cli/error" import { UI } from "../../src/cli/ui" describe("cli.error", () => { + test("formats legacy and tagged config errors the same way", () => { + const cases = [ + { + tag: "ConfigJsonError", + data: { path: "/tmp/opencode.jsonc", message: "Unexpected token" }, + expected: "Config file at /tmp/opencode.jsonc is not valid JSON(C): Unexpected token", + }, + { + tag: "ConfigDirectoryTypoError", + data: { path: "/tmp/opencode.jsonc", dir: ".opencode", suggestion: "opencode" }, + expected: + 'Directory ".opencode" in /tmp/opencode.jsonc is not valid. Rename the directory to "opencode" or remove it. This is a common typo.', + }, + { + tag: "ConfigFrontmatterError", + data: { path: "/tmp/AGENTS.md", message: "failed frontmatter" }, + expected: "failed frontmatter", + }, + { + tag: "ConfigInvalidError", + data: { + path: "/tmp/opencode.jsonc", + message: "schema mismatch", + issues: [{ message: "Expected string", path: ["provider", "id"] }], + }, + expected: "Configuration is invalid at /tmp/opencode.jsonc: schema mismatch\n↳ Expected string provider.id", + }, + ] + + for (const item of cases) { + expect(FormatError({ name: item.tag, data: item.data })).toBe(item.expected) + expect(FormatError({ _tag: item.tag, ...item.data })).toBe(item.expected) + } + }) + test("formats account transport errors clearly", () => { const error = new AccountTransportError({ method: "POST", From d3d7b44e73e4b63f2fe382cc95613759ba5f4525 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 13 May 2026 05:32:32 +0000 Subject: [PATCH 32/98] chore: generate --- packages/opencode/src/cli/error.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 9551fac1e..2bb0cb51f 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -33,7 +33,11 @@ function configIssues(input: Record): ConfigIssue[] { return Array.isArray(input.issues) ? input.issues.filter((issue): issue is ConfigIssue => { if (!isRecord(issue)) return false - return typeof issue.message === "string" && Array.isArray(issue.path) && issue.path.every((x) => typeof x === "string") + return ( + typeof issue.message === "string" && + Array.isArray(issue.path) && + issue.path.every((x) => typeof x === "string") + ) }) : [] } From dd46fddf22c17d5177a3a4efa17ca720d4b6327d Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 11:10:12 +0530 Subject: [PATCH 33/98] test(cli): cover config json diagnostics (#27257) --- packages/opencode/test/cli/error.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/opencode/test/cli/error.test.ts b/packages/opencode/test/cli/error.test.ts index e9ede9cdb..09119a4ff 100644 --- a/packages/opencode/test/cli/error.test.ts +++ b/packages/opencode/test/cli/error.test.ts @@ -39,6 +39,17 @@ describe("cli.error", () => { } }) + test("preserves multiline JSONC diagnostics for tagged config errors", () => { + const data = { + path: "/tmp/opencode.jsonc", + message: '\n--- JSONC Input ---\n{\n "model": \n}\n--- Errors ---\nValueExpected at line 3, column 1\n Line 3: }\n ^\n--- End ---', + } + const expected = `Config file at ${data.path} is not valid JSON(C): ${data.message}` + + expect(FormatError({ name: "ConfigJsonError", data })).toBe(expected) + expect(FormatError({ _tag: "ConfigJsonError", ...data })).toBe(expected) + }) + test("formats account transport errors clearly", () => { const error = new AccountTransportError({ method: "POST", From 9fe912439b23b44e0176239f6dc91231c7d7a249 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 13 May 2026 05:41:29 +0000 Subject: [PATCH 34/98] chore: generate --- packages/opencode/test/cli/error.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/cli/error.test.ts b/packages/opencode/test/cli/error.test.ts index 09119a4ff..6c5ab265b 100644 --- a/packages/opencode/test/cli/error.test.ts +++ b/packages/opencode/test/cli/error.test.ts @@ -42,7 +42,8 @@ describe("cli.error", () => { test("preserves multiline JSONC diagnostics for tagged config errors", () => { const data = { path: "/tmp/opencode.jsonc", - message: '\n--- JSONC Input ---\n{\n "model": \n}\n--- Errors ---\nValueExpected at line 3, column 1\n Line 3: }\n ^\n--- End ---', + message: + '\n--- JSONC Input ---\n{\n "model": \n}\n--- Errors ---\nValueExpected at line 3, column 1\n Line 3: }\n ^\n--- End ---', } const expected = `Config file at ${data.path} is not valid JSON(C): ${data.message}` From d93a06431e8746fb0b5df2ad2431a6e56b79d050 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 13 May 2026 14:36:00 +0800 Subject: [PATCH 35/98] refactor(app): clarify session_working logic in child-store (#27267) --- packages/app/src/context/global-sync/child-store.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 0c4c68b7f..08299b301 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -209,7 +209,8 @@ export function createChildStoreManager(input: { sessionTotal: 0, session_status: {}, session_working(id: string) { - return this.session_status[id]?.type !== "idle" + const type = this.session_status[id]?.type + return (type ?? "idle") !== "idle" }, session_diff: {}, todo: {}, From e9a29e4908fa1d28213e5b4788611e9cde1a92f2 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 12:25:04 +0530 Subject: [PATCH 36/98] fix(storage): type not found errors (#27265) --- .../httpapi/handlers/session-errors.ts | 6 ++--- .../instance/httpapi/middleware/error.ts | 5 ++++- packages/opencode/src/session/session.ts | 2 +- packages/opencode/src/storage/storage.ts | 20 ++++++++++++----- .../test/session/messages-pagination.test.ts | 22 ++++++++++++++++--- .../opencode/test/storage/storage.test.ts | 17 ++++++++------ 6 files changed, 51 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session-errors.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session-errors.ts index 98ac2b9ad..0fef2e776 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session-errors.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session-errors.ts @@ -2,8 +2,6 @@ import type { NotFoundError as StorageNotFoundError } from "@/storage/storage" import { Effect } from "effect" import * as ApiError from "../errors" -type StorageNotFound = InstanceType - -export function mapStorageNotFound(self: Effect.Effect) { - return self.pipe(Effect.mapError((error) => ApiError.notFound(error.data.message))) +export function mapStorageNotFound(self: Effect.Effect) { + return self.pipe(Effect.mapError((error) => ApiError.notFound(error.message))) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index 585f4bd18..c9d4871b0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -24,11 +24,14 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) const error = defect.defect log.error("failed", { error, cause: Cause.pretty(cause) }) + if (error instanceof NotFoundError) { + return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 404 })) + } + if (error instanceof NamedError) { return Effect.succeed( HttpServerResponse.jsonUnsafe(error.toObject(), { status: iife(() => { - if (error instanceof NotFoundError) return 404 if (error instanceof Provider.ModelNotFoundError) return 400 if (error.name === "ProviderAuthValidationFailed") return 400 if (error.name.startsWith("Worktree")) return 400 diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 5e574d991..0913b8568 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -448,7 +448,7 @@ export class BusyError extends Error { } } -export type NotFound = InstanceType +export type NotFound = NotFoundError export interface Interface { readonly list: (input?: ListInput) => Effect.Effect diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index e1f5f681b..ee2ee45f8 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -1,7 +1,6 @@ import * as Log from "@opencode-ai/core/util/log" import path from "path" import { Global } from "@opencode-ai/core/global" -import { NamedError } from "@opencode-ai/core/util/error" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" import { NonNegativeInt } from "@opencode-ai/core/schema" @@ -15,11 +14,22 @@ type Migration = ( git: Git.Interface, ) => Effect.Effect -export const NotFoundError = NamedError.create("NotFoundError", { +export class NotFoundError extends Schema.TaggedErrorClass()("NotFoundError", { message: Schema.String, -}) +}) { + static isInstance(input: unknown): input is NotFoundError { + return input instanceof NotFoundError + } -export type Error = AppFileSystem.Error | InstanceType + toObject() { + return { + name: "NotFoundError" as const, + data: { message: this.message }, + } + } +} + +export type Error = AppFileSystem.Error | NotFoundError const RootFile = Schema.Struct({ path: Schema.optional( @@ -245,7 +255,7 @@ export const layer = Layer.effect( }), ) - const fail = (target: string): Effect.Effect> => + const fail = (target: string): Effect.Effect => Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` })) const wrap =
(target: string, body: Effect.Effect) => diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 09e8d7b42..5585aa0b0 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -4,6 +4,7 @@ import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" +import { NotFoundError } from "@/storage/storage" import * as Log from "@opencode-ai/core/util/log" import { testEffect } from "../lib/effect" @@ -11,6 +12,20 @@ void Log.init({ print: false }) const it = testEffect(SessionNs.defaultLayer) +function expectNotFound(fn: () => unknown, message: string) { + let thrown: unknown + try { + fn() + } catch (error) { + thrown = error + } + expect(thrown).toBeInstanceOf(NotFoundError) + if (thrown instanceof NotFoundError) { + expect(thrown._tag).toBe("NotFoundError") + expect(thrown.message).toBe(message) + } +} + const withSession = ( fn: (input: { session: SessionNs.Interface; sessionID: SessionID }) => Effect.Effect, ) => @@ -186,7 +201,7 @@ describe("MessageV2.page", () => { it.instance("throws NotFoundError for non-existent session", () => Effect.gen(function* () { const fake = "non-existent-session" as SessionID - expect(() => MessageV2.page({ sessionID: fake, limit: 10 })).toThrow("NotFoundError") + expectNotFound(() => MessageV2.page({ sessionID: fake, limit: 10 }), `Session not found: ${fake}`) }), ) @@ -471,7 +486,8 @@ describe("MessageV2.get", () => { it.instance("throws NotFoundError for non-existent message", () => withSession(({ sessionID }) => Effect.gen(function* () { - expect(() => MessageV2.get({ sessionID, messageID: MessageID.ascending() })).toThrow("NotFoundError") + const messageID = MessageID.ascending() + expectNotFound(() => MessageV2.get({ sessionID, messageID }), `Message not found: ${messageID}`) }), ), ) @@ -483,7 +499,7 @@ describe("MessageV2.get", () => { const b = yield* session.create({}) const [id] = yield* fill(a.id, 1) - expect(() => MessageV2.get({ sessionID: b.id, messageID: id })).toThrow("NotFoundError") + expectNotFound(() => MessageV2.get({ sessionID: b.id, messageID: id }), `Message not found: ${id}`) const result = MessageV2.get({ sessionID: a.id, messageID: id }) expect(result.info.id).toBe(id) diff --git a/packages/opencode/test/storage/storage.test.ts b/packages/opencode/test/storage/storage.test.ts index f0aff4ba7..d0fe5dd34 100644 --- a/packages/opencode/test/storage/storage.test.ts +++ b/packages/opencode/test/storage/storage.test.ts @@ -74,20 +74,23 @@ describe("Storage", () => { it.live("maps missing reads to NotFoundError", () => Effect.gen(function* () { const { root, svc } = yield* scope() - const exit = yield* svc.read([...root, "missing", "value"]).pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) + const error = yield* Effect.flip(svc.read([...root, "missing", "value"])) + expect(error).toBeInstanceOf(Storage.NotFoundError) + expect(error._tag).toBe("NotFoundError") + expect(error.message).toContain(path.join(...root, "missing", "value") + ".json") }), ) it.live("update on missing key throws NotFoundError", () => Effect.gen(function* () { const { root, svc } = yield* scope() - const exit = yield* svc - .update<{ value: number }>([...root, "missing", "key"], (draft) => { + const error = yield* Effect.flip( + svc.update<{ value: number }>([...root, "missing", "key"], (draft) => { draft.value += 1 - }) - .pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) + }), + ) + expect(error).toBeInstanceOf(Storage.NotFoundError) + expect(error._tag).toBe("NotFoundError") }), ) From fed043a1ade7e51c5e8ac367bf56661f812becb5 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 13:04:07 +0530 Subject: [PATCH 37/98] fix(session): add typed message lookup wrappers (#27269) --- .../instance/httpapi/handlers/session.ts | 18 +++++++--------- packages/opencode/src/session/message-v2.ts | 21 +++++++++++++++++++ .../test/session/messages-pagination.test.ts | 20 ++++++++++++++++++ 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index daacfec63..80bff42bc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -14,7 +14,6 @@ import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" -import { NotFoundError } from "@/storage/storage" import { NamedError } from "@opencode-ai/core/util/error" import { Cause, Effect, Option, Schema, Scope } from "effect" import * as Stream from "effect/Stream" @@ -105,11 +104,13 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", return yield* session.messages({ sessionID: ctx.params.sessionID }) } - const page = MessageV2.page({ - sessionID: ctx.params.sessionID, - limit: ctx.query.limit, - before: ctx.query.before, - }) + const page = yield* SessionError.mapStorageNotFound( + MessageV2.pageEffect({ + sessionID: ctx.params.sessionID, + limit: ctx.query.limit, + before: ctx.query.before, + }), + ) if (!page.cursor) return page.items const request = yield* HttpServerRequest.HttpServerRequest @@ -131,10 +132,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID; messageID: MessageID } }) { return yield* SessionError.mapStorageNotFound( - Effect.try({ - try: () => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }), - catch: (error) => error, - }).pipe(Effect.catch((error) => (NotFoundError.isInstance(error) ? Effect.fail(error) : Effect.die(error)))), + MessageV2.getEffect({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }), ) }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index e6ee40e95..10754298b 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -956,6 +956,17 @@ export function page(input: { sessionID: SessionID; limit: number; before?: stri } } +export const pageEffect = Effect.fn("MessageV2.pageEffect")(function* (input: { + sessionID: SessionID + limit: number + before?: string +}) { + return yield* Effect.try({ + try: () => page(input), + catch: (error) => error, + }).pipe(Effect.catch((error) => (NotFoundError.isInstance(error) ? Effect.fail(error) : Effect.die(error)))) +}) + export function* stream(sessionID: SessionID) { const size = 50 let before: string | undefined @@ -1000,6 +1011,16 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With } } +export const getEffect = Effect.fn("MessageV2.getEffect")(function* (input: { + sessionID: SessionID + messageID: MessageID +}) { + return yield* Effect.try({ + try: () => get(input), + catch: (error) => error, + }).pipe(Effect.catch((error) => (NotFoundError.isInstance(error) ? Effect.fail(error) : Effect.die(error)))) +}) + export function filterCompacted(msgs: Iterable) { const result = [] as WithParts[] const completed = new Set() diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 5585aa0b0..8332bb9b3 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -205,6 +205,15 @@ describe("MessageV2.page", () => { }), ) + it.instance("fails pageEffect with NotFoundError for non-existent session", () => + Effect.gen(function* () { + const fake = "non-existent-session" as SessionID + const error = yield* Effect.flip(MessageV2.pageEffect({ sessionID: fake, limit: 10 })) + expect(error).toBeInstanceOf(NotFoundError) + expect(error.message).toBe(`Session not found: ${fake}`) + }), + ) + it.instance("handles exact limit boundary", () => withSession(({ sessionID }) => Effect.gen(function* () { @@ -492,6 +501,17 @@ describe("MessageV2.get", () => { ), ) + it.instance("fails getEffect with NotFoundError for non-existent message", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + const messageID = MessageID.ascending() + const error = yield* Effect.flip(MessageV2.getEffect({ sessionID, messageID })) + expect(error).toBeInstanceOf(NotFoundError) + expect(error.message).toBe(`Message not found: ${messageID}`) + }), + ), + ) + it.instance("scopes by session id", () => Effect.gen(function* () { const session = yield* SessionNs.Service From f01c6b3e37da1ee47dc506d4daf530e23c552e68 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 13:22:12 +0530 Subject: [PATCH 38/98] fix(session): type message list not found errors (#27275) --- packages/opencode/src/cli/cmd/stats.ts | 5 +- .../instance/httpapi/handlers/session.ts | 4 +- packages/opencode/src/session/compaction.ts | 4 +- packages/opencode/src/session/prompt.ts | 8 ++-- packages/opencode/src/session/revert.ts | 4 +- packages/opencode/src/session/session.ts | 42 ++++++++++++----- packages/opencode/src/session/summary.ts | 2 +- .../test/server/httpapi-exercise/runner.ts | 3 +- .../test/session/messages-pagination.test.ts | 46 ++++++++++++++++++- 9 files changed, 95 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 3dadea9dd..7dc182260 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -1,6 +1,7 @@ import { Effect } from "effect" import { effectCmd } from "../effect-cmd" import { Session } from "@/session/session" +import { NotFoundError } from "@/storage/storage" import { Database } from "@/storage/db" import { SessionTable } from "../../session/session.sql" import { Project } from "@/project/project" @@ -162,7 +163,9 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( filteredSessions, (session) => Effect.gen(function* () { - const messages = yield* svc.messages({ sessionID: session.id }) + const messages = yield* svc.messages({ sessionID: session.id }).pipe( + Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed([])), + ) const sessionCost = session.cost ?? 0 const sessionTokens = session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 80bff42bc..ebd0fcd4d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -101,7 +101,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", } yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) if (ctx.query.limit === undefined || ctx.query.limit === 0) { - return yield* session.messages({ sessionID: ctx.params.sessionID }) + return yield* SessionError.mapStorageNotFound(session.messages({ sessionID: ctx.params.sessionID })) } const page = yield* SessionError.mapStorageNotFound( @@ -250,7 +250,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", payload: typeof SummarizePayload.Type }) { yield* revertSvc.cleanup(yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))) - const messages = yield* session.messages({ sessionID: ctx.params.sessionID }) + const messages = yield* SessionError.mapStorageNotFound(session.messages({ sessionID: ctx.params.sessionID })) const defaultAgent = yield* agentSvc.defaultAgent() const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 4eafbdf74..f3c160fe7 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -565,7 +565,9 @@ export const layer: Layer.Layer< if (processor.message.error) return "stop" if (result === "continue") { const summary = summaryText( - (yield* session.messages({ sessionID: input.sessionID })).find((item) => item.info.id === msg.id) ?? { + (yield* session.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)).find( + (item) => item.info.id === msg.id, + ) ?? { info: msg, parts: [], }, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b89561d5d..8656050fa 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1077,7 +1077,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the ...(current.model.variant && current.model.variant !== "default" ? { variant: current.model.variant } : {}), } } - const match = yield* sessions.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model) + const match = yield* sessions + .findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model) + .pipe(Effect.orDie) if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model return yield* provider.defaultModel() }) @@ -1615,9 +1617,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) { - const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user") + const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user").pipe(Effect.orDie) if (Option.isSome(match)) return match.value - const msgs = yield* sessions.messages({ sessionID, limit: 1 }) + const msgs = yield* sessions.messages({ sessionID, limit: 1 }).pipe(Effect.orDie) if (msgs.length > 0) return msgs[0] throw new Error("Impossible") }) diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index ef9089c94..8ba7b265b 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -40,7 +40,7 @@ export const layer = Layer.effect( const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) { yield* state.assertNotBusy(input.sessionID) - const all = yield* sessions.messages({ sessionID: input.sessionID }) + const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) let lastUser: MessageV2.User | undefined const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) @@ -103,7 +103,7 @@ export const layer = Layer.effect( const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) { if (!session.revert) return const sessionID = session.id - const msgs = yield* sessions.messages({ sessionID }) + const msgs = yield* sessions.messages({ sessionID }).pipe(Effect.orDie) const messageID = session.revert.messageID const remove = [] as MessageV2.WithParts[] let target: MessageV2.WithParts | undefined diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 0913b8568..d82b2369e 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -474,7 +474,7 @@ export interface Interface { readonly clearRevert: (sessionID: SessionID) => Effect.Effect readonly setSummary: (input: { sessionID: SessionID; summary: Info["summary"] }) => Effect.Effect readonly diff: (sessionID: SessionID) => Effect.Effect - readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect + readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect readonly children: (parentID: SessionID) => Effect.Effect readonly remove: (sessionID: SessionID) => Effect.Effect readonly updateMessage: (msg: T) => Effect.Effect @@ -497,7 +497,7 @@ export interface Interface { readonly findMessage: ( sessionID: SessionID, predicate: (msg: MessageV2.WithParts) => boolean, - ) => Effect.Effect> + ) => Effect.Effect, NotFound> } export class Service extends Context.Service()("@opencode/Session") {} @@ -757,11 +757,25 @@ export const layer: Layer.Layer [])) }) - const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) { + const messages: Interface["messages"] = Effect.fn("Session.messages")(function* (input) { if (input.limit) { - return MessageV2.page({ sessionID: input.sessionID, limit: input.limit }).items + return (yield* MessageV2.pageEffect({ sessionID: input.sessionID, limit: input.limit })).items } - return Array.from(MessageV2.stream(input.sessionID)).reverse() + + const size = 50 + const result = [] as MessageV2.WithParts[] + let before: string | undefined + while (true) { + const page = yield* MessageV2.pageEffect({ sessionID: input.sessionID, limit: size, before }) + if (page.items.length === 0) break + for (let i = page.items.length - 1; i >= 0; i--) { + const item = page.items[i] + if (item) result.push(item) + } + if (!page.more || !page.cursor) break + before = page.cursor + } + return result.reverse() }) const removeMessage = Effect.fn("Session.removeMessage")(function* (input: { @@ -799,12 +813,18 @@ export const layer: Layer.Layer boolean, - ) { - for (const item of MessageV2.stream(sessionID)) { - if (predicate(item)) return Option.some(item) + const findMessage: Interface["findMessage"] = Effect.fn("Session.findMessage")(function* (sessionID, predicate) { + const size = 50 + let before: string | undefined + while (true) { + const page = yield* MessageV2.pageEffect({ sessionID, limit: size, before }) + if (page.items.length === 0) break + for (let i = page.items.length - 1; i >= 0; i--) { + const item = page.items[i] + if (item && predicate(item)) return Option.some(item) + } + if (!page.more || !page.cursor) break + before = page.cursor } return Option.none() }) diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index c03fc3269..aa4b8719b 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -102,7 +102,7 @@ export const layer = Layer.effect( sessionID: SessionID messageID: MessageID }) { - const all = yield* sessions.messages({ sessionID: input.sessionID }) + const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) if (!all.length) return const diffs = yield* computeDiff({ messages: all }) diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index bc246dbed..7ab5ccf99 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -168,7 +168,8 @@ function withContext( ) return { info, part } }), - messages: (sessionID) => run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))), + messages: (sessionID) => + run(modules.Session.Service.use((svc) => svc.messages({ sessionID }).pipe(Effect.orDie))), todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input))), worktreeRemove: (directory) => diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 8332bb9b3..247f95d28 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Effect } from "effect" +import { Effect, Option } from "effect" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" @@ -582,6 +582,50 @@ describe("MessageV2.get", () => { ) }) +describe("Session.messages", () => { + it.instance("returns all messages in chronological order across pages", () => + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 55) + const result = yield* session.messages({ sessionID }) + expect(result.map((item) => item.info.id)).toEqual(ids) + }), + ), + ) + + it.instance("fails with NotFoundError for non-existent session", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const fake = "non-existent-session" as SessionID + const error = yield* Effect.flip(session.messages({ sessionID: fake })) + expect(error).toBeInstanceOf(NotFoundError) + expect(error.message).toBe(`Session not found: ${fake}`) + }), + ) +}) + +describe("Session.findMessage", () => { + it.instance("searches newest-first", () => + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 3) + const result = yield* session.findMessage(sessionID, () => true) + expect(Option.isSome(result) ? result.value.info.id : undefined).toBe(ids.at(-1)) + }), + ), + ) + + it.instance("fails with NotFoundError for non-existent session", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const fake = "non-existent-session" as SessionID + const error = yield* Effect.flip(session.findMessage(fake, () => true)) + expect(error).toBeInstanceOf(NotFoundError) + expect(error.message).toBe(`Session not found: ${fake}`) + }), + ) +}) + describe("MessageV2.filterCompacted", () => { it.instance("returns all messages when no compaction", () => withSession(({ sessionID }) => From e5af7ab99aa2c959a1ee10238bf17e6b7767eb02 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 13 May 2026 07:53:28 +0000 Subject: [PATCH 39/98] chore: generate --- packages/opencode/src/cli/cmd/stats.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 7dc182260..7ee16c2e2 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -163,9 +163,9 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( filteredSessions, (session) => Effect.gen(function* () { - const messages = yield* svc.messages({ sessionID: session.id }).pipe( - Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed([])), - ) + const messages = yield* svc + .messages({ sessionID: session.id }) + .pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed([]))) const sessionCost = session.cost ?? 0 const sessionTokens = session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } From 3a810fcb9a681afd32ea99e5c9acd108a3e6c4ec Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 13 May 2026 17:57:13 +1000 Subject: [PATCH 40/98] perf(ui): render icons through an svg sprite (#26950) --- packages/ui/src/components/icon.tsx | 48 +++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 2e4d1f53b..7bd461f11 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -1,4 +1,4 @@ -import { splitProps, type ComponentProps } from "solid-js" +import { onMount, splitProps, type ComponentProps } from "solid-js" const icons = { "align-right": ``, @@ -105,6 +105,41 @@ const icons = { "arrow-undo-down": ``, } +const spriteID = "opencode-icon-sprite" +const symbol = (name: keyof typeof icons) => `opencode-icon-${name}` +let spriteInserted = false + +function viewBox(name: keyof typeof icons) { + return name === "magnifying-glass" || name === "arrow-undo-down" ? "0 0 16 16" : "0 0 20 20" +} + +function ensureSprite() { + if (spriteInserted) return + if (typeof document === "undefined") return + if (document.getElementById(spriteID)) { + spriteInserted = true + return + } + const body = document.body as HTMLElement | null + if (!body) return + + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg") + svg.id = spriteID + svg.setAttribute("aria-hidden", "true") + svg.setAttribute("width", "0") + svg.setAttribute("height", "0") + svg.style.position = "absolute" + svg.style.overflow = "hidden" + svg.innerHTML = Object.entries(icons) + .map(([name, path]) => { + const key = name as keyof typeof icons + return `${path}` + }) + .join("") + body.insertBefore(svg, body.firstChild) + spriteInserted = true +} + export interface IconProps extends ComponentProps<"svg"> { name: keyof typeof icons size?: "small" | "normal" | "medium" | "large" @@ -112,8 +147,8 @@ export interface IconProps extends ComponentProps<"svg"> { export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) - const viewBox = () => - local.name === "magnifying-glass" || local.name === "arrow-undo-down" ? "0 0 16 16" : "0 0 20 20" + onMount(ensureSprite) + return (
+ > + +
) } From 596f241db55f9f21d3c893e7cef135586e19cafa Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Wed, 13 May 2026 10:23:10 +0200 Subject: [PATCH 41/98] fix(app): enhance error handling by unwrapping SDK-wrapped errors in formatServerError (#27061) --- packages/app/src/utils/server-errors.test.ts | 13 +++++++++++++ packages/app/src/utils/server-errors.ts | 12 ++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/app/src/utils/server-errors.test.ts b/packages/app/src/utils/server-errors.test.ts index 1f53bb8cf..84f7c07d6 100644 --- a/packages/app/src/utils/server-errors.test.ts +++ b/packages/app/src/utils/server-errors.test.ts @@ -128,4 +128,17 @@ describe("formatServerError", () => { ["Modelo nao encontrado: x/y", "Voce quis dizer: x/y2, x/y3", "Revise provider/model no config"].join("\n"), ) }) + + test("unwraps SDK-wrapped errors from cause.body", () => { + const body = { + name: "ConfigInvalidError", + data: { + message: "Missing host", + }, + } satisfies ConfigInvalidError + + const wrapped = new Error("ConfigInvalidError", { cause: { body, status: 400 } }) + + expect(formatServerError(wrapped, language.t)).toBe("Arquivo de config em config invalido: Missing host") + }) }) diff --git a/packages/app/src/utils/server-errors.ts b/packages/app/src/utils/server-errors.ts index 2c3a8c54d..8a8db1781 100644 --- a/packages/app/src/utils/server-errors.ts +++ b/packages/app/src/utils/server-errors.ts @@ -26,14 +26,22 @@ function tr(translator: Translator | undefined, key: string, text: string, vars? } export function formatServerError(error: unknown, translate?: Translator, fallback?: string) { - if (isConfigInvalidErrorLike(error)) return parseReadableConfigInvalidError(error, translate) - if (isProviderModelNotFoundErrorLike(error)) return parseReadableProviderModelNotFoundError(error, translate) + const unwrapped = unwrapNamedError(error) + if (isConfigInvalidErrorLike(unwrapped)) return parseReadableConfigInvalidError(unwrapped, translate) + if (isProviderModelNotFoundErrorLike(unwrapped)) return parseReadableProviderModelNotFoundError(unwrapped, translate) if (error instanceof Error && error.message) return error.message if (typeof error === "string" && error) return error if (fallback) return fallback return tr(translate, "error.chain.unknown", "Unknown error") } +function unwrapNamedError(error: unknown): unknown { + if (error instanceof Error && error.cause && typeof error.cause === "object" && "body" in error.cause) { + return (error.cause as Record).body + } + return error +} + function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError { if (typeof error !== "object" || error === null) return false const o = error as Record From b0dc8e4638fef6c96665d68d2268a14443e7f05b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 14:12:51 +0530 Subject: [PATCH 42/98] fix(session): use typed message reads in tools (#27280) --- packages/opencode/src/share/share-next.ts | 2 +- packages/opencode/src/tool/plan.ts | 14 +++++--------- packages/opencode/src/tool/task.ts | 2 +- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 384027436..a386211dc 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -272,7 +272,7 @@ export const layer = Layer.effect( log.info("full sync", { sessionID }) const info = yield* session.get(sessionID) const diffs = yield* session.diff(sessionID) - const messages = yield* Effect.sync(() => Array.from(MessageV2.stream(sessionID))) + const messages = yield* session.messages({ sessionID }) const models = yield* Effect.forEach( Array.from( new Map( diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index d5195376b..af206f66a 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -6,16 +6,9 @@ import { Session } from "@/session/session" import { MessageV2 } from "../session/message-v2" import { Provider } from "@/provider/provider" import { InstanceState } from "@/effect/instance-state" -import { type SessionID, MessageID, PartID } from "../session/schema" +import { MessageID, PartID } from "../session/schema" import EXIT_DESCRIPTION from "./plan-exit.txt" -function getLastModel(sessionID: SessionID) { - for (const item of MessageV2.stream(sessionID)) { - if (item.info.role === "user" && item.info.model) return item.info.model - } - return undefined -} - export const Parameters = Schema.Struct({}) export const PlanExitTool = Tool.define( @@ -51,7 +44,10 @@ export const PlanExitTool = Tool.define( if (answers[0]?.[0] === "No") yield* new Question.RejectedError() - const model = getLastModel(ctx.sessionID) ?? (yield* provider.defaultModel()) + const messages = yield* session.messages({ sessionID: ctx.sessionID }).pipe(Effect.orDie) + const lastUser = messages.findLast((item) => item.info.role === "user" && item.info.model) + const model = + lastUser?.info.role === "user" && lastUser.info.model ? lastUser.info.model : yield* provider.defaultModel() const msg: MessageV2.User = { id: MessageID.ascending(), diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index c4d5bf7f4..d3572c1c4 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -86,7 +86,7 @@ export const TaskTool = Tool.define( ], })) - const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })) + const msg = yield* MessageV2.getEffect({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe(Effect.orDie) if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) const model = next.model ?? { From 4b041716fcd1809518df84eb9f3f11a4f94439ed Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 14:15:53 +0530 Subject: [PATCH 43/98] fix(server): remove storage not found defect fallback (#27287) --- .../instance/httpapi/middleware/error.ts | 5 ----- .../server/httpapi-error-middleware.test.ts | 20 +++++++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index c9d4871b0..087d5ee85 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -1,6 +1,5 @@ import { Provider } from "@/provider/provider" import { Session } from "@/session/session" -import { NotFoundError } from "@/storage/storage" import { iife } from "@/util/iife" import { NamedError } from "@opencode-ai/core/util/error" import * as Log from "@opencode-ai/core/util/log" @@ -24,10 +23,6 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) const error = defect.defect log.error("failed", { error, cause: Cause.pretty(cause) }) - if (error instanceof NotFoundError) { - return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 404 })) - } - if (error instanceof NamedError) { return Effect.succeed( HttpServerResponse.jsonUnsafe(error.toObject(), { diff --git a/packages/opencode/test/server/httpapi-error-middleware.test.ts b/packages/opencode/test/server/httpapi-error-middleware.test.ts index a07e31382..f53a9e887 100644 --- a/packages/opencode/test/server/httpapi-error-middleware.test.ts +++ b/packages/opencode/test/server/httpapi-error-middleware.test.ts @@ -3,6 +3,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" import { errorLayer } from "../../src/server/routes/instance/httpapi/middleware/error" +import { NotFoundError } from "../../src/storage/storage" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(NodeHttpServer.layerTest, NodeServices.layer)) @@ -27,4 +28,23 @@ describe("HttpApi error middleware", () => { expect(JSON.stringify(body)).not.toContain("secret stack marker") }), ) + + it.live("does not map storage not-found defects to 404", () => + Effect.gen(function* () { + yield* HttpRouter.add( + "GET", + "/missing", + Effect.die(new NotFoundError({ message: "Resource not found: secret" })), + ).pipe(Layer.provide(errorLayer), HttpRouter.serve, Layer.build) + + const response = yield* HttpClientRequest.get("/missing").pipe(HttpClient.execute) + const body = yield* response.json + + expect(response.status).toBe(500) + expect(body).toEqual({ + name: "UnknownError", + data: { message: "Unexpected server error. Check server logs for details." }, + }) + }), + ) }) From ccf93f3523393b5226a1172ddaa85ac07a5c4b6d Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 14:40:12 +0530 Subject: [PATCH 44/98] fix(session): make message reads effectful (#27291) --- .../instance/httpapi/handlers/session.ts | 4 +- packages/opencode/src/session/message-v2.ts | 41 +++----- packages/opencode/src/session/session.ts | 6 +- packages/opencode/src/tool/task.ts | 2 +- .../test/session/messages-pagination.test.ts | 94 +++++++------------ .../test/session/processor-effect.test.ts | 4 +- packages/opencode/test/session/prompt.test.ts | 8 +- 7 files changed, 60 insertions(+), 99 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index ebd0fcd4d..40444385f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -105,7 +105,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", } const page = yield* SessionError.mapStorageNotFound( - MessageV2.pageEffect({ + MessageV2.page({ sessionID: ctx.params.sessionID, limit: ctx.query.limit, before: ctx.query.before, @@ -132,7 +132,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID; messageID: MessageID } }) { return yield* SessionError.mapStorageNotFound( - MessageV2.getEffect({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }), + MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }), ) }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 10754298b..869ef979f 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -919,7 +919,11 @@ export function toModelMessages( return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer))) } -export function page(input: { sessionID: SessionID; limit: number; before?: string }) { +export const page = Effect.fn("MessageV2.page")(function* (input: { + sessionID: SessionID + limit: number + before?: string +}) { const before = input.before ? cursor.decode(input.before) : undefined const where = before ? and(eq(MessageTable.session_id, input.sessionID), older(before)) @@ -937,7 +941,7 @@ export function page(input: { sessionID: SessionID; limit: number; before?: stri const row = Database.use((db) => db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, input.sessionID)).get(), ) - if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` }) + if (!row) return yield* new NotFoundError({ message: `Session not found: ${input.sessionID}` }) return { items: [] as WithParts[], more: false, @@ -954,24 +958,19 @@ export function page(input: { sessionID: SessionID; limit: number; before?: stri more, cursor: more && tail ? cursor.encode({ id: tail.id, time: tail.time_created }) : undefined, } -} - -export const pageEffect = Effect.fn("MessageV2.pageEffect")(function* (input: { - sessionID: SessionID - limit: number - before?: string -}) { - return yield* Effect.try({ - try: () => page(input), - catch: (error) => error, - }).pipe(Effect.catch((error) => (NotFoundError.isInstance(error) ? Effect.fail(error) : Effect.die(error)))) }) export function* stream(sessionID: SessionID) { const size = 50 let before: string | undefined while (true) { - const next = page({ sessionID, limit: size, before }) + const next = Effect.runSync( + page({ sessionID, limit: size, before }).pipe( + Effect.catchIf(NotFoundError.isInstance, () => + Effect.succeed({ items: [] as WithParts[], more: false, cursor: undefined }), + ), + ), + ) if (next.items.length === 0) break for (let i = next.items.length - 1; i >= 0; i--) { yield next.items[i] @@ -996,7 +995,7 @@ export function parts(message_id: MessageID) { ) } -export function get(input: { sessionID: SessionID; messageID: MessageID }): WithParts { +export const get = Effect.fn("MessageV2.get")(function* (input: { sessionID: SessionID; messageID: MessageID }) { const row = Database.use((db) => db .select() @@ -1004,21 +1003,11 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With .where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID))) .get(), ) - if (!row) throw new NotFoundError({ message: `Message not found: ${input.messageID}` }) + if (!row) return yield* new NotFoundError({ message: `Message not found: ${input.messageID}` }) return { info: info(row), parts: parts(input.messageID), } -} - -export const getEffect = Effect.fn("MessageV2.getEffect")(function* (input: { - sessionID: SessionID - messageID: MessageID -}) { - return yield* Effect.try({ - try: () => get(input), - catch: (error) => error, - }).pipe(Effect.catch((error) => (NotFoundError.isInstance(error) ? Effect.fail(error) : Effect.die(error)))) }) export function filterCompacted(msgs: Iterable) { diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index d82b2369e..df173e895 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -759,14 +759,14 @@ export const layer: Layer.Layer= 0; i--) { const item = page.items[i] @@ -817,7 +817,7 @@ export const layer: Layer.Layer= 0; i--) { const item = page.items[i] diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index d3572c1c4..09bbaca26 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -86,7 +86,7 @@ export const TaskTool = Tool.define( ], })) - const msg = yield* MessageV2.getEffect({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe(Effect.orDie) + const msg = yield* MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe(Effect.orDie) if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) const model = next.model ?? { diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 247f95d28..e558d07b5 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -12,20 +12,6 @@ void Log.init({ print: false }) const it = testEffect(SessionNs.defaultLayer) -function expectNotFound(fn: () => unknown, message: string) { - let thrown: unknown - try { - fn() - } catch (error) { - thrown = error - } - expect(thrown).toBeInstanceOf(NotFoundError) - if (thrown instanceof NotFoundError) { - expect(thrown._tag).toBe("NotFoundError") - expect(thrown.message).toBe(message) - } -} - const withSession = ( fn: (input: { session: SessionNs.Interface; sessionID: SessionID }) => Effect.Effect, ) => @@ -140,12 +126,12 @@ const addCompactionPart = Effect.fn("Test.addCompactionPart")(function* ( }) describe("MessageV2.page", () => { - it.instance("returns sync result", () => + it.instance("returns page result", () => withSession(({ sessionID }) => Effect.gen(function* () { yield* fill(sessionID, 2) - const result = MessageV2.page({ sessionID, limit: 10 }) + const result = yield* MessageV2.page({ sessionID, limit: 10 }) expect(result).toBeDefined() expect(result.items).toBeArray() }), @@ -157,18 +143,18 @@ describe("MessageV2.page", () => { Effect.gen(function* () { const ids = yield* fill(sessionID, 6) - const a = MessageV2.page({ sessionID, limit: 2 }) + const a = yield* MessageV2.page({ sessionID, limit: 2 }) expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) expect(a.items.every((item) => item.parts.length === 1)).toBe(true) expect(a.more).toBe(true) expect(a.cursor).toBeTruthy() - const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) + const b = yield* MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) expect(b.more).toBe(true) expect(b.cursor).toBeTruthy() - const c = MessageV2.page({ sessionID, limit: 2, before: b.cursor! }) + const c = yield* MessageV2.page({ sessionID, limit: 2, before: b.cursor! }) expect(c.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) expect(c.more).toBe(false) expect(c.cursor).toBeUndefined() @@ -181,7 +167,7 @@ describe("MessageV2.page", () => { Effect.gen(function* () { const ids = yield* fill(sessionID, 4) - const result = MessageV2.page({ sessionID, limit: 4 }) + const result = yield* MessageV2.page({ sessionID, limit: 4 }) expect(result.items.map((item) => item.info.id)).toEqual(ids) }), ), @@ -190,7 +176,7 @@ describe("MessageV2.page", () => { it.instance("returns empty items for session with no messages", () => withSession(({ sessionID }) => Effect.gen(function* () { - const result = MessageV2.page({ sessionID, limit: 10 }) + const result = yield* MessageV2.page({ sessionID, limit: 10 }) expect(result.items).toEqual([]) expect(result.more).toBe(false) expect(result.cursor).toBeUndefined() @@ -198,17 +184,10 @@ describe("MessageV2.page", () => { ), ) - it.instance("throws NotFoundError for non-existent session", () => + it.instance("fails with NotFoundError for non-existent session", () => Effect.gen(function* () { const fake = "non-existent-session" as SessionID - expectNotFound(() => MessageV2.page({ sessionID: fake, limit: 10 }), `Session not found: ${fake}`) - }), - ) - - it.instance("fails pageEffect with NotFoundError for non-existent session", () => - Effect.gen(function* () { - const fake = "non-existent-session" as SessionID - const error = yield* Effect.flip(MessageV2.pageEffect({ sessionID: fake, limit: 10 })) + const error = yield* Effect.flip(MessageV2.page({ sessionID: fake, limit: 10 })) expect(error).toBeInstanceOf(NotFoundError) expect(error.message).toBe(`Session not found: ${fake}`) }), @@ -219,7 +198,7 @@ describe("MessageV2.page", () => { Effect.gen(function* () { const ids = yield* fill(sessionID, 3) - const result = MessageV2.page({ sessionID, limit: 3 }) + const result = yield* MessageV2.page({ sessionID, limit: 3 }) expect(result.items.map((item) => item.info.id)).toEqual(ids) expect(result.more).toBe(false) expect(result.cursor).toBeUndefined() @@ -232,7 +211,7 @@ describe("MessageV2.page", () => { Effect.gen(function* () { const ids = yield* fill(sessionID, 5) - const result = MessageV2.page({ sessionID, limit: 1 }) + const result = yield* MessageV2.page({ sessionID, limit: 1 }) expect(result.items).toHaveLength(1) expect(result.items[0].info.id).toBe(ids[ids.length - 1]) expect(result.more).toBe(true) @@ -253,7 +232,7 @@ describe("MessageV2.page", () => { text: "extra", }) - const result = MessageV2.page({ sessionID, limit: 10 }) + const result = yield* MessageV2.page({ sessionID, limit: 10 }) expect(result.items).toHaveLength(1) expect(result.items[0].parts).toHaveLength(2) }), @@ -265,8 +244,8 @@ describe("MessageV2.page", () => { Effect.gen(function* () { const ids = yield* fill(sessionID, 4, (i: number) => 1000.5 + i) - const a = MessageV2.page({ sessionID, limit: 2 }) - const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) + const a = yield* MessageV2.page({ sessionID, limit: 2 }) + const b = yield* MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) @@ -279,11 +258,11 @@ describe("MessageV2.page", () => { Effect.gen(function* () { const ids = yield* fill(sessionID, 4, () => 1000) - const a = MessageV2.page({ sessionID, limit: 2 }) + const a = yield* MessageV2.page({ sessionID, limit: 2 }) expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) expect(a.more).toBe(true) - const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) + const b = yield* MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) expect(b.more).toBe(false) }), @@ -298,8 +277,8 @@ describe("MessageV2.page", () => { yield* fill(a.id, 3) yield* fill(b.id, 2) - const resultA = MessageV2.page({ sessionID: a.id, limit: 10 }) - const resultB = MessageV2.page({ sessionID: b.id, limit: 10 }) + const resultA = yield* MessageV2.page({ sessionID: a.id, limit: 10 }) + const resultB = yield* MessageV2.page({ sessionID: b.id, limit: 10 }) expect(resultA.items).toHaveLength(3) expect(resultB.items).toHaveLength(2) expect(resultA.items.every((item) => item.info.sessionID === a.id)).toBe(true) @@ -315,7 +294,7 @@ describe("MessageV2.page", () => { Effect.gen(function* () { const ids = yield* fill(sessionID, 10) - const result = MessageV2.page({ sessionID, limit: 100 }) + const result = yield* MessageV2.page({ sessionID, limit: 100 }) expect(result.items).toHaveLength(10) expect(result.items.map((item) => item.info.id)).toEqual(ids) expect(result.more).toBe(false) @@ -482,7 +461,7 @@ describe("MessageV2.get", () => { Effect.gen(function* () { const [id] = yield* fill(sessionID, 1) - const result = MessageV2.get({ sessionID, messageID: id }) + const result = yield* MessageV2.get({ sessionID, messageID: id }) expect(result.info.id).toBe(id) expect(result.info.sessionID).toBe(sessionID) expect(result.info.role).toBe("user") @@ -492,20 +471,11 @@ describe("MessageV2.get", () => { ), ) - it.instance("throws NotFoundError for non-existent message", () => + it.instance("fails with NotFoundError for non-existent message", () => withSession(({ sessionID }) => Effect.gen(function* () { const messageID = MessageID.ascending() - expectNotFound(() => MessageV2.get({ sessionID, messageID }), `Message not found: ${messageID}`) - }), - ), - ) - - it.instance("fails getEffect with NotFoundError for non-existent message", () => - withSession(({ sessionID }) => - Effect.gen(function* () { - const messageID = MessageID.ascending() - const error = yield* Effect.flip(MessageV2.getEffect({ sessionID, messageID })) + const error = yield* Effect.flip(MessageV2.get({ sessionID, messageID })) expect(error).toBeInstanceOf(NotFoundError) expect(error.message).toBe(`Message not found: ${messageID}`) }), @@ -519,8 +489,10 @@ describe("MessageV2.get", () => { const b = yield* session.create({}) const [id] = yield* fill(a.id, 1) - expectNotFound(() => MessageV2.get({ sessionID: b.id, messageID: id }), `Message not found: ${id}`) - const result = MessageV2.get({ sessionID: a.id, messageID: id }) + const error = yield* Effect.flip(MessageV2.get({ sessionID: b.id, messageID: id })) + expect(error).toBeInstanceOf(NotFoundError) + expect(error.message).toBe(`Message not found: ${id}`) + const result = yield* MessageV2.get({ sessionID: a.id, messageID: id }) expect(result.info.id).toBe(id) yield* session.remove(a.id) @@ -541,7 +513,7 @@ describe("MessageV2.get", () => { text: "extra", }) - const result = MessageV2.get({ sessionID, messageID: id }) + const result = yield* MessageV2.get({ sessionID, messageID: id }) expect(result.parts).toHaveLength(2) }), ), @@ -561,7 +533,7 @@ describe("MessageV2.get", () => { text: "response", }) - const result = MessageV2.get({ sessionID, messageID: aid }) + const result = yield* MessageV2.get({ sessionID, messageID: aid }) expect(result.info.role).toBe("assistant") expect(result.parts).toHaveLength(1) expect((result.parts[0] as MessageV2.TextPart).text).toBe("response") @@ -574,7 +546,7 @@ describe("MessageV2.get", () => { Effect.gen(function* () { const id = yield* addUser(sessionID) - const result = MessageV2.get({ sessionID, messageID: id }) + const result = yield* MessageV2.get({ sessionID, messageID: id }) expect(result.info.id).toBe(id) expect(result.parts).toEqual([]) }), @@ -1026,9 +998,9 @@ describe("MessageV2 consistency", () => { Effect.gen(function* () { yield* fill(sessionID, 3) - const paged = MessageV2.page({ sessionID, limit: 10 }) + const paged = yield* MessageV2.page({ sessionID, limit: 10 }) for (const item of paged.items) { - const got = MessageV2.get({ sessionID, messageID: item.info.id as MessageID }) + const got = yield* MessageV2.get({ sessionID, messageID: item.info.id as MessageID }) expect(got.info).toEqual(item.info) expect(got.parts).toEqual(item.parts) } @@ -1041,7 +1013,7 @@ describe("MessageV2 consistency", () => { Effect.gen(function* () { const [id] = yield* fill(sessionID, 1) - const got = MessageV2.get({ sessionID, messageID: id }) + const got = yield* MessageV2.get({ sessionID, messageID: id }) const standalone = MessageV2.parts(id) expect(got.parts).toEqual(standalone) }), @@ -1058,7 +1030,7 @@ describe("MessageV2 consistency", () => { const paged = [] as MessageV2.WithParts[] let cursor: string | undefined while (true) { - const result = MessageV2.page({ sessionID, limit: 3, before: cursor }) + const result = yield* MessageV2.page({ sessionID, limit: 3, before: cursor }) for (let i = result.items.length - 1; i >= 0; i--) { paged.push(result.items[i]) } diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 56ff10243..fc5e73877 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -767,7 +767,7 @@ it.live("session.processor effect tests record aborted errors and idle state", ( const exit = yield* Fiber.await(run) yield* Effect.promise(() => seen.promise) - const stored = MessageV2.get({ sessionID: chat.id, messageID: msg.id }) + const stored = yield* MessageV2.get({ sessionID: chat.id, messageID: msg.id }) const state = yield* sts.get(chat.id) off() @@ -829,7 +829,7 @@ it.live("session.processor effect tests mark interruptions aborted without manua yield* Fiber.interrupt(run) const exit = yield* Fiber.await(run) - const stored = MessageV2.get({ sessionID: chat.id, messageID: msg.id }) + const stored = yield* MessageV2.get({ sessionID: chat.id, messageID: msg.id }) const state = yield* sts.get(chat.id) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index e7791db30..427ce17e8 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1787,7 +1787,7 @@ it.instance( if (msg.info.role !== "user") throw new Error("expected user message") - const stored = MessageV2.get({ + const stored = yield* MessageV2.get({ sessionID: session.id, messageID: msg.info.id, }) @@ -1875,7 +1875,7 @@ it.instance( parts: yield* prompt.resolvePromptParts("Use @docs for context"), }) - const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id }) const synthetic = stored.parts.filter( (part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true, ) @@ -1931,7 +1931,7 @@ it.instance( ], }) - const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id }) const synthetic = stored.parts.filter( (part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true, ) @@ -1991,7 +1991,7 @@ it.instance( parts, noReply: true, }) - const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id }) const textParts = stored.parts.filter((part) => part.type === "text") const hasContent = textParts.some((part) => part.text.includes("special content")) expect(hasContent).toBe(true) From 2e7cf92c8b04f16c899eb0daf693c092c3698c7a Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 15:05:30 +0530 Subject: [PATCH 45/98] fix(worktree): type expected errors (#27296) --- .../instance/httpapi/groups/experimental.ts | 23 +++- .../instance/httpapi/handlers/experimental.ts | 16 ++- .../instance/httpapi/middleware/error.ts | 1 - packages/opencode/src/worktree/index.ts | 112 ++++++++++-------- .../opencode/test/project/worktree.test.ts | 16 ++- .../test/server/httpapi-exercise/runner.ts | 2 +- .../test/server/httpapi-experimental.test.ts | 17 +++ 7 files changed, 125 insertions(+), 62 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 99a8a21a9..160bafb1e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -54,6 +54,22 @@ export const ToolListQuery = Schema.Struct({ }) const WorktreeList = Schema.Array(Schema.String) +const WorktreeErrorName = Schema.Union([ + Schema.Literal("WorktreeNotGitError"), + Schema.Literal("WorktreeNameGenerationFailedError"), + Schema.Literal("WorktreeCreateFailedError"), + Schema.Literal("WorktreeStartCommandFailedError"), + Schema.Literal("WorktreeRemoveFailedError"), + Schema.Literal("WorktreeResetFailedError"), + Schema.Literal("WorktreeListFailedError"), +]) +export class WorktreeApiError extends Schema.ErrorClass("WorktreeError")( + { + name: WorktreeErrorName, + data: Schema.Struct({ message: Schema.String }), + }, + { httpApiStatus: 400 }, +) {} export const SessionListQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, roots: Schema.optional(QueryBoolean), @@ -141,6 +157,7 @@ export const ExperimentalApi = HttpApi.make("experimental") HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, { query: WorkspaceRoutingQuery, success: described(WorktreeList, "List of worktree directories"), + error: WorktreeApiError, }).annotateMerge( OpenApi.annotations({ identifier: "worktree.list", @@ -152,7 +169,7 @@ export const ExperimentalApi = HttpApi.make("experimental") query: WorkspaceRoutingQuery, payload: Schema.optional(Worktree.CreateInput), success: described(Worktree.Info, "Worktree created"), - error: HttpApiError.BadRequest, + error: WorktreeApiError, }).annotateMerge( OpenApi.annotations({ identifier: "worktree.create", @@ -164,7 +181,7 @@ export const ExperimentalApi = HttpApi.make("experimental") query: WorkspaceRoutingQuery, payload: Worktree.RemoveInput, success: described(Schema.Boolean, "Worktree removed"), - error: HttpApiError.BadRequest, + error: WorktreeApiError, }).annotateMerge( OpenApi.annotations({ identifier: "worktree.remove", @@ -176,7 +193,7 @@ export const ExperimentalApi = HttpApi.make("experimental") query: WorkspaceRoutingQuery, payload: Worktree.ResetInput, success: described(Schema.Boolean, "Worktree reset"), - error: HttpApiError.BadRequest, + error: WorktreeApiError, }).annotateMerge( OpenApi.annotations({ identifier: "worktree.reset", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 9cf668ceb..89942ec4d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -12,7 +12,15 @@ import { Effect, Option } from "effect" import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery } from "../groups/experimental" +import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery, WorktreeApiError } from "../groups/experimental" + +function mapWorktreeError(self: Effect.Effect) { + return self.pipe( + Effect.mapError( + (error) => new WorktreeApiError({ name: error._tag, data: { message: error.message } }), + ), + ) +} export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "experimental", (handlers) => Effect.gen(function* () { @@ -100,14 +108,14 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: { payload: Worktree.CreateInput | undefined }) { - return yield* worktreeSvc.create(ctx.payload) + return yield* mapWorktreeError(worktreeSvc.create(ctx.payload)) }) const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: { payload: Worktree.RemoveInput }) { const ctx = yield* InstanceState.context - yield* worktreeSvc.remove(input.payload) + yield* mapWorktreeError(worktreeSvc.remove(input.payload)) yield* project.removeSandbox(ctx.project.id, input.payload.directory) return true }) @@ -115,7 +123,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: { payload: Worktree.ResetInput }) { - yield* worktreeSvc.reset(ctx.payload) + yield* mapWorktreeError(worktreeSvc.reset(ctx.payload)) return true }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index 087d5ee85..acc39fb1e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -29,7 +29,6 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) status: iife(() => { if (error instanceof Provider.ModelNotFoundError) return 400 if (error.name === "ProviderAuthValidationFailed") return 400 - if (error.name.startsWith("Worktree")) return 400 return 500 }), }), diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 7d0218926..58651ddec 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,4 +1,3 @@ -import { NamedError } from "@opencode-ai/core/util/error" import { Global } from "@opencode-ai/core/global" import { InstanceLayer } from "@/project/instance-layer" import { InstanceStore } from "@/project/instance-store" @@ -64,33 +63,48 @@ export const ResetInput = Schema.Struct({ }).annotate({ identifier: "WorktreeResetInput" }) export type ResetInput = Schema.Schema.Type -export const NotGitError = NamedError.create("WorktreeNotGitError", { +export class NotGitError extends Schema.TaggedErrorClass()("WorktreeNotGitError", { message: Schema.String, -}) +}) {} -export const NameGenerationFailedError = NamedError.create("WorktreeNameGenerationFailedError", { - message: Schema.String, -}) +export class NameGenerationFailedError extends Schema.TaggedErrorClass()( + "WorktreeNameGenerationFailedError", + { + message: Schema.String, + }, +) {} -export const CreateFailedError = NamedError.create("WorktreeCreateFailedError", { +export class CreateFailedError extends Schema.TaggedErrorClass()("WorktreeCreateFailedError", { message: Schema.String, -}) +}) {} -export const StartCommandFailedError = NamedError.create("WorktreeStartCommandFailedError", { - message: Schema.String, -}) +export class StartCommandFailedError extends Schema.TaggedErrorClass()( + "WorktreeStartCommandFailedError", + { + message: Schema.String, + }, +) {} -export const RemoveFailedError = NamedError.create("WorktreeRemoveFailedError", { +export class RemoveFailedError extends Schema.TaggedErrorClass()("WorktreeRemoveFailedError", { message: Schema.String, -}) +}) {} -export const ResetFailedError = NamedError.create("WorktreeResetFailedError", { +export class ResetFailedError extends Schema.TaggedErrorClass()("WorktreeResetFailedError", { message: Schema.String, -}) +}) {} -export const ListFailedError = NamedError.create("WorktreeListFailedError", { +export class ListFailedError extends Schema.TaggedErrorClass()("WorktreeListFailedError", { message: Schema.String, -}) +}) {} + +export type Error = + | NotGitError + | NameGenerationFailedError + | CreateFailedError + | StartCommandFailedError + | RemoveFailedError + | ResetFailedError + | ListFailedError function slugify(input: string) { return input @@ -121,12 +135,12 @@ function failedRemoves(...chunks: string[]) { // --------------------------------------------------------------------------- export interface Interface { - readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect - readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect - readonly create: (input?: CreateInput) => Effect.Effect - readonly list: () => Effect.Effect<(Omit & { branch?: string })[]> - readonly remove: (input: RemoveInput) => Effect.Effect - readonly reset: (input: ResetInput) => Effect.Effect + readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect + readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect + readonly create: (input?: CreateInput) => Effect.Effect + readonly list: () => Effect.Effect<(Omit & { branch?: string })[], Error> + readonly remove: (input: RemoveInput) => Effect.Effect + readonly reset: (input: ResetInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/Worktree") {} @@ -193,7 +207,7 @@ export const layer: Layer.Layer< return { name, directory, ...(branch ? { branch } : {}) } } - throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) + return yield* new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) }) const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (input?: { @@ -202,7 +216,7 @@ export const layer: Layer.Layer< }) { const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") { - throw new NotGitError({ message: "Worktrees are only supported for git projects" }) + return yield* new NotGitError({ message: "Worktrees are only supported for git projects" }) } const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id) @@ -220,7 +234,7 @@ export const layer: Layer.Layer< { cwd: ctx.worktree }, ) if (created.code !== 0) { - throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" }) + return yield* new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" }) } yield* project.addSandbox(ctx.project.id, info.directory).pipe(Effect.catch(() => Effect.void)) @@ -336,7 +350,7 @@ export const layer: Layer.Layer< const result = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) if (result.code !== 0) { - throw new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" }) + return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" }) } const primary = yield* canonical(ctx.worktree) @@ -364,27 +378,27 @@ export const layer: Layer.Layer< } function cleanDirectory(target: string) { - return Effect.promise(() => - import("fs/promises") - .then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })) - .catch((error) => { - const message = errorMessage(error) - throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" }) - }), - ) + return Effect.tryPromise({ + try: () => + import("fs/promises").then((fsp) => + fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), + ), + catch: (error) => + new RemoveFailedError({ message: errorMessage(error) || "Failed to remove git worktree directory" }), + }) } const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) { const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") { - throw new NotGitError({ message: "Worktrees are only supported for git projects" }) + return yield* new NotGitError({ message: "Worktrees are only supported for git projects" }) } const directory = yield* canonical(input.directory) const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) if (list.code !== 0) { - throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) + return yield* new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) } const entries = parseWorktreeList(list.text) @@ -404,14 +418,14 @@ export const layer: Layer.Layer< if (removed.code !== 0) { const next = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) if (next.code !== 0) { - throw new RemoveFailedError({ + return yield* new RemoveFailedError({ message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree", }) } const stale = yield* locateWorktree(parseWorktreeList(next.text), directory) if (stale?.path) { - throw new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" }) + return yield* new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" }) } } @@ -421,7 +435,7 @@ export const layer: Layer.Layer< if (branch) { const deleted = yield* git(["branch", "-D", branch], { cwd: ctx.worktree }) if (deleted.code !== 0) { - throw new RemoveFailedError({ + return yield* new RemoveFailedError({ message: deleted.stderr || deleted.text || "Failed to delete worktree branch", }) } @@ -436,7 +450,7 @@ export const layer: Layer.Layer< error: (r: GitResult) => Error, ) { const result = yield* git(args, opts) - if (result.code !== 0) throw error(result) + if (result.code !== 0) return yield* error(result) return result }) @@ -511,30 +525,30 @@ export const layer: Layer.Layer< const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) { const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") { - throw new NotGitError({ message: "Worktrees are only supported for git projects" }) + return yield* new NotGitError({ message: "Worktrees are only supported for git projects" }) } const directory = yield* canonical(input.directory) const primary = yield* canonical(ctx.worktree) if (directory === primary) { - throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) + return yield* new ResetFailedError({ message: "Cannot reset the primary workspace" }) } const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) if (list.code !== 0) { - throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) + return yield* new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) } const entry = yield* locateWorktree(parseWorktreeList(list.text), directory) if (!entry?.path) { - throw new ResetFailedError({ message: "Worktree not found" }) + return yield* new ResetFailedError({ message: "Worktree not found" }) } const worktreePath = entry.path const base = yield* gitSvc.defaultBranch(ctx.worktree) if (!base) { - throw new ResetFailedError({ message: "Default branch not found" }) + return yield* new ResetFailedError({ message: "Default branch not found" }) } const sep = base.ref.indexOf("/") @@ -556,7 +570,7 @@ export const layer: Layer.Layer< const cleanResult = yield* sweep(worktreePath) if (cleanResult.code !== 0) { - throw new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" }) + return yield* new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" }) } yield* gitExpect( @@ -579,11 +593,11 @@ export const layer: Layer.Layer< const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath }) if (status.code !== 0) { - throw new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" }) + return yield* new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" }) } if (status.text.trim()) { - throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` }) + return yield* new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` }) } yield* runStartScripts(worktreePath, { projectID: ctx.project.id }).pipe( diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 1de760014..308e2f957 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -135,13 +135,17 @@ describe("Worktree", () => { { git: true }, ) - it.instance("throws NotGitError for non-git directories", () => + it.instance("fails with NotGitError for non-git directories", () => Effect.gen(function* () { const svc = yield* Worktree.Service const exit = yield* Effect.exit(svc.makeWorktreeInfo()) expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Worktree.NotGitError) + if (error instanceof Worktree.NotGitError) expect(error._tag).toBe("WorktreeNotGitError") + } }), ) @@ -286,14 +290,18 @@ describe("Worktree", () => { { git: true }, ) - it.instance("throws NotGitError for non-git directories", () => + it.instance("fails with NotGitError for non-git directories", () => Effect.gen(function* () { const test = yield* TestInstance const svc = yield* Worktree.Service const exit = yield* Effect.exit(svc.remove({ directory: path.join(test.directory, "fake") })) expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Worktree.NotGitError) + if (error instanceof Worktree.NotGitError) expect(error._tag).toBe("WorktreeNotGitError") + } }), ) }) diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index 7ab5ccf99..b14647680 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -171,7 +171,7 @@ function withContext( messages: (sessionID) => run(modules.Session.Service.use((svc) => svc.messages({ sessionID }).pipe(Effect.orDie))), todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), - worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input))), + worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input).pipe(Effect.orDie))), worktreeRemove: (directory) => run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), llmText: (value) => Effect.suspend(() => llm().text(value)), diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 11aed69cb..2613ee385 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -192,6 +192,23 @@ describe("experimental HttpApi", () => { }, ) + it.instance("returns declared worktree errors", () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const response = yield* request(ExperimentalPaths.worktree, tmp.directory, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(400) + expect(yield* json(response)).toEqual({ + name: "WorktreeNotGitError", + data: { message: "Worktrees are only supported for git projects" }, + }) + }), + ) + it.instance( "serves Console org switch through the default server app", () => From 4498fc983dd0632feb4345a49940544bbb38866e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 13 May 2026 09:36:51 +0000 Subject: [PATCH 46/98] chore: generate --- .../instance/httpapi/handlers/experimental.ts | 4 +- packages/opencode/src/worktree/index.ts | 12 +++-- packages/sdk/js/src/v2/gen/sdk.gen.ts | 3 +- packages/sdk/js/src/v2/gen/types.gen.ts | 35 ++++++++++--- packages/sdk/openapi.json | 51 ++++++++++++++++--- 5 files changed, 86 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 89942ec4d..56a8de3ff 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -16,9 +16,7 @@ import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery, WorktreeApiError function mapWorktreeError(self: Effect.Effect) { return self.pipe( - Effect.mapError( - (error) => new WorktreeApiError({ name: error._tag, data: { message: error.message } }), - ), + Effect.mapError((error) => new WorktreeApiError({ name: error._tag, data: { message: error.message } })), ) } diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 58651ddec..93e56605b 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -234,7 +234,9 @@ export const layer: Layer.Layer< { cwd: ctx.worktree }, ) if (created.code !== 0) { - return yield* new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" }) + return yield* new CreateFailedError({ + message: created.stderr || created.text || "Failed to create git worktree", + }) } yield* project.addSandbox(ctx.project.id, info.directory).pipe(Effect.catch(() => Effect.void)) @@ -425,7 +427,9 @@ export const layer: Layer.Layer< const stale = yield* locateWorktree(parseWorktreeList(next.text), directory) if (stale?.path) { - return yield* new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" }) + return yield* new RemoveFailedError({ + message: removed.stderr || removed.text || "Failed to remove git worktree", + }) } } @@ -570,7 +574,9 @@ export const layer: Layer.Layer< const cleanResult = yield* sweep(worktreePath) if (cleanResult.code !== 0) { - return yield* new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" }) + return yield* new ResetFailedError({ + message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree", + }) } yield* gitExpect( diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index bf3201a5c..1b36c1513 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -214,6 +214,7 @@ import type { WorktreeCreateErrors, WorktreeCreateInput, WorktreeCreateResponses, + WorktreeListErrors, WorktreeListResponses, WorktreeRemoveErrors, WorktreeRemoveInput, @@ -1256,7 +1257,7 @@ export class Worktree extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/experimental/worktree", ...options, ...params, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 72b2a9f16..4350dff44 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1409,6 +1409,20 @@ export type ToolList = Array export type ToolIds = Array +export type WorktreeError = { + name: + | "WorktreeNotGitError" + | "WorktreeNameGenerationFailedError" + | "WorktreeCreateFailedError" + | "WorktreeStartCommandFailedError" + | "WorktreeRemoveFailedError" + | "WorktreeResetFailedError" + | "WorktreeListFailedError" + data: { + message: string + } +} + export type WorktreeCreateInput = { name?: string /** @@ -3843,9 +3857,9 @@ export type WorktreeRemoveData = { export type WorktreeRemoveErrors = { /** - * Bad request + * WorktreeError */ - 400: BadRequestError + 400: WorktreeError } export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors] @@ -3869,6 +3883,15 @@ export type WorktreeListData = { url: "/experimental/worktree" } +export type WorktreeListErrors = { + /** + * WorktreeError + */ + 400: WorktreeError +} + +export type WorktreeListError = WorktreeListErrors[keyof WorktreeListErrors] + export type WorktreeListResponses = { /** * List of worktree directories @@ -3890,9 +3913,9 @@ export type WorktreeCreateData = { export type WorktreeCreateErrors = { /** - * Bad request + * WorktreeError */ - 400: BadRequestError + 400: WorktreeError } export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors] @@ -3918,9 +3941,9 @@ export type WorktreeResetData = { export type WorktreeResetErrors = { /** - * Bad request + * WorktreeError */ - 400: BadRequestError + 400: WorktreeError } export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors] diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index fbbe237ec..0a93e92ee 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1013,6 +1013,16 @@ } } } + }, + "400": { + "description": "WorktreeError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorktreeError" + } + } + } } }, "description": "List all sandbox worktrees for the current project.", @@ -1057,11 +1067,11 @@ } }, "400": { - "description": "Bad request", + "description": "WorktreeError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "$ref": "#/components/schemas/WorktreeError" } } } @@ -1119,11 +1129,11 @@ } }, "400": { - "description": "Bad request", + "description": "WorktreeError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "$ref": "#/components/schemas/WorktreeError" } } } @@ -1183,11 +1193,11 @@ } }, "400": { - "description": "Bad request", + "description": "WorktreeError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "$ref": "#/components/schemas/WorktreeError" } } } @@ -12849,6 +12859,35 @@ "type": "string" } }, + "WorktreeError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "WorktreeNotGitError", + "WorktreeNameGenerationFailedError", + "WorktreeCreateFailedError", + "WorktreeStartCommandFailedError", + "WorktreeRemoveFailedError", + "WorktreeResetFailedError", + "WorktreeListFailedError" + ] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, "WorktreeCreateInput": { "type": "object", "properties": { From 2e94f505a442d5be0d42c2307c3db12c49ee250e Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Wed, 13 May 2026 11:38:31 +0200 Subject: [PATCH 47/98] chore: add low tps model alerts (#27055) --- infra/monitoring.ts | 68 +++++++++++++++++++ .../app/src/routes/honeycomb/webhook.ts | 29 ++++++-- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index c08d39f26..edc70fecd 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -111,6 +111,34 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => { }).json } +const modelLowTpsQuery = (product: "go" | "zen") => { + const filters = [ + { column: "model", op: "exists" }, + { column: "event_type", op: "=", value: "completions" }, + { column: "user_agent", op: "contains", value: "opencode" }, + { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, + { column: "status", op: ">=", value: "200" }, + { column: "status", op: "<", value: "400" }, + { column: "tps.output", op: "exists" }, + ] + + return honeycomb.getQuerySpecificationOutput({ + breakdowns: ["model"], + calculations: [ + { op: "COUNT", name: "TOTAL", filterCombination: "AND", filters }, + { + op: "P50", + name: "TPS", + column: "tps.output", + filterCombination: "AND", + filters, + }, + ], + formulas: [{ name: "LOW_TPS", expression: "IF(GTE($TOTAL, 100), $TPS, 999)" }], + timeRange: 900, + }).json +} + new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { name: "Increased Model HTTP Errors [Go]", description, @@ -149,6 +177,46 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { ], }) +new honeycomb.Trigger("LowModelTpsGo", { + disabled: true, + name: "Low Model TPS [Go]", + description, + queryJson: modelLowTpsQuery("go"), + alertType: "on_change", + frequency: 300, + thresholds: [{ op: "<", value: 20, exceededLimit: 1 }], + recipients: [ + { + id: webhookRecipient.id, + notificationDetails: [ + { + variables: [{ name: "type", value: "model_low_tps" }], + }, + ], + }, + ], +}) + +new honeycomb.Trigger("LowModelTpsZen", { + disabled: true, + name: "Low Model TPS [Zen]", + description, + queryJson: modelLowTpsQuery("zen"), + alertType: "on_change", + frequency: 300, + thresholds: [{ op: "<", value: 20, exceededLimit: 1 }], + recipients: [ + { + id: webhookRecipient.id, + notificationDetails: [ + { + variables: [{ name: "type", value: "model_low_tps" }], + }, + ], + }, + ], +}) + new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", { name: "Increased Provider HTTP Errors [Go]", description, diff --git a/packages/console/app/src/routes/honeycomb/webhook.ts b/packages/console/app/src/routes/honeycomb/webhook.ts index 367a93aeb..ae76d86fe 100644 --- a/packages/console/app/src/routes/honeycomb/webhook.ts +++ b/packages/console/app/src/routes/honeycomb/webhook.ts @@ -12,13 +12,19 @@ const basePayload = z.object({ url: z.string(), }) -const groups = z.object({ group: z.object({ key: z.string(), value: z.string() }).array() }).array() +const groups = z + .object({ result: z.union([z.number(), z.string()]).nullish(), group: z.object({ key: z.string(), value: z.string() }).array() }) + .array() const honeycombWebhookPayload = z.discriminatedUnion("type", [ basePayload.extend({ type: z.literal("model_http_errors"), groups, }), + basePayload.extend({ + type: z.literal("model_low_tps"), + groups, + }), basePayload.extend({ type: z.literal("provider_http_errors"), groups, @@ -29,14 +35,25 @@ const honeycombWebhookPayload = z.discriminatedUnion("type", [ ]) const postDiscordMessage = async (payload: z.infer) => { - const group = - payload.type === "model_http_errors" ? "model" : payload.type === "provider_http_errors" ? "provider" : undefined - const names = payload.type === "custom" ? [] : payload.groups.flatMap((item) => item.group.map((g) => g.value)) + const names = + payload.type === "custom" + ? [] + : payload.groups.flatMap((item) => + item.group.map((g) => { + const result = item.result == null ? undefined : Number(item.result) + return `- ${g.value}${ + result !== undefined && Number.isFinite(result) + ? payload.type === "model_low_tps" + ? ` (${Math.round(result)} TPS)` + : ` (${Math.round(result * 100)}% errors)` + : "" + }` + }), + ) const content = [ `[**${payload.isTest ? "[TEST] " : ""}${payload.name ?? "Honeycomb alert"}**](${payload.url})`, - group && names.length > 0 ? `Affected ${group}s:` : undefined, - ...names.map((name) => `- ${name}`), + ...names, "", `<@&${DISCORD_ALERT_ROLE_ID}>`, ] From 374951bf60320d7f58dd8edfa4b204710627a964 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 13 May 2026 09:39:54 +0000 Subject: [PATCH 48/98] chore: generate --- packages/console/app/src/routes/honeycomb/webhook.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/honeycomb/webhook.ts b/packages/console/app/src/routes/honeycomb/webhook.ts index ae76d86fe..05be68353 100644 --- a/packages/console/app/src/routes/honeycomb/webhook.ts +++ b/packages/console/app/src/routes/honeycomb/webhook.ts @@ -13,7 +13,10 @@ const basePayload = z.object({ }) const groups = z - .object({ result: z.union([z.number(), z.string()]).nullish(), group: z.object({ key: z.string(), value: z.string() }).array() }) + .object({ + result: z.union([z.number(), z.string()]).nullish(), + group: z.object({ key: z.string(), value: z.string() }).array(), + }) .array() const honeycombWebhookPayload = z.discriminatedUnion("type", [ From 733bd3c74e9d7ad671bbfe235e74647179860ddd Mon Sep 17 00:00:00 2001 From: vimtor Date: Wed, 13 May 2026 12:12:03 +0200 Subject: [PATCH 49/98] chore: activate low tps alerts --- infra/monitoring.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index edc70fecd..240e6c97e 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -135,7 +135,7 @@ const modelLowTpsQuery = (product: "go" | "zen") => { }, ], formulas: [{ name: "LOW_TPS", expression: "IF(GTE($TOTAL, 100), $TPS, 999)" }], - timeRange: 900, + timeRange: 1800, }).json } @@ -178,13 +178,12 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { }) new honeycomb.Trigger("LowModelTpsGo", { - disabled: true, name: "Low Model TPS [Go]", description, queryJson: modelLowTpsQuery("go"), alertType: "on_change", - frequency: 300, - thresholds: [{ op: "<", value: 20, exceededLimit: 1 }], + frequency: 600, + thresholds: [{ op: "<=", value: 10, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -198,13 +197,12 @@ new honeycomb.Trigger("LowModelTpsGo", { }) new honeycomb.Trigger("LowModelTpsZen", { - disabled: true, name: "Low Model TPS [Zen]", description, queryJson: modelLowTpsQuery("zen"), alertType: "on_change", - frequency: 300, - thresholds: [{ op: "<", value: 20, exceededLimit: 1 }], + frequency: 600, + thresholds: [{ op: "<=", value: 10, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, From 809af5c59080858e8f61068616cb237c885e325e Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 15:47:13 +0530 Subject: [PATCH 50/98] fix(provider): type auth errors (#27301) --- packages/opencode/src/provider/auth.ts | 34 +++--- .../instance/httpapi/groups/provider.ts | 24 +++- .../instance/httpapi/handlers/provider.ts | 41 +++++-- .../instance/httpapi/middleware/error.ts | 1 - .../test/server/httpapi-provider.test.ts | 109 +++++++++++++++++- packages/sdk/js/src/v2/gen/types.gen.ts | 23 +++- 6 files changed, 200 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index b63e1eaf4..2e1b1e2af 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,7 +1,6 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" -import { NamedError } from "@opencode-ai/core/util/error" import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" @@ -64,23 +63,30 @@ export const CallbackInput = Schema.Struct({ }) export type CallbackInput = Schema.Schema.Type -export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", { providerID: ProviderID }) +export class OauthMissing extends Schema.TaggedErrorClass()("ProviderAuthOauthMissing", { + providerID: ProviderID, +}) {} -export const OauthCodeMissing = NamedError.create("ProviderAuthOauthCodeMissing", { providerID: ProviderID }) +export class OauthCodeMissing extends Schema.TaggedErrorClass()("ProviderAuthOauthCodeMissing", { + providerID: ProviderID, +}) {} -export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", {}) +export class OauthCallbackFailed extends Schema.TaggedErrorClass()( + "ProviderAuthOauthCallbackFailed", + {}, +) {} -export const ValidationFailed = NamedError.create("ProviderAuthValidationFailed", { +export class ValidationFailed extends Schema.TaggedErrorClass()("ProviderAuthValidationFailed", { field: Schema.String, message: Schema.String, -}) +}) {} export type Error = | Auth.AuthError - | InstanceType - | InstanceType - | InstanceType - | InstanceType + | OauthMissing + | OauthCodeMissing + | OauthCallbackFailed + | ValidationFailed type Hook = NonNullable @@ -166,7 +172,7 @@ export const layer: Layer.Layer = for (const prompt of method.prompts) { if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) { const error = prompt.validate(input.inputs[prompt.key]) - if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error })) + if (error) return yield* new ValidationFailed({ field: prompt.key, message: error }) } } } @@ -183,15 +189,15 @@ export const layer: Layer.Layer = const callback = Effect.fn("ProviderAuth.callback")(function* (input: { providerID: ProviderID } & CallbackInput) { const pending = (yield* InstanceState.get(state)).pending const match = pending.get(input.providerID) - if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) + if (!match) return yield* new OauthMissing({ providerID: input.providerID }) if (match.method === "code" && !input.code) { - return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID })) + return yield* new OauthCodeMissing({ providerID: input.providerID }) } const result = yield* Effect.promise(() => match.method === "code" ? match.callback(input.code!) : match.callback(), ) - if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({})) + if (!result || result.type !== "success") return yield* new OauthCallbackFailed({}) if ("key" in result) { yield* auth.set(input.providerID, { diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts index 49792898d..b6eecff4c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts @@ -10,6 +10,26 @@ import { described } from "./metadata" const root = "/provider" +const ProviderAuthErrorName = Schema.Union([ + Schema.Literal("BadRequest"), + Schema.Literal("ProviderAuthOauthMissing"), + Schema.Literal("ProviderAuthOauthCodeMissing"), + Schema.Literal("ProviderAuthOauthCallbackFailed"), + Schema.Literal("ProviderAuthValidationFailed"), +]) +export class ProviderAuthApiError extends Schema.ErrorClass("ProviderAuthError")( + { + name: ProviderAuthErrorName, + data: Schema.Struct({ + providerID: Schema.optional(ProviderID), + field: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + kind: Schema.optional(Schema.String), + }), + }, + { httpApiStatus: 400 }, +) {} + export const ProviderApi = HttpApi.make("provider") .add( HttpApiGroup.make("provider") @@ -39,7 +59,7 @@ export const ProviderApi = HttpApi.make("provider") query: WorkspaceRoutingQuery, payload: ProviderAuth.AuthorizeInput, success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"), - error: HttpApiError.BadRequest, + error: ProviderAuthApiError, }).annotateMerge( OpenApi.annotations({ identifier: "provider.oauth.authorize", @@ -52,7 +72,7 @@ export const ProviderApi = HttpApi.make("provider") query: WorkspaceRoutingQuery, payload: ProviderAuth.CallbackInput, success: described(Schema.Boolean, "OAuth callback processed successfully"), - error: HttpApiError.BadRequest, + error: ProviderAuthApiError, }).annotateMerge( OpenApi.annotations({ identifier: "provider.oauth.callback", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index b9d5b5af1..9da31582a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -6,8 +6,29 @@ import { ProviderID } from "@/provider/schema" import { mapValues } from "remeda" import { Effect, Schema } from "effect" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" +import { ProviderAuthApiError } from "../groups/provider" + +function mapProviderAuthError(self: Effect.Effect) { + return self.pipe( + Effect.mapError((error) => { + if (error instanceof ProviderAuth.OauthMissing) { + return new ProviderAuthApiError({ name: error._tag, data: { providerID: error.providerID } }) + } + if (error instanceof ProviderAuth.OauthCodeMissing) { + return new ProviderAuthApiError({ name: error._tag, data: { providerID: error.providerID } }) + } + if (error instanceof ProviderAuth.OauthCallbackFailed) { + return new ProviderAuthApiError({ name: error._tag, data: {} }) + } + if (error instanceof ProviderAuth.ValidationFailed) { + return new ProviderAuthApiError({ name: error._tag, data: { field: error.field, message: error.message } }) + } + return new ProviderAuthApiError({ name: "BadRequest", data: {} }) + }), + ) +} export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider", (handlers) => Effect.gen(function* () { @@ -44,13 +65,13 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" params: { providerID: ProviderID } payload: ProviderAuth.AuthorizeInput }) { - return yield* svc - .authorize({ + return yield* mapProviderAuthError( + svc.authorize({ providerID: ctx.params.providerID, method: ctx.payload.method, inputs: ctx.payload.inputs, - }) - .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) + }), + ) }) const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: { @@ -59,7 +80,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" }) { const body = yield* Effect.orDie(ctx.request.text) const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe( - Effect.mapError(() => new HttpApiError.BadRequest({})), + Effect.mapError(() => new ProviderAuthApiError({ name: "BadRequest", data: {} })), ) // Match legacy route behavior: when authorize() resolves without a // result (e.g. no further redirect), serialize as JSON `null` instead @@ -72,13 +93,13 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" params: { providerID: ProviderID } payload: ProviderAuth.CallbackInput }) { - yield* svc - .callback({ + yield* mapProviderAuthError( + svc.callback({ providerID: ctx.params.providerID, method: ctx.payload.method, code: ctx.payload.code, - }) - .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) + }), + ) return true }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index acc39fb1e..bb75f6602 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -28,7 +28,6 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) HttpServerResponse.jsonUnsafe(error.toObject(), { status: iife(() => { if (error instanceof Provider.ModelNotFoundError) return 400 - if (error.name === "ProviderAuthValidationFailed") return 400 return 500 }), }), diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index cb47e5bbd..490b947fd 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -80,12 +80,33 @@ function requestAuthorize(input: { providerID: string method: number headers: HeadersInit + inputs?: Record }) { return Effect.promise(async () => { const response = await input.app.request(`/provider/${input.providerID}/oauth/authorize`, { method: "POST", headers: input.headers, - body: JSON.stringify({ method: input.method }), + body: JSON.stringify({ method: input.method, ...(input.inputs ? { inputs: input.inputs } : {}) }), + }) + return { + status: response.status, + body: await response.text(), + } + }) +} + +function requestCallback(input: { + app: ReturnType + providerID: string + method: number + headers: HeadersInit + code?: string +}) { + return Effect.promise(async () => { + const response = await input.app.request(`/provider/${input.providerID}/oauth/callback`, { + method: "POST", + headers: input.headers, + body: JSON.stringify({ method: input.method, ...(input.code ? { code: input.code } : {}) }), }) return { status: response.status, @@ -128,6 +149,47 @@ function writeProviderAuthPlugin(dir: string) { }) } +function writeProviderAuthValidationPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + + yield* fs.writeWithDirs( + path.join(dir, ".opencode", "plugin", "provider-oauth-validation.ts"), + [ + "export default {", + ' id: "test.provider-oauth-validation",', + " server: async () => ({", + " auth: {", + ' provider: "test-oauth-validation",', + " methods: [", + " {", + ' type: "oauth",', + ' label: "OAuth",', + " prompts: [", + " {", + ' type: "text",', + ' key: "token",', + ' message: "Token",', + " validate: (value) => value === 'ok' ? undefined : 'Token must be ok',", + " },", + " ],", + " authorize: async () => ({", + ` url: "${oauthURL}",`, + ' method: "code",', + ` instructions: "${oauthInstructions}",`, + " callback: async () => ({ type: 'success', key: 'token' }),", + " }),", + " },", + " ],", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + function writeFunctionOptionsPlugin(dir: string) { return Effect.gen(function* () { const fs = yield* AppFileSystem.Service @@ -240,6 +302,51 @@ describe("provider HttpApi", () => { }) }), projectOptions, + 30000, + ) + + it.instance( + "returns declared provider auth validation errors", + Effect.gen(function* () { + const instance = yield* TestInstance + yield* writeProviderAuthValidationPlugin(instance.directory) + const response = yield* requestAuthorize({ + app: app(), + providerID: "test-oauth-validation", + method: 0, + inputs: { token: "nope" }, + headers: { "x-opencode-directory": instance.directory, "content-type": "application/json" }, + }) + + expect(response.status).toBe(400) + expect(JSON.parse(response.body)).toEqual({ + name: "ProviderAuthValidationFailed", + data: { field: "token", message: "Token must be ok" }, + }) + }), + projectOptions, + 30000, + ) + + it.instance( + "returns declared provider auth callback errors", + Effect.gen(function* () { + const instance = yield* TestInstance + const response = yield* requestCallback({ + app: app(), + providerID, + method: 0, + headers: { "x-opencode-directory": instance.directory, "content-type": "application/json" }, + }) + + expect(response.status).toBe(400) + expect(JSON.parse(response.body)).toEqual({ + name: "ProviderAuthOauthMissing", + data: { providerID }, + }) + }), + projectOptions, + 30000, ) it.instance( diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4350dff44..a1591aa2c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1721,6 +1721,21 @@ export type ProviderAuthAuthorization = { instructions: string } +export type ProviderAuthError1 = { + name: + | "BadRequest" + | "ProviderAuthOauthMissing" + | "ProviderAuthOauthCodeMissing" + | "ProviderAuthOauthCallbackFailed" + | "ProviderAuthValidationFailed" + data: { + providerID?: string + field?: string + message?: string + kind?: string + } +} + export type TextPartInput = { id?: string type: "text" @@ -5155,9 +5170,9 @@ export type ProviderOauthAuthorizeData = { export type ProviderOauthAuthorizeErrors = { /** - * Bad request + * ProviderAuthError */ - 400: BadRequestError + 400: ProviderAuthError1 } export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] @@ -5191,9 +5206,9 @@ export type ProviderOauthCallbackData = { export type ProviderOauthCallbackErrors = { /** - * Bad request + * ProviderAuthError */ - 400: BadRequestError + 400: ProviderAuthError1 } export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors] From d488e3fd2a6fabcc65a0df758246bf17a409f288 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 13 May 2026 10:18:40 +0000 Subject: [PATCH 51/98] chore: generate --- packages/opencode/src/provider/auth.ts | 7 +---- packages/sdk/openapi.json | 43 +++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 2e1b1e2af..6f857ce52 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -81,12 +81,7 @@ export class ValidationFailed extends Schema.TaggedErrorClass( message: Schema.String, }) {} -export type Error = - | Auth.AuthError - | OauthMissing - | OauthCodeMissing - | OauthCallbackFailed - | ValidationFailed +export type Error = Auth.AuthError | OauthMissing | OauthCodeMissing | OauthCallbackFailed | ValidationFailed type Hook = NonNullable diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0a93e92ee..fc7e4c278 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -4217,11 +4217,11 @@ } }, "400": { - "description": "Bad request", + "description": "ProviderAuthError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "$ref": "#/components/schemas/ProviderAuthError1" } } } @@ -4303,11 +4303,11 @@ } }, "400": { - "description": "Bad request", + "description": "ProviderAuthError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "$ref": "#/components/schemas/ProviderAuthError1" } } } @@ -13785,6 +13785,41 @@ "required": ["url", "method", "instructions"], "additionalProperties": false }, + "ProviderAuthError1": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "BadRequest", + "ProviderAuthOauthMissing", + "ProviderAuthOauthCodeMissing", + "ProviderAuthOauthCallbackFailed", + "ProviderAuthValidationFailed" + ] + }, + "data": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "field": { + "type": "string" + }, + "message": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, "TextPartInput": { "type": "object", "properties": { From 5b2b30060228eaff29c4b3d576dccaf02051deeb Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 16:48:18 +0530 Subject: [PATCH 52/98] fix(session): tighten http error contracts (#27308) --- .../routes/instance/httpapi/groups/session.ts | 28 +++++------ .../instance/httpapi/handlers/session.ts | 39 ++++++++++----- .../test/server/httpapi-session.test.ts | 49 +++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 30 +++++------- 4 files changed, 104 insertions(+), 42 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 2053aba3b..b8c8a142b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -141,7 +141,7 @@ export const SessionApi = HttpApi.make("session") params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, success: described(Schema.Array(Session.Info), "List of children"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.children", @@ -153,7 +153,7 @@ export const SessionApi = HttpApi.make("session") params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, success: described(Schema.Array(Todo.Info), "Todo list"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.todo", @@ -250,7 +250,7 @@ export const SessionApi = HttpApi.make("session") params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Aborted session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "session.abort", @@ -263,7 +263,7 @@ export const SessionApi = HttpApi.make("session") query: WorkspaceRoutingQuery, payload: InitPayload, success: described(Schema.Boolean, "200"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.init", @@ -314,7 +314,7 @@ export const SessionApi = HttpApi.make("session") query: WorkspaceRoutingQuery, payload: PromptPayload, success: described(MessageV2.WithParts, "Created message"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.prompt", @@ -327,7 +327,7 @@ export const SessionApi = HttpApi.make("session") query: WorkspaceRoutingQuery, payload: PromptPayload, success: described(HttpApiSchema.NoContent, "Prompt accepted"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.prompt_async", @@ -341,7 +341,7 @@ export const SessionApi = HttpApi.make("session") query: WorkspaceRoutingQuery, payload: CommandPayload, success: described(MessageV2.WithParts, "Created message"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.command", @@ -354,7 +354,7 @@ export const SessionApi = HttpApi.make("session") query: WorkspaceRoutingQuery, payload: ShellPayload, success: described(MessageV2.WithParts, "Created message"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.shell", @@ -367,7 +367,7 @@ export const SessionApi = HttpApi.make("session") query: WorkspaceRoutingQuery, payload: RevertPayload, success: described(Session.Info, "Updated session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.revert", @@ -380,7 +380,7 @@ export const SessionApi = HttpApi.make("session") params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, success: described(Session.Info, "Updated session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.unrevert", @@ -393,7 +393,7 @@ export const SessionApi = HttpApi.make("session") query: WorkspaceRoutingQuery, payload: PermissionResponsePayload, success: described(Schema.Boolean, "Permission processed successfully"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "permission.respond", @@ -406,7 +406,7 @@ export const SessionApi = HttpApi.make("session") params: { sessionID: SessionID, messageID: MessageID }, query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted message"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.deleteMessage", @@ -419,7 +419,7 @@ export const SessionApi = HttpApi.make("session") params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted part"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "part.delete", @@ -431,7 +431,7 @@ export const SessionApi = HttpApi.make("session") query: WorkspaceRoutingQuery, payload: MessageV2.Part, success: described(MessageV2.Part, "Successfully updated part"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "part.update", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 40444385f..b12be2cfc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -68,15 +68,21 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", return Object.fromEntries(yield* statusSvc.list()) }) + const requireSession = Effect.fn("SessionHttpApi.requireSession")(function* (sessionID: SessionID) { + return yield* SessionError.mapStorageNotFound(session.get(sessionID)) + }) + const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) { - return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) + return yield* requireSession(ctx.params.sessionID) }) const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) { + yield* requireSession(ctx.params.sessionID) return yield* session.children(ctx.params.sessionID) }) const todo = Effect.fn("SessionHttpApi.todo")(function* (ctx: { params: { sessionID: SessionID } }) { + yield* requireSession(ctx.params.sessionID) return yield* todoSvc.get(ctx.params.sessionID) }) @@ -99,7 +105,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", catch: () => new HttpApiError.BadRequest({}), }) } - yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) + yield* requireSession(ctx.params.sessionID) if (ctx.query.limit === undefined || ctx.query.limit === 0) { return yield* SessionError.mapStorageNotFound(session.messages({ sessionID: ctx.params.sessionID })) } @@ -165,7 +171,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof UpdatePayload.Type }) { - const current = yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) + const current = yield* requireSession(ctx.params.sessionID) if (ctx.payload.title !== undefined) { yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title }) } @@ -178,7 +184,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", if (ctx.payload.time?.archived !== undefined) { yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived }) } - return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) + return yield* requireSession(ctx.params.sessionID) }) const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: { @@ -216,6 +222,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof InitPayload.Type }) { + yield* requireSession(ctx.params.sessionID) yield* promptSvc .command({ sessionID: ctx.params.sessionID, @@ -234,22 +241,24 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", // ErrorMiddleware → NamedError.Unknown 500) instead of blanket-mapping // every failure to a 400 BadRequest. const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) { + yield* requireSession(ctx.params.sessionID) yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.InternalServerError({}))) - return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) + return yield* requireSession(ctx.params.sessionID) }) const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) { + yield* requireSession(ctx.params.sessionID) yield* shareSvc .unshare(ctx.params.sessionID) .pipe(Effect.mapError(() => new HttpApiError.InternalServerError({}))) - return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) + return yield* requireSession(ctx.params.sessionID) }) const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: { params: { sessionID: SessionID } payload: typeof SummarizePayload.Type }) { - yield* revertSvc.cleanup(yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))) + yield* revertSvc.cleanup(yield* requireSession(ctx.params.sessionID)) const messages = yield* SessionError.mapStorageNotFound(session.messages({ sessionID: ctx.params.sessionID })) const defaultAgent = yield* agentSvc.defaultAgent() const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent @@ -271,6 +280,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof PromptPayload.Type }) { + yield* requireSession(ctx.params.sessionID) const message = yield* promptSvc .prompt({ ...ctx.payload, @@ -286,6 +296,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof PromptPayload.Type }) { + yield* requireSession(ctx.params.sessionID) yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe( Effect.catchCause((cause) => Effect.gen(function* () { @@ -307,6 +318,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof CommandPayload.Type }) { + yield* requireSession(ctx.params.sessionID) return yield* promptSvc .command({ ...ctx.payload, sessionID: ctx.params.sessionID }) .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) @@ -316,6 +328,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof ShellPayload.Type }) { + yield* requireSession(ctx.params.sessionID) return yield* promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID }) }) @@ -323,17 +336,20 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof RevertPayload.Type }) { + yield* requireSession(ctx.params.sessionID) return yield* revertSvc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload }) }) const unrevert = Effect.fn("SessionHttpApi.unrevert")(function* (ctx: { params: { sessionID: SessionID } }) { + yield* requireSession(ctx.params.sessionID) return yield* revertSvc.unrevert({ sessionID: ctx.params.sessionID }) }) const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: { - params: { permissionID: PermissionID } + params: { sessionID: SessionID; permissionID: PermissionID } payload: typeof PermissionResponsePayload.Type }) { + yield* requireSession(ctx.params.sessionID) yield* permissionSvc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response }) return true }) @@ -341,6 +357,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: { params: { sessionID: SessionID; messageID: MessageID } }) { + yield* requireSession(ctx.params.sessionID) yield* runState.assertNotBusy(ctx.params.sessionID) yield* session.removeMessage(ctx.params) return true @@ -349,6 +366,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const deletePart = Effect.fn("SessionHttpApi.deletePart")(function* (ctx: { params: { sessionID: SessionID; messageID: MessageID; partID: PartID } }) { + yield* requireSession(ctx.params.sessionID) yield* session.removePart(ctx.params) return true }) @@ -357,15 +375,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID; messageID: MessageID; partID: PartID } payload: typeof MessageV2.Part.Type }) { + yield* requireSession(ctx.params.sessionID) const payload = ctx.payload as MessageV2.Part if ( payload.id !== ctx.params.partID || payload.messageID !== ctx.params.messageID || payload.sessionID !== ctx.params.sessionID ) { - throw new Error( - `Part mismatch: body.id='${payload.id}' vs partID='${ctx.params.partID}', body.messageID='${payload.messageID}' vs messageID='${ctx.params.messageID}', body.sessionID='${payload.sessionID}' vs sessionID='${ctx.params.sessionID}'`, - ) + return yield* new HttpApiError.BadRequest({}) } return yield* session.updatePart(payload) }) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 8cffb6e82..8b686739d 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -212,6 +212,14 @@ describe("session HttpApi", () => { expect(get.status).toBe(404) expect(yield* responseJson(get)).toEqual(missingSessionBody) + const children = yield* request(pathFor(SessionPaths.children, { sessionID: missingSession }), { headers }) + expect(children.status).toBe(404) + expect(yield* responseJson(children)).toEqual(missingSessionBody) + + const todo = yield* request(pathFor(SessionPaths.todo, { sessionID: missingSession }), { headers }) + expect(todo.status).toBe(404) + expect(yield* responseJson(todo)).toEqual(missingSessionBody) + const messages = yield* request(pathFor(SessionPaths.messages, { sessionID: missingSession }), { headers }) expect(messages.status).toBe(404) expect(yield* responseJson(messages)).toEqual(missingSessionBody) @@ -223,6 +231,21 @@ describe("session HttpApi", () => { expect(remove.status).toBe(404) expect(yield* responseJson(remove)).toEqual(missingSessionBody) + const prompt = yield* request(pathFor(SessionPaths.prompt, { sessionID: missingSession }), { + headers: { ...headers, "content-type": "application/json" }, + method: "POST", + body: JSON.stringify({ agent: "build", noReply: true, parts: [{ type: "text", text: "hello" }] }), + }) + expect(prompt.status).toBe(404) + expect(yield* responseJson(prompt)).toEqual(missingSessionBody) + + const abort = yield* request(pathFor(SessionPaths.abort, { sessionID: missingSession }), { + headers, + method: "POST", + }) + expect(abort.status).toBe(200) + expect(yield* responseJson(abort)).toBe(true) + const session = yield* createSession({ title: "missing message" }) const missingMessage = MessageID.ascending() const message = yield* request( @@ -530,6 +553,32 @@ describe("session HttpApi", () => { { git: true, config: { formatter: false, lsp: false } }, ) + it.instance( + "rejects part updates whose path and body ids disagree", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" } + const session = yield* createSession({ title: "part mismatch" }) + const message = yield* createTextMessage(session.id, "first") + const response = yield* request( + pathFor(SessionPaths.updatePart, { + sessionID: session.id, + messageID: message.info.id, + partID: message.part.id, + }), + { + method: "PATCH", + headers, + body: JSON.stringify({ ...message.part, id: PartID.ascending() }), + }, + ) + + expect(response.status).toBe(400) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + it.instance( "serves remaining non-LLM session mutation routes", () => diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a1591aa2c..35fb6bf81 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5442,7 +5442,7 @@ export type SessionChildrenErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5476,7 +5476,7 @@ export type SessionTodoErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5586,7 +5586,7 @@ export type SessionPromptErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5624,7 +5624,7 @@ export type SessionDeleteMessageErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5731,10 +5731,6 @@ export type SessionAbortErrors = { * Bad request */ 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError } export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors] @@ -5770,7 +5766,7 @@ export type SessionInitErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5925,7 +5921,7 @@ export type SessionPromptAsyncErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5974,7 +5970,7 @@ export type SessionCommandErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -6019,7 +6015,7 @@ export type SessionShellErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -6059,7 +6055,7 @@ export type SessionRevertErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -6093,7 +6089,7 @@ export type SessionUnrevertErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -6130,7 +6126,7 @@ export type PermissionRespondErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -6166,7 +6162,7 @@ export type PartDeleteErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -6202,7 +6198,7 @@ export type PartUpdateErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } From 5975547c845432a90bec743032dee148a43be9f3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 13 May 2026 11:19:45 +0000 Subject: [PATCH 53/98] chore: generate --- packages/sdk/openapi.json | 36 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index fc7e4c278..97890a5dc 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -4903,7 +4903,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -4980,7 +4980,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -5237,7 +5237,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -5484,7 +5484,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -5645,16 +5645,6 @@ } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } } }, "description": "Abort an active session and stop any ongoing AI processing or command execution.", @@ -5721,7 +5711,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -6050,7 +6040,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -6205,7 +6195,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -6354,7 +6344,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -6463,7 +6453,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -6557,7 +6547,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -6640,7 +6630,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -6750,7 +6740,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -6838,7 +6828,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { From 4d205027caa2a965a7bef85762f0b8a1526b344a Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 17:07:53 +0530 Subject: [PATCH 54/98] refactor(flags): route scout through runtime flags (#27318) --- packages/opencode/src/agent/agent.ts | 6 +- packages/opencode/src/reference/reference.ts | 12 +- packages/opencode/test/agent/agent.test.ts | 122 +++++----- .../agent/plugin-agent-regression.test.ts | 1 + .../opencode/test/reference/reference.test.ts | 221 +++++++++--------- packages/opencode/test/tool/read.test.ts | 99 ++++---- 6 files changed, 227 insertions(+), 234 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 423a51318..1e4d7e156 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -15,12 +15,12 @@ import PROMPT_TITLE from "./prompt/title.txt" import { Permission } from "@/permission" import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@opencode-ai/core/global" -import { Flag } from "@opencode-ai/core/flag/flag" import path from "path" import { Plugin } from "@/plugin" import { Skill } from "../skill" import { Effect, Context, Layer, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" +import { RuntimeFlags } from "@/effect/runtime-flags" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" import { type DeepMutable } from "@opencode-ai/core/schema" @@ -81,6 +81,7 @@ export const layer = Layer.effect( const plugin = yield* Plugin.Service const skill = yield* Skill.Service const provider = yield* Provider.Service + const flags = yield* RuntimeFlags.Service const state = yield* InstanceState.make( Effect.fn("Agent.state")(function* (ctx) { @@ -195,7 +196,7 @@ export const layer = Layer.effect( mode: "subagent", native: true, }, - ...(Flag.OPENCODE_EXPERIMENTAL_SCOUT + ...(flags.experimentalScout ? { scout: { name: "scout", @@ -453,6 +454,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Auth.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(Skill.defaultLayer), + Layer.provide(RuntimeFlags.defaultLayer), ) export * as Agent from "./agent" diff --git a/packages/opencode/src/reference/reference.ts b/packages/opencode/src/reference/reference.ts index 748c3b238..3109c3749 100644 --- a/packages/opencode/src/reference/reference.ts +++ b/packages/opencode/src/reference/reference.ts @@ -1,10 +1,10 @@ import path from "path" import { Effect, Context, Layer, Scope } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" +import { RuntimeFlags } from "@/effect/runtime-flags" import { Git } from "@/git" import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository" import { RepositoryCache } from "./repository-cache" @@ -143,6 +143,7 @@ export const layer = Layer.effect( const fs = yield* AppFileSystem.Service const git = yield* Git.Service const scope = yield* Scope.Scope + const flags = yield* RuntimeFlags.Service const state = yield* InstanceState.make( Effect.fn("Reference.state")(function* (ctx) { @@ -181,7 +182,7 @@ export const layer = Layer.effect( ) const materializeAll = yield* Effect.cached( - Flag.OPENCODE_EXPERIMENTAL_SCOUT + flags.experimentalScout ? Effect.gen(function* () { yield* Effect.forEach( materializeByPath, @@ -200,7 +201,7 @@ export const layer = Layer.effect( return Service.of({ init: Effect.fn("Reference.init")(function* () { - if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return + if (!flags.experimentalScout) return yield* InstanceState.useEffect(state, (s) => s.materializeAll).pipe(Effect.forkIn(scope), Effect.asVoid) }), list: Effect.fn("Reference.list")(function* () { @@ -210,7 +211,7 @@ export const layer = Layer.effect( return yield* InstanceState.use(state, (s) => s.references.find((reference) => reference.name === name)) }), ensure: Effect.fn("Reference.ensure")(function* (target?: string) { - if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return + if (!flags.experimentalScout) return const full = normalizedTarget(target) if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll) return yield* InstanceState.useEffect( @@ -219,7 +220,7 @@ export const layer = Layer.effect( ) }), contains: Effect.fn("Reference.contains")(function* (target?: string) { - if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return false + if (!flags.experimentalScout) return false const full = normalizedTarget(target) if (!full) return false return yield* InstanceState.use(state, (s) => @@ -234,6 +235,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Config.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(RuntimeFlags.defaultLayer), ) export * as Reference from "./reference" diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index a781a855c..e0defc138 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,15 +1,31 @@ import { afterEach, expect } from "bun:test" -import { Cause, Effect, Exit } from "effect" +import { Cause, Effect, Exit, Layer } from "effect" import path from "path" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { Agent } from "../../src/agent/agent" +import { Auth } from "../../src/auth" +import { Config } from "../../src/config/config" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Global } from "@opencode-ai/core/global" -import { Flag } from "@opencode-ai/core/flag/flag" import { Permission } from "../../src/permission" +import { Plugin } from "../../src/plugin" +import { Provider } from "../../src/provider/provider" +import { Skill } from "../../src/skill" import { Truncate } from "../../src/tool/truncate" -const it = testEffect(Agent.defaultLayer) +const agentLayer = (flags: Partial = {}) => + Agent.layer.pipe( + Layer.provide(Plugin.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Auth.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Skill.defaultLayer), + Layer.provide(RuntimeFlags.layer(flags)), + ) + +const it = testEffect(agentLayer()) +const scout = testEffect(agentLayer({ experimentalScout: true })) // Helper to evaluate permission for a tool with wildcard pattern function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined { @@ -21,21 +37,6 @@ function load
(fn: (svc: Agent.Interface) => Effect.Effect) { return Agent.Service.use(fn) } -function withExperimentalScout(enabled: boolean, self: Effect.Effect) { - return Effect.acquireUseRelease( - Effect.sync(() => { - const original = Flag.OPENCODE_EXPERIMENTAL_SCOUT - Flag.OPENCODE_EXPERIMENTAL_SCOUT = enabled - return original - }), - () => self, - (original) => - Effect.sync(() => { - Flag.OPENCODE_EXPERIMENTAL_SCOUT = original - }), - ) -} - const expectDefaultAgentError = Effect.fn("AgentTest.expectDefaultAgentError")(function* (message: string) { const exit = yield* load((svc) => svc.defaultAgent()).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) @@ -47,21 +48,18 @@ afterEach(async () => { }) it.instance("returns default native agents when no config", () => - withExperimentalScout( - false, - Effect.gen(function* () { - const agents = yield* load((svc) => svc.list()) - const names = agents.map((a) => a.name) - expect(names).toContain("build") - expect(names).toContain("plan") - expect(names).toContain("general") - expect(names).toContain("explore") - expect(names).not.toContain("scout") - expect(names).toContain("compaction") - expect(names).toContain("title") - expect(names).toContain("summary") - }), - ), + Effect.gen(function* () { + const agents = yield* load((svc) => svc.list()) + const names = agents.map((a) => a.name) + expect(names).toContain("build") + expect(names).toContain("plan") + expect(names).toContain("general") + expect(names).toContain("explore") + expect(names).not.toContain("scout") + expect(names).toContain("compaction") + expect(names).toContain("title") + expect(names).toContain("summary") + }), ) it.instance("build agent has correct default properties", () => @@ -111,42 +109,36 @@ it.instance("explore agent asks for external directories and allows whitelisted }), ) -it.instance("scout agent allows repo cloning and repo cache reads", () => - withExperimentalScout( - true, - Effect.gen(function* () { - const scout = yield* load((svc) => svc.get("scout")) - expect(scout).toBeDefined() - expect(scout?.mode).toBe("subagent") - expect(evalPerm(scout, "repo_clone")).toBe("allow") - expect(evalPerm(scout, "repo_overview")).toBe("allow") - expect(evalPerm(scout, "edit")).toBe("deny") - expect( - Permission.evaluate( - "external_directory", - path.join(Global.Path.repos, "github.com", "owner", "repo", "README.md"), - scout!.permission, - ).action, - ).toBe("allow") - }), - ), +scout.instance("scout agent allows repo cloning and repo cache reads", () => + Effect.gen(function* () { + const scout = yield* load((svc) => svc.get("scout")) + expect(scout).toBeDefined() + expect(scout?.mode).toBe("subagent") + expect(evalPerm(scout, "repo_clone")).toBe("allow") + expect(evalPerm(scout, "repo_overview")).toBe("allow") + expect(evalPerm(scout, "edit")).toBe("deny") + expect( + Permission.evaluate( + "external_directory", + path.join(Global.Path.repos, "github.com", "owner", "repo", "README.md"), + scout!.permission, + ).action, + ).toBe("allow") + }), ) -it.instance( +scout.instance( "reference config does not create subagents", () => - withExperimentalScout( - true, - Effect.gen(function* () { - const agents = yield* load((svc) => svc.list()) - const names = agents.map((agent) => agent.name) - expect(names).toContain("scout") - expect(names).not.toContain("effect") - expect(names).not.toContain("effectFull") - expect(names).not.toContain("localdocs") - expect(names).not.toContain("localdocsFull") - }), - ), + Effect.gen(function* () { + const agents = yield* load((svc) => svc.list()) + const names = agents.map((agent) => agent.name) + expect(names).toContain("scout") + expect(names).not.toContain("effect") + expect(names).not.toContain("effectFull") + expect(names).not.toContain("localdocs") + expect(names).not.toContain("localdocsFull") + }), { config: { reference: { diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 60d59ee90..c437281cc 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -41,6 +41,7 @@ const agentLayer = Agent.layer.pipe( Layer.provide(SkillTest.empty), Layer.provide(provider.layer), Layer.provide(pluginLayer), + Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), ) const it = testEffect(Layer.mergeAll(agentLayer, pluginLayer)) diff --git a/packages/opencode/test/reference/reference.test.ts b/packages/opencode/test/reference/reference.test.ts index 4717c61d2..f4a73dbf9 100644 --- a/packages/opencode/test/reference/reference.test.ts +++ b/packages/opencode/test/reference/reference.test.ts @@ -3,8 +3,9 @@ import path from "path" import { Effect, Layer } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" +import { Config } from "../../src/config/config" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Git } from "../../src/git" import { Reference } from "../../src/reference/reference" import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" @@ -14,24 +15,26 @@ afterEach(async () => { await disposeAllInstances() }) -const it = testEffect( - Layer.mergeAll(AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, Reference.defaultLayer), -) - -const experimentalScout = (self: Effect.Effect) => - Effect.acquireUseRelease( - Effect.sync(() => { - const previous = Flag.OPENCODE_EXPERIMENTAL_SCOUT - Flag.OPENCODE_EXPERIMENTAL_SCOUT = true - return previous - }), - () => self, - (previous) => - Effect.sync(() => { - Flag.OPENCODE_EXPERIMENTAL_SCOUT = previous - }), +const referenceLayer = (flags: Partial = {}) => + Reference.layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Git.defaultLayer), + Layer.provide(RuntimeFlags.layer(flags)), ) +const it = testEffect( + Layer.mergeAll(AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, referenceLayer()), +) +const scout = testEffect( + Layer.mergeAll( + AppFileSystem.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Git.defaultLayer, + referenceLayer({ experimentalScout: true }), + ), +) + const githubBase = (url: string, self: Effect.Effect) => Effect.acquireUseRelease( Effect.sync(() => { @@ -127,118 +130,114 @@ describe("reference", () => { }), ) - it.live("materializes configured git references during init", () => - experimentalScout( - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-test", "repo") - yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) - yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + scout.live("materializes configured git references during init", () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-test", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) - const source = yield* tmpdirScoped({ git: true }) - const remoteRoot = yield* tmpdirScoped() - const remoteDir = path.join(remoteRoot, "opencode-reference-test") - const remoteRepo = path.join(remoteDir, "repo.git") + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-reference-test") + const remoteRepo = path.join(remoteDir, "repo.git") - yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "configured\n")) - yield* git(source, ["add", "."]) - yield* git(source, ["commit", "-m", "add readme"]) - yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) - yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "configured\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) - const reference = yield* Reference.Service - yield* githubBase( - `file://${remoteRoot}/`, - Effect.gen(function* () { - yield* reference.init() - yield* waitForContent(fs, path.join(cache, "README.md"), "configured\n") - }), - ) + const reference = yield* Reference.Service + yield* githubBase( + `file://${remoteRoot}/`, + Effect.gen(function* () { + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "configured\n") + }), + ) - expect(yield* fs.existsSafe(path.join(cache, ".git"))).toBe(true) - expect(yield* fs.readFileString(path.join(cache, "README.md"))).toBe("configured\n") + expect(yield* fs.existsSafe(path.join(cache, ".git"))).toBe(true) + expect(yield* fs.readFileString(path.join(cache, "README.md"))).toBe("configured\n") - const resolved = yield* reference.get("docs") - expect(resolved?.kind).toBe("git") - if (resolved?.kind === "git") expect(resolved.path).toBe(cache) - }), - { - config: { - reference: { - docs: "opencode-reference-test/repo", - }, + const resolved = yield* reference.get("docs") + expect(resolved?.kind).toBe("git") + if (resolved?.kind === "git") expect(resolved.path).toBe(cache) + }), + { + config: { + reference: { + docs: "opencode-reference-test/repo", }, }, - ), + }, ), ) - it.live("refreshes configured git references on new instance init", () => - experimentalScout( - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo") - yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) - yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + scout.live("refreshes configured git references on new instance init", () => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) - const source = yield* tmpdirScoped({ git: true }) - const remoteRoot = yield* tmpdirScoped() - const remoteDir = path.join(remoteRoot, "opencode-reference-refresh") - const remoteRepo = path.join(remoteDir, "repo.git") + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-reference-refresh") + const remoteRepo = path.join(remoteDir, "repo.git") - yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) - yield* git(source, ["add", "."]) - yield* git(source, ["commit", "-m", "add readme"]) - yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) - yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) - yield* githubBase( - `file://${remoteRoot}/`, - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const reference = yield* Reference.Service - yield* reference.init() - yield* waitForContent(fs, path.join(cache, "README.md"), "v1\n") - }), - { - config: { - reference: { - docs: "opencode-reference-refresh/repo", - }, + yield* githubBase( + `file://${remoteRoot}/`, + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const reference = yield* Reference.Service + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "v1\n") + }), + { + config: { + reference: { + docs: "opencode-reference-refresh/repo", }, }, - ), - ) + }, + ), + ) - const branch = yield* git(source, ["branch", "--show-current"]) - yield* git(source, ["remote", "add", "origin", remoteRepo]) - yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n")) - yield* git(source, ["add", "."]) - yield* git(source, ["commit", "-m", "update readme"]) - yield* git(source, ["push", "origin", `${branch}:${branch}`]) + const branch = yield* git(source, ["branch", "--show-current"]) + yield* git(source, ["remote", "add", "origin", remoteRepo]) + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "update readme"]) + yield* git(source, ["push", "origin", `${branch}:${branch}`]) - yield* githubBase( - `file://${remoteRoot}/`, - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const reference = yield* Reference.Service - yield* reference.init() - yield* waitForContent(fs, path.join(cache, "README.md"), "v2\n") - }), - { - config: { - reference: { - docs: "opencode-reference-refresh/repo", - }, + yield* githubBase( + `file://${remoteRoot}/`, + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const reference = yield* Reference.Service + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "v2\n") + }), + { + config: { + reference: { + docs: "opencode-reference-refresh/repo", }, }, - ), - ) - }), - ), + }, + ), + ) + }), ) }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 11bb1513f..fcbd10bb4 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -4,8 +4,10 @@ import path from "path" import { Agent } from "../../src/agent/agent" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" +import { Config } from "@/config/config" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { Git } from "@/git" import { LSP } from "@/lsp/lsp" import { Permission } from "../../src/permission" import { Instance } from "../../src/project/instance" @@ -36,17 +38,27 @@ const ctx = { ask: () => Effect.void, } -const it = testEffect( +const referenceLayer = (flags: Partial = {}) => + Reference.layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Git.defaultLayer), + Layer.provide(RuntimeFlags.layer(flags)), + ) + +const readLayer = (flags: Partial = {}) => Layer.mergeAll( Agent.defaultLayer, AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Instruction.defaultLayer, LSP.defaultLayer, - Reference.defaultLayer, + referenceLayer(flags), Truncate.defaultLayer, - ), -) + ) + +const it = testEffect(readLayer()) +const scout = testEffect(readLayer({ experimentalScout: true })) const init = Effect.fn("ReadToolTest.init")(function* () { const info = yield* ReadTool @@ -85,19 +97,6 @@ const fail = Effect.fn("ReadToolTest.fail")(function* ( const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p) const glob = (p: string) => process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") -const experimentalScout = (self: Effect.Effect) => - Effect.acquireUseRelease( - Effect.sync(() => { - const previous = Flag.OPENCODE_EXPERIMENTAL_SCOUT - Flag.OPENCODE_EXPERIMENTAL_SCOUT = true - return previous - }), - () => self, - (previous) => - Effect.sync(() => { - Flag.OPENCODE_EXPERIMENTAL_SCOUT = previous - }), - ) const githubBase = (url: string, self: Effect.Effect) => Effect.acquireUseRelease( Effect.sync(() => { @@ -260,44 +259,42 @@ describe("tool.read external_directory permission", () => { }), ) - it.live("does not ask for external_directory permission when reading configured references", () => - experimentalScout( - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo") - yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) - yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + scout.live("does not ask for external_directory permission when reading configured references", () => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) - const source = yield* tmpdirScoped({ git: true }) - const remoteRoot = yield* tmpdirScoped() - const remoteDir = path.join(remoteRoot, "opencode-read-reference") - const remoteRepo = path.join(remoteDir, "repo.git") - yield* put(path.join(source, "notes.md"), "reference notes") - yield* git(source, ["add", "."]) - yield* git(source, ["commit", "-m", "add notes"]) - yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) - yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-read-reference") + const remoteRepo = path.join(remoteDir, "repo.git") + yield* put(path.join(source, "notes.md"), "reference notes") + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add notes"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) - const dir = yield* tmpdirScoped({ - git: true, - config: { - reference: { - docs: "opencode-read-reference/repo", - }, + const dir = yield* tmpdirScoped({ + git: true, + config: { + reference: { + docs: "opencode-read-reference/repo", }, - }) + }, + }) - const { items, next } = asks() - const result = yield* githubBase( - `file://${remoteRoot}/`, - exec(dir, { filePath: path.join(cache, "notes.md") }, next), - ) - const ext = items.find((item) => item.permission === "external_directory") + const { items, next } = asks() + const result = yield* githubBase( + `file://${remoteRoot}/`, + exec(dir, { filePath: path.join(cache, "notes.md") }, next), + ) + const ext = items.find((item) => item.permission === "external_directory") - expect(result.output).toContain("reference notes") - expect(ext).toBeUndefined() - }), - ), + expect(result.output).toContain("reference notes") + expect(ext).toBeUndefined() + }), ) }) From 098bdd8ae2c26d2414fa6fe1ff1672c8ce42c45b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 17:17:04 +0530 Subject: [PATCH 55/98] refactor(flags): route plan mode through runtime flags (#27320) --- packages/opencode/src/session/prompt.ts | 5 ++++- packages/opencode/test/session/prompt.test.ts | 1 + packages/opencode/test/session/snapshot-tool-race.test.ts | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8656050fa..3b1f2c41a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -51,6 +51,7 @@ import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect/bridge" +import { RuntimeFlags } from "@/effect/runtime-flags" import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" import { Modelv2 } from "@/v2/model" @@ -202,6 +203,7 @@ export const layer = Layer.effect( const llm = yield* LLM.Service const references = yield* Reference.Service const sync = yield* SyncEvent.Service + const flags = yield* RuntimeFlags.Service const runner = Effect.fn("SessionPrompt.runner")(function* () { return yield* EffectBridge.make() }) @@ -384,7 +386,7 @@ export const layer = Layer.effect( const userMessage = input.messages.findLast((msg) => msg.info.role === "user") if (!userMessage) return input.messages - if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) { + if (!flags.experimentalPlanMode) { if (input.agent.name === "plan") { userMessage.parts.push({ id: PartID.ascending(), @@ -2024,6 +2026,7 @@ export const defaultLayer = Layer.suspend(() => Bus.layer, CrossSpawnSpawner.defaultLayer, SyncEvent.defaultLayer, + RuntimeFlags.defaultLayer, ), ), ), diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 427ce17e8..de548c926 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -206,6 +206,7 @@ function makeHttp() { Layer.provideMerge(trunc), Layer.provide(Instruction.defaultLayer), Layer.provide(SystemPrompt.defaultLayer), + Layer.provide(RuntimeFlags.layer()), Layer.provideMerge(deps), ), ).pipe(Layer.provide(summary)) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index adf892687..49632a829 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -165,6 +165,7 @@ function makeHttp() { Layer.provideMerge(trunc), Layer.provide(Instruction.defaultLayer), Layer.provide(SystemPrompt.defaultLayer), + Layer.provide(RuntimeFlags.layer()), Layer.provideMerge(deps), ), ) From f13fc5a8a83658aaba7f298033500726d82a875d Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 17:44:09 +0530 Subject: [PATCH 56/98] refactor(flags): route event system through runtime flags (#27323) --- .../src/cli/cmd/tui/plugin/internal.ts | 32 ++++++++++--------- .../src/cli/cmd/tui/plugin/runtime.ts | 9 ++++-- packages/opencode/src/effect/runtime-flags.ts | 1 + packages/opencode/src/session/compaction.ts | 9 ++++-- packages/opencode/src/session/processor.ts | 31 ++++++++++-------- packages/opencode/src/session/prompt.ts | 9 +++--- .../test/effect/runtime-flags.test.ts | 1 + .../opencode/test/session/compaction.test.ts | 4 +++ .../test/session/processor-effect.test.ts | 8 ++++- packages/opencode/test/session/prompt.test.ts | 11 +++++-- .../test/session/snapshot-tool-race.test.ts | 11 +++++-- 11 files changed, 80 insertions(+), 46 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index eaa9dfb32..5e0aec3ba 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -11,7 +11,7 @@ import Notifications from "../feature-plugins/system/notifications" import SessionV2Debug from "../feature-plugins/system/session-v2" import WhichKey from "../feature-plugins/system/which-key" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" -import { Flag } from "@opencode-ai/core/flag/flag" +import type { RuntimeFlags } from "@/effect/runtime-flags" export type InternalTuiPlugin = Omit & { id: string @@ -19,17 +19,19 @@ export type InternalTuiPlugin = Omit & { enabled?: boolean } -export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ - HomeFooter, - HomeTips, - SidebarContext, - SidebarMcp, - SidebarLsp, - SidebarTodo, - SidebarFiles, - SidebarFooter, - Notifications, - PluginManager, - WhichKey, - ...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []), -] +export function internalTuiPlugins(flags: Pick): InternalTuiPlugin[] { + return [ + HomeFooter, + HomeTips, + SidebarContext, + SidebarMcp, + SidebarLsp, + SidebarTodo, + SidebarFiles, + SidebarFooter, + Notifications, + PluginManager, + WhichKey, + ...(flags.experimentalEventSystem ? [SessionV2Debug] : []), + ] +} diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 4af16d2b8..420826ad0 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -35,11 +35,13 @@ import { Filesystem } from "@/util/filesystem" import { Process } from "@/util/process" import { Flock } from "@opencode-ai/core/util/flock" import { Flag } from "@opencode-ai/core/flag/flag" -import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" +import { internalTuiPlugins, type InternalTuiPlugin } from "./internal" import { setupSlots, Slot as View } from "./slots" import type { HostPluginApi, HostSlots } from "./slots" import { ConfigPlugin } from "@/config/plugin" import { createCommandShim } from "./command-shim" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { Effect } from "effect" ensureRuntimePluginSupport({ additional: keymapRuntimeModules }) @@ -1070,7 +1072,10 @@ async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: () log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length }) } - for (const item of INTERNAL_TUI_PLUGINS) { + const flags = await Effect.runPromise( + RuntimeFlags.Service.use((flags) => Effect.succeed(flags)).pipe(Effect.provide(RuntimeFlags.defaultLayer)), + ) + for (const item of internalTuiPlugins(flags)) { log.info("loading internal tui plugin", { id: item.id }) const entry = loadInternalPlugin(item) const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 3dfb2e99f..b1b8ab25a 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -22,6 +22,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime experimentalScout: enabledByExperimental("OPENCODE_EXPERIMENTAL_SCOUT"), experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"), experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"), + experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")), }) {} diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index f3c160fe7..3340e0fbe 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -18,9 +18,9 @@ import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" import { serviceUse } from "@/effect/service-use" +import { RuntimeFlags } from "@/effect/runtime-flags" import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" -import { Flag } from "@opencode-ai/core/flag/flag" const log = Log.create({ service: "session.compaction" }) @@ -221,6 +221,7 @@ export const layer: Layer.Layer< | SessionProcessor.Service | Provider.Service | SyncEvent.Service + | RuntimeFlags.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -232,6 +233,7 @@ export const layer: Layer.Layer< const processors = yield* SessionProcessor.Service const provider = yield* Provider.Service const sync = yield* SyncEvent.Service + const flags = yield* RuntimeFlags.Service const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: { tokens: MessageV2.Assistant["tokens"] @@ -572,7 +574,7 @@ export const layer: Layer.Layer< parts: [], }, ) - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Compaction.Ended.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), @@ -608,7 +610,7 @@ export const layer: Layer.Layer< auto: input.auto, overflow: input.overflow, }) - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Compaction.Started.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), @@ -636,6 +638,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), Layer.provide(SyncEvent.defaultLayer), + Layer.provide(RuntimeFlags.defaultLayer), ), ) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 506ec0c40..c731239b6 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -25,7 +25,7 @@ import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" import { Modelv2 } from "@/v2/model" import * as DateTime from "effect/DateTime" -import { Flag } from "@opencode-ai/core/flag/flag" +import { RuntimeFlags } from "@/effect/runtime-flags" const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) @@ -98,6 +98,7 @@ export const layer: Layer.Layer< | SessionSummary.Service | SessionStatus.Service | SyncEvent.Service + | RuntimeFlags.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -114,6 +115,7 @@ export const layer: Layer.Layer< const status = yield* SessionStatus.Service const image = yield* Image.Service const sync = yield* SyncEvent.Service + const flags = yield* RuntimeFlags.Service const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { // Pre-capture snapshot before the LLM stream starts. The AI SDK @@ -232,7 +234,7 @@ export const layer: Layer.Layer< case "reasoning-start": if (value.id in ctx.reasoningMap) return // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Reasoning.Started.Sync, { sessionID: ctx.sessionID, reasoningID: value.id, @@ -267,7 +269,7 @@ export const layer: Layer.Layer< case "reasoning-end": if (!(value.id in ctx.reasoningMap)) return // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Reasoning.Ended.Sync, { sessionID: ctx.sessionID, reasoningID: value.id, @@ -288,7 +290,7 @@ export const layer: Layer.Layer< throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Tool.Input.Started.Sync, { sessionID: ctx.sessionID, callID: value.id, @@ -319,7 +321,7 @@ export const layer: Layer.Layer< case "tool-input-end": { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Tool.Input.Ended.Sync, { sessionID: ctx.sessionID, callID: value.id, @@ -336,7 +338,7 @@ export const layer: Layer.Layer< } const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Tool.Called.Sync, { sessionID: ctx.sessionID, callID: value.toolCallId, @@ -422,7 +424,7 @@ export const layer: Layer.Layer< attachments: attachments?.length ? attachments : undefined, } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Tool.Success.Sync, { sessionID: ctx.sessionID, callID: value.toolCallId, @@ -452,7 +454,7 @@ export const layer: Layer.Layer< case "tool-error": { const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Tool.Failed.Sync, { sessionID: ctx.sessionID, callID: value.toolCallId, @@ -477,7 +479,7 @@ export const layer: Layer.Layer< if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track() if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Step.Started.Sync, { sessionID: ctx.sessionID, agent: input.assistantMessage.agent, @@ -509,7 +511,7 @@ export const layer: Layer.Layer< }) if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Step.Ended.Sync, { sessionID: ctx.sessionID, finish: value.finishReason, @@ -566,7 +568,7 @@ export const layer: Layer.Layer< case "text-start": if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Text.Started.Sync, { sessionID: ctx.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), @@ -613,7 +615,7 @@ export const layer: Layer.Layer< )).text if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Text.Ended.Sync, { sessionID: ctx.sessionID, text: ctx.currentText.text, @@ -709,7 +711,7 @@ export const layer: Layer.Layer< } if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Step.Failed.Sync, { sessionID: ctx.sessionID, error: { @@ -763,7 +765,7 @@ export const layer: Layer.Layer< parse, set: (info) => { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - const event = Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM + const event = flags.experimentalEventSystem ? sync.run(SessionEvent.Retried.Sync, { sessionID: ctx.sessionID, attempt: info.attempt, @@ -826,6 +828,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), Layer.provide(SyncEvent.defaultLayer), + Layer.provide(RuntimeFlags.defaultLayer), ), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3b1f2c41a..cae0dd384 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -23,7 +23,6 @@ import { ToolRegistry } from "@/tool/registry" import { ToolJsonSchema } from "@/tool/json-schema" import { MCP } from "../mcp" import { LSP } from "@/lsp/lsp" -import { Flag } from "@opencode-ai/core/flag/flag" import { ulid } from "ulid" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -957,7 +956,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, } yield* sessions.updatePart(part) - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Shell.Started.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(started), @@ -980,7 +979,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the output += "\n\n" + ["", "User aborted the command", ""].join("\n") } const completed = Date.now() - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Shell.Ended.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(completed), @@ -1571,7 +1570,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, ) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Prompted.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), @@ -1585,7 +1584,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } for (const text of nextPrompt.synthetic) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Synthetic.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index 058fa7559..c21399017 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -33,6 +33,7 @@ describe("RuntimeFlags", () => { expect(flags.experimentalScout).toBe(true) expect(flags.experimentalLspTool).toBe(true) expect(flags.experimentalPlanMode).toBe(true) + expect(flags.experimentalEventSystem).toBe(true) expect(flags.client).toBe("desktop") }), ) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 2b623511b..d8a416790 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -28,6 +28,7 @@ import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { TestConfig } from "../fixture/config" import { SyncEvent } from "@/sync" +import { RuntimeFlags } from "@/effect/runtime-flags" void Log.init({ print: false }) @@ -225,6 +226,7 @@ const deps = Layer.mergeAll( Bus.layer, Config.defaultLayer, SyncEvent.defaultLayer, + RuntimeFlags.layer({ experimentalEventSystem: true }), ) const env = Layer.mergeAll( @@ -257,6 +259,7 @@ function compactionProcessLayer(options?: CompactionProcessOptions) { ? SessionProcessorModule.SessionProcessor.layer.pipe( Layer.provide(summary), Layer.provide(Image.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provide(status), ) : layer(options?.result ?? "continue") @@ -272,6 +275,7 @@ function compactionProcessLayer(options?: CompactionProcessOptions) { Layer.provide(bus), Layer.provide(options?.config ?? Config.defaultLayer), Layer.provide(SyncEvent.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), ) } diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index fc5e73877..537665b9a 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -25,6 +25,7 @@ import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { raw, reply, TestLLMServer } from "../lib/llm-server" import { SyncEvent } from "@/sync" +import { RuntimeFlags } from "@/effect/runtime-flags" void Log.init({ print: false }) @@ -171,7 +172,12 @@ const deps = Layer.mergeAll( ).pipe(Layer.provideMerge(infra)) const env = Layer.mergeAll( TestLLMServer.layer, - SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provide(Image.defaultLayer), Layer.provideMerge(deps)), + SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), + Layer.provideMerge(deps), + ), ) const it = testEffect(env) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index de548c926..c3d113d52 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -180,7 +180,7 @@ function makeHttp() { Layer.provide(Reference.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), - Layer.provide(RuntimeFlags.layer()), + Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps), @@ -189,9 +189,14 @@ function makeHttp() { const proc = SessionProcessor.layer.pipe( Layer.provide(summary), Layer.provide(Image.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), + Layer.provideMerge(deps), + ) + const compact = SessionCompaction.layer.pipe( + Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), + Layer.provideMerge(proc), Layer.provideMerge(deps), ) - const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( @@ -206,7 +211,7 @@ function makeHttp() { Layer.provideMerge(trunc), Layer.provide(Instruction.defaultLayer), Layer.provide(SystemPrompt.defaultLayer), - Layer.provide(RuntimeFlags.layer()), + Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provideMerge(deps), ), ).pipe(Layer.provide(summary)) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 49632a829..28565699b 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -138,7 +138,7 @@ function makeHttp() { Layer.provide(Reference.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), - Layer.provide(RuntimeFlags.layer()), + Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps), @@ -147,9 +147,14 @@ function makeHttp() { const proc = SessionProcessor.layer.pipe( Layer.provide(SessionSummary.defaultLayer), Layer.provide(Image.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), + Layer.provideMerge(deps), + ) + const compact = SessionCompaction.layer.pipe( + Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), + Layer.provideMerge(proc), Layer.provideMerge(deps), ) - const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, SessionSummary.defaultLayer, @@ -165,7 +170,7 @@ function makeHttp() { Layer.provideMerge(trunc), Layer.provide(Instruction.defaultLayer), Layer.provide(SystemPrompt.defaultLayer), - Layer.provide(RuntimeFlags.layer()), + Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provideMerge(deps), ), ) From 762020f63a3ff69631c93b593c28a267bc7aba59 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 13 May 2026 09:56:15 -0400 Subject: [PATCH 57/98] chore: delete unused util/network module (#27329) --- packages/opencode/src/util/network.ts | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 packages/opencode/src/util/network.ts diff --git a/packages/opencode/src/util/network.ts b/packages/opencode/src/util/network.ts deleted file mode 100644 index 69e5d1758..000000000 --- a/packages/opencode/src/util/network.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function online() { - const nav = globalThis.navigator - if (!nav || typeof nav.onLine !== "boolean") return true - return nav.onLine -} - -export function proxied() { - return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy) -} From bc25342f3400280b666d446ad455107a180d213a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 13 May 2026 10:01:19 -0400 Subject: [PATCH 58/98] chore: delete util/scrap module (#27330) --- packages/opencode/src/util/scrap.ts | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 packages/opencode/src/util/scrap.ts diff --git a/packages/opencode/src/util/scrap.ts b/packages/opencode/src/util/scrap.ts deleted file mode 100644 index 554dba1c5..000000000 --- a/packages/opencode/src/util/scrap.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const foo: string = "42" -export const bar: number = 123 - -export function dummyFunction(): void { - console.log("This is a dummy function") -} - -export function randomHelper(): boolean { - return Math.random() > 0.5 -} From bebe5442a5c2395f2eafe9d7dca463e453532e52 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 13 May 2026 10:02:17 -0400 Subject: [PATCH 59/98] chore: delete unused util/color module (#27331) --- packages/opencode/src/util/color.ts | 19 ------------------- .../opencode/test/config/agent-color.test.ts | 16 +--------------- 2 files changed, 1 insertion(+), 34 deletions(-) delete mode 100644 packages/opencode/src/util/color.ts diff --git a/packages/opencode/src/util/color.ts b/packages/opencode/src/util/color.ts deleted file mode 100644 index 3752fd19b..000000000 --- a/packages/opencode/src/util/color.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function isValidHex(hex?: string): hex is string { - if (!hex) return false - return /^#[0-9a-fA-F]{6}$/.test(hex) -} - -export function hexToRgb(hex: string): { r: number; g: number; b: number } { - const r = parseInt(hex.slice(1, 3), 16) - const g = parseInt(hex.slice(3, 5), 16) - const b = parseInt(hex.slice(5, 7), 16) - return { r, g, b } -} - -export function hexToAnsiBold(hex?: string): string | undefined { - if (!isValidHex(hex)) return undefined - const { r, g, b } = hexToRgb(hex) - return `\x1b[38;2;${r};${g};${b}m\x1b[1m` -} - -export * as Color from "./color" diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index 369b3a1fd..d19808059 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -1,9 +1,8 @@ -import { test, expect } from "bun:test" +import { expect } from "bun:test" import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Config } from "@/config/config" import { Agent as AgentSvc } from "../../src/agent/agent" -import { Color } from "@/util/color" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(Config.defaultLayer, AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer)) @@ -46,16 +45,3 @@ it.instance( }, }, ) - -test("Color.hexToAnsiBold converts valid hex to ANSI", () => { - const result = Color.hexToAnsiBold("#FFA500") - expect(result).toBe("\x1b[38;2;255;165;0m\x1b[1m") -}) - -test("Color.hexToAnsiBold returns undefined for invalid hex", () => { - expect(Color.hexToAnsiBold(undefined)).toBeUndefined() - expect(Color.hexToAnsiBold("")).toBeUndefined() - expect(Color.hexToAnsiBold("#FFF")).toBeUndefined() - expect(Color.hexToAnsiBold("FFA500")).toBeUndefined() - expect(Color.hexToAnsiBold("#GGGGGG")).toBeUndefined() -}) From 8345152319b8c4e38431ec7c0cd85c2caafd9338 Mon Sep 17 00:00:00 2001 From: Dax Date: Wed, 13 May 2026 10:43:08 -0400 Subject: [PATCH 60/98] core: expose v2 model listing API (#25821) --- AGENTS.md | 23 + bun.lock | 35 +- packages/core/package.json | 32 +- packages/core/src/aisdk.ts | 172 +++++++ .../{opencode/src/v2 => core/src}/auth.ts | 54 +- packages/core/src/catalog.ts | 258 ++++++++++ .../src/github-copilot}/README.md | 0 ...vert-to-openai-compatible-chat-messages.ts | 0 .../chat/get-response-metadata.ts | 0 .../map-openai-compatible-finish-reason.ts | 0 .../chat/openai-compatible-api-types.ts | 0 .../openai-compatible-chat-language-model.ts | 0 .../chat/openai-compatible-chat-options.ts | 0 .../openai-compatible-metadata-extractor.ts | 0 .../chat/openai-compatible-prepare-tools.ts | 0 .../src/github-copilot}/copilot-provider.ts | 0 .../openai-compatible-error.ts | 0 .../convert-to-openai-responses-input.ts | 0 .../map-openai-responses-finish-reason.ts | 0 .../responses/openai-config.ts | 0 .../github-copilot}/responses/openai-error.ts | 0 .../responses/openai-responses-api-types.ts | 0 .../openai-responses-language-model.ts | 0 .../openai-responses-prepare-tools.ts | 0 .../responses/openai-responses-settings.ts | 0 .../responses/tool/code-interpreter.ts | 0 .../responses/tool/file-search.ts | 0 .../responses/tool/image-generation.ts | 0 .../responses/tool/local-shell.ts | 0 .../responses/tool/web-search-preview.ts | 0 .../responses/tool/web-search.ts | 0 packages/core/src/model.ts | 116 +++++ packages/core/src/plugin.ts | 146 ++++++ packages/core/src/plugin/auth.ts | 27 + packages/core/src/plugin/env.ts | 18 + packages/core/src/plugin/provider.ts | 1 + packages/core/src/plugin/provider/alibaba.ts | 15 + .../src/plugin/provider/amazon-bedrock.ts | 94 ++++ .../core/src/plugin/provider/anthropic.ts | 21 + packages/core/src/plugin/provider/azure.ts | 67 +++ packages/core/src/plugin/provider/cerebras.ts | 20 + .../plugin/provider/cloudflare-ai-gateway.ts | 81 +++ .../plugin/provider/cloudflare-workers-ai.ts | 69 +++ packages/core/src/plugin/provider/cohere.ts | 15 + .../core/src/plugin/provider/deepinfra.ts | 15 + packages/core/src/plugin/provider/dynamic.ts | 31 ++ packages/core/src/plugin/provider/gateway.ts | 15 + .../src/plugin/provider/github-copilot.ts | 44 ++ packages/core/src/plugin/provider/gitlab.ts | 64 +++ .../core/src/plugin/provider/google-vertex.ts | 124 +++++ packages/core/src/plugin/provider/google.ts | 15 + packages/core/src/plugin/provider/groq.ts | 15 + packages/core/src/plugin/provider/index.ts | 67 +++ packages/core/src/plugin/provider/kilo.ts | 16 + .../core/src/plugin/provider/llmgateway.ts | 18 + packages/core/src/plugin/provider/mistral.ts | 15 + packages/core/src/plugin/provider/nvidia.ts | 16 + .../src/plugin/provider/openai-compatible.ts | 17 + packages/core/src/plugin/provider/openai.ts | 27 + packages/core/src/plugin/provider/opencode.ts | 27 + .../core/src/plugin/provider/openrouter.ts | 29 ++ .../core/src/plugin/provider/perplexity.ts | 15 + .../core/src/plugin/provider/sap-ai-core.ts | 40 ++ .../core/src/plugin/provider/togetherai.ts | 15 + packages/core/src/plugin/provider/venice.ts | 15 + packages/core/src/plugin/provider/vercel.ts | 21 + packages/core/src/plugin/provider/xai.ts | 20 + packages/core/src/plugin/provider/zenmux.ts | 16 + packages/core/src/provider.ts | 120 +++++ .../src/v2 => core/src}/session-prompt.ts | 0 .../src/v2 => core/src}/tool-output.ts | 0 .../v2/schema.ts => core/src/v2-schema.ts} | 2 +- .../convert-to-copilot-messages.test.ts | 2 +- .../copilot-chat-model.test.ts | 2 +- .../plugin/provider-github-copilot.test.ts | 188 +++++++ packages/core/test/v2/catalog.test.ts | 199 ++++++++ .../v2/plugin/fixtures/provider-factory.ts | 9 + .../test/v2/plugin/provider-alibaba.test.ts | 67 +++ .../v2/plugin/provider-amazon-bedrock.test.ts | 464 ++++++++++++++++++ .../test/v2/plugin/provider-anthropic.test.ts | 91 ++++ .../provider-azure-cognitive-services.test.ts | 127 +++++ .../test/v2/plugin/provider-azure.test.ts | 245 +++++++++ .../test/v2/plugin/provider-cerebras.test.ts | 102 ++++ .../provider-cloudflare-ai-gateway.test.ts | 384 +++++++++++++++ .../provider-cloudflare-workers-ai.test.ts | 267 ++++++++++ .../test/v2/plugin/provider-cohere.test.ts | 86 ++++ .../test/v2/plugin/provider-deepinfra.test.ts | 129 +++++ .../test/v2/plugin/provider-dynamic.test.ts | 172 +++++++ .../test/v2/plugin/provider-gateway.test.ts | 87 ++++ .../test/v2/plugin/provider-gitlab.test.ts | 346 +++++++++++++ .../provider-google-vertex-anthropic.test.ts | 147 ++++++ .../v2/plugin/provider-google-vertex.test.ts | 300 +++++++++++ .../test/v2/plugin/provider-google.test.ts | 70 +++ .../core/test/v2/plugin/provider-groq.test.ts | 101 ++++ .../core/test/v2/plugin/provider-helper.ts | 100 ++++ .../core/test/v2/plugin/provider-kilo.test.ts | 90 ++++ .../v2/plugin/provider-llmgateway.test.ts | 63 +++ .../test/v2/plugin/provider-mistral.test.ts | 106 ++++ .../test/v2/plugin/provider-nvidia.test.ts | 41 ++ .../plugin/provider-openai-compatible.test.ts | 101 ++++ .../test/v2/plugin/provider-openai.test.ts | 100 ++++ .../test/v2/plugin/provider-opencode.test.ts | 195 ++++++++ .../v2/plugin/provider-openrouter.test.ts | 105 ++++ .../v2/plugin/provider-perplexity.test.ts | 107 ++++ .../v2/plugin/provider-sap-ai-core.test.ts | 127 +++++ .../v2/plugin/provider-togetherai.test.ts | 97 ++++ .../test/v2/plugin/provider-venice.test.ts | 86 ++++ .../test/v2/plugin/provider-vercel.test.ts | 62 +++ .../core/test/v2/plugin/provider-xai.test.ts | 115 +++++ .../test/v2/plugin/provider-zenmux.test.ts | 103 ++++ packages/opencode/package.json | 1 - packages/opencode/src/cli/cmd/debug/index.ts | 2 + packages/opencode/src/cli/cmd/debug/v2.ts | 40 ++ .../src/cli/cmd/tui/routes/session/index.tsx | 33 +- packages/opencode/src/provider/provider.ts | 26 +- .../routes/instance/httpapi/groups/v2.ts | 4 + .../instance/httpapi/groups/v2/model.ts | 24 + .../instance/httpapi/groups/v2/provider.ts | 38 ++ .../instance/httpapi/groups/v2/session.ts | 2 +- .../routes/instance/httpapi/handlers/v2.ts | 8 +- .../instance/httpapi/handlers/v2/model.ts | 12 + .../instance/httpapi/handlers/v2/provider.ts | 22 + packages/opencode/src/session/llm.ts | 1 - packages/opencode/src/session/processor.ts | 9 +- packages/opencode/src/session/prompt.ts | 11 +- packages/opencode/src/v2/model.ts | 193 -------- packages/opencode/src/v2/plugin-boot.ts | 50 ++ packages/opencode/src/v2/plugin/models-dev.ts | 108 ++++ .../src/v2/provider-parity-checklist.md | 95 ++++ packages/opencode/src/v2/session-event.ts | 12 +- packages/opencode/src/v2/session-message.ts | 10 +- packages/opencode/src/v2/session.ts | 35 +- .../test/server/httpapi-exercise/index.ts | 6 + .../test/server/httpapi-session.test.ts | 9 +- .../test/v2/session-message-updater.test.ts | 21 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 38 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 91 ++++ specs/v2/provider-model.md | 329 +++++++++++++ 138 files changed, 8191 insertions(+), 305 deletions(-) create mode 100644 packages/core/src/aisdk.ts rename packages/{opencode/src/v2 => core/src}/auth.ts (86%) create mode 100644 packages/core/src/catalog.ts rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/README.md (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/chat/convert-to-openai-compatible-chat-messages.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/chat/get-response-metadata.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/chat/map-openai-compatible-finish-reason.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/chat/openai-compatible-api-types.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/chat/openai-compatible-chat-language-model.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/chat/openai-compatible-chat-options.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/chat/openai-compatible-metadata-extractor.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/chat/openai-compatible-prepare-tools.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/copilot-provider.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/openai-compatible-error.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/responses/convert-to-openai-responses-input.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/responses/map-openai-responses-finish-reason.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/responses/openai-config.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/responses/openai-error.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/responses/openai-responses-api-types.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/responses/openai-responses-language-model.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/responses/openai-responses-prepare-tools.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/responses/openai-responses-settings.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/responses/tool/code-interpreter.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/responses/tool/file-search.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/responses/tool/image-generation.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/responses/tool/local-shell.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/responses/tool/web-search-preview.ts (100%) rename packages/{opencode/src/provider/sdk/copilot => core/src/github-copilot}/responses/tool/web-search.ts (100%) create mode 100644 packages/core/src/model.ts create mode 100644 packages/core/src/plugin.ts create mode 100644 packages/core/src/plugin/auth.ts create mode 100644 packages/core/src/plugin/env.ts create mode 100644 packages/core/src/plugin/provider.ts create mode 100644 packages/core/src/plugin/provider/alibaba.ts create mode 100644 packages/core/src/plugin/provider/amazon-bedrock.ts create mode 100644 packages/core/src/plugin/provider/anthropic.ts create mode 100644 packages/core/src/plugin/provider/azure.ts create mode 100644 packages/core/src/plugin/provider/cerebras.ts create mode 100644 packages/core/src/plugin/provider/cloudflare-ai-gateway.ts create mode 100644 packages/core/src/plugin/provider/cloudflare-workers-ai.ts create mode 100644 packages/core/src/plugin/provider/cohere.ts create mode 100644 packages/core/src/plugin/provider/deepinfra.ts create mode 100644 packages/core/src/plugin/provider/dynamic.ts create mode 100644 packages/core/src/plugin/provider/gateway.ts create mode 100644 packages/core/src/plugin/provider/github-copilot.ts create mode 100644 packages/core/src/plugin/provider/gitlab.ts create mode 100644 packages/core/src/plugin/provider/google-vertex.ts create mode 100644 packages/core/src/plugin/provider/google.ts create mode 100644 packages/core/src/plugin/provider/groq.ts create mode 100644 packages/core/src/plugin/provider/index.ts create mode 100644 packages/core/src/plugin/provider/kilo.ts create mode 100644 packages/core/src/plugin/provider/llmgateway.ts create mode 100644 packages/core/src/plugin/provider/mistral.ts create mode 100644 packages/core/src/plugin/provider/nvidia.ts create mode 100644 packages/core/src/plugin/provider/openai-compatible.ts create mode 100644 packages/core/src/plugin/provider/openai.ts create mode 100644 packages/core/src/plugin/provider/opencode.ts create mode 100644 packages/core/src/plugin/provider/openrouter.ts create mode 100644 packages/core/src/plugin/provider/perplexity.ts create mode 100644 packages/core/src/plugin/provider/sap-ai-core.ts create mode 100644 packages/core/src/plugin/provider/togetherai.ts create mode 100644 packages/core/src/plugin/provider/venice.ts create mode 100644 packages/core/src/plugin/provider/vercel.ts create mode 100644 packages/core/src/plugin/provider/xai.ts create mode 100644 packages/core/src/plugin/provider/zenmux.ts create mode 100644 packages/core/src/provider.ts rename packages/{opencode/src/v2 => core/src}/session-prompt.ts (100%) rename packages/{opencode/src/v2 => core/src}/tool-output.ts (100%) rename packages/{opencode/src/v2/schema.ts => core/src/v2-schema.ts} (88%) rename packages/{opencode/test/provider/copilot => core/test/github-copilot}/convert-to-copilot-messages.test.ts (99%) rename packages/{opencode/test/provider/copilot => core/test/github-copilot}/copilot-chat-model.test.ts (99%) create mode 100644 packages/core/test/plugin/provider-github-copilot.test.ts create mode 100644 packages/core/test/v2/catalog.test.ts create mode 100644 packages/core/test/v2/plugin/fixtures/provider-factory.ts create mode 100644 packages/core/test/v2/plugin/provider-alibaba.test.ts create mode 100644 packages/core/test/v2/plugin/provider-amazon-bedrock.test.ts create mode 100644 packages/core/test/v2/plugin/provider-anthropic.test.ts create mode 100644 packages/core/test/v2/plugin/provider-azure-cognitive-services.test.ts create mode 100644 packages/core/test/v2/plugin/provider-azure.test.ts create mode 100644 packages/core/test/v2/plugin/provider-cerebras.test.ts create mode 100644 packages/core/test/v2/plugin/provider-cloudflare-ai-gateway.test.ts create mode 100644 packages/core/test/v2/plugin/provider-cloudflare-workers-ai.test.ts create mode 100644 packages/core/test/v2/plugin/provider-cohere.test.ts create mode 100644 packages/core/test/v2/plugin/provider-deepinfra.test.ts create mode 100644 packages/core/test/v2/plugin/provider-dynamic.test.ts create mode 100644 packages/core/test/v2/plugin/provider-gateway.test.ts create mode 100644 packages/core/test/v2/plugin/provider-gitlab.test.ts create mode 100644 packages/core/test/v2/plugin/provider-google-vertex-anthropic.test.ts create mode 100644 packages/core/test/v2/plugin/provider-google-vertex.test.ts create mode 100644 packages/core/test/v2/plugin/provider-google.test.ts create mode 100644 packages/core/test/v2/plugin/provider-groq.test.ts create mode 100644 packages/core/test/v2/plugin/provider-helper.ts create mode 100644 packages/core/test/v2/plugin/provider-kilo.test.ts create mode 100644 packages/core/test/v2/plugin/provider-llmgateway.test.ts create mode 100644 packages/core/test/v2/plugin/provider-mistral.test.ts create mode 100644 packages/core/test/v2/plugin/provider-nvidia.test.ts create mode 100644 packages/core/test/v2/plugin/provider-openai-compatible.test.ts create mode 100644 packages/core/test/v2/plugin/provider-openai.test.ts create mode 100644 packages/core/test/v2/plugin/provider-opencode.test.ts create mode 100644 packages/core/test/v2/plugin/provider-openrouter.test.ts create mode 100644 packages/core/test/v2/plugin/provider-perplexity.test.ts create mode 100644 packages/core/test/v2/plugin/provider-sap-ai-core.test.ts create mode 100644 packages/core/test/v2/plugin/provider-togetherai.test.ts create mode 100644 packages/core/test/v2/plugin/provider-venice.test.ts create mode 100644 packages/core/test/v2/plugin/provider-vercel.test.ts create mode 100644 packages/core/test/v2/plugin/provider-xai.test.ts create mode 100644 packages/core/test/v2/plugin/provider-zenmux.test.ts create mode 100644 packages/opencode/src/cli/cmd/debug/v2.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts delete mode 100644 packages/opencode/src/v2/model.ts create mode 100644 packages/opencode/src/v2/plugin-boot.ts create mode 100644 packages/opencode/src/v2/plugin/models-dev.ts create mode 100644 packages/opencode/src/v2/provider-parity-checklist.md create mode 100644 specs/v2/provider-model.md diff --git a/AGENTS.md b/AGENTS.md index 7913ddabd..0b1998ec5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,29 @@ function foo() { } ``` +### Complex Logic + +When a function has several validation branches or supporting details, make the main function read as the happy path and move supporting details into small helpers below it. + +```ts +// Good +export function loadThing(input: unknown) { + const config = requireConfig(input) + const metadata = readMetadata(input) + return createThing({ config, metadata }) +} + +function requireConfig(input: unknown) { + ... +} +``` + +- Keep helpers close to the code they support, below the main export when that improves readability. +- Do not over-abstract simple expressions into many single-use helpers; extract only when it names a real concept like `requireConfig` or `readMetadata`. +- Do not return `Effect` from helpers unless they actually perform effectful work. Synchronous parsing, validation, and option building should stay synchronous. +- Prefer Effect schema helpers such as `Schema.UnknownFromJsonString` and `Schema.decodeUnknownOption` over manual `JSON.parse` wrapped in `Effect.try` when parsing untrusted JSON strings. +- Add comments for non-obvious constraints and surprising behavior, not for obvious assignments or control flow. + ### Schema Definitions (Drizzle) Use snake_case for field names so column names don't need to be redefined as strings. diff --git a/bun.lock b/bun.lock index e8ff7adaf..2a79552b9 100644 --- a/bun.lock +++ b/bun.lock @@ -197,22 +197,50 @@ "opencode": "./bin/opencode", }, "dependencies": { + "@ai-sdk/alibaba": "1.0.17", + "@ai-sdk/amazon-bedrock": "4.0.96", + "@ai-sdk/anthropic": "3.0.71", + "@ai-sdk/azure": "3.0.49", + "@ai-sdk/cerebras": "2.0.41", + "@ai-sdk/cohere": "3.0.27", + "@ai-sdk/deepinfra": "2.0.41", + "@ai-sdk/gateway": "3.0.104", + "@ai-sdk/google": "3.0.63", + "@ai-sdk/google-vertex": "4.0.112", + "@ai-sdk/groq": "3.0.31", + "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai-compatible": "2.0.41", + "@ai-sdk/perplexity": "3.0.26", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23", + "@ai-sdk/togetherai": "2.0.41", + "@ai-sdk/vercel": "2.0.39", + "@ai-sdk/xai": "3.0.82", + "@aws-sdk/credential-providers": "3.993.0", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", + "@openrouter/ai-sdk-provider": "2.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", + "ai-gateway-provider": "3.1.2", "cross-spawn": "catalog:", "effect": "catalog:", + "gitlab-ai-provider": "6.6.0", "glob": "13.0.5", + "google-auth-library": "10.5.0", + "immer": "11.1.4", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "semver": "^7.6.3", + "venice-ai-sdk-provider": "2.0.1", "xdg-basedir": "5.1.0", + "zod": "catalog:", }, "devDependencies": { "@tsconfig/bun": "catalog:", @@ -380,7 +408,6 @@ "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.23", "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", "@ai-sdk/xai": "3.0.82", @@ -5423,6 +5450,12 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + "@opencode-ai/core/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="], + + "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + + "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], "@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], diff --git a/packages/core/package.json b/packages/core/package.json index 6bcef68dc..4c47fea8b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,6 +26,27 @@ "@types/semver": "catalog:" }, "dependencies": { + "@ai-sdk/alibaba": "1.0.17", + "@ai-sdk/amazon-bedrock": "4.0.96", + "@ai-sdk/anthropic": "3.0.71", + "@ai-sdk/azure": "3.0.49", + "@ai-sdk/cerebras": "2.0.41", + "@ai-sdk/cohere": "3.0.27", + "@ai-sdk/deepinfra": "2.0.41", + "@ai-sdk/gateway": "3.0.104", + "@ai-sdk/google": "3.0.63", + "@ai-sdk/google-vertex": "4.0.112", + "@ai-sdk/groq": "3.0.31", + "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai-compatible": "2.0.41", + "@ai-sdk/perplexity": "3.0.26", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23", + "@ai-sdk/togetherai": "2.0.41", + "@ai-sdk/vercel": "2.0.39", + "@ai-sdk/xai": "3.0.82", + "@aws-sdk/credential-providers": "3.993.0", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@npmcli/arborist": "9.4.0", @@ -34,14 +55,21 @@ "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", - "effect": "catalog:", + "@openrouter/ai-sdk-provider": "2.8.1", + "ai-gateway-provider": "3.1.2", "cross-spawn": "catalog:", + "effect": "catalog:", + "gitlab-ai-provider": "6.6.0", "glob": "13.0.5", + "google-auth-library": "10.5.0", + "immer": "11.1.4", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "semver": "^7.6.3", - "xdg-basedir": "5.1.0" + "venice-ai-sdk-provider": "2.0.1", + "xdg-basedir": "5.1.0", + "zod": "catalog:" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts new file mode 100644 index 000000000..5fa229430 --- /dev/null +++ b/packages/core/src/aisdk.ts @@ -0,0 +1,172 @@ +export * as AISDK from "./aisdk" + +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { Cause, Context, Effect, Layer, Schema } from "effect" +import { ModelV2 } from "./model" +import { PluginV2 } from "./plugin" +import { ProviderV2 } from "./provider" + +type SDK = any + +function wrapSSE(res: Response, ms: number, ctl: AbortController) { + if (typeof ms !== "number" || ms <= 0) return res + if (!res.body) return res + if (!res.headers.get("content-type")?.includes("text/event-stream")) return res + + const reader = res.body.getReader() + const body = new ReadableStream({ + async pull(ctrl) { + const part = await new Promise>>((resolve, reject) => { + const id = setTimeout(() => { + const err = new Error("SSE read timed out") + ctl.abort(err) + void reader.cancel(err) + reject(err) + }, ms) + + reader.read().then( + (part) => { + clearTimeout(id) + resolve(part) + }, + (err) => { + clearTimeout(id) + reject(err) + }, + ) + }) + + if (part.done) { + ctrl.close() + return + } + + ctrl.enqueue(part.value) + }, + async cancel(reason) { + ctl.abort(reason) + await reader.cancel(reason) + }, + }) + + return new Response(body, { + headers: new Headers(res.headers), + status: res.status, + statusText: res.statusText, + }) +} + +function prepareOptions(model: ModelV2.Info, pkg: string) { + const options: Record = { name: model.providerID, ...model.options.aisdk.provider } + if (model.endpoint.type === "aisdk" && model.endpoint.url) options.baseURL = model.endpoint.url + + const customFetch = options.fetch + const chunkTimeout = options.chunkTimeout + delete options.chunkTimeout + options.fetch = async (input: Parameters[0], init?: RequestInit) => { + const opts = { ...(init ?? {}) } + const signals = [ + opts.signal, + typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined, + options.timeout !== undefined && options.timeout !== null && options.timeout !== false + ? AbortSignal.timeout(options.timeout) + : undefined, + ].filter((item): item is AbortSignal | AbortController => Boolean(item)) + const chunkAbortCtl = signals.find((item): item is AbortController => item instanceof AbortController) + const abortSignals = signals.map((item) => (item instanceof AbortController ? item.signal : item)) + if (abortSignals.length === 1) opts.signal = abortSignals[0] + if (abortSignals.length > 1) opts.signal = AbortSignal.any(abortSignals) + + if ((pkg === "@ai-sdk/openai" || pkg === "@ai-sdk/azure") && opts.body && opts.method === "POST") { + const body = JSON.parse(opts.body as string) + if (body.store !== true && Array.isArray(body.input)) { + for (const item of body.input) { + if ("id" in item) delete item.id + } + opts.body = JSON.stringify(body) + } + } + + const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, { + ...opts, + timeout: false, + }) + if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res + return wrapSSE(res, chunkTimeout, chunkAbortCtl) + } + + return options +} + +export class InitError extends Schema.TaggedErrorClass()("AISDK.InitError", { + providerID: ProviderV2.ID, + cause: Schema.Defect, +}) {} + +function initError(providerID: ProviderV2.ID) { + return Effect.catchCause((cause) => Effect.fail(new InitError({ providerID, cause: Cause.squash(cause) }))) +} + +export interface Interface { + readonly language: (model: ModelV2.Info) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/AISDK") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const languages = new Map() + const sdks = new Map() + + return Service.of({ + language: Effect.fn("AISDK.language")(function* (model) { + const key = `${model.providerID}/${model.id}/${model.options.variant ?? "default"}` + const existing = languages.get(key) + if (existing) return existing + if (model.endpoint.type !== "aisdk") + return yield* new InitError({ + providerID: model.providerID, + cause: new Error(`Unsupported endpoint ${model.endpoint.type}`), + }) + + const options = prepareOptions(model, model.endpoint.package) + const sdkKey = JSON.stringify({ + providerID: model.providerID, + endpoint: model.endpoint, + options, + }) + const sdk = + sdks.get(sdkKey) ?? + (yield* plugin + .trigger("aisdk.sdk", { model, package: model.endpoint.package, options }, {}) + .pipe(initError(model.providerID))).sdk + if (!sdk) + return yield* new InitError({ + providerID: model.providerID, + cause: new Error("No AISDK provider plugin returned an SDK"), + }) + sdks.set(sdkKey, sdk) + const result = yield* plugin + .trigger( + "aisdk.language", + { + model, + sdk, + options, + }, + {}, + ) + .pipe(initError(model.providerID)) + const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.apiID)).pipe( + initError(model.providerID), + ) + languages.set(key, language) + return language + }), + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer)) diff --git a/packages/opencode/src/v2/auth.ts b/packages/core/src/auth.ts similarity index 86% rename from packages/opencode/src/v2/auth.ts rename to packages/core/src/auth.ts index 0ac6223a6..843c9504b 100644 --- a/packages/opencode/src/v2/auth.ts +++ b/packages/core/src/auth.ts @@ -1,9 +1,9 @@ import path from "path" import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" -import { Identifier } from "@opencode-ai/core/util/identifier" -import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" -import { Global } from "@opencode-ai/core/global" -import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Identifier } from "./util/identifier" +import { NonNegativeInt, withStatics } from "./schema" +import { Global } from "./global" +import { AppFileSystem } from "./filesystem" export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" @@ -106,27 +106,45 @@ export const layer = Layer.effect( const fsys = yield* AppFileSystem.Service const global = yield* Global.Service const file = path.join(global.data, "auth-v2.json") + const legacyFile = path.join(global.data, "auth.json") - const load: () => Effect.Effect = Effect.fnUntraced(function* () { - if (process.env.OPENCODE_AUTH_CONTENT) { - try { - return JSON.parse(process.env.OPENCODE_AUTH_CONTENT) - } catch {} - } - - const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) - - if (!raw || typeof raw !== "object") return { version: 2, accounts: {}, active: {} } - - if ("version" in raw && raw.version === 2) return raw as Writable - - const migrated = migrate(raw as Record) + const writeMigrated = Effect.fnUntraced(function* (raw: Record) { + const migrated = migrate(raw) yield* fsys .writeJson(file, migrated, 0o600) .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause }))) return migrated }) + const parseAuthContent = () => { + try { + return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "") + } catch {} + } + + const load: () => Effect.Effect = Effect.fnUntraced(function* () { + if (process.env.OPENCODE_AUTH_CONTENT) { + const raw = parseAuthContent() + if (raw && typeof raw === "object") { + if ("version" in raw && raw.version === 2) return raw as Writable + return yield* writeMigrated(raw as Record) + } + return { version: 2, accounts: {}, active: {} } + } + + const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null)) + if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record) + + const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) + + if (raw && typeof raw === "object") { + if ("version" in raw && raw.version === 2) return raw as Writable + return yield* writeMigrated(raw as Record) + } + + return { version: 2, accounts: {}, active: {} } + }) + const write = (data: Writable) => fsys .writeJson(file, data, 0o600) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts new file mode 100644 index 000000000..3aa591542 --- /dev/null +++ b/packages/core/src/catalog.ts @@ -0,0 +1,258 @@ +export * as Catalog from "./catalog" + +import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect" +import { produce, type Draft } from "immer" +import { ModelV2 } from "./model" +import { PluginV2 } from "./plugin" +import { ProviderV2 } from "./provider" + +type ProviderRecord = { + provider: ProviderV2.Info + models: HashMap.HashMap +} + +export class ProviderNotFoundError extends Schema.TaggedErrorClass()( + "CatalogV2.ProviderNotFound", + { + providerID: ProviderV2.ID, + }, +) {} + +export class ModelNotFoundError extends Schema.TaggedErrorClass()("CatalogV2.ModelNotFound", { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, +}) {} + +export interface Interface { + readonly provider: { + readonly get: (providerID: ProviderV2.ID) => Effect.Effect + readonly update: (providerID: ProviderV2.ID, fn: (provider: Draft) => void) => Effect.Effect + readonly all: () => Effect.Effect + readonly available: () => Effect.Effect + } + readonly model: { + readonly get: ( + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + ) => Effect.Effect + readonly update: ( + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + fn: (model: Draft) => void, + ) => Effect.Effect + readonly all: () => Effect.Effect + readonly available: () => Effect.Effect + readonly default: () => Effect.Effect> + readonly setDefault: ( + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + ) => Effect.Effect + readonly small: (providerID: ProviderV2.ID) => Effect.Effect> + } +} + +export class Service extends Context.Service()("@opencode/v2/Catalog") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + let records = HashMap.empty() + let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined + const plugin = yield* PluginV2.Service + + const resolve = (model: ModelV2.Info) => { + const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider + const endpoint = + model.endpoint.type === "unknown" + ? provider.endpoint + : model.endpoint.type === "aisdk" && provider.endpoint.type === "aisdk" && !model.endpoint.url + ? { ...model.endpoint, url: provider.endpoint.url } + : model.endpoint + const options = { + headers: { + ...provider.options.headers, + ...model.options.headers, + }, + body: { + ...provider.options.body, + ...model.options.body, + }, + aisdk: { + provider: { + ...provider.options.aisdk.provider, + ...model.options.aisdk.provider, + }, + request: model.options.aisdk.request, + }, + variant: model.options.variant, + } + return new ModelV2.Info({ + ...model, + endpoint, + options, + }) + } + + function* getRecord(providerID: ProviderV2.ID) { + const match = HashMap.get(records, providerID) + if (!match.valueOrUndefined) return yield* new ProviderNotFoundError({ providerID }) + return match.value + } + + const result: Interface = { + provider: { + get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { + const record = yield* getRecord(providerID) + return record.provider + }), + + update: Effect.fnUntraced(function* (providerID, fn) { + const current = Option.getOrUndefined(HashMap.get(records, providerID)) + const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => { + fn(draft) + if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") { + draft.endpoint.url = draft.options.aisdk.provider.baseURL + delete draft.options.aisdk.provider.baseURL + } + }) + const updated = yield* plugin.trigger("provider.update", {}, { provider, cancel: false }) + records = HashMap.set(records, providerID, { + provider: updated.provider, + models: current?.models ?? HashMap.empty(), + }) + }), + + all: Effect.fn("CatalogV2.provider.all")(function* () { + return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider) + }), + + available: Effect.fn("CatalogV2.provider.available")(function* () { + return globalThis.Array.from(HashMap.values(records)) + .map((record) => record.provider) + .filter((provider) => provider.enabled) + }), + }, + + model: { + get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) { + const record = yield* getRecord(providerID) + const model = Option.getOrUndefined(HashMap.get(record.models, modelID)) + if (!model) return yield* new ModelNotFoundError({ providerID, modelID }) + return resolve(model) + }), + + update: Effect.fnUntraced(function* (providerID, modelID, fn) { + const record = yield* getRecord(providerID) + const model = produce( + HashMap.get(record.models, modelID).pipe(Option.getOrElse(() => ModelV2.Info.empty(providerID, modelID))), + (draft) => { + fn(draft) + if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") { + draft.endpoint.url = draft.options.aisdk.provider.baseURL + delete draft.options.aisdk.provider.baseURL + } + }, + ) + const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false }) + if (updated.cancel) return + records = HashMap.set(records, providerID, { + provider: record.provider, + models: HashMap.set( + record.models, + modelID, + new ModelV2.Info({ ...updated.model, id: modelID, providerID }), + ), + }) + return + }), + + all: Effect.fn("CatalogV2.model.all")(function* () { + return pipe( + records, + HashMap.toValues, + Array.flatMap((record) => HashMap.toValues(record.models)), + Array.map(resolve), + Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), + ) + }), + + available: Effect.fn("CatalogV2.model.available")(function* () { + return (yield* result.model.all()).filter((model) => { + const record = Option.getOrUndefined(HashMap.get(records, model.providerID)) + return record?.provider.enabled !== false && model.enabled + }) + }), + + default: Effect.fn("CatalogV2.model.default")(function* () { + if (defaultModel) { + const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option) + if (Option.isSome(model) && model.value.enabled) return model + } + + return pipe( + yield* result.model.available(), + Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), + Array.head, + ) + }), + + setDefault: Effect.fn("CatalogV2.model.setDefault")(function* (providerID, modelID) { + yield* result.model.get(providerID, modelID) + defaultModel = { providerID, modelID } + }), + + small: Effect.fn("CatalogV2.model.small")(function* (providerID) { + const record = Option.getOrUndefined(HashMap.get(records, providerID)) + if (!record) return Option.none() + + if (providerID === ProviderV2.ID.opencode) { + const gpt5Nano = Option.getOrUndefined(HashMap.get(record.models, ModelV2.ID.make("gpt-5-nano"))) + if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano)) + } + + const candidates = pipe( + HashMap.toValues(record.models), + Array.filter( + (model) => + model.providerID === providerID && + model.enabled && + model.status === "active" && + model.capabilities.input.some((item) => item.startsWith("text")) && + model.capabilities.output.some((item) => item.startsWith("text")), + ), + Array.map((model) => ({ + model, + cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999, + age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30), + small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()), + })), + Array.filter((item) => item.cost > 0 && item.age <= 18), + ) + + const pick = (items: typeof candidates) => { + const maxCost = Math.max(...items.map((item) => item.cost), 0.01) + const maxAge = Math.max(...items.map((item) => item.age), 0.01) + return pipe( + items, + Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number), + Array.map((item) => resolve(item.model)), + Array.head, + ) + } + + return pipe( + candidates, + Array.filter((item) => item.small), + (items) => (items.length > 0 ? pick(items) : pick(candidates)), + ) + }), + }, + } + + return Service.of(result) + }), +) + +const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ + +export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer)) diff --git a/packages/opencode/src/provider/sdk/copilot/README.md b/packages/core/src/github-copilot/README.md similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/README.md rename to packages/core/src/github-copilot/README.md diff --git a/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts b/packages/core/src/github-copilot/chat/convert-to-openai-compatible-chat-messages.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts rename to packages/core/src/github-copilot/chat/convert-to-openai-compatible-chat-messages.ts diff --git a/packages/opencode/src/provider/sdk/copilot/chat/get-response-metadata.ts b/packages/core/src/github-copilot/chat/get-response-metadata.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/chat/get-response-metadata.ts rename to packages/core/src/github-copilot/chat/get-response-metadata.ts diff --git a/packages/opencode/src/provider/sdk/copilot/chat/map-openai-compatible-finish-reason.ts b/packages/core/src/github-copilot/chat/map-openai-compatible-finish-reason.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/chat/map-openai-compatible-finish-reason.ts rename to packages/core/src/github-copilot/chat/map-openai-compatible-finish-reason.ts diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-api-types.ts b/packages/core/src/github-copilot/chat/openai-compatible-api-types.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-api-types.ts rename to packages/core/src/github-copilot/chat/openai-compatible-api-types.ts diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts b/packages/core/src/github-copilot/chat/openai-compatible-chat-language-model.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts rename to packages/core/src/github-copilot/chat/openai-compatible-chat-language-model.ts diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-options.ts b/packages/core/src/github-copilot/chat/openai-compatible-chat-options.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-options.ts rename to packages/core/src/github-copilot/chat/openai-compatible-chat-options.ts diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-metadata-extractor.ts b/packages/core/src/github-copilot/chat/openai-compatible-metadata-extractor.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-metadata-extractor.ts rename to packages/core/src/github-copilot/chat/openai-compatible-metadata-extractor.ts diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-prepare-tools.ts b/packages/core/src/github-copilot/chat/openai-compatible-prepare-tools.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-prepare-tools.ts rename to packages/core/src/github-copilot/chat/openai-compatible-prepare-tools.ts diff --git a/packages/opencode/src/provider/sdk/copilot/copilot-provider.ts b/packages/core/src/github-copilot/copilot-provider.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/copilot-provider.ts rename to packages/core/src/github-copilot/copilot-provider.ts diff --git a/packages/opencode/src/provider/sdk/copilot/openai-compatible-error.ts b/packages/core/src/github-copilot/openai-compatible-error.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/openai-compatible-error.ts rename to packages/core/src/github-copilot/openai-compatible-error.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/convert-to-openai-responses-input.ts b/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/convert-to-openai-responses-input.ts rename to packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/map-openai-responses-finish-reason.ts b/packages/core/src/github-copilot/responses/map-openai-responses-finish-reason.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/map-openai-responses-finish-reason.ts rename to packages/core/src/github-copilot/responses/map-openai-responses-finish-reason.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-config.ts b/packages/core/src/github-copilot/responses/openai-config.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/openai-config.ts rename to packages/core/src/github-copilot/responses/openai-config.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-error.ts b/packages/core/src/github-copilot/responses/openai-error.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/openai-error.ts rename to packages/core/src/github-copilot/responses/openai-error.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-api-types.ts b/packages/core/src/github-copilot/responses/openai-responses-api-types.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/openai-responses-api-types.ts rename to packages/core/src/github-copilot/responses/openai-responses-api-types.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts b/packages/core/src/github-copilot/responses/openai-responses-language-model.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts rename to packages/core/src/github-copilot/responses/openai-responses-language-model.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-prepare-tools.ts b/packages/core/src/github-copilot/responses/openai-responses-prepare-tools.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/openai-responses-prepare-tools.ts rename to packages/core/src/github-copilot/responses/openai-responses-prepare-tools.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-settings.ts b/packages/core/src/github-copilot/responses/openai-responses-settings.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/openai-responses-settings.ts rename to packages/core/src/github-copilot/responses/openai-responses-settings.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/code-interpreter.ts b/packages/core/src/github-copilot/responses/tool/code-interpreter.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/tool/code-interpreter.ts rename to packages/core/src/github-copilot/responses/tool/code-interpreter.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/file-search.ts b/packages/core/src/github-copilot/responses/tool/file-search.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/tool/file-search.ts rename to packages/core/src/github-copilot/responses/tool/file-search.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/image-generation.ts b/packages/core/src/github-copilot/responses/tool/image-generation.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/tool/image-generation.ts rename to packages/core/src/github-copilot/responses/tool/image-generation.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/local-shell.ts b/packages/core/src/github-copilot/responses/tool/local-shell.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/tool/local-shell.ts rename to packages/core/src/github-copilot/responses/tool/local-shell.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/web-search-preview.ts b/packages/core/src/github-copilot/responses/tool/web-search-preview.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/tool/web-search-preview.ts rename to packages/core/src/github-copilot/responses/tool/web-search-preview.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/web-search.ts b/packages/core/src/github-copilot/responses/tool/web-search.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/tool/web-search.ts rename to packages/core/src/github-copilot/responses/tool/web-search.ts diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts new file mode 100644 index 000000000..77b8c60eb --- /dev/null +++ b/packages/core/src/model.ts @@ -0,0 +1,116 @@ +import { DateTime, Schema } from "effect" +import { DateTimeUtcFromMillis } from "effect/Schema" +import { ProviderV2 } from "./provider" + +export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID")) +export type ID = typeof ID.Type + +export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export type VariantID = typeof VariantID.Type + +// Grouping of models, eg claude opus, claude sonnet +export const Family = Schema.String.pipe(Schema.brand("Family")) +export type Family = typeof Family.Type + +export const Capabilities = Schema.Struct({ + tools: Schema.Boolean, + // mime patterns, image, audio, video/*, text/* + input: Schema.String.pipe(Schema.Array), + output: Schema.String.pipe(Schema.Array), +}) +export type Capabilities = typeof Capabilities.Type + +export const Cost = Schema.Struct({ + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Int, + }).pipe(Schema.optional), + input: Schema.Finite, + output: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) + +export const Ref = Schema.Struct({ + id: ID, + providerID: ProviderV2.ID, + variant: VariantID, +}) +export type Ref = typeof Ref.Type + +export class Info extends Schema.Class("ModelV2.Info")({ + id: ID, + apiID: ID, + providerID: ProviderV2.ID, + family: Family.pipe(Schema.optional), + name: Schema.String, + endpoint: ProviderV2.Endpoint, + capabilities: Capabilities, + options: Schema.Struct({ + ...ProviderV2.Options.fields, + variant: Schema.String.pipe(Schema.optional), + }), + variants: Schema.Struct({ + id: VariantID, + ...ProviderV2.Options.fields, + }).pipe(Schema.Array), + time: Schema.Struct({ + released: DateTimeUtcFromMillis, + }), + cost: Cost.pipe(Schema.Array), + status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + enabled: Schema.Boolean, + limit: Schema.Struct({ + context: Schema.Int, + input: Schema.Int.pipe(Schema.optional), + output: Schema.Int, + }), +}) { + static empty(providerID: ProviderV2.ID, modelID: ID) { + return new Info({ + id: modelID, + apiID: modelID, + providerID, + name: modelID, + endpoint: { + type: "unknown", + }, + capabilities: { + tools: false, + input: [], + output: [], + }, + options: { + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + }, + variants: [], + time: { + released: DateTime.makeUnsafe(0), + }, + cost: [], + status: "active", + enabled: true, + limit: { + context: 0, + output: 0, + }, + }) + } +} + +export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { + const [providerID, ...modelID] = input.split("/") + return { + providerID: ProviderV2.ID.make(providerID), + modelID: ID.make(modelID.join("/")), + } +} + +export * as ModelV2 from "./model" diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts new file mode 100644 index 000000000..dfcae9468 --- /dev/null +++ b/packages/core/src/plugin.ts @@ -0,0 +1,146 @@ +export * as PluginV2 from "./plugin" + +import { createDraft, finishDraft, type Draft } from "immer" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { type ProviderV2 } from "./provider" +import { Context, Effect, Layer, Schema } from "effect" +import type { ModelV2 } from "./model" + +export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) +export type ID = typeof ID.Type + +type HookSpec = { + "provider.update": { + input: {} + output: { + provider: ProviderV2.Info + cancel: boolean + } + } + "model.update": { + input: {} + output: { + model: ModelV2.Info + cancel: boolean + } + } + "aisdk.language": { + input: { + model: ModelV2.Info + sdk: any + options: Record + } + output: { + language?: LanguageModelV3 + } + } + "aisdk.sdk": { + input: { + model: ModelV2.Info + package: string + options: Record + } + output: { + sdk?: any + } + } +} + +export type Hooks = { + [Name in keyof HookSpec]: Readonly & { + -readonly [Field in keyof HookSpec[Name]["output"]]: HookSpec[Name]["output"][Field] extends object + ? Draft + : HookSpec[Name]["output"][Field] + } +} + +export type HookFunctions = { + [key in keyof Hooks]?: (input: Hooks[key]) => Effect.Effect +} + +export type HookInput = HookSpec[Name]["input"] +export type HookOutput = HookSpec[Name]["output"] + +export type Effect = Effect.Effect + +export function define(input: { id: ID; effect: Effect.Effect }) { + return input +} + +export interface Interface { + readonly add: (input: { id: ID; effect: Effect }) => Effect.Effect + readonly remove: (id: ID) => Effect.Effect + readonly trigger: ( + name: Name, + input: HookInput, + output: HookOutput, + ) => Effect.Effect & HookOutput> +} + +export class Service extends Context.Service()("@opencode/v2/Plugin") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + let hooks: { + id: ID + hooks: HookFunctions + }[] = [] + + const svc = Service.of({ + add: Effect.fn("Plugin.add")(function* (input) { + const result = yield* input.effect + if (!result) return + hooks = [ + ...hooks.filter((item) => item.id !== input.id), + { + id: input.id, + hooks: result, + }, + ] + }), + trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) { + const draftEntries = new Map>() + const event = { + ...input, + ...output, + } as Record + + for (const [field, value] of Object.entries(output)) { + if (value && typeof value === "object") { + draftEntries.set(field, createDraft(value)) + event[field] = draftEntries.get(field) + } + } + + for (const item of hooks) { + const match = item.hooks[name] + if (!match) continue + yield* match(event as any).pipe( + Effect.withSpan(`Plugin.hook.${name}`, { + attributes: { + plugin: item.id, + hook: name, + }, + }), + ) + } + + for (const [field, draft] of draftEntries) { + event[field] = finishDraft(draft) + } + + return event as any + }), + remove: Effect.fn("Plugin.remove")(function* (id) { + hooks = hooks.filter((item) => item.id !== id) + }), + }) + return svc + }), +) + +export const defaultLayer = layer + +// opencode +// sdcok diff --git a/packages/core/src/plugin/auth.ts b/packages/core/src/plugin/auth.ts new file mode 100644 index 000000000..81cbfbe3f --- /dev/null +++ b/packages/core/src/plugin/auth.ts @@ -0,0 +1,27 @@ +import { Effect } from "effect" +import { AuthV2 } from "../auth" +import { PluginV2 } from "../plugin" + +export const AuthPlugin = PluginV2.define({ + id: PluginV2.ID.make("auth"), + effect: Effect.gen(function* () { + const auth = yield* AuthV2.Service + return { + "provider.update": Effect.fn(function* (evt) { + const account = yield* auth.active(AuthV2.ServiceID.make(evt.provider.id)).pipe(Effect.orDie) + if (!account) return + evt.provider.enabled = { + via: "auth", + service: account.serviceID, + } + if (account.credential.type === "api") { + evt.provider.options.aisdk.provider.apiKey = account.credential.key + Object.assign(evt.provider.options.aisdk.provider, account.credential.metadata ?? {}) + } + if (account.credential.type === "oauth") { + evt.provider.options.aisdk.provider.apiKey = account.credential.access + } + }), + } + }), +}) diff --git a/packages/core/src/plugin/env.ts b/packages/core/src/plugin/env.ts new file mode 100644 index 000000000..d63936fa1 --- /dev/null +++ b/packages/core/src/plugin/env.ts @@ -0,0 +1,18 @@ +import { Effect } from "effect" +import { PluginV2 } from "../plugin" + +export const EnvPlugin = PluginV2.define({ + id: PluginV2.ID.make("env"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + const key = evt.provider.env.find((item) => process.env[item]) + if (!key) return + evt.provider.enabled = { + via: "env", + name: key, + } + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts new file mode 100644 index 000000000..188078749 --- /dev/null +++ b/packages/core/src/plugin/provider.ts @@ -0,0 +1 @@ +export { ProviderPlugins } from "./provider/index" diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts new file mode 100644 index 000000000..fa5c0a91c --- /dev/null +++ b/packages/core/src/plugin/provider/alibaba.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const AlibabaPlugin = PluginV2.define({ + id: PluginV2.ID.make("alibaba"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/alibaba") return + const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba")) + evt.sdk = mod.createAlibaba(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts new file mode 100644 index 000000000..366548a0a --- /dev/null +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -0,0 +1,94 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +// Bedrock cross-region inference profiles require regional prefixes only for +// specific model/region combinations. Keep the mapping narrow and avoid +// double-prefixing model IDs that models.dev already marks as global/us/eu/etc. +function resolveModelID(modelID: string, region: string | undefined) { + const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."] + if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) return modelID + + const resolvedRegion = region ?? "us-east-1" + const regionPrefix = resolvedRegion.split("-")[0] + if (regionPrefix === "us") { + const requiresPrefix = ["nova-micro", "nova-lite", "nova-pro", "nova-premier", "nova-2", "claude", "deepseek"].some( + (item) => modelID.includes(item), + ) + if (requiresPrefix && !resolvedRegion.startsWith("us-gov")) return `${regionPrefix}.${modelID}` + return modelID + } + if (regionPrefix === "eu") { + const regionRequiresPrefix = [ + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-north-1", + "eu-central-1", + "eu-south-1", + "eu-south-2", + ].some((item) => resolvedRegion.includes(item)) + const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((item) => + modelID.includes(item), + ) + return regionRequiresPrefix && modelRequiresPrefix ? `${regionPrefix}.${modelID}` : modelID + } + if (regionPrefix !== "ap") return modelID + + const australia = ["ap-southeast-2", "ap-southeast-4"].includes(resolvedRegion) + if (australia && ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((item) => modelID.includes(item))) { + return `au.${modelID}` + } + + const prefix = resolvedRegion === "ap-northeast-1" ? "jp" : "apac" + return ["claude", "nova-lite", "nova-micro", "nova-pro"].some((item) => modelID.includes(item)) + ? `${prefix}.${modelID}` + : modelID +} + +export const AmazonBedrockPlugin = PluginV2.define({ + id: PluginV2.ID.make("amazon-bedrock"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.amazonBedrock) return + if (evt.provider.endpoint.type !== "aisdk") return + if (typeof evt.provider.options.aisdk.provider.endpoint !== "string") return + // The AI SDK expects a base URL, but users configure Bedrock private/VPC + // endpoints as `endpoint`; move it into the catalog endpoint URL once. + evt.provider.endpoint.url = evt.provider.options.aisdk.provider.endpoint + delete evt.provider.options.aisdk.provider.endpoint + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/amazon-bedrock") return + const options = { ...evt.options } + const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE + const region = typeof options.region === "string" ? options.region : (process.env.AWS_REGION ?? "us-east-1") + const bearerToken = + process.env.AWS_BEARER_TOKEN_BEDROCK ?? + (typeof options.bearerToken === "string" ? options.bearerToken : undefined) + if (bearerToken && !process.env.AWS_BEARER_TOKEN_BEDROCK) process.env.AWS_BEARER_TOKEN_BEDROCK = bearerToken + const containerCreds = Boolean( + process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI, + ) + + options.region = region + if (typeof options.endpoint === "string") options.baseURL = options.endpoint + if (!bearerToken && options.credentialProvider === undefined) { + // Do not gate SDK creation on explicit AWS env vars. The default chain + // also handles ~/.aws/credentials, SSO, process creds, and instance roles. + const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers")) + options.credentialProvider = fromNodeProviderChain(profile ? { profile } : {}) + } + + const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock")) + evt.sdk = mod.createAmazonBedrock(options) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return + const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION + evt.language = evt.sdk.languageModel(resolveModelID(evt.model.apiID, region)) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts new file mode 100644 index 000000000..14851c4a3 --- /dev/null +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -0,0 +1,21 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const AnthropicPlugin = PluginV2.define({ + id: PluginV2.ID.make("anthropic"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.anthropic) return + evt.provider.options.headers["anthropic-beta"] = + "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/anthropic") return + const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) + evt.sdk = mod.createAnthropic(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts new file mode 100644 index 000000000..86c3eb924 --- /dev/null +++ b/packages/core/src/plugin/provider/azure.ts @@ -0,0 +1,67 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +function selectLanguage(sdk: any, modelID: string, useChat: boolean) { + if (useChat && sdk.chat) return sdk.chat(modelID) + if (sdk.responses) return sdk.responses(modelID) + if (sdk.messages) return sdk.messages(modelID) + if (sdk.chat) return sdk.chat(modelID) + return sdk.languageModel(modelID) +} + +export const AzurePlugin = PluginV2.define({ + id: PluginV2.ID.make("azure"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.azure) return + const configured = evt.provider.options.aisdk.provider.resourceName + const resourceName = + typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME + if (resourceName) evt.provider.options.aisdk.provider.resourceName = resourceName + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/azure") return + if (evt.model.providerID === ProviderV2.ID.azure) { + if (!evt.options.resourceName && !evt.options.baseURL && (evt.model.endpoint.type !== "aisdk" || !evt.model.endpoint.url)) { + throw new Error( + "AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it", + ) + } + } + const mod = yield* Effect.promise(() => import("@ai-sdk/azure")) + evt.sdk = mod.createAzure(evt.options) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.azure) return + evt.language = selectLanguage( + evt.sdk, + evt.model.apiID, + Boolean(evt.options.useCompletionUrls), + ) + }), + } + }), +}) + +export const AzureCognitiveServicesPlugin = PluginV2.define({ + id: PluginV2.ID.make("azure-cognitive-services"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("azure-cognitive-services")) return + const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME + if (resourceName) evt.provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai` + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return + evt.language = selectLanguage( + evt.sdk, + evt.model.apiID, + Boolean(evt.options.useCompletionUrls), + ) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts new file mode 100644 index 000000000..b2fadd8bf --- /dev/null +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -0,0 +1,20 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const CerebrasPlugin = PluginV2.define({ + id: PluginV2.ID.make("cerebras"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("cerebras")) return + evt.provider.options.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/cerebras") return + const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras")) + evt.sdk = mod.createCerebras(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts new file mode 100644 index 000000000..ffcd4adcf --- /dev/null +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -0,0 +1,81 @@ +import os from "os" +import { InstallationVersion } from "../../installation/version" +import { Effect, Option, Schema } from "effect" +import { PluginV2 } from "../../plugin" + +export const CloudflareAIGatewayPlugin = PluginV2.define({ + id: PluginV2.ID.make("cloudflare-ai-gateway"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "ai-gateway-provider") return + if (evt.options.baseURL) return + + const config = gatewayConfig(evt.options) + if (!config) return + const metadata = gatewayMetadata(evt.options) + const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider")).pipe(Effect.orDie) + const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified")).pipe( + Effect.orDie, + ) + const gateway = createAiGateway({ + accountId: config.accountId, + gateway: config.gatewayId, + apiKey: config.apiKey, + options: gatewayOptions(evt.options, metadata), + } as any) + const unified = createUnified() + evt.sdk = { + languageModel(modelID: string) { + return gateway(unified(modelID)) + }, + } + }), + } + }), +}) + +type GatewayConfig = { + accountId: string + gatewayId: string + apiKey: string +} + +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) + +function gatewayConfig(options: Record): GatewayConfig | undefined { + const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId") + // AuthPlugin copies CLI prompt metadata into options. The prompt stores the + // gateway as gatewayId, while older config examples may use gateway. + const gatewayId = + process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway") + const apiKey = process.env.CLOUDFLARE_API_TOKEN ?? process.env.CF_AIG_TOKEN ?? stringOption(options, "apiKey") + if (!accountId || !gatewayId || !apiKey) return undefined + + return { accountId, gatewayId, apiKey } +} + +function gatewayMetadata(options: Record) { + // Preserve the legacy cf-aig-metadata header escape hatch for gateway logging + // metadata, but prefer the typed metadata option when present. + if (options.metadata !== undefined) return options.metadata + const raw = (options.headers as Record | undefined)?.["cf-aig-metadata"] + return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined +} + +function gatewayOptions(options: Record, metadata: unknown) { + return { + metadata, + cacheTtl: options.cacheTtl, + cacheKey: options.cacheKey, + skipCache: options.skipCache, + collectLog: options.collectLog, + headers: { + "User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`, + }, + } +} + +function stringOption(options: Record, key: string) { + return typeof options[key] === "string" ? options[key] : undefined +} diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts new file mode 100644 index 000000000..f39869b57 --- /dev/null +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -0,0 +1,69 @@ +import os from "os" +import { InstallationVersion } from "../../installation/version" +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +const providerID = ProviderV2.ID.make("cloudflare-workers-ai") + +export const CloudflareWorkersAIPlugin = PluginV2.define({ + id: PluginV2.ID.make("cloudflare-workers-ai"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== providerID) return + if (evt.provider.endpoint.type !== "aisdk") return + if (evt.provider.endpoint.url) return + + const accountId = resolveAccountId(evt.provider.options.aisdk.provider) + if (accountId) evt.provider.endpoint.url = workersEndpoint(accountId) + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.model.providerID !== providerID) return + if (evt.package !== "@ai-sdk/openai-compatible") return + + if (!hasWorkersEndpoint(evt.model.endpoint)) return + const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) + evt.sdk = mod.createOpenAICompatible(sdkOptions(evt.options) as any) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== providerID) return + evt.language = evt.sdk.languageModel(evt.model.apiID) + }), + } + }), +}) + +function resolveAccountId(options: Record) { + return process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId") +} + +function workersEndpoint(accountId: string) { + return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1` +} + +function hasWorkersEndpoint(endpoint: ProviderV2.Endpoint) { + return endpoint.type === "aisdk" && Boolean(endpoint.url) +} + +function sdkOptions(options: Record) { + return { + ...options, + baseURL: expandAccountId(options.baseURL), + apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey, + headers: { + "User-Agent": `opencode/${InstallationVersion} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`, + ...options.headers, + }, + name: providerID, + } +} + +function expandAccountId(baseURL: unknown) { + if (typeof baseURL !== "string") return baseURL + return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", process.env.CLOUDFLARE_ACCOUNT_ID ?? "${CLOUDFLARE_ACCOUNT_ID}") +} + +function stringOption(options: Record, key: string) { + return typeof options[key] === "string" ? options[key] : undefined +} diff --git a/packages/core/src/plugin/provider/cohere.ts b/packages/core/src/plugin/provider/cohere.ts new file mode 100644 index 000000000..991c370d1 --- /dev/null +++ b/packages/core/src/plugin/provider/cohere.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const CoherePlugin = PluginV2.define({ + id: PluginV2.ID.make("cohere"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/cohere") return + const mod = yield* Effect.promise(() => import("@ai-sdk/cohere")) + evt.sdk = mod.createCohere(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/deepinfra.ts b/packages/core/src/plugin/provider/deepinfra.ts new file mode 100644 index 000000000..bbd42f6e2 --- /dev/null +++ b/packages/core/src/plugin/provider/deepinfra.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const DeepInfraPlugin = PluginV2.define({ + id: PluginV2.ID.make("deepinfra"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/deepinfra") return + const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra")) + evt.sdk = mod.createDeepInfra(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts new file mode 100644 index 000000000..e5abc7009 --- /dev/null +++ b/packages/core/src/plugin/provider/dynamic.ts @@ -0,0 +1,31 @@ +import { Npm } from "../../npm" +import { Effect, Option } from "effect" +import { pathToFileURL } from "url" +import { PluginV2 } from "../../plugin" + +export const DynamicProviderPlugin = PluginV2.define({ + id: PluginV2.ID.make("dynamic-provider"), + effect: Effect.gen(function* () { + const npm = yield* Npm.Service + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.sdk) return + + const installedPath = evt.package.startsWith("file://") + ? evt.package + : Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint) + if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) + + const mod = yield* Effect.promise(async () => { + return (await import( + installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href + )) as Record any> + }).pipe(Effect.orDie) + const match = Object.keys(mod).find((name) => name.startsWith("create")) + if (!match) throw new Error(`Package ${evt.package} has no provider factory export`) + + evt.sdk = mod[match](evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/gateway.ts b/packages/core/src/plugin/provider/gateway.ts new file mode 100644 index 000000000..5b08ad9ef --- /dev/null +++ b/packages/core/src/plugin/provider/gateway.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const GatewayPlugin = PluginV2.define({ + id: PluginV2.ID.make("gateway"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/gateway") return + const mod = yield* Effect.promise(() => import("@ai-sdk/gateway")) + evt.sdk = mod.createGateway(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts new file mode 100644 index 000000000..31e57ba12 --- /dev/null +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -0,0 +1,44 @@ +import { Effect } from "effect" +import { ModelV2 } from "../../model" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +function shouldUseResponses(modelID: string) { + // Copilot supports Responses for GPT-5 class models, except mini variants + // which still need the chat-completions endpoint. + const match = /^gpt-(\d+)/.exec(modelID) + if (!match) return false + return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") +} + +export const GithubCopilotPlugin = PluginV2.define({ + id: PluginV2.ID.make("github-copilot"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.githubCopilot) return + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/github-copilot") return + const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) + evt.sdk = mod.createOpenaiCompatible(evt.options) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return + if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { + evt.language = evt.sdk.languageModel(evt.model.apiID) + return + } + evt.language = shouldUseResponses(evt.model.apiID) + ? evt.sdk.responses(evt.model.apiID) + : evt.sdk.chat(evt.model.apiID) + }), + "model.update": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return + // This chat-only alias conflicts with the Copilot GPT-5 Responses route, + // so hide it only for Copilot rather than for every provider catalog. + if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts new file mode 100644 index 000000000..be923e7cb --- /dev/null +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -0,0 +1,64 @@ +import os from "os" +import { InstallationVersion } from "../../installation/version" +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const GitLabPlugin = PluginV2.define({ + id: PluginV2.ID.make("gitlab"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "gitlab-ai-provider") return + const mod = yield* Effect.promise(() => import("gitlab-ai-provider")) + evt.sdk = mod.createGitLab({ + ...evt.options, + instanceUrl: + typeof evt.options.instanceUrl === "string" + ? evt.options.instanceUrl + : (process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com"), + apiKey: typeof evt.options.apiKey === "string" ? evt.options.apiKey : process.env.GITLAB_TOKEN, + aiGatewayHeaders: { + "User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${mod.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, + "anthropic-beta": "context-1m-2025-08-07", + ...evt.options.aiGatewayHeaders, + }, + featureFlags: { + duo_agent_platform_agentic_chat: true, + duo_agent_platform: true, + ...evt.options.featureFlags, + }, + }) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.gitlab) return + const featureFlags = typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {} + if (evt.model.apiID.startsWith("duo-workflow-")) { + const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider")).pipe(Effect.orDie) + const workflowRef = + typeof evt.model.options.aisdk.request.workflowRef === "string" + ? evt.model.options.aisdk.request.workflowRef + : undefined + const workflowDefinition = + typeof evt.model.options.aisdk.request.workflowDefinition === "string" + ? evt.model.options.aisdk.request.workflowDefinition + : undefined + const language = evt.sdk.workflowChat( + gitlab.isWorkflowModel(evt.model.apiID) ? evt.model.apiID : "duo-workflow", + { + featureFlags, + workflowDefinition, + }, + ) + if (workflowRef) language.selectedModelRef = workflowRef + evt.language = language + return + } + evt.language = evt.sdk.agenticChat(evt.model.apiID, { + aiGatewayHeaders: evt.options.aiGatewayHeaders, + featureFlags, + }) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts new file mode 100644 index 000000000..f22f79f45 --- /dev/null +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -0,0 +1,124 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +function resolveProject(options: Record) { + // models.dev advertises GOOGLE_VERTEX_PROJECT for Vertex, while Google SDKs + // and ADC examples commonly use the broader Google Cloud project aliases. + return ( + options.project ?? + process.env.GOOGLE_VERTEX_PROJECT ?? + process.env.GOOGLE_CLOUD_PROJECT ?? + process.env.GCP_PROJECT ?? + process.env.GCLOUD_PROJECT + ) +} + +function resolveLocation(options: Record) { + return options.location ?? process.env.GOOGLE_VERTEX_LOCATION ?? process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "us-central1" +} + +function vertexEndpoint(location: string) { + return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com` +} + +function replaceVertexVars(value: string, project: string | undefined, location: string) { + // Vertex OpenAI-compatible endpoints are stored as templates in the catalog; + // expand them after provider config/env project and location have been resolved. + return value + .replaceAll("${GOOGLE_VERTEX_PROJECT}", project ?? "${GOOGLE_VERTEX_PROJECT}") + .replaceAll("${GOOGLE_VERTEX_LOCATION}", location) + .replaceAll("${GOOGLE_VERTEX_ENDPOINT}", vertexEndpoint(location)) +} + +function authFetch(fetchWithRuntimeOptions?: unknown) { + // Native Vertex SDKs handle ADC internally. OpenAI-compatible Vertex endpoints + // do not, so inject a Google access token into their fetch path. + return async (input: Parameters[0], init?: RequestInit) => { + const { GoogleAuth } = await import("google-auth-library") + const auth = new GoogleAuth() + const client = await auth.getApplicationDefault() + const token = await client.credential.getAccessToken() + const headers = new Headers(init?.headers) + headers.set("Authorization", `Bearer ${token.token}`) + return typeof fetchWithRuntimeOptions === "function" + ? fetchWithRuntimeOptions(input, { ...init, headers }) + : fetch(input, { ...init, headers }) + } +} + +export const GoogleVertexPlugin = PluginV2.define({ + id: PluginV2.ID.make("google-vertex"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.googleVertex) return + const project = resolveProject(evt.provider.options.aisdk.provider) + const location = String(resolveLocation(evt.provider.options.aisdk.provider)) + if (project) evt.provider.options.aisdk.provider.project = project + evt.provider.options.aisdk.provider.location = location + if (evt.provider.endpoint.type === "aisdk" && evt.provider.endpoint.url) { + evt.provider.endpoint.url = replaceVertexVars(evt.provider.endpoint.url, project, location) + } + if (evt.provider.endpoint.type === "aisdk" && evt.provider.endpoint.package.includes("@ai-sdk/openai-compatible")) { + evt.provider.options.aisdk.provider.fetch = authFetch(evt.provider.options.aisdk.provider.fetch) + } + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { + evt.options.fetch = authFetch(evt.options.fetch) + return + } + if (evt.package !== "@ai-sdk/google-vertex") return + const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex")) + const project = resolveProject(evt.options) + const location = resolveLocation(evt.options) + const options = { ...evt.options } + delete options.fetch + evt.sdk = mod.createVertex({ + ...options, + project, + location, + }) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.googleVertex) return + evt.language = evt.sdk.languageModel(String(evt.model.apiID).trim()) + }), + } + }), +}) + +export const GoogleVertexAnthropicPlugin = PluginV2.define({ + id: PluginV2.ID.make("google-vertex-anthropic"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("google-vertex-anthropic")) return + const project = evt.provider.options.aisdk.provider.project ?? process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCP_PROJECT ?? process.env.GCLOUD_PROJECT + const location = evt.provider.options.aisdk.provider.location ?? process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "global" + if (project) evt.provider.options.aisdk.provider.project = project + evt.provider.options.aisdk.provider.location = location + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/google-vertex/anthropic") return + const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) + evt.sdk = mod.createVertexAnthropic({ + ...evt.options, + project: + typeof evt.options.project === "string" + ? evt.options.project + : (process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCP_PROJECT ?? process.env.GCLOUD_PROJECT), + location: + typeof evt.options.location === "string" + ? evt.options.location + : (process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "global"), + }) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return + evt.language = evt.sdk.languageModel(String(evt.model.apiID).trim()) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/google.ts b/packages/core/src/plugin/provider/google.ts new file mode 100644 index 000000000..47e29c6b5 --- /dev/null +++ b/packages/core/src/plugin/provider/google.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const GooglePlugin = PluginV2.define({ + id: PluginV2.ID.make("google"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/google") return + const mod = yield* Effect.promise(() => import("@ai-sdk/google")) + evt.sdk = mod.createGoogleGenerativeAI(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/groq.ts b/packages/core/src/plugin/provider/groq.ts new file mode 100644 index 000000000..f2052afd1 --- /dev/null +++ b/packages/core/src/plugin/provider/groq.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const GroqPlugin = PluginV2.define({ + id: PluginV2.ID.make("groq"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/groq") return + const mod = yield* Effect.promise(() => import("@ai-sdk/groq")) + evt.sdk = mod.createGroq(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/index.ts b/packages/core/src/plugin/provider/index.ts new file mode 100644 index 000000000..fd02d322a --- /dev/null +++ b/packages/core/src/plugin/provider/index.ts @@ -0,0 +1,67 @@ +import { AlibabaPlugin } from "./alibaba" +import { AmazonBedrockPlugin } from "./amazon-bedrock" +import { AnthropicPlugin } from "./anthropic" +import { AzureCognitiveServicesPlugin, AzurePlugin } from "./azure" +import { CerebrasPlugin } from "./cerebras" +import { CloudflareAIGatewayPlugin } from "./cloudflare-ai-gateway" +import { CloudflareWorkersAIPlugin } from "./cloudflare-workers-ai" +import { CoherePlugin } from "./cohere" +import { DeepInfraPlugin } from "./deepinfra" +import { DynamicProviderPlugin } from "./dynamic" +import { GatewayPlugin } from "./gateway" +import { GithubCopilotPlugin } from "./github-copilot" +import { GitLabPlugin } from "./gitlab" +import { GooglePlugin } from "./google" +import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./google-vertex" +import { GroqPlugin } from "./groq" +import { KiloPlugin } from "./kilo" +import { LLMGatewayPlugin } from "./llmgateway" +import { MistralPlugin } from "./mistral" +import { NvidiaPlugin } from "./nvidia" +import { OpenAIPlugin } from "./openai" +import { OpenAICompatiblePlugin } from "./openai-compatible" +import { OpencodePlugin } from "./opencode" +import { OpenRouterPlugin } from "./openrouter" +import { PerplexityPlugin } from "./perplexity" +import { SapAICorePlugin } from "./sap-ai-core" +import { TogetherAIPlugin } from "./togetherai" +import { VercelPlugin } from "./vercel" +import { VenicePlugin } from "./venice" +import { XAIPlugin } from "./xai" +import { ZenmuxPlugin } from "./zenmux" + +export const ProviderPlugins = [ + AlibabaPlugin, + AmazonBedrockPlugin, + AnthropicPlugin, + AzureCognitiveServicesPlugin, + AzurePlugin, + CerebrasPlugin, + CloudflareAIGatewayPlugin, + CloudflareWorkersAIPlugin, + CoherePlugin, + DeepInfraPlugin, + GatewayPlugin, + GithubCopilotPlugin, + GitLabPlugin, + GooglePlugin, + GoogleVertexAnthropicPlugin, + GoogleVertexPlugin, + GroqPlugin, + KiloPlugin, + LLMGatewayPlugin, + MistralPlugin, + NvidiaPlugin, + OpencodePlugin, + OpenAICompatiblePlugin, + OpenAIPlugin, + OpenRouterPlugin, + PerplexityPlugin, + SapAICorePlugin, + TogetherAIPlugin, + VercelPlugin, + VenicePlugin, + XAIPlugin, + ZenmuxPlugin, + DynamicProviderPlugin, +] diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts new file mode 100644 index 000000000..47b8ec99c --- /dev/null +++ b/packages/core/src/plugin/provider/kilo.ts @@ -0,0 +1,16 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const KiloPlugin = PluginV2.define({ + id: PluginV2.ID.make("kilo"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("kilo")) return + evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" + evt.provider.options.headers["X-Title"] = "opencode" + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts new file mode 100644 index 000000000..da1ab282b --- /dev/null +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -0,0 +1,18 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const LLMGatewayPlugin = PluginV2.define({ + id: PluginV2.ID.make("llmgateway"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("llmgateway")) return + if (evt.provider.enabled === false) return + evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" + evt.provider.options.headers["X-Title"] = "opencode" + evt.provider.options.headers["X-Source"] = "opencode" + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/mistral.ts b/packages/core/src/plugin/provider/mistral.ts new file mode 100644 index 000000000..e7f0decb7 --- /dev/null +++ b/packages/core/src/plugin/provider/mistral.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const MistralPlugin = PluginV2.define({ + id: PluginV2.ID.make("mistral"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/mistral") return + const mod = yield* Effect.promise(() => import("@ai-sdk/mistral")) + evt.sdk = mod.createMistral(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts new file mode 100644 index 000000000..b227e5cef --- /dev/null +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -0,0 +1,16 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const NvidiaPlugin = PluginV2.define({ + id: PluginV2.ID.make("nvidia"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("nvidia")) return + evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" + evt.provider.options.headers["X-Title"] = "opencode" + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts new file mode 100644 index 000000000..76c337370 --- /dev/null +++ b/packages/core/src/plugin/provider/openai-compatible.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const OpenAICompatiblePlugin = PluginV2.define({ + id: PluginV2.ID.make("openai-compatible"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.sdk) return + if (!evt.package.includes("@ai-sdk/openai-compatible")) return + if (evt.options.includeUsage !== false) evt.options.includeUsage = true + const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) + evt.sdk = mod.createOpenAICompatible(evt.options as any) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts new file mode 100644 index 000000000..a81455f19 --- /dev/null +++ b/packages/core/src/plugin/provider/openai.ts @@ -0,0 +1,27 @@ +import { Effect } from "effect" +import { ModelV2 } from "../../model" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const OpenAIPlugin = PluginV2.define({ + id: PluginV2.ID.make("openai"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/openai") return + const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) + evt.sdk = mod.createOpenAI(evt.options) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.openai) return + evt.language = evt.sdk.responses(evt.model.apiID) + }), + "model.update": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.openai) return + // OpenAIPlugin sends OpenAI models through Responses; this alias is a + // chat-completions-only model, so remove it only from OpenAI's catalog. + if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts new file mode 100644 index 000000000..44c904aec --- /dev/null +++ b/packages/core/src/plugin/provider/opencode.ts @@ -0,0 +1,27 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const OpencodePlugin = PluginV2.define({ + id: PluginV2.ID.make("opencode"), + effect: Effect.gen(function* () { + let hasKey = false + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.opencode) return + hasKey = Boolean( + process.env.OPENCODE_API_KEY || + evt.provider.env.some((item) => process.env[item]) || + evt.provider.options.aisdk.provider.apiKey || + (evt.provider.enabled && evt.provider.enabled.via === "auth"), + ) + if (!hasKey) evt.provider.options.aisdk.provider.apiKey = "public" + }), + "model.update": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.opencode) return + if (hasKey) return + if (evt.model.cost.some((item) => item.input > 0)) evt.cancel = true + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts new file mode 100644 index 000000000..976eea8c0 --- /dev/null +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -0,0 +1,29 @@ +import { Effect } from "effect" +import { ModelV2 } from "../../model" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const OpenRouterPlugin = PluginV2.define({ + id: PluginV2.ID.make("openrouter"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.openrouter) return + evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" + evt.provider.options.headers["X-Title"] = "opencode" + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@openrouter/ai-sdk-provider") return + const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) + evt.sdk = mod.createOpenRouter(evt.options) + }), + "model.update": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.openrouter) return + // These are OpenRouter-specific OpenAI chat aliases that do not work on + // the generic path. Keep custom providers with matching IDs untouched. + if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true + if (evt.model.id === ModelV2.ID.make("openai/gpt-5-chat")) evt.cancel = true + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/perplexity.ts b/packages/core/src/plugin/provider/perplexity.ts new file mode 100644 index 000000000..2415ab7c1 --- /dev/null +++ b/packages/core/src/plugin/provider/perplexity.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const PerplexityPlugin = PluginV2.define({ + id: PluginV2.ID.make("perplexity"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/perplexity") return + const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity")) + evt.sdk = mod.createPerplexity(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts new file mode 100644 index 000000000..619f01eb3 --- /dev/null +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -0,0 +1,40 @@ +import { Npm } from "../../npm" +import { Effect, Option } from "effect" +import { pathToFileURL } from "url" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const SapAICorePlugin = PluginV2.define({ + id: PluginV2.ID.make("sap-ai-core"), + effect: Effect.gen(function* () { + const npm = yield* Npm.Service + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return + const serviceKey = + process.env.AICORE_SERVICE_KEY ?? + (typeof evt.options.serviceKey === "string" ? evt.options.serviceKey : undefined) + if (serviceKey && !process.env.AICORE_SERVICE_KEY) process.env.AICORE_SERVICE_KEY = serviceKey + + const installedPath = evt.package.startsWith("file://") + ? evt.package + : Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint) + if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) + + const mod = yield* Effect.promise(async () => { + return (await import( + installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href + )) as Record any> + }).pipe(Effect.orDie) + const match = Object.keys(mod).find((name) => name.startsWith("create")) + if (!match) throw new Error(`Package ${evt.package} has no provider factory export`) + + evt.sdk = mod[match](serviceKey ? { deploymentId: process.env.AICORE_DEPLOYMENT_ID, resourceGroup: process.env.AICORE_RESOURCE_GROUP } : {}) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return + evt.language = evt.sdk(evt.model.apiID) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/togetherai.ts b/packages/core/src/plugin/provider/togetherai.ts new file mode 100644 index 000000000..b1870f266 --- /dev/null +++ b/packages/core/src/plugin/provider/togetherai.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const TogetherAIPlugin = PluginV2.define({ + id: PluginV2.ID.make("togetherai"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/togetherai") return + const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai")) + evt.sdk = mod.createTogetherAI(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/venice.ts b/packages/core/src/plugin/provider/venice.ts new file mode 100644 index 000000000..8a3b95024 --- /dev/null +++ b/packages/core/src/plugin/provider/venice.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const VenicePlugin = PluginV2.define({ + id: PluginV2.ID.make("venice"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "venice-ai-sdk-provider") return + const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider")) + evt.sdk = mod.createVenice(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts new file mode 100644 index 000000000..2108542b1 --- /dev/null +++ b/packages/core/src/plugin/provider/vercel.ts @@ -0,0 +1,21 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const VercelPlugin = PluginV2.define({ + id: PluginV2.ID.make("vercel"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("vercel")) return + evt.provider.options.headers["http-referer"] = "https://opencode.ai/" + evt.provider.options.headers["x-title"] = "opencode" + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/vercel") return + const mod = yield* Effect.promise(() => import("@ai-sdk/vercel")) + evt.sdk = mod.createVercel(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts new file mode 100644 index 000000000..b54aa7374 --- /dev/null +++ b/packages/core/src/plugin/provider/xai.ts @@ -0,0 +1,20 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const XAIPlugin = PluginV2.define({ + id: PluginV2.ID.make("xai"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/xai") return + const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) + evt.sdk = mod.createXai(evt.options) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.make("xai")) return + evt.language = evt.sdk.responses(evt.model.apiID) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts new file mode 100644 index 000000000..6bdd42601 --- /dev/null +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -0,0 +1,16 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const ZenmuxPlugin = PluginV2.define({ + id: PluginV2.ID.make("zenmux"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("zenmux")) return + evt.provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/" + evt.provider.options.headers["X-Title"] ??= "opencode" + }), + } + }), +}) diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts new file mode 100644 index 000000000..7c1c96665 --- /dev/null +++ b/packages/core/src/provider.ts @@ -0,0 +1,120 @@ +export * as ProviderV2 from "./provider" + +import { withStatics } from "./schema" +import { Schema } from "effect" + +export const ID = Schema.String.pipe( + Schema.brand("ProviderV2.ID"), + withStatics((schema) => ({ + // Well-known providers + opencode: schema.make("opencode"), + anthropic: schema.make("anthropic"), + openai: schema.make("openai"), + google: schema.make("google"), + googleVertex: schema.make("google-vertex"), + githubCopilot: schema.make("github-copilot"), + amazonBedrock: schema.make("amazon-bedrock"), + azure: schema.make("azure"), + openrouter: schema.make("openrouter"), + mistral: schema.make("mistral"), + gitlab: schema.make("gitlab"), + })), +) +export type ID = typeof ID.Type + +const OpenAIResponses = Schema.Struct({ + type: Schema.Literal("openai/responses"), + url: Schema.String, + websocket: Schema.optional(Schema.Boolean), +}) + +const OpenAICompletions = Schema.Struct({ + type: Schema.Literal("openai/completions"), + url: Schema.String, + reasoning: Schema.Union([ + Schema.Struct({ + type: Schema.Literal("reasoning_content"), + }), + Schema.Struct({ + type: Schema.Literal("reasoning_details"), + }), + ]).pipe(Schema.optional), +}) +export type OpenAICompletions = typeof OpenAICompletions.Type + +const AISDK = Schema.Struct({ + type: Schema.Literal("aisdk"), + package: Schema.String, + url: Schema.String.pipe(Schema.optional), +}) + +const AnthropicMessages = Schema.Struct({ + type: Schema.Literal("anthropic/messages"), + url: Schema.String, +}) + +const UnknownEndpoint = Schema.Struct({ + type: Schema.Literal("unknown"), +}) + +export const Endpoint = Schema.Union([ + UnknownEndpoint, + OpenAIResponses, + OpenAICompletions, + AnthropicMessages, + AISDK, +]).pipe(Schema.toTaggedUnion("type")) +export type Endpoint = typeof Endpoint.Type + +export const Options = Schema.Struct({ + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Record(Schema.String, Schema.Any), + aisdk: Schema.Struct({ + provider: Schema.Record(Schema.String, Schema.Any), + request: Schema.Record(Schema.String, Schema.Any), + }), +}) +export type Options = typeof Options.Type + +export class Info extends Schema.Class("ProviderV2.Info")({ + id: ID, + name: Schema.String, + enabled: Schema.Union([ + Schema.Literal(false), + Schema.Struct({ + via: Schema.Literal("env"), + name: Schema.String, + }), + Schema.Struct({ + via: Schema.Literal("auth"), + service: Schema.String, + }), + Schema.Struct({ + via: Schema.Literal("custom"), + data: Schema.Record(Schema.String, Schema.Any), + }), + ]), + env: Schema.String.pipe(Schema.Array), + endpoint: Endpoint, + options: Options, +}) { + static empty(providerID: ID) { + return new Info({ + id: providerID, + name: providerID, + enabled: false, + env: [], + endpoint: { + type: "unknown", + }, + options: { + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + }, + }) + } +} diff --git a/packages/opencode/src/v2/session-prompt.ts b/packages/core/src/session-prompt.ts similarity index 100% rename from packages/opencode/src/v2/session-prompt.ts rename to packages/core/src/session-prompt.ts diff --git a/packages/opencode/src/v2/tool-output.ts b/packages/core/src/tool-output.ts similarity index 100% rename from packages/opencode/src/v2/tool-output.ts rename to packages/core/src/tool-output.ts diff --git a/packages/opencode/src/v2/schema.ts b/packages/core/src/v2-schema.ts similarity index 88% rename from packages/opencode/src/v2/schema.ts rename to packages/core/src/v2-schema.ts index 44587b838..a34b0b151 100644 --- a/packages/opencode/src/v2/schema.ts +++ b/packages/core/src/v2-schema.ts @@ -7,4 +7,4 @@ export const DateTimeUtcFromMillis = Schema.Finite.pipe( }), ) -export * as V2Schema from "./schema" +export * as V2Schema from "./v2-schema" diff --git a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts b/packages/core/test/github-copilot/convert-to-copilot-messages.test.ts similarity index 99% rename from packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts rename to packages/core/test/github-copilot/convert-to-copilot-messages.test.ts index 6f874db6d..65f4b6a53 100644 --- a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts +++ b/packages/core/test/github-copilot/convert-to-copilot-messages.test.ts @@ -1,4 +1,4 @@ -import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages" +import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@opencode-ai/core/github-copilot/chat/convert-to-openai-compatible-chat-messages" import { describe, test, expect } from "bun:test" describe("system messages", () => { diff --git a/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts b/packages/core/test/github-copilot/copilot-chat-model.test.ts similarity index 99% rename from packages/opencode/test/provider/copilot/copilot-chat-model.test.ts rename to packages/core/test/github-copilot/copilot-chat-model.test.ts index 389a72bb3..bc1e2ecd9 100644 --- a/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts +++ b/packages/core/test/github-copilot/copilot-chat-model.test.ts @@ -1,4 +1,4 @@ -import { OpenAICompatibleChatLanguageModel } from "@/provider/sdk/copilot/chat/openai-compatible-chat-language-model" +import { OpenAICompatibleChatLanguageModel } from "@opencode-ai/core/github-copilot/chat/openai-compatible-chat-language-model" import { describe, test, expect, mock } from "bun:test" import type { LanguageModelV3Prompt } from "@ai-sdk/provider" diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts new file mode 100644 index 000000000..c825f7b8e --- /dev/null +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -0,0 +1,188 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" +import { fakeSelectorSdk, it, model } from "../v2/plugin/provider-helper" + +describe("GithubCopilotPlugin", () => { + it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GithubCopilotPlugin) + const ignored = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("github-copilot", "gpt-5"), + package: "@ai-sdk/openai-compatible", + options: { name: "github-copilot" }, + }, + {}, + ) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("github-copilot", "gpt-5"), + package: "@ai-sdk/github-copilot", + options: { name: "github-copilot" }, + }, + {}, + ) + expect(ignored.sdk).toBeUndefined() + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("selects languageModel when responses and chat are absent", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(GithubCopilotPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("github-copilot", "claude-sonnet-4"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }, + {}, + ) + expect(calls).toEqual(["languageModel:claude-sonnet-4"]) + }), + ) + + it.effect("selects languageModel with the API model ID when responses and chat are absent", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(GithubCopilotPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("github-copilot", "alias", { apiID: ModelV2.ID.make("claude-sonnet-4") }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }, + {}, + ) + expect(calls).toEqual(["languageModel:claude-sonnet-4"]) + }), + ) + + it.effect("uses responses for gpt-5 models except gpt-5-mini", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(GithubCopilotPlugin) + yield* plugin.trigger( + "aisdk.language", + { model: model("github-copilot", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { model: model("github-copilot", "gpt-5.1-codex"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { model: model("github-copilot", "gpt-4o"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { model: model("github-copilot", "gpt-5-mini"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { model: model("github-copilot", "gpt-5-mini-2025-08-07"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + expect(calls).toEqual([ + "responses:gpt-5", + "responses:gpt-5.1-codex", + "chat:gpt-4o", + "chat:gpt-5-mini", + "chat:gpt-5-mini-2025-08-07", + ]) + }), + ) + + it.effect("uses the API model ID when selecting responses or chat", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(GithubCopilotPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("github-copilot", "default", { apiID: ModelV2.ID.make("gpt-5") }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { + model: model("github-copilot", "small", { apiID: ModelV2.ID.make("gpt-5-mini") }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { + model: model("github-copilot", "sonnet", { apiID: ModelV2.ID.make("claude-sonnet-4") }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + expect(calls).toEqual(["responses:gpt-5", "chat:gpt-5-mini", "chat:claude-sonnet-4"]) + }), + ) + + it.effect("filters gpt-5-chat-latest before Copilot language selection", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GithubCopilotPlugin) + const result = yield* plugin.trigger( + "model.update", + {}, + { model: model("github-copilot", "gpt-5-chat-latest"), cancel: false }, + ) + expect(result.cancel).toBe(true) + }), + ) + + it.effect("does not filter gpt-5-chat-latest for non-Copilot providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GithubCopilotPlugin) + const result = yield* plugin.trigger( + "model.update", + {}, + { model: model("custom-copilot", "gpt-5-chat-latest"), cancel: false }, + ) + expect(result.cancel).toBe(false) + }), + ) + + it.effect("ignores non-Copilot providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(GithubCopilotPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { model: model("openai", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + expect(calls).toEqual([]) + expect(result.language).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/v2/catalog.test.ts b/packages/core/test/v2/catalog.test.ts new file mode 100644 index 000000000..cba3405bc --- /dev/null +++ b/packages/core/test/v2/catalog.test.ts @@ -0,0 +1,199 @@ +import { describe, expect } from "bun:test" +import { DateTime, Effect, Layer, Option } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" + +const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer))) + +describe("CatalogV2", () => { + it.effect("normalizes provider baseURL into endpoint url", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + + yield* catalog.provider.update(providerID, (provider) => { + provider.endpoint = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://default.example.com", + } + provider.options.aisdk.provider.baseURL = "https://override.example.com" + }) + + const provider = yield* catalog.provider.get(providerID) + + expect(provider.endpoint).toEqual({ + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://override.example.com", + }) + expect(provider.options.aisdk.provider.baseURL).toBeUndefined() + }), + ) + + it.effect("normalizes model baseURL into endpoint url", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + const modelID = ModelV2.ID.make("model") + + yield* catalog.provider.update(providerID, (provider) => { + provider.endpoint = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://provider.example.com", + } + }) + yield* catalog.model.update(providerID, modelID, (model) => { + model.endpoint = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://model.example.com", + } + model.options.aisdk.provider.baseURL = "https://override.example.com" + }) + + const model = yield* catalog.model.get(providerID, modelID) + + expect(model.endpoint).toEqual({ + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://override.example.com", + }) + expect(model.options.aisdk.provider.baseURL).toBeUndefined() + }), + ) + + it.effect("resolves unknown model endpoint from provider endpoint", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + const modelID = ModelV2.ID.make("model") + + yield* catalog.provider.update(providerID, (provider) => { + provider.endpoint = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://provider.example.com", + } + }) + yield* catalog.model.update(providerID, modelID, () => {}) + + const model = yield* catalog.model.get(providerID, modelID) + + expect(model.endpoint).toEqual({ + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://provider.example.com", + }) + }), + ) + + it.effect("runs provider hooks after baseURL is normalized", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const plugin = yield* PluginV2.Service + const providerID = ProviderV2.ID.make("test") + const seen: unknown[] = [] + + yield* plugin.add({ + id: PluginV2.ID.make("test"), + effect: Effect.succeed({ + "provider.update": (evt) => + Effect.sync(() => { + seen.push(evt.provider.endpoint.type) + if (evt.provider.endpoint.type === "aisdk") seen.push(evt.provider.endpoint.url) + seen.push(evt.provider.options.aisdk.provider.baseURL) + }), + }), + }) + yield* catalog.provider.update(providerID, (provider) => { + provider.endpoint = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + } + provider.options.aisdk.provider.baseURL = "https://provider.example.com" + }) + + expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined]) + }), + ) + + it.effect("resolves provider and model option merges", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + const modelID = ModelV2.ID.make("model") + + yield* catalog.provider.update(providerID, (provider) => { + provider.options.headers.provider = "provider" + provider.options.headers.shared = "provider" + provider.options.body.provider = true + provider.options.aisdk.provider.provider = true + }) + yield* catalog.model.update(providerID, modelID, (model) => { + model.options.headers.model = "model" + model.options.headers.shared = "model" + model.options.body.model = true + model.options.aisdk.provider.model = true + model.options.aisdk.request.request = true + }) + + const model = yield* catalog.model.get(providerID, modelID) + + expect(model.options.headers).toEqual({ provider: "provider", shared: "model", model: "model" }) + expect(model.options.body).toEqual({ provider: true, model: true }) + expect(model.options.aisdk.provider).toEqual({ provider: true, model: true }) + expect(model.options.aisdk.request).toEqual({ request: true }) + }), + ) + + it.effect("falls back to newest available model when no default is configured", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + + yield* catalog.provider.update(providerID, (provider) => { + provider.enabled = { via: "custom", data: {} } + }) + yield* catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { + model.time.released = DateTime.makeUnsafe(1000) + }) + yield* catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => { + model.time.released = DateTime.makeUnsafe(2000) + }) + + const model = yield* catalog.model.default() + + expect(Option.getOrUndefined(model)?.id).toMatch("new") + }), + ) + + it.effect("small model prefers small keyword candidates before cost scoring", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + + yield* catalog.provider.update(providerID, () => {}) + yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }] + model.time.released = DateTime.makeUnsafe(Date.now()) + }) + yield* catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => { + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }] + model.time.released = DateTime.makeUnsafe(Date.now()) + }) + + const model = yield* catalog.model.small(providerID) + + expect(Option.getOrUndefined(model)?.id).toMatch("expensive-mini") + }), + ) +}) diff --git a/packages/core/test/v2/plugin/fixtures/provider-factory.ts b/packages/core/test/v2/plugin/fixtures/provider-factory.ts new file mode 100644 index 000000000..7278c231d --- /dev/null +++ b/packages/core/test/v2/plugin/fixtures/provider-factory.ts @@ -0,0 +1,9 @@ +export function createFixtureProvider(options: Record) { + const captured = Object.fromEntries(Object.entries(options)) + return Object.assign((modelID: string) => ({ modelID, options: captured }), { + options: captured, + languageModel(modelID: string) { + return { modelID, options: captured } + }, + }) +} diff --git a/packages/core/test/v2/plugin/provider-alibaba.test.ts b/packages/core/test/v2/plugin/provider-alibaba.test.ts new file mode 100644 index 000000000..06e6f969f --- /dev/null +++ b/packages/core/test/v2/plugin/provider-alibaba.test.ts @@ -0,0 +1,67 @@ +import { describe, expect } from "bun:test" +import { createAlibaba } from "@ai-sdk/alibaba" +import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba" +import { it, model } from "./provider-helper" + +describe("AlibabaPlugin", () => { + it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AlibabaPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("alibaba", "qwen"), package: "@ai-sdk/alibaba", options: { name: "alibaba" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("ignores non-Alibaba SDK packages", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AlibabaPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("alibaba", "qwen"), package: "@ai-sdk/openai-compatible", options: { name: "alibaba" } }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ) + + it.effect("matches the old bundled Alibaba SDK provider naming", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AlibabaPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-alibaba", "qwen"), + package: "@ai-sdk/alibaba", + options: { name: "custom-alibaba", apiKey: "test" }, + }, + {}, + ) + const expected = createAlibaba({ apiKey: "test", ...{ name: "custom-alibaba" } }).languageModel("qwen") + const actual = result.sdk?.languageModel("qwen") + expect(actual?.provider).toBe(expected.provider) + expect(actual?.modelId).toBe(expected.modelId) + }), + ) + + it.effect("uses the old default languageModel(apiID) behavior", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AlibabaPlugin) + const item = model("alibaba", "alias", { apiID: ModelV2.ID.make("qwen-plus") }) + const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {}) + const language = result.sdk?.languageModel(item.apiID) + expect(language?.modelId).toBe("qwen-plus") + expect(language?.provider).toBe("alibaba.chat") + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/v2/plugin/provider-amazon-bedrock.test.ts new file mode 100644 index 000000000..e7e53cb8d --- /dev/null +++ b/packages/core/test/v2/plugin/provider-amazon-bedrock.test.ts @@ -0,0 +1,464 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock" +import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" + +function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { + const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID) + return (language as { config: { baseUrl: () => string } }).config.baseUrl() +} + +function bedrockFetch(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { + const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID) + return (language as { config: { fetch: (input: Parameters[0], init?: RequestInit) => Promise } }).config + .fetch +} + +describe("AmazonBedrockPlugin", () => { + it.effect("moves endpoint option to endpoint URL", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("amazon-bedrock", { + options: { + headers: {}, + body: {}, + aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} }, + }, + }), + cancel: false, + }, + ) + expect(result.provider.endpoint).toEqual({ + type: "aisdk", + package: "test-provider", + url: "https://bedrock.example", + }) + expect(result.provider.options.aisdk.provider.endpoint).toBeUndefined() + }), + ) + + it.effect("prefers endpoint over baseURL for SDK base URL", () => + withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "token", + baseURL: "https://base.example", + endpoint: "https://endpoint.example", + region: "us-east-1", + }, + }, + {}, + ) + expect(bedrockBaseURL(result.sdk)).toBe("https://endpoint.example") + }), + ), + ) + + it.effect("uses baseURL as SDK base URL", () => + withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "token", + baseURL: "https://base.example", + region: "us-east-1", + }, + }, + {}, + ) + expect(bedrockBaseURL(result.sdk)).toBe("https://base.example") + }), + ), + ) + + it.effect("creates SDK without explicit credential env so the default AWS chain can resolve credentials", () => + withEnv( + { + AWS_ACCESS_KEY_ID: undefined, + AWS_BEARER_TOKEN_BEDROCK: undefined, + AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined, + AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined, + AWS_PROFILE: undefined, + AWS_REGION: undefined, + AWS_WEB_IDENTITY_TOKEN_FILE: undefined, + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }, + {}, + ) + expect(result.sdk).toBeDefined() + expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com") + }), + ), + ) + + it.effect("uses config region over AWS_REGION for SDK base URL", () => + withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock", region: "eu-west-1" }, + }, + {}, + ) + expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com") + }), + ), + ) + + it.effect("uses AWS_REGION for SDK base URL when config region is absent", () => + withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }, + {}, + ) + expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com") + }), + ), + ) + + it.effect("defaults SDK region to us-east-1", () => + withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }, + {}, + ) + expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com") + }), + ), + ) + + it.effect("loads bearer token option into env and uses bearer auth", () => + withEnv({ AWS_ACCESS_KEY_ID: undefined, AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const headers: Array = [] + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "option-token", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") + }, + }, + }, + {}, + ) + yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" })) + expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("option-token") + expect(headers).toEqual(["Bearer option-token"]) + }), + ), + ) + + it.effect("prefers bearer token env over bearer token option", () => + withEnv({ AWS_BEARER_TOKEN_BEDROCK: "env-token" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const headers: Array = [] + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "option-token", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") + }, + }, + }, + {}, + ) + yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" })) + expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("env-token") + expect(headers).toEqual(["Bearer env-token"]) + }), + ), + ) + + it.effect("uses SigV4 credential env when bearer token is absent", () => + withEnv( + { + AWS_ACCESS_KEY_ID: "test-access-key", + AWS_BEARER_TOKEN_BEDROCK: undefined, + AWS_REGION: "us-east-1", + AWS_SECRET_ACCESS_KEY: "test-secret-key", + AWS_SESSION_TOKEN: "test-session-token", + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const headers: Array = [] + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") + }, + }, + }, + {}, + ) + yield* Effect.promise(() => + bedrockFetch(result.sdk)("https://bedrock-runtime.us-east-1.amazonaws.com/model/test/invoke", { + body: "{}", + method: "POST", + }), + ) + expect(headers[0]?.startsWith("AWS4-HMAC-SHA256 ")).toBe(true) + }), + ), + ) + + it.effect("applies legacy cross-region inference prefixes", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AmazonBedrockPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { + model: model("amazon-bedrock", "global.anthropic.claude-sonnet-4-5"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "ap-northeast-1" }, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "ap-southeast-2" }, + }, + {}, + ) + expect(calls).toEqual([ + "languageModel:us.anthropic.claude-sonnet-4-5", + "languageModel:eu.anthropic.claude-sonnet-4-5", + "languageModel:global.anthropic.claude-sonnet-4-5", + "languageModel:jp.anthropic.claude-sonnet-4-5", + "languageModel:au.anthropic.claude-sonnet-4-5", + ]) + }), + ) + + it.effect("uses AWS_REGION for language prefixes when region option is absent", () => + withEnv({ AWS_REGION: "eu-west-1" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AmazonBedrockPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }, + {}, + ) + expect(calls).toEqual(["languageModel:eu.anthropic.claude-sonnet-4-5"]) + }), + ), + ) + + it.effect("applies the full legacy cross-region prefix matrix", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + const cases = [ + { region: "us-east-1", modelID: "amazon.nova-micro-v1:0", expected: "us.amazon.nova-micro-v1:0" }, + { region: "us-east-1", modelID: "amazon.nova-lite-v1:0", expected: "us.amazon.nova-lite-v1:0" }, + { region: "us-east-1", modelID: "amazon.nova-pro-v1:0", expected: "us.amazon.nova-pro-v1:0" }, + { region: "us-east-1", modelID: "amazon.nova-premier-v1:0", expected: "us.amazon.nova-premier-v1:0" }, + { region: "us-east-1", modelID: "amazon.nova-2-lite-v1:0", expected: "us.amazon.nova-2-lite-v1:0" }, + { region: "us-east-1", modelID: "anthropic.claude-sonnet-4-5", expected: "us.anthropic.claude-sonnet-4-5" }, + { region: "us-east-1", modelID: "deepseek.r1-v1:0", expected: "us.deepseek.r1-v1:0" }, + { region: "us-gov-west-1", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" }, + { region: "us-east-1", modelID: "cohere.command-r-plus-v1:0", expected: "cohere.command-r-plus-v1:0" }, + { region: "eu-west-1", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" }, + { region: "eu-west-2", modelID: "amazon.nova-lite-v1:0", expected: "eu.amazon.nova-lite-v1:0" }, + { region: "eu-west-3", modelID: "amazon.nova-micro-v1:0", expected: "eu.amazon.nova-micro-v1:0" }, + { + region: "eu-north-1", + modelID: "meta.llama3-70b-instruct-v1:0", + expected: "eu.meta.llama3-70b-instruct-v1:0", + }, + { region: "eu-central-1", modelID: "mistral.pixtral-large-v1:0", expected: "eu.mistral.pixtral-large-v1:0" }, + { region: "eu-south-1", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" }, + { region: "eu-south-2", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" }, + { region: "eu-central-2", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" }, + { region: "eu-west-1", modelID: "cohere.command-r-plus-v1:0", expected: "cohere.command-r-plus-v1:0" }, + { + region: "ap-southeast-2", + modelID: "anthropic.claude-sonnet-4-5", + expected: "au.anthropic.claude-sonnet-4-5", + }, + { + region: "ap-southeast-4", + modelID: "anthropic.claude-haiku-v1:0", + expected: "au.anthropic.claude-haiku-v1:0", + }, + { region: "ap-southeast-2", modelID: "anthropic.claude-opus-4", expected: "apac.anthropic.claude-opus-4" }, + { + region: "ap-northeast-1", + modelID: "anthropic.claude-sonnet-4-5", + expected: "jp.anthropic.claude-sonnet-4-5", + }, + { region: "ap-northeast-1", modelID: "amazon.nova-pro-v1:0", expected: "jp.amazon.nova-pro-v1:0" }, + { region: "ap-south-1", modelID: "anthropic.claude-sonnet-4-5", expected: "apac.anthropic.claude-sonnet-4-5" }, + { region: "ap-south-1", modelID: "amazon.nova-lite-v1:0", expected: "apac.amazon.nova-lite-v1:0" }, + { region: "ca-central-1", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" }, + { + region: "us-east-1", + modelID: "global.anthropic.claude-sonnet-4-5", + expected: "global.anthropic.claude-sonnet-4-5", + }, + { region: "us-east-1", modelID: "us.anthropic.claude-sonnet-4-5", expected: "us.anthropic.claude-sonnet-4-5" }, + { region: "eu-west-1", modelID: "eu.anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" }, + { + region: "ap-northeast-1", + modelID: "jp.anthropic.claude-sonnet-4-5", + expected: "jp.anthropic.claude-sonnet-4-5", + }, + { + region: "ap-south-1", + modelID: "apac.anthropic.claude-sonnet-4-5", + expected: "apac.anthropic.claude-sonnet-4-5", + }, + { + region: "ap-southeast-2", + modelID: "au.anthropic.claude-sonnet-4-5", + expected: "au.anthropic.claude-sonnet-4-5", + }, + ] + yield* plugin.add(AmazonBedrockPlugin) + for (const item of cases) { + yield* plugin.trigger( + "aisdk.language", + { + model: model("amazon-bedrock", item.modelID), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: item.region }, + }, + {}, + ) + } + expect(calls).toEqual(cases.map((item) => `languageModel:${item.expected}`)) + }), + ) + + it.effect("ignores non-Bedrock providers for language selection", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: model("openai", "anthropic.claude-sonnet-4-5"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }, + {}, + ) + expect(calls).toEqual([]) + expect(result.language).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-anthropic.test.ts b/packages/core/test/v2/plugin/provider-anthropic.test.ts new file mode 100644 index 000000000..bbea4a372 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-anthropic.test.ts @@ -0,0 +1,91 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic" +import { it, model, provider } from "./provider-helper" + +describe("AnthropicPlugin", () => { + it.effect("applies legacy beta headers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AnthropicPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("anthropic", { + options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + expect(result.provider.options.headers["anthropic-beta"]).toBe( + "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", + ) + expect(result.provider.options.headers.Existing).toBe("1") + }), + ) + + it.effect("ignores non-Anthropic providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AnthropicPlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) + expect(result.provider.options.headers["anthropic-beta"]).toBeUndefined() + }), + ) + + it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const providers: string[] = [] + yield* plugin.add(AnthropicPlugin) + yield* plugin.add({ + id: PluginV2.ID.make("anthropic-sdk-inspector"), + effect: Effect.succeed({ + "aisdk.sdk": (evt) => + Effect.sync(() => { + providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider) + }), + }), + }) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-anthropic", "claude-sonnet-4-5"), + package: "@ai-sdk/anthropic", + options: { name: "custom-anthropic", apiKey: "test" }, + }, + {}, + ) + expect(providers).toEqual(["custom-anthropic"]) + }), + ) + + it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const providers: string[] = [] + yield* plugin.add(AnthropicPlugin) + yield* plugin.add({ + id: PluginV2.ID.make("anthropic-sdk-inspector"), + effect: Effect.succeed({ + "aisdk.sdk": (evt) => + Effect.sync(() => { + providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider) + }), + }), + }) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("anthropic", "claude-sonnet-4-5"), + package: "@ai-sdk/anthropic", + options: { name: "anthropic", apiKey: "test" }, + }, + {}, + ) + expect(providers).toEqual(["anthropic"]) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/v2/plugin/provider-azure-cognitive-services.test.ts new file mode 100644 index 000000000..b835cbeef --- /dev/null +++ b/packages/core/test/v2/plugin/provider-azure-cognitive-services.test.ts @@ -0,0 +1,127 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure" +import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" + +describe("AzureCognitiveServicesPlugin", () => { + it.effect("maps the resource env var to the Azure SDK baseURL", () => + withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AzureCognitiveServicesPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { provider: provider("azure-cognitive-services"), cancel: false }, + ) + expect(result.provider.endpoint).toEqual({ + type: "aisdk", + package: "test-provider", + }) + expect(result.provider.options.aisdk.provider.baseURL).toBe( + "https://cognitive.cognitiveservices.azure.com/openai", + ) + expect(result.provider.options.aisdk.provider.resourceName).toBeUndefined() + }), + ), + ) + + it.effect("leaves baseURL unset without resource env and ignores other providers", () => + withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AzureCognitiveServicesPlugin) + const azure = yield* plugin.trigger( + "provider.update", + {}, + { provider: provider("azure-cognitive-services"), cancel: false }, + ) + const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) + expect(azure.provider.options.aisdk.provider.baseURL).toBeUndefined() + expect(azure.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" }) + expect(other.provider.options.aisdk.provider.baseURL).toBeUndefined() + expect(other.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" }) + }), + ), + ) + + it.effect("selects chat only for completion URLs", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AzureCognitiveServicesPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("azure-cognitive-services", "deployment"), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }, + {}, + ) + expect(calls).toEqual(["chat:deployment"]) + }), + ) + + it.effect("uses the legacy Azure selector order and provider guard", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AzureCognitiveServicesPlugin) + yield* plugin.trigger( + "aisdk.language", + { model: model("azure-cognitive-services", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + const ignored = yield* plugin.trigger( + "aisdk.language", + { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + expect(calls).toEqual(["responses:deployment"]) + expect(ignored.language).toBeUndefined() + }), + ) + + it.effect("falls back from responses to messages, chat, then languageModel", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + const sdk = fakeSelectorSdk(calls) + yield* plugin.add(AzureCognitiveServicesPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("azure-cognitive-services", "messages-deployment"), + sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel }, + options: {}, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { + model: model("azure-cognitive-services", "chat-deployment"), + sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, + options: {}, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { + model: model("azure-cognitive-services", "language-deployment"), + sdk: { languageModel: sdk.languageModel }, + options: {}, + }, + {}, + ) + expect(calls).toEqual([ + "messages:messages-deployment", + "chat:chat-deployment", + "languageModel:language-deployment", + ]) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-azure.test.ts b/packages/core/test/v2/plugin/provider-azure.test.ts new file mode 100644 index 000000000..12d8363e7 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-azure.test.ts @@ -0,0 +1,245 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AuthV2 } from "@opencode-ai/core/auth" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AuthPlugin } from "@opencode-ai/core/plugin/auth" +import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" +import { testEffect } from "../../lib/effect" +import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" + +const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer)) + +describe("AzurePlugin", () => { + it.effect("resolves resourceName from env", () => + withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AzurePlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false }) + expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env") + }), + ), + ) + + it.effect("keeps explicit resourceName over env and ignores other providers", () => + withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AzurePlugin) + const azure = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("azure", { + options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } }, + }), + cancel: false, + }, + ) + const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) + expect(azure.provider.options.aisdk.provider.resourceName).toBe("from-config") + expect(other.provider.options.aisdk.provider.resourceName).toBeUndefined() + }), + ), + ) + + itWithAuth.effect("prefers auth resourceName over env", () => + withEnv( + { + AZURE_RESOURCE_NAME: "from-env", + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const auth = yield* AuthV2.Service + yield* auth.create({ + serviceID: AuthV2.ServiceID.make("azure"), + credential: new AuthV2.ApiKeyCredential({ + type: "api", + key: "key", + metadata: { resourceName: "from-auth" }, + }), + active: true, + }) + yield* plugin.add({ + ...AuthPlugin, + effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), + }) + yield* plugin.add(AzurePlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false }) + expect(result.provider.options.aisdk.provider.resourceName).toBe("from-auth") + }), + ), + ) + + it.effect("falls back to env when configured resourceName is blank", () => + withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AzurePlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("azure", { + options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } }, + }), + cancel: false, + }, + ) + expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env") + }), + ), + ) + + it.effect("falls back to env when configured resourceName is whitespace", () => + withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AzurePlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("azure", { + options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } }, + }), + cancel: false, + }, + ) + expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env") + }), + ), + ) + + it.effect("allows configured baseURL without resourceName", () => + withEnv({ AZURE_RESOURCE_NAME: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AzurePlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("azure", "deployment"), + package: "@ai-sdk/azure", + options: { name: "azure", baseURL: "https://proxy.example.com/openai" }, + }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ), + ) + + it.effect("rejects missing resourceName when baseURL is not configured", () => + withEnv({ AZURE_RESOURCE_NAME: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AzurePlugin) + const exit = yield* plugin + .trigger( + "aisdk.sdk", + { model: model("azure", "deployment"), package: "@ai-sdk/azure", options: { name: "azure" } }, + {}, + ) + .pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + }), + ), + ) + + it.effect("selects chat only for completion URLs", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AzurePlugin) + yield* plugin.trigger( + "aisdk.language", + { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, + {}, + ) + expect(calls).toEqual(["chat:deployment"]) + }), + ) + + it.effect("selects chat from per-call useCompletionUrls", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AzurePlugin) + yield* plugin.trigger( + "aisdk.language", + { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, + {}, + ) + expect(calls).toEqual(["chat:deployment"]) + }), + ) + + it.effect("ignores model useCompletionUrls when per-call option is unset", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AzurePlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("azure", "deployment", { + options: { headers: {}, body: {}, aisdk: { provider: {}, request: { useCompletionUrls: true } } }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + expect(calls).toEqual(["responses:deployment"]) + }), + ) + + it.effect("uses the legacy Azure selector order and provider guard", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AzurePlugin) + yield* plugin.trigger( + "aisdk.language", + { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + const ignored = yield* plugin.trigger( + "aisdk.language", + { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + expect(calls).toEqual(["responses:deployment"]) + expect(ignored.language).toBeUndefined() + }), + ) + + it.effect("falls back through the legacy Azure selector order", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } + } + yield* plugin.add(AzurePlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("azure", "messages-deployment"), + sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") }, + options: {}, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { model: model("azure", "language-deployment"), sdk: { languageModel: make("languageModel") }, options: {} }, + {}, + ) + expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"]) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-cerebras.test.ts b/packages/core/test/v2/plugin/provider-cerebras.test.ts new file mode 100644 index 000000000..7270d5367 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-cerebras.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, mock } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras" +import { it, model, provider } from "./provider-helper" + +const cerebrasOptions: Record[] = [] + +void mock.module("@ai-sdk/cerebras", () => ({ + createCerebras: (options: Record) => { + const snapshot = { ...options } + cerebrasOptions.push(snapshot) + return { + languageModel: (modelID: string) => ({ modelID, provider: snapshot.name, specificationVersion: "v3" }), + } + }, +})) + +describe("CerebrasPlugin", () => { + it.effect("applies the legacy integration header", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CerebrasPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("cerebras", { + options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + expect(result.provider.options.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode" }) + }), + ) + + it.effect("ignores non-Cerebras providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CerebrasPlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("groq"), cancel: false }) + expect(result.provider.options.headers).toEqual({}) + }), + ) + + it.effect("creates a bundled Cerebras SDK with the model provider ID as the SDK name", () => + Effect.gen(function* () { + cerebrasOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(CerebrasPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), + package: "@ai-sdk/cerebras", + options: { name: "custom-cerebras", apiKey: "test" }, + }, + {}, + ) + expect(cerebrasOptions).toEqual([{ name: "custom-cerebras", apiKey: "test" }]) + expect(result.sdk.languageModel("llama-4-scout-17b-16e-instruct").provider).toBe("custom-cerebras") + }), + ) + + it.effect("preserves an explicit bundled Cerebras SDK name option", () => + Effect.gen(function* () { + cerebrasOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(CerebrasPlugin) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), + package: "@ai-sdk/cerebras", + options: { name: "configured-cerebras", apiKey: "test" }, + }, + {}, + ) + expect(cerebrasOptions).toEqual([{ name: "configured-cerebras", apiKey: "test" }]) + }), + ) + + it.effect("ignores non-Cerebras SDK packages", () => + Effect.gen(function* () { + cerebrasOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(CerebrasPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), + package: "@ai-sdk/groq", + options: { name: "custom-cerebras", apiKey: "test" }, + }, + {}, + ) + expect(cerebrasOptions).toEqual([]) + expect(result.sdk).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/v2/plugin/provider-cloudflare-ai-gateway.test.ts new file mode 100644 index 000000000..72ad5da33 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-cloudflare-ai-gateway.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, mock } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway" +import { it, model, withEnv } from "./provider-helper" + +const aiGatewayCalls: Record[] = [] +const unifiedCalls: string[] = [] +const gatewayModelCalls: unknown[] = [] + +function captureAiGatewayOptions(options: Record) { + const nested = + options.options && typeof options.options === "object" ? (options.options as Record) : undefined + return { + ...options, + ...(nested + ? { + options: { + ...nested, + headers: + nested.headers && typeof nested.headers === "object" + ? { ...(nested.headers as Record) } + : nested.headers, + }, + } + : {}), + } +} + +function resetCalls() { + aiGatewayCalls.length = 0 + unifiedCalls.length = 0 + gatewayModelCalls.length = 0 +} + +function cloudflareEnv(overrides: Record = {}) { + return { + CLOUDFLARE_ACCOUNT_ID: "env-account", + CLOUDFLARE_GATEWAY_ID: "env-gateway", + CLOUDFLARE_API_TOKEN: "env-token", + CF_AIG_TOKEN: undefined, + ...overrides, + } +} + +mock.module("ai-gateway-provider", () => ({ + createAiGateway(options: Record) { + aiGatewayCalls.push(captureAiGatewayOptions(options)) + return (input: unknown) => { + gatewayModelCalls.push(input) + return { + modelId: input, + provider: "cloudflare-ai-gateway", + specificationVersion: "v3", + } + } + }, +})) + +mock.module("ai-gateway-provider/providers/unified", () => ({ + createUnified() { + return (modelID: string) => { + unifiedCalls.push(modelID) + return { unifiedModelID: modelID } + } + }, +})) + +describe("CloudflareAIGatewayPlugin", () => { + it.effect("requires account, gateway, and token before creating the unified SDK", () => + withEnv( + { + CLOUDFLARE_ACCOUNT_ID: "acct", + CLOUDFLARE_GATEWAY_ID: "gateway", + CLOUDFLARE_API_TOKEN: "token", + CF_AIG_TOKEN: undefined, + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }, + {}, + ) + expect(result.sdk.languageModel("openai/gpt-5")).toBeDefined() + }), + ), + ) + + it.effect("passes legacy metadata, cache, log, and User-Agent values under the AI Gateway options key", () => + withEnv(cloudflareEnv(), () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + metadata: { invoked_by: "test", project: "opencode" }, + cacheTtl: 300, + cacheKey: "cache-key", + skipCache: true, + collectLog: false, + }, + }, + {}, + ) + + expect(aiGatewayCalls).toHaveLength(1) + expect(aiGatewayCalls[0]).toEqual({ + accountId: "env-account", + gateway: "env-gateway", + apiKey: "env-token", + options: { + metadata: { invoked_by: "test", project: "opencode" }, + cacheTtl: 300, + cacheKey: "cache-key", + skipCache: true, + collectLog: false, + headers: { + "User-Agent": expect.stringContaining("opencode/"), + }, + }, + }) + }), + ), + ) + + it.effect("parses legacy cf-aig-metadata header when metadata option is absent", () => + withEnv(cloudflareEnv(), () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + headers: { + "cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }), + }, + }, + }, + {}, + ) + + expect(aiGatewayCalls[0]?.options).toMatchObject({ + metadata: { invoked_by: "header", project: "opencode" }, + }) + }), + ), + ) + + it.effect("prefers Cloudflare env values over auth/config-derived options", () => + withEnv(cloudflareEnv(), () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + accountId: "auth-account", + gateway: "auth-gateway", + apiKey: "auth-token", + }, + }, + {}, + ) + + expect(aiGatewayCalls[0]).toMatchObject({ + accountId: "env-account", + gateway: "env-gateway", + apiKey: "env-token", + }) + }), + ), + ) + + it.effect("accepts gatewayId metadata copied from auth into provider options", () => + withEnv( + cloudflareEnv({ + CLOUDFLARE_ACCOUNT_ID: undefined, + CLOUDFLARE_GATEWAY_ID: undefined, + CLOUDFLARE_API_TOKEN: undefined, + }), + () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + accountId: "auth-account", + gatewayId: "auth-gateway", + apiKey: "auth-token", + }, + }, + {}, + ) + + expect(aiGatewayCalls[0]).toMatchObject({ + accountId: "auth-account", + gateway: "auth-gateway", + apiKey: "auth-token", + }) + }), + ), + ) + + it.effect("falls back to CF_AIG_TOKEN when CLOUDFLARE_API_TOKEN is unset", () => + withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: "cf-aig-token" }), () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }, + {}, + ) + + expect(aiGatewayCalls[0]).toMatchObject({ apiKey: "cf-aig-token" }) + }), + ), + ) + + it.effect("does not create an SDK when account and gateway IDs are missing", () => + withEnv(cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }), () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }, + {}, + ) + + expect(result.sdk).toBeUndefined() + expect(aiGatewayCalls).toHaveLength(0) + }), + ), + ) + + it.effect("does not create an SDK when the token is missing", () => + withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: undefined }), () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }, + {}, + ) + + expect(result.sdk).toBeUndefined() + expect(aiGatewayCalls).toHaveLength(0) + }), + ), + ) + + it.effect("does not replace a configured baseURL with the Cloudflare AI Gateway SDK", () => + withEnv( + cloudflareEnv({ + CLOUDFLARE_ACCOUNT_ID: undefined, + CLOUDFLARE_GATEWAY_ID: undefined, + CLOUDFLARE_API_TOKEN: undefined, + }), + () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, + }, + {}, + ) + + expect(result.sdk).toBeUndefined() + expect(aiGatewayCalls).toHaveLength(0) + }), + ), + ) + + it.effect("maps provider/model IDs through the unified Cloudflare provider unchanged", () => + withEnv(cloudflareEnv(), () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "anthropic/claude-sonnet-4-5"), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }, + {}, + ) + + expect(result.sdk.languageModel("anthropic/claude-sonnet-4-5")).toEqual({ + modelId: { unifiedModelID: "anthropic/claude-sonnet-4-5" }, + provider: "cloudflare-ai-gateway", + specificationVersion: "v3", + }) + expect(unifiedCalls).toEqual(["anthropic/claude-sonnet-4-5"]) + expect(gatewayModelCalls).toEqual([{ unifiedModelID: "anthropic/claude-sonnet-4-5" }]) + }), + ), + ) + + it.effect("ignores non Cloudflare AI Gateway packages", () => + withEnv(cloudflareEnv(), () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-ai-gateway" }, + }, + {}, + ) + + expect(result.sdk).toBeUndefined() + expect(aiGatewayCalls).toHaveLength(0) + }), + ), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/v2/plugin/provider-cloudflare-workers-ai.test.ts new file mode 100644 index 000000000..3aed2a17b --- /dev/null +++ b/packages/core/test/v2/plugin/provider-cloudflare-workers-ai.test.ts @@ -0,0 +1,267 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AuthV2 } from "@opencode-ai/core/auth" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AuthPlugin } from "@opencode-ai/core/plugin/auth" +import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" +import { testEffect } from "../../lib/effect" +import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" + +const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer)) + +function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") { + return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( + modelID, + ) +} + +type CloudflareConfig = { + url: (input: { path: string; modelId: string }) => string + headers: () => Record | Promise> +} + +function cloudflareURL(sdk: unknown, modelID = "@cf/model") { + return cloudflareLanguage(sdk, modelID).config.url({ path: "/chat/completions", modelId: modelID }) +} + +function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") { + return cloudflareLanguage(sdk, modelID).config.headers() +} + +describe("CloudflareWorkersAIPlugin", () => { + it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareWorkersAIPlugin) + const updated = yield* plugin.trigger( + "provider.update", + {}, + { provider: provider("cloudflare-workers-ai"), cancel: false }, + ) + const sdk = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-workers-ai", "@cf/model", { endpoint: updated.provider.endpoint }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, + }, + {}, + ) + expect(updated.provider.endpoint).toEqual({ + type: "aisdk", + package: "test-provider", + url: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1", + }) + expect(sdk.sdk).toBeDefined() + }), + ), + ) + + it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareWorkersAIPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("cloudflare-workers-ai", { + endpoint: { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }, + }), + cancel: false, + }, + ) + expect(result.provider.endpoint).toEqual({ + type: "aisdk", + package: "test-provider", + url: "https://proxy.example/v1", + }) + }), + ), + ) + + it.effect("allows a configured baseURL without account ID", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareWorkersAIPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-workers-ai", "@cf/model", { + endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, + }, + {}, + ) + expect(cloudflareURL(result.sdk)).toBe("https://proxy.example/v1/chat/completions") + }), + ), + ) + + itWithAuth.effect("falls back to auth account metadata when account env is absent", () => + withEnv( + { + CLOUDFLARE_ACCOUNT_ID: undefined, + CLOUDFLARE_API_KEY: undefined, + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const auth = yield* AuthV2.Service + yield* auth.create({ + serviceID: AuthV2.ServiceID.make("cloudflare-workers-ai"), + credential: new AuthV2.ApiKeyCredential({ + type: "api", + key: "auth-key", + metadata: { accountId: "auth-acct" }, + }), + active: true, + }) + yield* plugin.add({ + ...AuthPlugin, + effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), + }) + yield* plugin.add(CloudflareWorkersAIPlugin) + const updated = yield* plugin.trigger( + "provider.update", + {}, + { provider: provider("cloudflare-workers-ai"), cancel: false }, + ) + expect(updated.provider.endpoint).toEqual({ + type: "aisdk", + package: "test-provider", + url: "https://api.cloudflare.com/client/v4/accounts/auth-acct/ai/v1", + }) + }), + ), + ) + + it.effect("uses env account ID over configured account ID", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareWorkersAIPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("cloudflare-workers-ai", { + options: { headers: {}, body: {}, aisdk: { provider: { accountId: "configured-acct" }, request: {} } }, + }), + cancel: false, + }, + ) + expect(result.provider.endpoint).toEqual({ + type: "aisdk", + package: "test-provider", + url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1", + }) + }), + ), + ) + + it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareWorkersAIPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-workers-ai", "@cf/model", { + endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "cloudflare-workers-ai", + apiKey: "auth-key", + baseURL: "https://proxy.example/v1", + headers: { custom: "header" }, + }, + }, + {}, + ) + const headers = yield* Effect.promise(() => Promise.resolve(cloudflareHeaders(result.sdk))) + expect(headers.authorization).toBe("Bearer env-key") + expect(headers.custom).toBe("header") + expect(headers["user-agent"]).toMatch(/^opencode\/.* cloudflare-workers-ai \(.+\) ai-sdk\/openai-compatible\//) + }), + ), + ) + + it.effect("expands account ID vars in endpoint URLs", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareWorkersAIPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-workers-ai", "@cf/model", { + endpoint: { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", + }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "cloudflare-workers-ai", + baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", + }, + }, + {}, + ) + expect(cloudflareURL(result.sdk)).toBe( + "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/chat/completions", + ) + }), + ), + ) + + it.effect("selects languageModel with the API model ID", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(CloudflareWorkersAIPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: model("cloudflare-workers-ai", "alias", { apiID: ModelV2.ID.make("@cf/api-model") }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + expect(result.language).toBeDefined() + expect(calls).toEqual(["languageModel:@cf/api-model"]) + }), + ) + + it.effect("does not create an SDK for non OpenAI-compatible packages", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareWorkersAIPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-workers-ai", "@cf/model", { + endpoint: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://proxy.example/v1" }, + }), + package: "@ai-sdk/anthropic", + options: { name: "cloudflare-workers-ai" }, + }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-cohere.test.ts b/packages/core/test/v2/plugin/provider-cohere.test.ts new file mode 100644 index 000000000..54bec2cec --- /dev/null +++ b/packages/core/test/v2/plugin/provider-cohere.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, mock } from "bun:test" +import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere" +import { fakeSelectorSdk, it, model } from "./provider-helper" + +const cohereOptions: Record[] = [] + +void mock.module("@ai-sdk/cohere", () => ({ + createCohere: (options: Record) => { + cohereOptions.push({ ...options }) + return { + languageModel: (modelID: string) => ({ + modelID, + provider: `${options.name ?? "cohere"}.chat`, + specificationVersion: "v3", + }), + } + }, +})) + +describe("CoherePlugin", () => { + it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CoherePlugin) + + const ignored = yield* plugin.trigger( + "aisdk.sdk", + { model: model("cohere", "command"), package: "@ai-sdk/openai-compatible", options: { name: "cohere" } }, + {}, + ) + expect(ignored.sdk).toBeUndefined() + + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("cohere", "command"), package: "@ai-sdk/cohere", options: { name: "cohere" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("uses the model provider ID as the bundled SDK name", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CoherePlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-cohere", "command-r-plus"), + package: "@ai-sdk/cohere", + options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, + }, + {}, + ) + + expect(cohereOptions.at(-1)).toEqual({ + name: "custom-cohere", + apiKey: "test", + baseURL: "https://cohere.example", + }) + expect(result.sdk?.languageModel("command-r-plus").provider).toBe("custom-cohere.chat") + }), + ) + + it.effect("leaves language selection to the default languageModel fallback", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + const sdk = fakeSelectorSdk(calls) + yield* plugin.add(CoherePlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { model: model("cohere", "alias", { apiID: ModelV2.ID.make("command-r-plus") }), sdk, options: {} }, + {}, + ) + + expect(result.language).toBeUndefined() + expect(calls).toEqual([]) + expect(result.language ?? sdk.languageModel("command-r-plus")).toBeDefined() + expect(calls).toEqual(["languageModel:command-r-plus"]) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-deepinfra.test.ts b/packages/core/test/v2/plugin/provider-deepinfra.test.ts new file mode 100644 index 000000000..1195b8c18 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-deepinfra.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, mock } from "bun:test" +import { Effect, Layer } from "effect" +import { AISDK } from "@opencode-ai/core/aisdk" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra" +import { testEffect } from "../../lib/effect" +import { it, model } from "./provider-helper" + +const itAISDK = testEffect(Layer.provideMerge(AISDK.layer, PluginV2.defaultLayer)) +const deepinfraOptions: Record[] = [] +const deepinfraLanguageModels: string[] = [] + +void mock.module("@ai-sdk/deepinfra", () => ({ + createDeepInfra: (options: Record) => { + const captured = { ...options } + deepinfraOptions.push(captured) + return { + languageModel: (modelID: string) => { + deepinfraLanguageModels.push(modelID) + return { modelID, provider: `${captured.name ?? "deepinfra"}.chat`, specificationVersion: "v3" } + }, + } + }, +})) + +function resetDeepInfraMock() { + deepinfraOptions.length = 0 + deepinfraLanguageModels.length = 0 +} + +describe("DeepInfraPlugin", () => { + it.effect("creates a DeepInfra SDK for @ai-sdk/deepinfra", () => + Effect.gen(function* () { + resetDeepInfraMock() + const plugin = yield* PluginV2.Service + yield* plugin.add(DeepInfraPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("passes the model provider ID as the bundled DeepInfra SDK name", () => + Effect.gen(function* () { + resetDeepInfraMock() + const plugin = yield* PluginV2.Service + yield* plugin.add(DeepInfraPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-deepinfra", "model"), + package: "@ai-sdk/deepinfra", + options: { name: "custom-deepinfra", apiKey: "test" }, + }, + {}, + ) + expect(result.sdk.languageModel("model").provider).toBe("custom-deepinfra.chat") + expect(deepinfraOptions).toEqual([{ name: "custom-deepinfra", apiKey: "test" }]) + }), + ) + + it.effect("uses the canonical provider ID as the bundled DeepInfra SDK name", () => + Effect.gen(function* () { + resetDeepInfraMock() + const plugin = yield* PluginV2.Service + yield* plugin.add(DeepInfraPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("deepinfra", "model"), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra", apiKey: "test" }, + }, + {}, + ) + expect(result.sdk.languageModel("model").provider).toBe("deepinfra.chat") + expect(deepinfraOptions).toEqual([{ name: "deepinfra", apiKey: "test" }]) + }), + ) + + it.effect("matches only the exact bundled DeepInfra package", () => + Effect.gen(function* () { + resetDeepInfraMock() + const plugin = yield* PluginV2.Service + yield* plugin.add(DeepInfraPlugin) + const packages = [ + "unmatched-package", + "@ai-sdk/deepinfra-compatible", + "file:///tmp/@ai-sdk/deepinfra-provider.js", + ] + yield* Effect.forEach(packages, (item) => + Effect.gen(function* () { + const ignored = yield* plugin.trigger( + "aisdk.sdk", + { model: model("deepinfra", "model"), package: item, options: { name: "deepinfra" } }, + {}, + ) + expect(ignored.sdk).toBeUndefined() + }), + ) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } }, + {}, + ) + expect(result.sdk).toBeDefined() + expect(deepinfraOptions).toEqual([{ name: "deepinfra" }]) + }), + ) + + itAISDK.effect("uses the default languageModel selection for DeepInfra models", () => + Effect.gen(function* () { + resetDeepInfraMock() + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + yield* plugin.add(DeepInfraPlugin) + const language = yield* aisdk.language( + model("deepinfra", "meta-llama/Llama-3.3-70B-Instruct", { + endpoint: { type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + ) + expect(language.provider).toBe("deepinfra.chat") + expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"]) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-dynamic.test.ts b/packages/core/test/v2/plugin/provider-dynamic.test.ts new file mode 100644 index 000000000..cca331b11 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-dynamic.test.ts @@ -0,0 +1,172 @@ +import { Npm } from "@opencode-ai/core/npm" +import { describe, expect } from "bun:test" +import { Cause, Effect, Layer, Option } from "effect" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { fileURLToPath } from "url" +import { AISDK } from "@opencode-ai/core/aisdk" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic" +import { testEffect } from "../../lib/effect" +import { fixtureProvider, it, model, npmLayer } from "./provider-helper" + +const fixtureProviderPath = fileURLToPath(fixtureProvider) +const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer))) + +function npmEntrypointLayer(entrypoint: Option.Option) { + return Layer.succeed( + Npm.Service, + Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint }), + install: () => Effect.void, + which: () => Effect.succeed(Option.none()), + }), + ) +} + +function dynamicPlugin(layer = npmLayer) { + return { id: DynamicProviderPlugin.id, effect: DynamicProviderPlugin.effect.pipe(Effect.provide(layer)) } +} + +function tempEntrypoint(source: string) { + return Effect.acquireRelease( + Effect.promise(async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-provider-dynamic-")) + const entrypoint = path.join(directory, "provider.mjs") + await Bun.write(entrypoint, source) + return { directory, entrypoint } + }), + (tmp) => Effect.promise(() => fs.rm(tmp.directory, { recursive: true, force: true })), + ) +} + +describe("DynamicProviderPlugin", () => { + it.effect("creates an SDK from a provider factory export", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(dynamicPlugin()) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom", "test-model"), + package: fixtureProvider, + options: { name: "custom", marker: "dynamic" }, + }, + {}, + ) + expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom" }) + expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "dynamic", name: "custom" } }) + }), + ) + + it.effect("does not override an SDK already supplied by an earlier plugin", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const sdk = { marker: "existing" } + yield* plugin.add(dynamicPlugin()) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom", "test-model"), + package: fixtureProvider, + options: { name: "custom", marker: "dynamic" }, + }, + { sdk }, + ) + expect(result.sdk).toBe(sdk) + }), + ) + + it.effect("injects the provider ID as the SDK factory name", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(dynamicPlugin()) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-provider", "test-model"), + package: fixtureProvider, + options: { name: "custom-provider", marker: "dynamic" }, + }, + {}, + ) + expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom-provider" }) + }), + ) + + it.effect("loads npm packages through their resolved import entrypoint", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.some(fixtureProviderPath)))) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("npm-provider", "test-model"), + package: "fixture-provider", + options: { name: "npm-provider", marker: "npm" }, + }, + {}, + ) + expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "npm", name: "npm-provider" } }) + }), + ) + + itWithAISDK.effect("wraps missing npm entrypoint failures as AISDK init errors", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.none()))) + const exit = yield* aisdk + .language(model("missing-entrypoint", "alias", { endpoint: { type: "aisdk", package: "fixture-provider" } })) + .pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") + }), + ) + + itWithAISDK.effect("wraps dynamic import failures as AISDK init errors", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + yield* plugin.add(dynamicPlugin()) + const exit = yield* aisdk + .language( + model("bad-import", "alias", { endpoint: { type: "aisdk", package: "file:///missing/provider-factory.js" } }), + ) + .pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") + }), + ) + + itWithAISDK.live("wraps missing provider factory exports as AISDK init errors", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n") + yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.some(tmp.entrypoint)))) + const exit = yield* aisdk + .language(model("missing-factory", "alias", { endpoint: { type: "aisdk", package: "fixture-provider" } })) + .pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") + }), + ) + + itWithAISDK.effect("uses the model apiID for the default language model", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + yield* plugin.add(dynamicPlugin()) + const language = yield* aisdk.language( + model("custom", "alias", { + apiID: ModelV2.ID.make("test-model-api"), + endpoint: { type: "aisdk", package: fixtureProvider }, + }), + ) + expect(language).toMatchObject({ modelID: "test-model-api", options: { name: "custom" } }) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-gateway.test.ts b/packages/core/test/v2/plugin/provider-gateway.test.ts new file mode 100644 index 000000000..8ee69b7dd --- /dev/null +++ b/packages/core/test/v2/plugin/provider-gateway.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, mock } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway" +import { it, model } from "./provider-helper" + +const gatewayCalls: Record[] = [] +const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"] + +mock.module("@ai-sdk/gateway", () => ({ + createGateway(options: Record) { + gatewayCalls.push({ ...options }) + return { + languageModel(modelID: string) { + return { + modelId: modelID, + provider: options.name, + specificationVersion: "v3", + } + }, + } + }, +})) + +describe("GatewayPlugin", () => { + it.effect("creates a Gateway SDK for @ai-sdk/gateway", () => + Effect.gen(function* () { + gatewayCalls.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GatewayPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("gateway", "model"), package: "@ai-sdk/gateway", options: { name: "gateway" } }, + {}, + ) + expect(result.sdk).toBeDefined() + expect(gatewayCalls).toHaveLength(1) + }), + ) + + it.effect("passes the model providerID as the Gateway SDK name", () => + Effect.gen(function* () { + gatewayCalls.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GatewayPlugin) + + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("vercel", "anthropic/claude-sonnet-4"), + package: "@ai-sdk/gateway", + options: { name: "vercel", apiKey: "test-key" }, + }, + {}, + ) + + expect(gatewayCalls).toEqual([{ name: "vercel", apiKey: "test-key" }]) + expect(result.sdk.languageModel("anthropic/claude-sonnet-4").provider).toBe("vercel") + }), + ) + + it.effect("matches Vercel AI Gateway models by their @ai-sdk/gateway package", () => + Effect.gen(function* () { + gatewayCalls.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GatewayPlugin) + + for (const modelID of vercelGatewayModels) { + const ignored = yield* plugin.trigger( + "aisdk.sdk", + { model: model("vercel", modelID), package: "@ai-sdk/vercel", options: { name: "vercel" } }, + {}, + ) + expect(ignored.sdk).toBeUndefined() + + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("vercel", modelID), package: "@ai-sdk/gateway", options: { name: "vercel" } }, + {}, + ) + expect(result.sdk).toBeDefined() + } + + expect(gatewayCalls).toHaveLength(3) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-gitlab.test.ts b/packages/core/test/v2/plugin/provider-gitlab.test.ts new file mode 100644 index 000000000..0b71310e0 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-gitlab.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, mock } from "bun:test" +import { Effect, Layer } from "effect" +import { AuthV2 } from "@opencode-ai/core/auth" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AuthPlugin } from "@opencode-ai/core/plugin/auth" +import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" +import { testEffect } from "../../lib/effect" +import { it, model, npmLayer, provider, withEnv } from "./provider-helper" + +const gitlabSDKOptions: Record[] = [] + +void mock.module("gitlab-ai-provider", () => ({ + VERSION: "test-version", + createGitLab: (options: Record) => { + gitlabSDKOptions.push(options) + return { + agenticChat: (id: string, options: unknown) => ({ id, options, type: "agentic" }), + workflowChat: (id: string, options: unknown) => ({ id, options, type: "workflow" }), + } + }, + discoverWorkflowModels: async () => ({ models: [], project: undefined }), + isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact", +})) + +const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer)) + +describe("GitLabPlugin", () => { + it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () => + withEnv( + { + GITLAB_INSTANCE_URL: undefined, + GITLAB_TOKEN: "env-token", + }, + () => + Effect.gen(function* () { + gitlabSDKOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GitLabPlugin) + yield* plugin.trigger( + "aisdk.sdk", + { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, + {}, + ) + expect(gitlabSDKOptions).toHaveLength(1) + expect(gitlabSDKOptions[0].instanceUrl).toBe("https://gitlab.com") + expect(gitlabSDKOptions[0].apiKey).toBe("env-token") + expect(gitlabSDKOptions[0].aiGatewayHeaders).toMatchObject({ + "anthropic-beta": "context-1m-2025-08-07", + }) + expect(String((gitlabSDKOptions[0].aiGatewayHeaders as Record)["User-Agent"])).toContain( + "gitlab-ai-provider/test-version", + ) + expect(gitlabSDKOptions[0].featureFlags).toEqual({ + duo_agent_platform_agentic_chat: true, + duo_agent_platform: true, + }) + }), + ), + ) + + it.effect("uses GITLAB_INSTANCE_URL when instanceUrl is not configured", () => + withEnv( + { + GITLAB_INSTANCE_URL: "https://env.gitlab.example", + GITLAB_TOKEN: undefined, + }, + () => + Effect.gen(function* () { + gitlabSDKOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GitLabPlugin) + yield* plugin.trigger( + "aisdk.sdk", + { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, + {}, + ) + expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example") + }), + ), + ) + + it.effect("keeps configured instance URL, apiKey, aiGatewayHeaders, and featureFlags over env/defaults", () => + withEnv( + { + GITLAB_INSTANCE_URL: "https://env.gitlab.example", + GITLAB_TOKEN: "env-token", + }, + () => + Effect.gen(function* () { + gitlabSDKOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GitLabPlugin) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("gitlab", "claude"), + package: "gitlab-ai-provider", + options: { + name: "gitlab", + instanceUrl: "https://configured.gitlab.example", + apiKey: "configured-token", + aiGatewayHeaders: { + "anthropic-beta": "configured-beta", + "x-gitlab-test": "1", + }, + featureFlags: { + duo_agent_platform: false, + custom_flag: true, + }, + }, + }, + {}, + ) + expect(gitlabSDKOptions[0].instanceUrl).toBe("https://configured.gitlab.example") + expect(gitlabSDKOptions[0].apiKey).toBe("configured-token") + expect(gitlabSDKOptions[0].aiGatewayHeaders).toMatchObject({ + "anthropic-beta": "configured-beta", + "x-gitlab-test": "1", + }) + expect(gitlabSDKOptions[0].featureFlags).toEqual({ + duo_agent_platform_agentic_chat: true, + duo_agent_platform: false, + custom_flag: true, + }) + }), + ), + ) + + it.effect("ignores non-GitLab SDK packages", () => + Effect.gen(function* () { + gitlabSDKOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GitLabPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("gitlab", "claude"), package: "@ai-sdk/openai", options: { name: "gitlab" } }, + {}, + ) + expect(result.sdk).toBeUndefined() + expect(gitlabSDKOptions).toHaveLength(0) + }), + ) + + itWithAuth.effect("uses active API auth token over GITLAB_TOKEN", () => + withEnv( + { + GITLAB_TOKEN: "env-token", + }, + () => + Effect.gen(function* () { + gitlabSDKOptions.length = 0 + const plugin = yield* PluginV2.Service + const auth = yield* AuthV2.Service + yield* auth.create({ + serviceID: AuthV2.ServiceID.make("gitlab"), + credential: new AuthV2.ApiKeyCredential({ type: "api", key: "auth-token" }), + active: true, + }) + yield* plugin.add({ + ...AuthPlugin, + effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), + }) + yield* plugin.add(GitLabPlugin) + const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false }) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("gitlab", "claude"), + package: "gitlab-ai-provider", + options: updated.provider.options.aisdk.provider, + }, + {}, + ) + expect(gitlabSDKOptions[0].apiKey).toBe("auth-token") + }), + ), + ) + + itWithAuth.effect("uses active OAuth access token when no API auth exists", () => + withEnv( + { + GITLAB_TOKEN: undefined, + }, + () => + Effect.gen(function* () { + gitlabSDKOptions.length = 0 + const plugin = yield* PluginV2.Service + const auth = yield* AuthV2.Service + yield* auth.create({ + serviceID: AuthV2.ServiceID.make("gitlab"), + credential: new AuthV2.OAuthCredential({ + type: "oauth", + refresh: "refresh-token", + access: "oauth-token", + expires: 9999999999999, + }), + active: true, + }) + yield* plugin.add({ + ...AuthPlugin, + effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), + }) + yield* plugin.add(GitLabPlugin) + const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false }) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("gitlab", "claude"), + package: "gitlab-ai-provider", + options: updated.provider.options.aisdk.provider, + }, + {}, + ) + expect(gitlabSDKOptions[0].apiKey).toBe("oauth-token") + }), + ), + ) + + it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: [string, unknown][] = [] + yield* plugin.add(GitLabPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: model("gitlab", "duo-workflow-custom", { + options: { + headers: {}, + body: {}, + aisdk: { provider: {}, request: { workflowRef: "ref", workflowDefinition: "definition" } }, + }, + }), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } + }, + agenticChat: () => undefined, + }, + options: { featureFlags: { configured: true } }, + }, + {}, + ) + expect(calls).toEqual([ + ["duo-workflow", { featureFlags: { configured: true }, workflowDefinition: "definition" }], + ]) + expect(result.language as unknown).toEqual({ + id: "duo-workflow", + options: calls[0]?.[1], + selectedModelRef: "ref", + }) + }), + ) + + it.effect("uses exact static workflow model ids when the provider recognizes them", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: [string, unknown][] = [] + yield* plugin.add(GitLabPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: model("gitlab", "duo-workflow-exact"), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } + }, + agenticChat: () => undefined, + }, + options: { featureFlags: { configured: true } }, + }, + {}, + ) + expect(calls).toEqual([ + ["duo-workflow-exact", { featureFlags: { configured: true }, workflowDefinition: undefined }], + ]) + expect(result.language as unknown).toEqual({ id: "duo-workflow-exact", options: calls[0]?.[1] }) + }), + ) + + it.effect("uses provider feature flags instead of request feature flags", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: [string, unknown][] = [] + yield* plugin.add(GitLabPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("gitlab", "duo-workflow-custom", { + options: { + headers: {}, + body: {}, + aisdk: { provider: {}, request: { featureFlags: { request_flag: true } } }, + }, + }), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } + }, + agenticChat: () => undefined, + }, + options: { featureFlags: { configured: true } }, + }, + {}, + ) + expect(calls).toEqual([["duo-workflow", { featureFlags: { configured: true }, workflowDefinition: undefined }]]) + }), + ) + + it.effect("uses agenticChat with provider aiGatewayHeaders and feature flags for normal models", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: [string, unknown][] = [] + yield* plugin.add(GitLabPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("gitlab", "claude", { + options: { headers: { h: "v" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + sdk: { + workflowChat: () => undefined, + agenticChat: (id: string, options: unknown) => { + const selected = options as { + aiGatewayHeaders?: Record + featureFlags?: Record + } + calls.push([ + id, + { aiGatewayHeaders: { ...selected.aiGatewayHeaders }, featureFlags: { ...selected.featureFlags } }, + ]) + }, + }, + options: { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }, + }, + {}, + ) + expect(calls).toEqual([ + ["claude", { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }], + ]) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/v2/plugin/provider-google-vertex-anthropic.test.ts new file mode 100644 index 000000000..6bcece53c --- /dev/null +++ b/packages/core/test/v2/plugin/provider-google-vertex-anthropic.test.ts @@ -0,0 +1,147 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { GoogleVertexAnthropicPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" +import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" + +describe("GoogleVertexAnthropicPlugin", () => { + it.effect("resolves legacy project and location env on provider update", () => + withEnv( + { + GOOGLE_CLOUD_PROJECT: "cloud-project", + GCP_PROJECT: "gcp-project", + GCLOUD_PROJECT: "gcloud-project", + GOOGLE_CLOUD_LOCATION: "cloud-location", + VERTEX_LOCATION: "vertex-location", + GOOGLE_VERTEX_LOCATION: "google-vertex-location", + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexAnthropicPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { provider: provider("google-vertex-anthropic"), cancel: false }, + ) + expect(result.provider.options.aisdk.provider.project).toBe("cloud-project") + expect(result.provider.options.aisdk.provider.location).toBe("cloud-location") + }), + ), + ) + + it.effect("keeps configured project and location over env fallback", () => + withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexAnthropicPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("google-vertex-anthropic", { + options: { + headers: {}, + body: {}, + aisdk: { provider: { project: "configured-project", location: "configured-location" }, request: {} }, + }, + }), + cancel: false, + }, + ) + expect(result.provider.options.aisdk.provider.project).toBe("configured-project") + expect(result.provider.options.aisdk.provider.location).toBe("configured-location") + }), + ), + ) + + it.effect("creates SDKs from legacy env fallback and default location", () => + withEnv( + { + GOOGLE_CLOUD_PROJECT: undefined, + GCP_PROJECT: "gcp-project", + GCLOUD_PROJECT: "gcloud-project", + GOOGLE_CLOUD_LOCATION: undefined, + VERTEX_LOCATION: undefined, + GOOGLE_VERTEX_LOCATION: "ignored-location", + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexAnthropicPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("google-vertex-anthropic", "claude-sonnet-4-5"), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex-anthropic" }, + }, + {}, + ) + expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( + "https://aiplatform.googleapis.com/v1/projects/gcp-project/locations/global/publishers/anthropic/models", + ) + }), + ), + ) + + it.effect("uses GOOGLE_CLOUD_LOCATION before VERTEX_LOCATION when creating SDKs", () => + withEnv( + { GOOGLE_CLOUD_PROJECT: "project", GOOGLE_CLOUD_LOCATION: "cloud-location", VERTEX_LOCATION: "vertex-location" }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexAnthropicPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("google-vertex-anthropic", "claude-sonnet-4-5"), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex-anthropic" }, + }, + {}, + ) + expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( + "https://cloud-location-aiplatform.googleapis.com/v1/projects/project/locations/cloud-location/publishers/anthropic/models", + ) + }), + ), + ) + + it.effect("trims model IDs before selecting language models", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(GoogleVertexAnthropicPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("google-vertex-anthropic", " claude-sonnet-4-5 "), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }, + {}, + ) + expect(calls).toEqual(["languageModel:claude-sonnet-4-5"]) + }), + ) + + it.effect("ignores non Vertex Anthropic providers for language selection", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(GoogleVertexAnthropicPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: model("google-vertex", "claude-sonnet-4-5"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }, + {}, + ) + expect(calls).toEqual([]) + expect(result.language).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-google-vertex.test.ts b/packages/core/test/v2/plugin/provider-google-vertex.test.ts new file mode 100644 index 000000000..3bd60fd72 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-google-vertex.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, mock } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" +import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" + +const vertexOptions: Record[] = [] + +void mock.module("@ai-sdk/google-vertex", () => ({ + createVertex: (options: Record) => { + vertexOptions.push(options) + return { + languageModel: (modelID: string) => ({ modelID, provider: "google-vertex", specificationVersion: "v3" }), + } + }, +})) + +void mock.module("google-auth-library", () => ({ + GoogleAuth: class { + async getApplicationDefault() { + return { + credential: { + async getAccessToken() { + return { token: "vertex-token" } + }, + }, + } + } + }, +})) + +describe("GoogleVertexPlugin", () => { + it.effect("resolves project and location from env using legacy precedence", () => + withEnv( + { + GOOGLE_CLOUD_PROJECT: "google-cloud-project", + GCP_PROJECT: "gcp-project", + GCLOUD_PROJECT: "gcloud-project", + GOOGLE_VERTEX_LOCATION: "google-vertex-location", + GOOGLE_CLOUD_LOCATION: "google-cloud-location", + VERTEX_LOCATION: "vertex-location", + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("google-vertex", { + endpoint: { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", + }, + }), + cancel: false, + }, + ) + expect(result.provider.options.aisdk.provider.project).toBe("google-cloud-project") + expect(result.provider.options.aisdk.provider.location).toBe("google-vertex-location") + expect(result.provider.endpoint).toEqual({ + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location", + }) + }), + ), + ) + + it.effect("resolves the advertised GOOGLE_VERTEX_PROJECT env for provider updates and SDKs", () => + withEnv( + { + GOOGLE_VERTEX_PROJECT: "vertex-project", + GOOGLE_CLOUD_PROJECT: undefined, + GCP_PROJECT: undefined, + GCLOUD_PROJECT: undefined, + GOOGLE_VERTEX_LOCATION: "europe-west4", + GOOGLE_CLOUD_LOCATION: undefined, + VERTEX_LOCATION: undefined, + }, + () => + Effect.gen(function* () { + vertexOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexPlugin) + const updated = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("google-vertex", { + endpoint: { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", + }, + }), + cancel: false, + }, + ) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("google-vertex", "gemini", { + endpoint: { type: "aisdk", package: "@ai-sdk/google-vertex" }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google-vertex" }, + }, + {}, + ) + + expect(updated.provider.options.aisdk.provider.project).toBe("vertex-project") + expect(updated.provider.endpoint).toEqual({ + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4", + }) + expect(vertexOptions[0].project).toBe("vertex-project") + expect(vertexOptions[0].location).toBe("europe-west4") + }), + ), + ) + + it.effect("keeps configured project and location over env and uses global endpoint", () => + withEnv( + { + GOOGLE_CLOUD_PROJECT: "env-project", + GCP_PROJECT: "env-gcp-project", + GCLOUD_PROJECT: "env-gcloud-project", + GOOGLE_VERTEX_LOCATION: "env-location", + GOOGLE_CLOUD_LOCATION: "env-google-cloud-location", + VERTEX_LOCATION: "env-vertex-location", + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("google-vertex", { + endpoint: { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", + }, + options: { + headers: {}, + body: {}, + aisdk: { provider: { project: "config-project", location: "global" }, request: {} }, + }, + }), + cancel: false, + }, + ) + expect(result.provider.options.aisdk.provider.project).toBe("config-project") + expect(result.provider.options.aisdk.provider.location).toBe("global") + expect(result.provider.endpoint).toEqual({ + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global", + }) + }), + ), + ) + + it.effect("defaults location to us-central1 when only project is configured", () => + withEnv( + { + GOOGLE_CLOUD_PROJECT: undefined, + GCP_PROJECT: undefined, + GCLOUD_PROJECT: undefined, + GOOGLE_VERTEX_LOCATION: undefined, + GOOGLE_CLOUD_LOCATION: undefined, + VERTEX_LOCATION: undefined, + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("google-vertex", { + options: { headers: {}, body: {}, aisdk: { provider: { project: "config-project" }, request: {} } }, + }), + cancel: false, + }, + ) + expect(result.provider.options.aisdk.provider.project).toBe("config-project") + expect(result.provider.options.aisdk.provider.location).toBe("us-central1") + }), + ), + ) + + it.effect("does not pass Google auth fetch to the native Vertex SDK", () => + withEnv( + { + GOOGLE_CLOUD_PROJECT: "env-project", + GOOGLE_VERTEX_LOCATION: "env-location", + }, + () => + Effect.gen(function* () { + vertexOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexPlugin) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("google-vertex", "gemini", { + endpoint: { type: "aisdk", package: "@ai-sdk/google-vertex" }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google-vertex" }, + }, + {}, + ) + expect(vertexOptions).toHaveLength(1) + expect(vertexOptions[0].project).toBe("env-project") + expect(vertexOptions[0].location).toBe("env-location") + expect(vertexOptions[0].fetch).toBeUndefined() + }), + ), + ) + + it.effect("keeps Google auth fetch for OpenAI-compatible Vertex endpoints", () => + Effect.gen(function* () { + const fetchCalls: { input: Parameters[0]; init?: RequestInit }[] = [] + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexPlugin) + yield* plugin.add({ + id: PluginV2.ID.make("capture-openai-compatible"), + effect: Effect.succeed({ + "aisdk.sdk": (evt) => + Effect.promise(async () => { + if (evt.model.providerID !== "google-vertex") return + if (evt.package !== "@ai-sdk/openai-compatible") return + expect(typeof evt.options.fetch).toBe("function") + await evt.options.fetch("https://vertex.example", { + headers: { "x-test": "1" }, + }) + }), + }), + }) + const originalFetch = fetch + ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async ( + input: Parameters[0], + init?: RequestInit, + ) => { + fetchCalls.push({ input, init }) + return new Response("ok") + }) as typeof fetch + yield* Effect.acquireUseRelease( + Effect.void, + () => + plugin.trigger( + "aisdk.sdk", + { + model: model("google-vertex", "gemini", { + endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "google-vertex" }, + }, + {}, + ), + () => + Effect.sync(() => { + ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = originalFetch + }), + ) + expect(fetchCalls).toHaveLength(1) + expect(fetchCalls[0].input).toBe("https://vertex.example") + expect(new Headers(fetchCalls[0].init?.headers).get("authorization")).toBe("Bearer vertex-token") + expect(new Headers(fetchCalls[0].init?.headers).get("x-test")).toBe("1") + }), + ) + + it.effect("trims model IDs before selecting language models", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(GoogleVertexPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("google-vertex", " gemini-2.5-pro "), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }, + {}, + ) + expect(calls).toEqual(["languageModel:gemini-2.5-pro"]) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-google.test.ts b/packages/core/test/v2/plugin/provider-google.test.ts new file mode 100644 index 000000000..ee33b980b --- /dev/null +++ b/packages/core/test/v2/plugin/provider-google.test.ts @@ -0,0 +1,70 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AISDK } from "@opencode-ai/core/aisdk" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google" +import { testEffect } from "../../lib/effect" +import { it, model } from "./provider-helper" + +const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer))) + +describe("GooglePlugin", () => { + it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GooglePlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-google", "gemini"), + package: "@ai-sdk/google", + options: { name: "custom-google", apiKey: "test" }, + }, + {}, + ) + expect(result.sdk).toBeDefined() + expect(result.sdk?.languageModel("gemini").provider).toBe("custom-google") + }), + ) + + it.effect("ignores non-Google SDK packages", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GooglePlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("google", "gemini"), package: "@ai-sdk/google-vertex", options: { name: "google" } }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ) + + itWithAISDK.effect("uses default languageModel loading with provider ID parity", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + yield* plugin.add(GooglePlugin) + const language = yield* aisdk.language( + model("custom-google", "alias", { + apiID: ModelV2.ID.make("gemini-api"), + endpoint: { + type: "aisdk", + package: "@ai-sdk/google", + }, + options: { + headers: {}, + body: {}, + aisdk: { + provider: { apiKey: "test" }, + request: {}, + }, + }, + }), + ) + expect(language.modelId).toBe("gemini-api") + expect(language.provider).toBe("custom-google") + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-groq.test.ts b/packages/core/test/v2/plugin/provider-groq.test.ts new file mode 100644 index 000000000..14c10b651 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-groq.test.ts @@ -0,0 +1,101 @@ +import { describe, expect } from "bun:test" +import { createGroq } from "@ai-sdk/groq" +import { Effect, Layer } from "effect" +import { AISDK } from "@opencode-ai/core/aisdk" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq" +import { it, model } from "./provider-helper" +import { testEffect } from "../../lib/effect" + +const aisdkIt = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer))) + +describe("GroqPlugin", () => { + it.effect("creates a Groq SDK for @ai-sdk/groq", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GroqPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("groq", "llama"), package: "@ai-sdk/groq", options: { name: "groq" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("ignores non-Groq SDK packages", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GroqPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("groq", "llama"), package: "@ai-sdk/openai-compatible", options: { name: "groq" } }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ) + + it.effect("only matches the bundled @ai-sdk/groq package exactly", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GroqPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("groq", "llama"), package: "@ai-sdk/groq/compat", options: { name: "groq" } }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ) + + it.effect("matches the old bundled Groq SDK provider naming", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GroqPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-groq", "llama"), + package: "@ai-sdk/groq", + options: { name: "custom-groq", apiKey: "test" }, + }, + {}, + ) + const expected = createGroq({ name: "custom-groq", apiKey: "test" } as Parameters[0] & { + name: string + }).languageModel("llama") + const actual = result.sdk?.languageModel("llama") + expect(actual?.provider).toBe(expected.provider) + expect(actual?.modelId).toBe(expected.modelId) + }), + ) + + aisdkIt.effect("uses the default languageModel(apiID) behavior", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + yield* plugin.add(GroqPlugin) + const result = yield* aisdk.language( + model("groq", "alias", { + apiID: ModelV2.ID.make("llama-api"), + endpoint: { + type: "aisdk", + package: "@ai-sdk/groq", + }, + options: { + headers: {}, + body: {}, + aisdk: { + provider: { apiKey: "test" }, + request: {}, + }, + }, + }), + ) + expect(result.modelId).toBe("llama-api") + expect(result.provider).toBe("groq.chat") + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-helper.ts b/packages/core/test/v2/plugin/provider-helper.ts new file mode 100644 index 000000000..84a3044bf --- /dev/null +++ b/packages/core/test/v2/plugin/provider-helper.ts @@ -0,0 +1,100 @@ +import { Npm } from "@opencode-ai/core/npm" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { expect } from "bun:test" +import { Effect, Layer, Option } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../../lib/effect" + +export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href + +export const npmLayer = Layer.succeed( + Npm.Service, + Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint: Option.none() }), + install: () => Effect.void, + which: () => Effect.succeed(Option.none()), + }), +) + +export const it = testEffect(Layer.mergeAll(PluginV2.defaultLayer, npmLayer)) + +export function provider(providerID: string, options?: Partial) { + return new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.make(providerID)), + endpoint: { + type: "aisdk", + package: "test-provider", + }, + ...options, + options: { + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + ...options?.options, + }, + }) +} + +export function model(providerID: string, modelID: string, options?: Partial) { + return new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + apiID: ModelV2.ID.make(modelID), + endpoint: { + type: "aisdk", + package: "test-provider", + }, + ...options, + options: { + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + ...options?.options, + }, + }) +} + +export function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + for (const [key, value] of Object.entries(vars)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + return previous + }), + () => fx(), + (previous) => + Effect.sync(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }), + ) +} + +export function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} + +export function expectPluginRegistered(ids: string[], id: string) { + expect(ids).toContain(PluginV2.ID.make(id)) +} diff --git a/packages/core/test/v2/plugin/provider-kilo.test.ts b/packages/core/test/v2/plugin/provider-kilo.test.ts new file mode 100644 index 000000000..4261ae132 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-kilo.test.ts @@ -0,0 +1,90 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo" +import { expectPluginRegistered, it, provider } from "./provider-helper" + +describe("KiloPlugin", () => { + it.effect("is registered so legacy referer headers can be applied", () => + Effect.sync(() => + expectPluginRegistered( + ProviderPlugins.map((item) => item.id), + "kilo", + ), + ), + ) + + it.effect("applies legacy referer headers only to kilo", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(KiloPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("kilo", { + options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false }) + expect(result.provider.options.headers).toEqual({ + Existing: "value", + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }) + expect(ignored.provider.options.headers).toEqual({}) + }), + ) + + it.effect("uses the exact legacy Kilo header casing and set", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(KiloPlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("kilo"), cancel: false }) + + expect(result.provider.options.headers).toEqual({ + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }) + expect(result.provider.options.headers).not.toHaveProperty("http-referer") + expect(result.provider.options.headers).not.toHaveProperty("x-title") + expect(result.provider.options.headers).not.toHaveProperty("X-Source") + }), + ) + + it.effect("uses the legacy provider-id guard instead of endpoint package matching", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(KiloPlugin) + const matchingID = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("kilo", { + endpoint: { type: "aisdk", package: "not-kilo" }, + }), + cancel: false, + }, + ) + const matchingPackage = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("custom-kilo", { + endpoint: { type: "aisdk", package: "kilo" }, + }), + cancel: false, + }, + ) + + expect(matchingID.provider.options.headers).toEqual({ + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }) + expect(matchingPackage.provider.options.headers).toEqual({}) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-llmgateway.test.ts b/packages/core/test/v2/plugin/provider-llmgateway.test.ts new file mode 100644 index 000000000..1ffea96bc --- /dev/null +++ b/packages/core/test/v2/plugin/provider-llmgateway.test.ts @@ -0,0 +1,63 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" +import { expectPluginRegistered, it, provider } from "./provider-helper" + +describe("LLMGatewayPlugin", () => { + it.effect("is registered so legacy referer headers can be applied", () => + Effect.sync(() => + expectPluginRegistered( + ProviderPlugins.map((item) => item.id), + "llmgateway", + ), + ), + ) + + it.effect("applies legacy referer headers only to enabled llmgateway", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(LLMGatewayPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("llmgateway", { + enabled: { via: "env", name: "LLMGATEWAY_API_KEY" }, + options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + const ignored = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("openrouter", { + enabled: { via: "env", name: "OPENROUTER_API_KEY" }, + }), + cancel: false, + }, + ) + expect(result.provider.options.headers).toEqual({ + Existing: "value", + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + "X-Source": "opencode", + }) + expect(ignored.provider.options.headers).toEqual({}) + }), + ) + + it.effect("does not apply legacy headers to a disabled llmgateway provider", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(LLMGatewayPlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("llmgateway"), cancel: false }) + + expect(result.provider.enabled).toBe(false) + expect(result.provider.options.headers).toEqual({}) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-mistral.test.ts b/packages/core/test/v2/plugin/provider-mistral.test.ts new file mode 100644 index 000000000..f24ff53e5 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-mistral.test.ts @@ -0,0 +1,106 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral" +import { fakeSelectorSdk, it, model } from "./provider-helper" + +describe("MistralPlugin", () => { + it.effect("creates a Mistral SDK for @ai-sdk/mistral", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(MistralPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("ignores non-Mistral SDK packages", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(MistralPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("mistral", "mistral-large"), + package: "@ai-sdk/openai-compatible", + options: { name: "mistral" }, + }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ) + + it.effect("matches the old bundled Mistral SDK provider name for the bundled provider ID", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const providers: string[] = [] + yield* plugin.add(MistralPlugin) + yield* plugin.add({ + id: PluginV2.ID.make("mistral-sdk-inspector"), + effect: Effect.succeed({ + "aisdk.sdk": (evt) => + Effect.sync(() => { + providers.push(evt.sdk.languageModel("mistral-large").provider) + }), + }), + }) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } }, + {}, + ) + expect(result.sdk).toBeDefined() + expect(providers).toEqual(["mistral.chat"]) + }), + ) + + it.effect("matches the old bundled Mistral SDK provider name for custom provider IDs", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const providers: string[] = [] + yield* plugin.add(MistralPlugin) + yield* plugin.add({ + id: PluginV2.ID.make("mistral-sdk-inspector"), + effect: Effect.succeed({ + "aisdk.sdk": (evt) => + Effect.sync(() => { + providers.push(evt.sdk.languageModel("mistral-large").provider) + }), + }), + }) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-mistral", "mistral-large"), + package: "@ai-sdk/mistral", + options: { name: "custom-mistral" }, + }, + {}, + ) + expect(providers).toEqual(["mistral.chat"]) + }), + ) + + it.effect("leaves Mistral language selection on the default sdk.languageModel(apiID) path", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + const sdk = fakeSelectorSdk(calls) + yield* plugin.add(MistralPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { model: model("mistral", "alias", { apiID: ModelV2.ID.make("mistral-large") }), sdk, options: {} }, + {}, + ) + const language = result.language ?? sdk.languageModel(result.model.apiID) + expect(calls).toEqual(["languageModel:mistral-large"]) + expect(language).toBeDefined() + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-nvidia.test.ts b/packages/core/test/v2/plugin/provider-nvidia.test.ts new file mode 100644 index 000000000..0e06356cd --- /dev/null +++ b/packages/core/test/v2/plugin/provider-nvidia.test.ts @@ -0,0 +1,41 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia" +import { expectPluginRegistered, it, provider } from "./provider-helper" + +describe("NvidiaPlugin", () => { + it.effect("is registered so legacy referer headers can be applied", () => + Effect.sync(() => + expectPluginRegistered( + ProviderPlugins.map((item) => item.id), + "nvidia", + ), + ), + ) + + it.effect("applies legacy referer headers only to nvidia", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(NvidiaPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("nvidia", { + options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false }) + expect(result.provider.options.headers).toEqual({ + Existing: "value", + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }) + expect(ignored.provider.options.headers).toEqual({}) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-openai-compatible.test.ts b/packages/core/test/v2/plugin/provider-openai-compatible.test.ts new file mode 100644 index 000000000..e8bf1f757 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-openai-compatible.test.ts @@ -0,0 +1,101 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible" +import { it, model } from "./provider-helper" + +describe("OpenAICompatiblePlugin", () => { + it.effect("preserves explicit includeUsage false and defaults it to true", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenAICompatiblePlugin) + const defaulted = yield* plugin.trigger( + "aisdk.sdk", + { model: model("custom", "model"), package: "@ai-sdk/openai-compatible", options: { name: "custom" } }, + {}, + ) + const disabled = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom", "model"), + package: "@ai-sdk/openai-compatible", + options: { name: "custom", includeUsage: false }, + }, + {}, + ) + expect(defaulted.options.includeUsage).toBe(true) + expect(disabled.options.includeUsage).toBe(false) + }), + ) + + it.effect("defaults includeUsage for OpenAI-compatible package matches", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenAICompatiblePlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom", "model"), + package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", + options: { name: "custom" }, + }, + {}, + ) + expect(result.options.includeUsage).toBe(true) + }), + ) + + it.effect("uses the provider ID as the OpenAI-compatible provider name", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const observed: string[] = [] + yield* plugin.add(OpenAICompatiblePlugin) + yield* plugin.add({ + id: PluginV2.ID.make("inspector"), + effect: Effect.succeed({ + "aisdk.sdk": (evt) => + Effect.sync(() => { + observed.push(evt.sdk.languageModel("model").provider) + }), + }), + }) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-provider", "model"), + package: "@ai-sdk/openai-compatible", + options: { name: "custom-provider", baseURL: "https://example.com/v1" }, + }, + {}, + ) + expect(observed).toEqual(["custom-provider.chat"]) + }), + ) + + it.effect("does not overwrite an SDK created by an earlier provider-specific plugin", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const sentinel = { languageModel: (modelID: string) => ({ modelID }) } + yield* plugin.add({ + id: PluginV2.ID.make("sentinel"), + effect: Effect.succeed({ + "aisdk.sdk": (evt) => + Effect.sync(() => { + evt.sdk = sentinel + }), + }), + }) + yield* plugin.add(OpenAICompatiblePlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-workers-ai", "model"), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai" }, + }, + {}, + ) + expect(result.sdk).toBe(sentinel) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-openai.test.ts b/packages/core/test/v2/plugin/provider-openai.test.ts new file mode 100644 index 000000000..31d6dd0b6 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-openai.test.ts @@ -0,0 +1,100 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" +import { fakeSelectorSdk, it, model } from "./provider-helper" + +describe("OpenAIPlugin", () => { + it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenAIPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-openai", "gpt-5"), + package: "@ai-sdk/openai", + options: { name: "custom-openai", apiKey: "test" }, + }, + {}, + ) + expect(result.sdk?.responses("gpt-5").provider).toBe("custom-openai.responses") + }), + ) + + it.effect("ignores non-OpenAI SDK packages", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenAIPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("openai", "gpt-5"), package: "@ai-sdk/openai-compatible", options: { name: "openai" } }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ) + + it.effect("uses the Responses API for language models", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(OpenAIPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: model("openai", "alias", { apiID: ModelV2.ID.make("gpt-5") }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + expect(calls).toEqual(["responses:gpt-5"]) + expect(result.language).toBeDefined() + }), + ) + + it.effect("ignores non-OpenAI providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(OpenAIPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { model: model("anthropic", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + expect(calls).toEqual([]) + expect(result.language).toBeUndefined() + }), + ) + + it.effect("cancels gpt-5-chat-latest during model updates", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenAIPlugin) + const normal = yield* plugin.trigger("model.update", {}, { model: model("openai", "gpt-5"), cancel: false }) + const filtered = yield* plugin.trigger( + "model.update", + {}, + { model: model("openai", "gpt-5-chat-latest"), cancel: false }, + ) + expect(normal.cancel).toBe(false) + expect(filtered.cancel).toBe(true) + }), + ) + + it.effect("does not cancel gpt-5-chat-latest for non-OpenAI providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenAIPlugin) + const result = yield* plugin.trigger( + "model.update", + {}, + { model: model("custom-openai", "gpt-5-chat-latest"), cancel: false }, + ) + expect(result.cancel).toBe(false) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-opencode.test.ts b/packages/core/test/v2/plugin/provider-opencode.test.ts new file mode 100644 index 000000000..f080776d4 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-opencode.test.ts @@ -0,0 +1,195 @@ +import { describe, expect } from "bun:test" +import { DateTime, Effect, Option } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { it, model, provider, withEnv } from "./provider-helper" + +const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] + +describe("OpencodePlugin", () => { + it.effect("uses a public key and cancels paid models without credentials", () => + withEnv({ OPENCODE_API_KEY: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpencodePlugin) + const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) + const paid = yield* plugin.trigger( + "model.update", + {}, + { model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, + ) + expect(updated.provider.options.aisdk.provider.apiKey).toBe("public") + expect(paid.cancel).toBe(true) + }), + ), + ) + + it.effect("keeps free models without credentials", () => + withEnv({ OPENCODE_API_KEY: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpencodePlugin) + yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) + const free = yield* plugin.trigger( + "model.update", + {}, + { model: model("opencode", "free", { cost: cost(0) }), cancel: false }, + ) + expect(free.cancel).toBe(false) + }), + ), + ) + + it.effect("treats output-only cost as free without credentials", () => + withEnv({ OPENCODE_API_KEY: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpencodePlugin) + yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) + const outputOnly = yield* plugin.trigger( + "model.update", + {}, + { model: model("opencode", "output-only", { cost: cost(0, 1) }), cancel: false }, + ) + expect(outputOnly.cancel).toBe(false) + }), + ), + ) + + it.effect("uses OPENCODE_API_KEY as credentials", () => + withEnv({ OPENCODE_API_KEY: "secret" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpencodePlugin) + const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) + const paid = yield* plugin.trigger( + "model.update", + {}, + { model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, + ) + expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() + expect(paid.cancel).toBe(false) + }), + ), + ) + + it.effect("uses configured provider env vars as credentials", () => + withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpencodePlugin) + const updated = yield* plugin.trigger( + "provider.update", + {}, + { provider: provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }), cancel: false }, + ) + const paid = yield* plugin.trigger( + "model.update", + {}, + { model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, + ) + expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() + expect(paid.cancel).toBe(false) + }), + ), + ) + + it.effect("uses configured apiKey as credentials", () => + withEnv({ OPENCODE_API_KEY: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpencodePlugin) + const updated = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("opencode", { + options: { + headers: {}, + body: {}, + aisdk: { + provider: { apiKey: "configured" }, + request: {}, + }, + }, + }), + cancel: false, + }, + ) + const paid = yield* plugin.trigger( + "model.update", + {}, + { model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, + ) + expect(updated.provider.options.aisdk.provider.apiKey).toBe("configured") + expect(paid.cancel).toBe(false) + }), + ), + ) + + it.effect("uses auth-enabled providers as credentials", () => + withEnv({ OPENCODE_API_KEY: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpencodePlugin) + const updated = yield* plugin.trigger( + "provider.update", + {}, + { provider: provider("opencode", { enabled: { via: "auth", service: "opencode" } }), cancel: false }, + ) + const paid = yield* plugin.trigger( + "model.update", + {}, + { model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, + ) + expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() + expect(paid.cancel).toBe(false) + }), + ), + ) + + it.effect("ignores non-opencode providers and models", () => + withEnv({ OPENCODE_API_KEY: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpencodePlugin) + const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) + const paid = yield* plugin.trigger( + "model.update", + {}, + { model: model("openai", "paid", { cost: cost(1) }), cancel: false }, + ) + expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() + expect(paid.cancel).toBe(false) + }), + ), + ) + + it.effect("prefers gpt-5-nano as the opencode small model", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.opencode + + yield* catalog.provider.update(providerID, () => {}) + yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = cost(1, 1) + model.time.released = DateTime.makeUnsafe(Date.now()) + }) + yield* catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => { + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = cost(10, 10) + model.time.released = DateTime.makeUnsafe(Date.now()) + }) + + const selected = yield* catalog.model.small(providerID) + + expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano")) + }).pipe(Effect.provide(Catalog.defaultLayer)), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-openrouter.test.ts b/packages/core/test/v2/plugin/provider-openrouter.test.ts new file mode 100644 index 000000000..3d143ac7f --- /dev/null +++ b/packages/core/test/v2/plugin/provider-openrouter.test.ts @@ -0,0 +1,105 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter" +import { expectPluginRegistered, it, model, provider } from "./provider-helper" + +describe("OpenRouterPlugin", () => { + it.effect("is registered so legacy OpenRouter behavior can be applied", () => + Effect.sync(() => + expectPluginRegistered( + ProviderPlugins.map((item) => item.id), + "openrouter", + ), + ), + ) + + it.effect("applies legacy referer headers only to openrouter", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenRouterPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("openrouter", { + options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("nvidia"), cancel: false }) + expect(result.provider.options.headers).toEqual({ + Existing: "value", + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }) + expect(ignored.provider.options.headers).toEqual({}) + }), + ) + + it.effect("creates an SDK only for the OpenRouter package", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenRouterPlugin) + + const ignored = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("openrouter", "openai/gpt-5"), + package: "@ai-sdk/openai-compatible", + options: { name: "openrouter" }, + }, + {}, + ) + expect(ignored.sdk).toBeUndefined() + + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("custom", "openai/gpt-5"), package: "@openrouter/ai-sdk-provider", options: { name: "custom" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("filters OpenRouter's gpt-5 chat alias", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenRouterPlugin) + const result = yield* plugin.trigger( + "model.update", + {}, + { model: model("openrouter", "openai/gpt-5-chat"), cancel: false }, + ) + const regular = yield* plugin.trigger( + "model.update", + {}, + { model: model("openrouter", "openai/gpt-5"), cancel: false }, + ) + const ignored = yield* plugin.trigger( + "model.update", + {}, + { model: model("openai", "openai/gpt-5-chat"), cancel: false }, + ) + + expect(result.cancel).toBe(true) + expect(regular.cancel).toBe(false) + expect(ignored.cancel).toBe(false) + }), + ) + + it.effect("does not filter gpt-5-chat-latest for non-OpenRouter providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenRouterPlugin) + const result = yield* plugin.trigger( + "model.update", + {}, + { model: model("custom-openrouter", "gpt-5-chat-latest"), cancel: false }, + ) + expect(result.cancel).toBe(false) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-perplexity.test.ts b/packages/core/test/v2/plugin/provider-perplexity.test.ts new file mode 100644 index 000000000..d03f58337 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-perplexity.test.ts @@ -0,0 +1,107 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity" +import { fakeSelectorSdk, it, model } from "./provider-helper" + +describe("PerplexityPlugin", () => { + it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(PerplexityPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("ignores packages that are not the bundled Perplexity package", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(PerplexityPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("perplexity", "sonar"), + package: "@ai-sdk/perplexity-compatible", + options: { name: "perplexity" }, + }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ) + + it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const providers: string[] = [] + yield* plugin.add(PerplexityPlugin) + yield* plugin.add({ + id: PluginV2.ID.make("perplexity-sdk-inspector"), + effect: Effect.succeed({ + "aisdk.sdk": (evt) => + Effect.sync(() => { + providers.push(evt.sdk.languageModel("sonar").provider) + }), + }), + }) + yield* plugin.trigger( + "aisdk.sdk", + { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } }, + {}, + ) + expect(providers).toEqual(["perplexity"]) + }), + ) + + it.effect("creates bundled Perplexity SDKs for custom provider IDs", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const providers: string[] = [] + yield* plugin.add(PerplexityPlugin) + yield* plugin.add({ + id: PluginV2.ID.make("custom-perplexity-sdk-inspector"), + effect: Effect.succeed({ + "aisdk.sdk": (evt) => + Effect.sync(() => { + providers.push(evt.sdk.languageModel("sonar").provider) + }), + }), + }) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-perplexity", "sonar"), + package: "@ai-sdk/perplexity", + options: { name: "custom-perplexity" }, + }, + {}, + ) + expect(providers).toEqual(["perplexity"]) + }), + ) + + it.effect("leaves Perplexity language selection to the default languageModel fallback", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(PerplexityPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: model("perplexity", "alias", { apiID: ModelV2.ID.make("sonar") }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + expect(calls).toEqual([]) + expect(result.language).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-sap-ai-core.test.ts b/packages/core/test/v2/plugin/provider-sap-ai-core.test.ts new file mode 100644 index 000000000..565b9280a --- /dev/null +++ b/packages/core/test/v2/plugin/provider-sap-ai-core.test.ts @@ -0,0 +1,127 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core" +import { fixtureProvider, it, model, npmLayer, withEnv } from "./provider-helper" + +const pluginWithNpm = { id: SapAICorePlugin.id, effect: SapAICorePlugin.effect.pipe(Effect.provide(npmLayer)) } + +describe("SapAICorePlugin", () => { + it.effect("copies serviceKey option into AICORE_SERVICE_KEY but keeps SDK options to deployment metadata", () => + withEnv( + { AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(pluginWithNpm) + const sdk = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("sap-ai-core", "sap-model"), + package: fixtureProvider, + options: { name: "sap-ai-core", serviceKey: "service-key" }, + }, + {}, + ) + expect(process.env.AICORE_SERVICE_KEY).toBe("service-key") + expect(sdk.sdk.options).toEqual({ deploymentId: "deployment", resourceGroup: "resource-group" }) + }), + ), + ) + + it.effect("preserves existing AICORE_SERVICE_KEY over serviceKey option", () => + withEnv( + { + AICORE_SERVICE_KEY: "env-service-key", + AICORE_DEPLOYMENT_ID: "deployment", + AICORE_RESOURCE_GROUP: "resource-group", + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(pluginWithNpm) + const sdk = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("sap-ai-core", "sap-model"), + package: fixtureProvider, + options: { name: "sap-ai-core", serviceKey: "option-service-key" }, + }, + {}, + ) + expect(process.env.AICORE_SERVICE_KEY).toBe("env-service-key") + expect(sdk.sdk.options).toEqual({ deploymentId: "deployment", resourceGroup: "resource-group" }) + }), + ), + ) + + it.effect("omits deployment and resourceGroup SDK options when no service key is available", () => + withEnv( + { AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(pluginWithNpm) + const sdk = yield* plugin.trigger( + "aisdk.sdk", + { model: model("sap-ai-core", "sap-model"), package: fixtureProvider, options: { name: "sap-ai-core" } }, + {}, + ) + expect(process.env.AICORE_SERVICE_KEY).toBeUndefined() + expect(sdk.sdk.options).toEqual({}) + }), + ), + ) + + it.effect("uses the callable SDK for language selection", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(pluginWithNpm) + const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), { + languageModel() { + throw new Error("SAP AI Core should call the SDK directly") + }, + }) + const language = yield* plugin.trigger( + "aisdk.language", + { model: model("sap-ai-core", "sap-model"), sdk, options: {} }, + {}, + ) + expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" }) + }), + ) + + it.effect("ignores non-SAP AI Core providers", () => + withEnv( + { AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(pluginWithNpm) + const sdk = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("openai", "sap-model"), + package: fixtureProvider, + options: { name: "openai", serviceKey: "service-key" }, + }, + {}, + ) + const language = yield* plugin.trigger( + "aisdk.language", + { + model: model("openai", "sap-model"), + sdk: () => { + throw new Error("SAP AI Core should ignore other providers") + }, + options: {}, + }, + {}, + ) + expect(process.env.AICORE_SERVICE_KEY).toBeUndefined() + expect(sdk.sdk).toBeUndefined() + expect(language.language).toBeUndefined() + }), + ), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-togetherai.test.ts b/packages/core/test/v2/plugin/provider-togetherai.test.ts new file mode 100644 index 000000000..65090037b --- /dev/null +++ b/packages/core/test/v2/plugin/provider-togetherai.test.ts @@ -0,0 +1,97 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai" +import { fakeSelectorSdk, it, model } from "./provider-helper" + +describe("TogetherAIPlugin", () => { + it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(TogetherAIPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("matches the old bundled provider package exactly", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(TogetherAIPlugin) + + const ignored = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("togetherai", "model"), + package: "file:///tmp/@ai-sdk/togetherai-provider.js", + options: { name: "togetherai" }, + }, + {}, + ) + expect(ignored.sdk).toBeUndefined() + + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const observed: string[] = [] + yield* plugin.add(TogetherAIPlugin) + yield* plugin.add({ + id: PluginV2.ID.make("inspector"), + effect: Effect.succeed({ + "aisdk.sdk": (evt) => + Effect.sync(() => { + observed.push(evt.sdk.languageModel("model").provider) + }), + }), + }) + + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-togetherai", "model"), + package: "@ai-sdk/togetherai", + options: { name: "custom-togetherai" }, + }, + {}, + ) + + expect(observed).toEqual(["togetherai.chat"]) + }), + ) + + it.effect("defaults language selection to sdk.languageModel with the model API ID", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(TogetherAIPlugin) + + const result = yield* plugin.trigger( + "aisdk.language", + { + model: model("togetherai", "meta-llama/Llama-3.3-70B-Instruct-Turbo"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }, + {}, + ) + + expect(result.language).toBeUndefined() + expect(calls).toEqual([]) + expect(result.language ?? fakeSelectorSdk(calls).languageModel(result.model.apiID)).toBeDefined() + expect(calls).toEqual(["languageModel:meta-llama/Llama-3.3-70B-Instruct-Turbo"]) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-venice.test.ts b/packages/core/test/v2/plugin/provider-venice.test.ts new file mode 100644 index 000000000..ff4a922ab --- /dev/null +++ b/packages/core/test/v2/plugin/provider-venice.test.ts @@ -0,0 +1,86 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice" +import { fakeSelectorSdk, it, model } from "./provider-helper" + +describe("VenicePlugin", () => { + it.effect("creates a Venice SDK for venice-ai-sdk-provider", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(VenicePlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("venice", "model"), package: "venice-ai-sdk-provider", options: { name: "venice" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("uses the model provider ID as the bundled Venice SDK name", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const observed: string[] = [] + yield* plugin.add(VenicePlugin) + yield* plugin.add({ + id: PluginV2.ID.make("inspector"), + effect: Effect.succeed({ + "aisdk.sdk": (evt) => + Effect.sync(() => { + observed.push(evt.sdk.languageModel("model").provider) + }), + }), + }) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-venice", "model"), + package: "venice-ai-sdk-provider", + options: { name: "custom-venice", apiKey: "test" }, + }, + {}, + ) + expect(result.sdk).toBeDefined() + expect(observed).toEqual(["custom-venice.chat"]) + }), + ) + + it.effect("only handles the bundled venice-ai-sdk-provider package", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(VenicePlugin) + const similar = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("venice", "model"), + package: "file:///tmp/venice-ai-sdk-provider.js", + options: { name: "venice" }, + }, + {}, + ) + const other = yield* plugin.trigger( + "aisdk.sdk", + { model: model("venice", "model"), package: "@ai-sdk/openai-compatible", options: { name: "venice" } }, + {}, + ) + expect(similar.sdk).toBeUndefined() + expect(other.sdk).toBeUndefined() + }), + ) + + it.effect("leaves Venice language selection to the default languageModel fallback", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(VenicePlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { model: model("venice", "alias"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + expect(calls).toEqual([]) + expect(result.language).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-vercel.test.ts b/packages/core/test/v2/plugin/provider-vercel.test.ts new file mode 100644 index 000000000..3134a7b83 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-vercel.test.ts @@ -0,0 +1,62 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel" +import { it, model, provider } from "./provider-helper" + +describe("VercelPlugin", () => { + it.effect("applies legacy lower-case referer headers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(VercelPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("vercel", { + options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + expect(result.provider.options.headers).toEqual({ + Existing: "1", + "http-referer": "https://opencode.ai/", + "x-title": "opencode", + }) + }), + ) + + it.effect("does not add legacy upper-case referer headers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(VercelPlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("vercel"), cancel: false }) + expect(result.provider.options.headers).not.toHaveProperty("HTTP-Referer") + expect(result.provider.options.headers).not.toHaveProperty("X-Title") + }), + ) + + it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(VercelPlugin) + const event = yield* plugin.trigger( + "aisdk.sdk", + { model: model("custom-vercel", "v0-1.0-md"), package: "@ai-sdk/vercel", options: { name: "custom-vercel" } }, + {}, + ) + expect(event.sdk).toBeDefined() + expect(event.sdk.languageModel("v0-1.0-md").provider).toBe("vercel.chat") + }), + ) + + it.effect("ignores non-Vercel providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(VercelPlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("gateway"), cancel: false }) + expect(result.provider.options.headers).toEqual({}) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-xai.test.ts b/packages/core/test/v2/plugin/provider-xai.test.ts new file mode 100644 index 000000000..bb2828ff4 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-xai.test.ts @@ -0,0 +1,115 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../../lib/effect" +import { fakeSelectorSdk } from "./provider-helper" + +const it = testEffect(PluginV2.defaultLayer) + +const model = new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + apiID: ModelV2.ID.make("grok-4"), + endpoint: { + type: "aisdk", + package: "@ai-sdk/xai", + }, +}) + +describe("XAIPlugin", () => { + it.effect("creates an xAI SDK only for @ai-sdk/xai", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(XAIPlugin) + + const ignored = yield* plugin.trigger( + "aisdk.sdk", + { model, package: "@ai-sdk/openai-compatible", options: {} }, + {}, + ) + + const result = yield* plugin.trigger("aisdk.sdk", { model, package: "@ai-sdk/xai", options: {} }, {}) + + expect(ignored.sdk).toBeUndefined() + expect(typeof result.sdk?.responses).toBe("function") + }), + ) + + it.effect("creates xAI SDKs for custom provider IDs", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const providers: string[] = [] + + yield* plugin.add(XAIPlugin) + yield* plugin.add( + PluginV2.define({ + id: PluginV2.ID.make("xai-sdk-name-observer"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (!evt.sdk) return + providers.push(evt.sdk.responses("grok-4").provider) + }), + } + }), + }), + ) + + yield* plugin.trigger( + "aisdk.sdk", + { + model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.make("custom-xai") }), + package: "@ai-sdk/xai", + options: {}, + }, + {}, + ) + + expect(providers).toEqual(["xai.responses"]) + }), + ) + + it.effect("uses responses with the model apiID for xAI language models", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + + yield* plugin.add(XAIPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: new ModelV2.Info({ ...model, id: ModelV2.ID.make("alias"), apiID: ModelV2.ID.make("grok-4") }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + + expect(calls).toEqual(["responses:grok-4"]) + expect(result.language).toBeDefined() + }), + ) + + it.effect("ignores non-xAI providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + + yield* plugin.add(XAIPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.openai }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + + expect(calls).toEqual([]) + expect(result.language).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-zenmux.test.ts b/packages/core/test/v2/plugin/provider-zenmux.test.ts new file mode 100644 index 000000000..2b7730e6c --- /dev/null +++ b/packages/core/test/v2/plugin/provider-zenmux.test.ts @@ -0,0 +1,103 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux" +import { expectPluginRegistered, it, provider } from "./provider-helper" + +describe("ZenmuxPlugin", () => { + it.effect("is registered so legacy referer headers can be applied", () => + Effect.sync(() => + expectPluginRegistered( + ProviderPlugins.map((item) => item.id), + "zenmux", + ), + ), + ) + + it.effect("applies the exact legacy Zenmux headers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(ZenmuxPlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("zenmux"), cancel: false }) + expect(result.provider.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }) + expect(Object.keys(result.provider.options.headers).sort()).toEqual(["HTTP-Referer", "X-Title"]) + expect(result.cancel).toBe(false) + }), + ) + + it.effect("merges legacy Zenmux headers with existing headers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(ZenmuxPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("zenmux", { + options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + + expect(result.provider.options.headers).toEqual({ + Existing: "value", + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }) + }), + ) + + it.effect("lets configured Zenmux legacy headers override defaults", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(ZenmuxPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("zenmux", { + options: { + headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, + body: {}, + aisdk: { provider: {}, request: {} }, + }, + }), + cancel: false, + }, + ) + + expect(result.provider.options.headers).toEqual({ + "HTTP-Referer": "https://example.com/", + "X-Title": "custom-title", + }) + }), + ) + + it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(ZenmuxPlugin) + const ignored = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("openrouter", { + options: { + headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, + body: {}, + aisdk: { provider: {}, request: {} }, + }, + }), + cancel: false, + }, + ) + + expect(ignored.provider.options.headers).toEqual({ + "HTTP-Referer": "https://example.com/", + "X-Title": "custom-title", + }) + }), + ) +}) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 48cb7b345..f3cea500c 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -91,7 +91,6 @@ "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.23", "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", "@ai-sdk/xai": "3.0.82", diff --git a/packages/opencode/src/cli/cmd/debug/index.ts b/packages/opencode/src/cli/cmd/debug/index.ts index 6e2643f68..67ea51af6 100644 --- a/packages/opencode/src/cli/cmd/debug/index.ts +++ b/packages/opencode/src/cli/cmd/debug/index.ts @@ -16,6 +16,7 @@ import { SkillCommand } from "./skill" import { SnapshotCommand } from "./snapshot" import { AgentCommand } from "./agent" import { StartupCommand } from "./startup" +import { V2Command } from "./v2" export const DebugCommand = cmd({ command: "debug", @@ -31,6 +32,7 @@ export const DebugCommand = cmd({ .command(SnapshotCommand) .command(StartupCommand) .command(AgentCommand) + .command(V2Command) .command(InfoCommand) .command(PathsCommand) .command(WaitCommand) diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts new file mode 100644 index 000000000..a4e69cc49 --- /dev/null +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -0,0 +1,40 @@ +import { EOL } from "os" +import { Effect, Layer, Option } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { effectCmd } from "../../effect-cmd" +import { PluginBoot } from "@/v2/plugin-boot" + +const layer = Catalog.defaultLayer.pipe(Layer.provide(PluginBoot.defaultLayer)) + +export const V2Command = effectCmd({ + command: "v2", + describe: "debug v2 catalog and built-in plugins", + instance: false, + handler: Effect.fn("Cli.debug.v2")(function* () { + const result = yield* Effect.gen(function* () { + const catalog = yield* Catalog.Service + + const providers = (yield* catalog.provider.available()).sort((a, b) => a.id.localeCompare(b.id)) + const all = (yield* catalog.provider.all()).sort((a, b) => a.id.localeCompare(b.id)) + return { + providers, + default: catalog.model + .default() + .pipe(Effect.map(Option.map((item) => item.id)), Effect.map(Option.getOrUndefined)), + small: Object.fromEntries( + yield* Effect.all( + all.map((provider) => + Effect.map( + catalog.model.small(provider.id), + (model) => [provider.id, Option.getOrUndefined(Option.map(model, (item) => item.id))] as const, + ), + ), + { concurrency: "unbounded" }, + ), + ), + } + }).pipe(Effect.provide(layer), Effect.orDie) + + process.stdout.write(JSON.stringify(result, null, 2) + EOL) + }), +}) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 95d1b072f..b5e8e1028 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -64,7 +64,6 @@ import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" import { SubagentFooter } from "./subagent-footer.tsx" -import { Flag } from "@opencode-ai/core/flag/flag" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import parsers from "../../../../../../parsers-config.ts" import * as Clipboard from "../../util/clipboard" @@ -1529,29 +1528,15 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess return ( - - - - - - - - + ) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d4d28088d..ca87c40b7 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -24,7 +24,6 @@ import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" import { optionalOmitUndefined } from "@opencode-ai/core/schema" - import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" import { ModelStatus } from "./model-status" @@ -112,7 +111,8 @@ const BUNDLED_PROVIDERS: Record Promise<(opts: any) => BundledSDK> "@ai-sdk/vercel": () => import("@ai-sdk/vercel").then((m) => m.createVercel), "@ai-sdk/alibaba": () => import("@ai-sdk/alibaba").then((m) => m.createAlibaba), "gitlab-ai-provider": () => import("gitlab-ai-provider").then((m) => m.createGitLab), - "@ai-sdk/github-copilot": () => import("./sdk/copilot/copilot-provider").then((m) => m.createOpenaiCompatible), + "@ai-sdk/github-copilot": () => + import("@opencode-ai/core/github-copilot/copilot-provider").then((m) => m.createOpenaiCompatible), "venice-ai-sdk-provider": () => import("venice-ai-sdk-provider").then((m) => m.createVenice), } @@ -449,8 +449,14 @@ function custom(dep: CustomDep): Record { }), "google-vertex": Effect.fnUntraced(function* (provider: Info) { const env = yield* dep.env() + // models.dev advertises GOOGLE_VERTEX_PROJECT for Vertex; keep the wider + // Google Cloud project env names as fallbacks for existing ADC setups. const project = - provider.options?.project ?? env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"] + provider.options?.project ?? + env["GOOGLE_VERTEX_PROJECT"] ?? + env["GOOGLE_CLOUD_PROJECT"] ?? + env["GCP_PROJECT"] ?? + env["GCLOUD_PROJECT"] const location = String( provider.options?.location ?? @@ -739,6 +745,7 @@ function custom(dep: CustomDep): Record { const auth = yield* dep.auth(input.id) const env = yield* dep.env() const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined) + // The Cloudflare auth prompt stores this value as gatewayId metadata. const gateway = env["CLOUDFLARE_GATEWAY_ID"] || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined) if (!accountId || !gateway) { @@ -1097,11 +1104,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { } } -const layer: Layer.Layer< - Service, - never, - Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service | ModelsDev.Service -> = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service @@ -1392,7 +1395,12 @@ const layer: Layer.Layer< for (const [modelID, model] of Object.entries(provider.models)) { model.api.id = model.api.id ?? model.id ?? modelID if ( - modelID === "gpt-5-chat-latest" || + // These chat aliases are invalid for the special handling in the + // built-in providers below, but custom providers may support them. + (modelID === "gpt-5-chat-latest" && + (providerID === ProviderID.openai || + providerID === ProviderID.githubCopilot || + providerID === ProviderID.openrouter)) || (providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat") ) delete provider.models[modelID] diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts index 05da5b720..532ccce51 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts @@ -1,10 +1,14 @@ import { HttpApi, OpenApi } from "effect/unstable/httpapi" import { MessageGroup } from "./v2/message" +import { ModelGroup } from "./v2/model" +import { ProviderGroup } from "./v2/provider" import { SessionGroup } from "./v2/session" export const V2Api = HttpApi.make("v2") .add(SessionGroup) .add(MessageGroup) + .add(ModelGroup) + .add(ProviderGroup) .annotateMerge( OpenApi.annotations({ title: "opencode experimental HttpApi", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts new file mode 100644 index 000000000..35e7aeb85 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts @@ -0,0 +1,24 @@ +import { ModelV2 } from "@opencode-ai/core/model" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../../middleware/authorization" + +export const ModelGroup = HttpApiGroup.make("v2.model") + .add( + HttpApiEndpoint.get("models", "/api/model", { + success: Schema.Array(ModelV2.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.model.list", + summary: "List v2 models", + description: "Retrieve available v2 models ordered by release date.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2 models", + description: "Experimental v2 model routes.", + }), + ) + .middleware(Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts new file mode 100644 index 000000000..6d9210088 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts @@ -0,0 +1,38 @@ +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { ApiNotFoundError } from "../../errors" +import { Authorization } from "../../middleware/authorization" + +export const ProviderGroup = HttpApiGroup.make("v2.provider") + .add( + HttpApiEndpoint.get("providers", "/api/provider", { + success: Schema.Array(ProviderV2.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.provider.list", + summary: "List v2 providers", + description: "Retrieve active v2 AI providers so clients can show provider availability and configuration.", + }), + ), + ) + .add( + HttpApiEndpoint.get("provider", "/api/provider/:providerID", { + params: { providerID: ProviderV2.ID }, + success: ProviderV2.Info, + error: ApiNotFoundError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.provider.get", + summary: "Get v2 provider", + description: "Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2 providers", + description: "Experimental v2 provider routes.", + }), + ) + .middleware(Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 231f1915b..3776f5c72 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -1,6 +1,6 @@ import { SessionID } from "@/session/schema" import { SessionMessage } from "@/v2/session-message" -import { Prompt } from "@/v2/session-prompt" +import { Prompt } from "@opencode-ai/core/session-prompt" import { SessionV2 } from "@/v2/session" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts index 55cb53458..b277e7701 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts @@ -1,6 +1,12 @@ +import { Catalog } from "@opencode-ai/core/catalog" import { SessionV2 } from "@/v2/session" import { Layer } from "effect" import { messageHandlers } from "./v2/message" +import { modelHandlers } from "./v2/model" +import { providerHandlers } from "./v2/provider" import { sessionHandlers } from "./v2/session" -export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers).pipe(Layer.provide(SessionV2.defaultLayer)) +export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers, modelHandlers, providerHandlers).pipe( + Layer.provide(Catalog.defaultLayer), + Layer.provide(SessionV2.defaultLayer), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts new file mode 100644 index 000000000..7eb1b310b --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts @@ -0,0 +1,12 @@ +import { Catalog } from "@opencode-ai/core/catalog" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" + +export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (handlers) => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + + return handlers.handle("models", () => catalog.model.available()) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts new file mode 100644 index 000000000..c19213f5b --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts @@ -0,0 +1,22 @@ +import { Catalog } from "@opencode-ai/core/catalog" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" +import { notFound } from "../../errors" + +export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provider", (handlers) => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + + return handlers + .handle("providers", () => catalog.provider.available()) + .handle( + "provider", + Effect.fn(function* (ctx) { + return yield* catalog.provider + .get(ctx.params.providerID) + .pipe(Effect.catchTag("CatalogV2.ProviderNotFound", () => Effect.fail(notFound("Provider not found")))) + }), + ) + }), +) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index c7990d1b3..c31545a3d 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -19,7 +19,6 @@ import { Bus } from "@/bus" import { Wildcard } from "@/util/wildcard" import { SessionID } from "@/session/schema" import { Auth } from "@/auth" -import { Installation } from "@/installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { EffectBridge } from "@/effect/bridge" import * as Option from "effect/Option" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index c731239b6..9765175e9 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -23,7 +23,8 @@ import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" -import { Modelv2 } from "@/v2/model" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -484,9 +485,9 @@ export const layer: Layer.Layer< sessionID: ctx.sessionID, agent: input.assistantMessage.agent, model: { - id: Modelv2.ID.make(ctx.model.id), - providerID: Modelv2.ProviderID.make(ctx.model.providerID), - variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"), + id: ModelV2.ID.make(ctx.model.id), + providerID: ProviderV2.ID.make(ctx.model.providerID), + variant: ModelV2.VariantID.make(input.assistantMessage.variant ?? "default"), }, snapshot: ctx.snapshot, timestamp: DateTime.makeUnsafe(Date.now()), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index cae0dd384..bc58fbdf3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -53,8 +53,9 @@ import { EffectBridge } from "@/effect/bridge" import { RuntimeFlags } from "@/effect/runtime-flags" import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" -import { Modelv2 } from "@/v2/model" -import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@/v2/session-prompt" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@opencode-ai/core/session-prompt" import { Reference } from "@/reference/reference" import * as DateTime from "effect/DateTime" import { eq } from "@/storage/db" @@ -1143,9 +1144,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), model: { - id: Modelv2.ID.make(info.model.modelID), - providerID: Modelv2.ProviderID.make(info.model.providerID), - variant: Modelv2.VariantID.make(info.model.variant ?? "default"), + id: ModelV2.ID.make(info.model.modelID), + providerID: ProviderV2.ID.make(info.model.providerID), + variant: ModelV2.VariantID.make(info.model.variant ?? "default"), }, }) } diff --git a/packages/opencode/src/v2/model.ts b/packages/opencode/src/v2/model.ts deleted file mode 100644 index 56357ab40..000000000 --- a/packages/opencode/src/v2/model.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { withStatics } from "@opencode-ai/core/schema" -import { ModelStatus } from "@/provider/model-status" -import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect" -import { DateTimeUtcFromMillis } from "effect/Schema" - -export const ID = Schema.String.pipe(Schema.brand("Model.ID")) -export type ID = typeof ID.Type - -export const ProviderID = Schema.String.pipe( - Schema.brand("Model.ProviderID"), - withStatics((schema) => ({ - // Well-known providers - opencode: schema.make("opencode"), - anthropic: schema.make("anthropic"), - openai: schema.make("openai"), - google: schema.make("google"), - googleVertex: schema.make("google-vertex"), - githubCopilot: schema.make("github-copilot"), - amazonBedrock: schema.make("amazon-bedrock"), - azure: schema.make("azure"), - openrouter: schema.make("openrouter"), - mistral: schema.make("mistral"), - gitlab: schema.make("gitlab"), - })), -) -export type ProviderID = typeof ProviderID.Type - -export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) -export type VariantID = typeof VariantID.Type - -// Grouping of models, eg claude opus, claude sonnet -export const Family = Schema.String.pipe(Schema.brand("Family")) -export type Family = typeof Family.Type - -const OpenAIResponses = Schema.Struct({ - type: Schema.Literal("openai/responses"), - url: Schema.String, - websocket: Schema.optional(Schema.Boolean), -}) - -const OpenAICompletions = Schema.Struct({ - type: Schema.Literal("openai/completions"), - url: Schema.String, - reasoning: Schema.Union([ - Schema.Struct({ - type: Schema.Literal("reasoning_content"), - }), - Schema.Struct({ - type: Schema.Literal("reasoning_details"), - }), - ]).pipe(Schema.optional), -}) -export type OpenAICompletions = typeof OpenAICompletions.Type - -const AnthropicMessages = Schema.Struct({ - type: Schema.Literal("anthropic/messages"), - url: Schema.String, -}) - -export const Endpoint = Schema.Union([OpenAIResponses, OpenAICompletions, AnthropicMessages]).pipe( - Schema.toTaggedUnion("type"), -) -export type Endpoint = typeof Endpoint.Type - -export const Capabilities = Schema.Struct({ - tools: Schema.Boolean, - // mime patterns, image, audio, video/*, text/* - input: Schema.String.pipe(Schema.Array), - output: Schema.String.pipe(Schema.Array), -}) -export type Capabilities = typeof Capabilities.Type - -export const Options = Schema.Struct({ - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.Record(Schema.String, Schema.Any), -}) -export type Options = typeof Options.Type - -export const Cost = Schema.Struct({ - tier: Schema.Struct({ - type: Schema.Literal("context"), - size: Schema.Int, - }).pipe(Schema.optional), - input: Schema.Finite, - output: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), -}) - -export const Ref = Schema.Struct({ - id: ID, - providerID: ProviderID, - variant: VariantID, -}) -export type Ref = typeof Ref.Type - -export class Info extends Schema.Class("Model.Info")({ - id: ID, - providerID: ProviderID, - family: Family.pipe(Schema.optional), - name: Schema.String, - endpoint: Endpoint, - capabilities: Capabilities, - options: Schema.Struct({ - ...Options.fields, - variant: Schema.String.pipe(Schema.optional), - }), - variants: Schema.Struct({ - id: VariantID, - ...Options.fields, - }).pipe(Schema.Array), - time: Schema.Struct({ - released: DateTimeUtcFromMillis, - }), - cost: Cost.pipe(Schema.Array), - status: ModelStatus, - limit: Schema.Struct({ - context: Schema.Int, - input: Schema.Int.pipe(Schema.optional), - output: Schema.Int, - }), -}) {} - -export function parse(input: string): { providerID: ProviderID; modelID: ID } { - const [providerID, ...modelID] = input.split("/") - return { - providerID: ProviderID.make(providerID), - modelID: ID.make(modelID.join("/")), - } -} - -export interface Interface { - readonly get: (providerID: ProviderID, modelID: ID) => Effect.Effect> - readonly add: (model: Info) => Effect.Effect - readonly remove: (providerID: ProviderID, modelID: ID) => Effect.Effect - readonly all: () => Effect.Effect - readonly default: () => Effect.Effect> - readonly small: (provider: ProviderID) => Effect.Effect> -} - -export class Service extends Context.Service()("@opencode/v2/Model") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - let models = HashMap.empty() - - function key(providerID: ProviderID, modelID: ID) { - return `${providerID}/${modelID}` - } - - const result: Interface = { - get: Effect.fn("V2Model.get")(function* (providerID, modelID) { - return HashMap.get(models, key(providerID, modelID)) - }), - - add: Effect.fn("V2Model.add")(function* (model) { - models = HashMap.set(models, key(model.providerID, model.id), model) - }), - - remove: Effect.fn("V2Model.remove")(function* (providerID, modelID) { - models = HashMap.remove(models, key(providerID, modelID)) - }), - - all: Effect.fn("V2Model.all")(function* () { - return pipe( - models, - HashMap.toValues, - Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), - ) - }), - - default: Effect.fn("V2Model.default")(function* () { - const all = yield* result.all() - return Option.fromUndefinedOr(all[0]) - }), - - small: Effect.fn("V2Model.small")(function* (providerID) { - const all = yield* result.all() - const match = all.find((model) => model.providerID === providerID && model.id.toLowerCase().includes("small")) - return Option.fromUndefinedOr(match) - }), - } - - return Service.of(result) - }), -) - -export const defaultLayer = layer - -export * as Modelv2 from "./model" diff --git a/packages/opencode/src/v2/plugin-boot.ts b/packages/opencode/src/v2/plugin-boot.ts new file mode 100644 index 000000000..d19872f2d --- /dev/null +++ b/packages/opencode/src/v2/plugin-boot.ts @@ -0,0 +1,50 @@ +export * as PluginBoot from "./plugin-boot" + +import { Npm } from "@opencode-ai/core/npm" +import { Effect, Layer } from "effect" +import { AuthV2 } from "@opencode-ai/core/auth" +import { Catalog } from "@opencode-ai/core/catalog" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AuthPlugin } from "@opencode-ai/core/plugin/auth" +import { EnvPlugin } from "@opencode-ai/core/plugin/env" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { ModelsDevPlugin } from "./plugin/models-dev" + +type Plugin = { + id: PluginV2.ID + effect: Effect.Effect +} + +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const plugin = yield* PluginV2.Service + const auth = yield* AuthV2.Service + const npm = yield* Npm.Service + + const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { + yield* plugin.add({ + id: input.id, + effect: input.effect.pipe( + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(AuthV2.Service, auth), + Effect.provideService(Npm.Service, npm), + ), + }) + }) + + yield* add(EnvPlugin) + yield* add(AuthPlugin) + for (const item of ProviderPlugins) { + yield* add(item) + } + yield* add(ModelsDevPlugin) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Catalog.defaultLayer), + Layer.provide(PluginV2.defaultLayer), + Layer.provide(Layer.orDie(AuthV2.defaultLayer)), + Layer.provide(Npm.defaultLayer), +) diff --git a/packages/opencode/src/v2/plugin/models-dev.ts b/packages/opencode/src/v2/plugin/models-dev.ts new file mode 100644 index 000000000..7c0e902c7 --- /dev/null +++ b/packages/opencode/src/v2/plugin/models-dev.ts @@ -0,0 +1,108 @@ +import { DateTime, Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelsDev } from "@/provider/models" +import { PluginV2 } from "@opencode-ai/core/plugin" + +function released(date: string) { + const time = Date.parse(date) + return DateTime.makeUnsafe(Number.isFinite(time) ? time : 0) +} + +function cost(input: ModelsDev.Model["cost"]) { + const base = { + input: input?.input ?? 0, + output: input?.output ?? 0, + cache: { + read: input?.cache_read ?? 0, + write: input?.cache_write ?? 0, + }, + } + if (!input?.context_over_200k) return [base] + return [ + base, + { + tier: { + type: "context" as const, + size: 200_000, + }, + input: input.context_over_200k.input, + output: input.context_over_200k.output, + cache: { + read: input.context_over_200k.cache_read ?? 0, + write: input.context_over_200k.cache_write ?? 0, + }, + }, + ] +} + +function variants(model: ModelsDev.Model) { + return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => ({ + id: ModelV2.VariantID.make(id), + headers: { ...(item.provider?.headers ?? {}) }, + body: { ...(item.provider?.body ?? {}) }, + aisdk: { + provider: {}, + request: {}, + }, + })) +} + +export const ModelsDevPlugin = PluginV2.define({ + id: PluginV2.ID.make("models-dev"), + effect: Effect.gen(function* () { + const catalog = yield* Catalog.Service + const modelsDev = yield* ModelsDev.Service + for (const item of Object.values(yield* modelsDev.get())) { + const providerID = ProviderV2.ID.make(item.id) + yield* catalog.provider.update(providerID, (provider) => { + provider.name = item.name + provider.env = [...item.env] + provider.endpoint = item.npm + ? { + type: "aisdk", + package: item.npm, + url: item.api, + } + : { + type: "unknown", + } + }) + + for (const model of Object.values(item.models)) { + const modelID = ModelV2.ID.make(model.id) + yield* catalog.model + .update(providerID, modelID, (draft) => { + draft.name = model.name + draft.family = model.family ? ModelV2.Family.make(model.family) : undefined + draft.endpoint = model.provider?.npm + ? { + type: "aisdk", + package: model.provider?.npm, + url: model.provider.api, + } + : { + type: "unknown", + } + draft.capabilities = { + tools: model.tool_call, + input: [...(model.modalities?.input ?? [])], + output: [...(model.modalities?.output ?? [])], + } + draft.variants = variants(model) + draft.time.released = released(model.release_date) + draft.cost = cost(model.cost) + draft.status = model.status ?? "active" + draft.enabled = true + draft.limit = { + context: model.limit.context, + input: model.limit.input, + output: model.limit.output, + } + }) + .pipe(Effect.orDie) + } + } + }).pipe(Effect.provide(ModelsDev.defaultLayer)), +}) diff --git a/packages/opencode/src/v2/provider-parity-checklist.md b/packages/opencode/src/v2/provider-parity-checklist.md new file mode 100644 index 000000000..e3a599d8e --- /dev/null +++ b/packages/opencode/src/v2/provider-parity-checklist.md @@ -0,0 +1,95 @@ +# Unported Provider Logic Checklist + +This tracks legacy provider behavior from `packages/opencode/src/provider/provider.ts` that still needs to be ported into the v2 provider plugins under `packages/opencode/src/v2/plugin/provider/`. Keep entries checked only when v2 has equivalent behavior or when the item is intentionally skipped. + +## Provider Setup + +- [x] Cloudflare AI Gateway custom SDK construction with `createAiGateway` / `createUnified`. +- [x] Google Vertex authenticated `fetch` injection. +- [x] Amazon Bedrock AWS credential chain setup. +- [x] Amazon Bedrock bearer token setup. +- [x] SAP AI Core service key setup. + +## Provider Options + +- [x] Azure resource name resolution. +- [x] Azure missing-resource error. +- [x] Azure Cognitive Services baseURL resolution. +- [x] Cloudflare Workers AI account ID validation. +- [x] Cloudflare Workers AI account ID vars. +- [x] Cloudflare AI Gateway account ID validation. +- [x] Cloudflare AI Gateway gateway ID validation. +- [x] Cloudflare AI Gateway token validation. +- [x] Amazon Bedrock region precedence. +- [x] Amazon Bedrock profile precedence. +- [x] Amazon Bedrock endpoint precedence. +- [x] Google Vertex project resolution. +- [x] Google Vertex location resolution. +- [x] GitLab instance URL resolution. +- [x] GitLab token resolution. +- [x] GitLab AI gateway headers. +- [x] GitLab feature flags. +- [x] Opencode unauthenticated paid-model filtering. +- [x] Opencode public API key fallback. + +## Request Behavior + +- [x] Request timeout handling. +- [x] Chunk timeout handling. +- [x] SSE timeout wrapping. +- [x] OpenAI response item ID stripping. +- [x] Azure response item ID stripping. +- [x] OpenAI-compatible `includeUsage` defaulting. + +## Dynamic Models + +- [ ] GitLab workflow model discovery. + +## Model Filtering + +- [ ] Experimental alpha model filtering. +- [ ] Deprecated model filtering. +- [ ] Config whitelist filtering. +- [ ] Config blacklist filtering. +- [ ] `gpt-5-chat-latest` filtering. +- [ ] OpenRouter `openai/gpt-5-chat` filtering. + +## Default Models + +- [x] Configured default model selection. Replaced by explicit `Catalog.model.setDefault`. +- [SKIP] Recent-history default model selection — not porting to server-side v2 catalog. +- [x] Default model fallback sorting. Uses newest available model, not legacy hard-coded priority. + +## Small Models + +- [SKIP] Configured `small_model` selection — not porting config-driven selection to server-side v2 catalog. +- [x] Provider-specific small model priority. Replaced by cheapest output cost selection. +- [x] Opencode small model priority. Replaced by cheapest output cost selection. +- [x] GitHub Copilot small model priority. Replaced by cheapest output cost selection. +- [x] Amazon Bedrock region-aware small model selection. Replaced by cheapest output cost selection. + +## URL And Env Vars + +- [SKIP] BaseURL `${VAR}` interpolation — not porting generic URL templating; provider plugins should construct concrete URLs. +- [x] Azure `AZURE_RESOURCE_NAME` vars. Handled by Azure provider plugins. +- [x] Google Vertex vars. Handled by Google Vertex provider plugins. +- [x] Cloudflare Workers AI vars. Handled by Cloudflare Workers AI provider plugin. + +## Auth + +- [ ] Auth-derived provider API keys. +- [ ] OpenAI OAuth/API auth distinction. +- [ ] GitLab OAuth token selection. +- [ ] GitLab API token selection. +- [ ] Azure auth metadata resource name. +- [ ] Cloudflare auth metadata account ID. +- [ ] Cloudflare auth metadata gateway ID. + +## Config And Plugin Parity + +- [ ] Legacy plugin auth loader behavior. +- [ ] Config provider merge behavior. +- [ ] Config model merge behavior. +- [ ] Variant generation from model metadata. +- [ ] Config variant merge behavior. +- [ ] Config variant disable behavior. diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index fa211bd8c..1fd0f909d 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,12 +1,12 @@ import { SessionID } from "@/session/schema" import { NonNegativeInt } from "@opencode-ai/core/schema" import { EventV2 } from "./event" -import { FileAttachment, Prompt } from "./session-prompt" +import { FileAttachment, Prompt } from "@opencode-ai/core/session-prompt" import { Schema } from "effect" export { FileAttachment } -import { ToolOutput } from "./tool-output" -import { V2Schema } from "./schema" -import { Modelv2 } from "./model" +import { ToolOutput } from "@opencode-ai/core/tool-output" +import { V2Schema } from "@opencode-ai/core/v2-schema" +import { ModelV2 } from "@opencode-ai/core/model" export const Source = Schema.Struct({ start: NonNegativeInt, @@ -47,7 +47,7 @@ export const ModelSwitched = EventV2.define({ version: 1, schema: { ...Base, - model: Modelv2.Ref, + model: ModelV2.Ref, }, }) export type ModelSwitched = Schema.Schema.Type @@ -104,7 +104,7 @@ export namespace Step { schema: { ...Base, agent: Schema.String, - model: Modelv2.Ref, + model: ModelV2.Ref, snapshot: Schema.String.pipe(Schema.optional), }, }) diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts index 62fc75fc8..fa7c299ae 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" -import { Prompt } from "./session-prompt" +import { Prompt } from "@opencode-ai/core/session-prompt" import { SessionEvent } from "./session-event" import { EventV2 } from "./event" -import { ToolOutput } from "./tool-output" -import { V2Schema } from "./schema" -import { Modelv2 } from "./model" +import { ToolOutput } from "@opencode-ai/core/tool-output" +import { V2Schema } from "@opencode-ai/core/v2-schema" +import { ModelV2 } from "@opencode-ai/core/model" export const ID = EventV2.ID export type ID = Schema.Schema.Type @@ -26,7 +26,7 @@ export class AgentSwitched extends Schema.Class("Session.Message. export class ModelSwitched extends Schema.Class("Session.Message.ModelSwitched")({ ...Base, type: Schema.Literal("model-switched"), - model: Modelv2.Ref, + model: ModelV2.Ref, }) {} export class User extends Schema.Class("Session.Message.User")({ diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index f6084cb4c..97c31d39b 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -5,14 +5,15 @@ import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/s import * as Database from "@/storage/db" import { Context, DateTime, Effect, Layer, Option, Schema } from "effect" import { SessionMessage } from "./session-message" -import type { Prompt } from "./session-prompt" +import type { Prompt } from "@opencode-ai/core/session-prompt" import { EventV2 } from "./event" import { ProjectID } from "@/project/schema" import { SessionEvent } from "./session-event" -import { V2Schema } from "./schema" +import { V2Schema } from "@opencode-ai/core/v2-schema" import { optionalOmitUndefined } from "@opencode-ai/core/schema" -import { Modelv2 } from "./model" import { SyncEvent } from "@/sync" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ identifier: "Session.Delivery", @@ -28,7 +29,7 @@ export class Info extends Schema.Class("Session.Info")({ workspaceID: optionalOmitUndefined(WorkspaceID), path: optionalOmitUndefined(Schema.String), agent: optionalOmitUndefined(Schema.String), - model: Modelv2.Ref.pipe(optionalOmitUndefined), + model: ModelV2.Ref.pipe(optionalOmitUndefined), cost: Schema.Finite, tokens: Schema.Struct({ input: Schema.Finite, @@ -67,7 +68,7 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Ses export interface Interface { readonly create: (input?: { agent?: string - model?: Modelv2.Ref + model?: ModelV2.Ref parentID?: SessionID workspaceID?: WorkspaceID }) => Effect.Effect @@ -111,10 +112,10 @@ export interface Interface { parentID: SessionID prompt: Prompt agent: string - model?: Modelv2.Ref + model?: ModelV2.Ref }) => Effect.Effect readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect - readonly switchModel: (input: { sessionID: SessionID; model: Modelv2.Ref }) => Effect.Effect + readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect readonly compact: (sessionID: SessionID) => Effect.Effect readonly wait: (sessionID: SessionID) => Effect.Effect } @@ -141,9 +142,9 @@ export const layer = Layer.effect( agent: row.agent ?? undefined, model: row.model ? { - id: Modelv2.ID.make(row.model.id), - providerID: Modelv2.ProviderID.make(row.model.providerID), - variant: Modelv2.VariantID.make(row.model.variant ?? "default"), + id: ModelV2.ID.make(row.model.id), + providerID: ProviderV2.ID.make(row.model.providerID), + variant: ModelV2.VariantID.make(row.model.variant ?? "default"), } : undefined, cost: row.cost, @@ -164,7 +165,7 @@ export const layer = Layer.effect( }) } - const result: Interface = { + const result = Service.of({ create: Effect.fn("V2Session.create")(function* (_input) { return {} as any }), @@ -306,7 +307,7 @@ export const layer = Layer.effect( }), subagent: Effect.fn("V2Session.subagent")(function* (input) { const parent = yield* result.get(input.parentID) - const session = yield* result.create({ + const child = yield* result.create({ agent: input.agent, model: input.model, parentID: input.parentID, @@ -314,11 +315,11 @@ export const layer = Layer.effect( }) yield* result.prompt({ prompt: input.prompt, - sessionID: session.id, + sessionID: child.id, }) yield* Effect.gen(function* () { - yield* result.wait(session.id) - const messages = yield* result.messages({ sessionID: session.id, order: "desc" }) + yield* result.wait(child.id) + const messages = yield* result.messages({ sessionID: child.id, order: "desc" }) const assistant = messages.find((msg) => msg.type === "assistant") if (!assistant) return const text = assistant.content.findLast((part) => part.type === "text") @@ -327,9 +328,9 @@ export const layer = Layer.effect( }), compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}), wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}), - } + }) - return Service.of(result) + return result }), ) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 0d6bec2df..293e2a344 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -593,6 +593,12 @@ const scenarios: Scenario[] = [ check(auth.test === undefined, "auth remove should delete provider from isolated auth file") }), ), + http.protected.get("/api/model", "v2.model.list").json(200, array), + http.protected.get("/api/provider", "v2.provider.list").json(200, array), + http.protected + .get("/api/provider/{providerID}", "v2.provider.get") + .at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() })) + .json(404, object, "status"), http.protected .get("/api/session", "v2.session.list") .at((ctx) => ({ path: "/api/session?roots=true", headers: ctx.headers() })) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 8b686739d..3e5527761 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -20,7 +20,8 @@ import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@/storage/db" import { SessionMessageTable, SessionTable } from "@/session/session.sql" import { SessionMessage } from "../../src/v2/session-message" -import { Modelv2 } from "../../src/v2/model" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" import * as Log from "@opencode-ai/core/util/log" import { eq } from "drizzle-orm" @@ -110,9 +111,9 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType) => type: "assistant", agent: "build", model: { - id: Modelv2.ID.make("model"), - providerID: Modelv2.ProviderID.make("provider"), - variant: Modelv2.VariantID.make("default"), + id: ModelV2.ID.make("model"), + providerID: ProviderV2.ID.make("provider"), + variant: ModelV2.VariantID.make("default"), }, time: { created: DateTime.makeUnsafe(1) }, content: [], diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 44ac031ed..180483937 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -2,7 +2,8 @@ import { expect, test } from "bun:test" import * as DateTime from "effect/DateTime" import { SessionID } from "../../src/session/schema" import { EventV2 } from "../../src/v2/event" -import { Modelv2 } from "../../src/v2/model" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionEvent } from "../../src/v2/session-event" import { SessionMessageUpdater } from "../../src/v2/session-message-updater" @@ -18,9 +19,9 @@ test("step snapshots carry over to assistant messages", () => { timestamp: DateTime.makeUnsafe(1), agent: "build", model: { - id: Modelv2.ID.make("model"), - providerID: Modelv2.ProviderID.make("provider"), - variant: Modelv2.VariantID.make("default"), + id: ModelV2.ID.make("model"), + providerID: ProviderV2.ID.make("provider"), + variant: ModelV2.VariantID.make("default"), }, snapshot: "before", }, @@ -62,9 +63,9 @@ test("text ended populates assistant text content", () => { timestamp: DateTime.makeUnsafe(1), agent: "build", model: { - id: Modelv2.ID.make("model"), - providerID: Modelv2.ProviderID.make("provider"), - variant: Modelv2.VariantID.make("default"), + id: ModelV2.ID.make("model"), + providerID: ProviderV2.ID.make("provider"), + variant: ModelV2.VariantID.make("default"), }, }, } satisfies SessionEvent.Event) @@ -106,9 +107,9 @@ test("tool completion stores completed timestamp", () => { timestamp: DateTime.makeUnsafe(1), agent: "build", model: { - id: Modelv2.ID.make("model"), - providerID: Modelv2.ProviderID.make("provider"), - variant: Modelv2.VariantID.make("default"), + id: ModelV2.ID.make("model"), + providerID: ProviderV2.ID.make("provider"), + variant: ModelV2.VariantID.make("default"), }, }, } satisfies SessionEvent.Event) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 1b36c1513..e6e0c4638 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -197,6 +197,7 @@ import type { TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, + V2ModelListResponses, V2SessionCompactResponses, V2SessionContextResponses, V2SessionListErrors, @@ -4375,11 +4376,48 @@ export class Session3 extends HeyApiClient { } } +export class Model extends HeyApiClient { + /** + * List v2 models + * + * Retrieve available v2 models ordered by release date. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/model", + ...options, + ...params, + }) + } +} + export class V2 extends HeyApiClient { private _session?: Session3 get session(): Session3 { return (this._session ??= new Session3({ client: this.client })) } + + private _model?: Model + get model(): Model { + return (this._model ??= new Model({ client: this.client })) + } } export class Control extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 35fb6bf81..99bbfd5ec 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3378,6 +3378,78 @@ export type SessionMessage = | SessionMessageAssistant | SessionMessageCompaction +export type ModelV2Info = { + id: string + providerID: string + family?: string + name: string + endpoint: + | { + type: "openai/responses" + url: string + websocket?: boolean + } + | { + type: "openai/completions" + url: string + reasoning?: + | { + type: "reasoning_content" + } + | { + type: "reasoning_details" + } + } + | { + type: "anthropic/messages" + url: string + } + capabilities: { + tools: boolean + input: Array + output: Array + } + options: { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + variant?: string + } + variants: Array<{ + id: string + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + }> + time: { + released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + cost: Array<{ + tier?: { + type: "context" + size: number + } + input: number + output: number + cache: { + read: number + write: number + } + }> + status: "alpha" | "beta" | "deprecated" | "active" + limit: { + context: number + input?: number + output: number + } +} + export type EventTuiToastShow1 = { id: string type: "tui.toast.show" @@ -6505,6 +6577,25 @@ export type V2SessionMessagesResponses = { export type V2SessionMessagesResponse2 = V2SessionMessagesResponses[keyof V2SessionMessagesResponses] +export type V2ModelListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/api/model" +} + +export type V2ModelListResponses = { + /** + * Success + */ + 200: Array +} + +export type V2ModelListResponse = V2ModelListResponses[keyof V2ModelListResponses] + export type TuiAppendPromptData = { body?: { text: string diff --git a/specs/v2/provider-model.md b/specs/v2/provider-model.md new file mode 100644 index 000000000..fe5a98bdd --- /dev/null +++ b/specs/v2/provider-model.md @@ -0,0 +1,329 @@ +# Provider and Model Catalog + +## Provider Schema + +```ts +export const ID = Schema.String.pipe( + Schema.brand("ProviderV2.ID"), + withStatics((schema) => ({ + opencode: schema.make("opencode"), + anthropic: schema.make("anthropic"), + openai: schema.make("openai"), + google: schema.make("google"), + googleVertex: schema.make("google-vertex"), + githubCopilot: schema.make("github-copilot"), + amazonBedrock: schema.make("amazon-bedrock"), + azure: schema.make("azure"), + openrouter: schema.make("openrouter"), + mistral: schema.make("mistral"), + gitlab: schema.make("gitlab"), + })), +) +export type ID = typeof ID.Type + +const OpenAIResponses = Schema.Struct({ + type: Schema.Literal("openai/responses"), + url: Schema.String, + websocket: Schema.optional(Schema.Boolean), +}) + +const OpenAICompletions = Schema.Struct({ + type: Schema.Literal("openai/completions"), + url: Schema.String, + reasoning: Schema.Union([ + Schema.Struct({ + type: Schema.Literal("reasoning_content"), + }), + Schema.Struct({ + type: Schema.Literal("reasoning_details"), + }), + ]).pipe(Schema.optional), +}) +export type OpenAICompletions = typeof OpenAICompletions.Type + +const AISDK = Schema.Struct({ + type: Schema.Literal("aisdk"), + package: Schema.String, +}) + +const AnthropicMessages = Schema.Struct({ + type: Schema.Literal("anthropic/messages"), + url: Schema.String, +}) + +const UnknownEndpoint = Schema.Struct({ + type: Schema.Literal("unknown"), +}) + +export const Endpoint = Schema.Union([UnknownEndpoint, OpenAIResponses, OpenAICompletions, AnthropicMessages, AISDK]).pipe( + Schema.toTaggedUnion("type"), +) +export type Endpoint = typeof Endpoint.Type + +export const Options = Schema.Struct({ + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Record(Schema.String, Schema.Any), +}) +export type Options = typeof Options.Type + +export class Info extends Schema.Class("ProviderV2.Info")({ + id: ID, + name: Schema.String, + enabled: Schema.Boolean, + env: Schema.String.pipe(Schema.Array), + endpoint: Endpoint, + options: Options, +}) { + static empty(providerID: ID) { + return new Info({ + id: providerID, + name: providerID, + enabled: false, + env: [], + endpoint: { + type: "unknown", + }, + options: { + headers: {}, + body: {}, + }, + }) + } +} + +export class NotFound extends Schema.TaggedErrorClass("ProviderV2.NotFound")("ProviderV2.NotFound", { + providerID: ID, +}) {} +``` + +## Model Schema + +```ts +export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID")) +export type ID = typeof ID.Type + +export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export type VariantID = typeof VariantID.Type + +export const Family = Schema.String.pipe(Schema.brand("Family")) +export type Family = typeof Family.Type + +export const Capabilities = Schema.Struct({ + tools: Schema.Boolean, + input: Schema.String.pipe(Schema.Array), + output: Schema.String.pipe(Schema.Array), +}) +export type Capabilities = typeof Capabilities.Type + +export const Variant = Schema.Struct({ + id: VariantID, + ...ProviderV2.Options.fields, +}) +export type Variant = typeof Variant.Type + +export const Cost = Schema.Struct({ + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Int, + }).pipe(Schema.optional), + input: Schema.Finite, + output: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) +export type Cost = typeof Cost.Type + +export const Limit = Schema.Struct({ + context: Schema.Int, + input: Schema.Int.pipe(Schema.optional), + output: Schema.Int, +}) +export type Limit = typeof Limit.Type + +export const Ref = Schema.Struct({ + id: ID, + providerID: ProviderV2.ID, + variant: VariantID, +}) +export type Ref = typeof Ref.Type + +export class Info extends Schema.Class("ModelV2.Info")({ + id: ID, + providerID: ProviderV2.ID, + family: Family.pipe(Schema.optional), + name: Schema.String, + endpoint: ProviderV2.Endpoint, + options: Schema.Struct({ + ...ProviderV2.Options.fields, + variant: Schema.String.pipe(Schema.optional), + }), + capabilities: Capabilities, + variants: Variant.pipe(Schema.Array), + time: Schema.Struct({ + released: DateTimeUtcFromMillis, + }), + cost: Cost.pipe(Schema.Array), + status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + limit: Limit, +}) { + static empty(providerID: ProviderV2.ID, modelID: ID) { + return new Info({ + id: modelID, + providerID, + name: modelID, + endpoint: { + type: "unknown", + }, + capabilities: { + tools: false, + input: [], + output: [], + }, + options: { + headers: {}, + body: {}, + }, + variants: [], + time: { + released: DateTime.makeUnsafe(0), + }, + cost: [], + status: "active", + limit: { + context: 0, + output: 0, + }, + }) + } +} + +``` + +## Catalog Interface + +```ts +export interface Interface { + readonly provider: { + readonly get: (providerID: ProviderV2.ID) => Effect.Effect> + readonly update: (providerID: ProviderV2.ID, fn: (provider: Draft) => void) => Effect.Effect + readonly remove: (providerID: ProviderV2.ID) => Effect.Effect + readonly all: () => Effect.Effect + readonly available: () => Effect.Effect + } + + readonly model: { + readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect> + readonly update: ( + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + fn: (model: Draft) => void, + ) => Effect.Effect + readonly remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect + readonly all: () => Effect.Effect + readonly available: () => Effect.Effect + readonly default: () => Effect.Effect> + readonly small: (providerID: ProviderV2.ID) => Effect.Effect> + } +} +``` + +`ProviderV2.Info.enabled` is stored provider state. Provider plugins set this field after checking env, auth, config, or provider-specific availability. + +`ProviderV2.Endpoint` includes `{ type: "unknown" }`. `CatalogV2.model.get()` and `CatalogV2.model.all()` resolve `unknown` endpoints from the provider before returning models. + +Model storage is nested by provider because model ids are only unique within a provider. + +```ts +type ProviderRecord = { + provider: ProviderV2.Info + models: HashMap.HashMap +} + +let records = HashMap.empty() +``` + +`ModelV2.Info` does not have an `enabled` field. Model availability is derived by `CatalogV2.model.available()` from provider state and model status. + +```ts +const available = provider.enabled && model.status !== "deprecated" +``` + +## Plugin Interface + +```ts +export type Definition = Effect.Effect<{ + readonly order: number + readonly hooks: HookFunctions +}, never, R> + +export interface Interface { + readonly add: (input: { + id: ID + definition: Definition + }) => Effect.Effect + + readonly remove: (id: ID) => Effect.Effect + + readonly trigger: ( + name: Name, + input: HookInput, + ) => Effect.Effect> +} +``` + +## Plugin Order + +```ts +export const Order = { + modelsDev: 0, + env: 10, + auth: 20, + provider: 30, + config: 40, + discovery: 50, +} as const +``` + +## Built-In Plugins + +```ts +export const ModelsDevPlugin: PluginV2.Definition + +export const EnvPlugin: PluginV2.Definition + +export const AuthPlugin: PluginV2.Definition + +export const ConfigPlugin: PluginV2.Definition + +export const AnthropicPlugin: PluginV2.Definition + +export const OpenRouterPlugin: PluginV2.Definition + +export const AmazonBedrockPlugin: PluginV2.Definition + +export const GoogleVertexPlugin: PluginV2.Definition + +export const GitLabPlugin: PluginV2.Definition + +export const GitLabDiscoveryPlugin: PluginV2.Definition +``` + +## Plugin Hooks + +```ts +export type Hooks = { + init: {} + + "provider.update": { + provider: Draft + cancel: boolean + } + + "model.update": { + model: Draft + cancel: boolean + } +} +``` From eed0eddc638ec1b3acc7d5252fd6ce85811943cd Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 20:14:40 +0530 Subject: [PATCH 61/98] refactor(flags): route session workspaces through runtime flags (#27335) --- packages/opencode/src/effect/runtime-flags.ts | 1 + packages/opencode/src/session/session.ts | 13 ++++++----- .../test/effect/runtime-flags.test.ts | 1 + packages/opencode/test/preload.ts | 1 + .../opencode/test/server/session-list.test.ts | 22 +++++++++++-------- .../opencode/test/session/session.test.ts | 19 ++++++++++++---- 6 files changed, 39 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index b1b8ab25a..4d184c43b 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -23,6 +23,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"), experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"), experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), + experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")), }) {} diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index df173e895..edd4fe119 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -4,7 +4,6 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Decimal } from "decimal.js" import { type ProviderMetadata, type LanguageModelUsage } from "ai" -import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Database } from "@/storage/db" @@ -38,6 +37,7 @@ import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Option, Context, Schema, Types } from "effect" import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema" +import { RuntimeFlags } from "@/effect/runtime-flags" const log = Log.create({ service: "session" }) @@ -507,12 +507,13 @@ export type Patch = Types.DeepMutable["dat const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => Effect.sync(() => Database.use(fn)) -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service const storage = yield* Storage.Service const sync = yield* SyncEvent.Service + const flags = yield* RuntimeFlags.Service const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID @@ -550,7 +551,7 @@ export const layer: Layer.Layer { expect(flags.experimentalLspTool).toBe(true) expect(flags.experimentalPlanMode).toBe(true) expect(flags.experimentalEventSystem).toBe(true) + expect(flags.experimentalWorkspaces).toBe(true) expect(flags.client).toBe("desktop") }), ) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 6447c2fe9..24b804819 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -35,6 +35,7 @@ process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true" +process.env["OPENCODE_EXPERIMENTAL_WORKSPACES"] = "true" // Set test home directory to isolate tests from user's actual home directory // This prevents tests from picking up real user configs/skills from ~/.claude/skills diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index e5dc72546..1bd3c6647 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,19 +1,28 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" -import { Flag } from "@opencode-ai/core/flag/flag" import { mkdir } from "fs/promises" import path from "path" import { Database } from "@/storage/db" import { SessionTable } from "@/session/session.sql" import { eq } from "drizzle-orm" import { testEffect } from "../lib/effect" +import { Bus } from "@/bus" +import { Storage } from "@/storage/storage" +import { SyncEvent } from "@/sync" +import { RuntimeFlags } from "@/effect/runtime-flags" void Log.init({ print: false }) -const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES -const it = testEffect(SessionNs.defaultLayer) +const it = testEffect( + SessionNs.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Storage.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), + ), +) const withSession = (input?: Parameters[0]) => Effect.acquireRelease( @@ -22,7 +31,6 @@ const withSession = (input?: Parameters[0]) => ) afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await disposeAllInstances() }) @@ -31,7 +39,6 @@ describe("session.list", () => { "does not filter by directory when directory is omitted", () => Effect.gen(function* () { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false const test = yield* TestInstance yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true })) yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) @@ -60,7 +67,6 @@ describe("session.list", () => { "filters by directory when directory is provided", () => Effect.gen(function* () { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false const test = yield* TestInstance yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true })) yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) @@ -91,7 +97,6 @@ describe("session.list", () => { "filters by path and ignores directory when path is provided", () => Effect.gen(function* () { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false const test = yield* TestInstance yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode", "src", "deep"), { recursive: true }), @@ -129,7 +134,6 @@ describe("session.list", () => { "falls back to directory when filtering legacy sessions without path", () => Effect.gen(function* () { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false const test = yield* TestInstance yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode", "src"), { recursive: true }), diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index ada55d134..63920d218 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -3,16 +3,29 @@ import { Deferred, Effect, Exit, Layer } from "effect" import { Session as SessionNs } from "@/session/session" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import * as Log from "@opencode-ai/core/util/log" -import { Flag } from "@opencode-ai/core/flag/flag" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { Bus } from "@/bus" +import { Storage } from "@/storage/storage" +import { SyncEvent } from "@/sync" +import { RuntimeFlags } from "@/effect/runtime-flags" void Log.init({ print: false }) -const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect( + Layer.mergeAll( + SessionNs.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Storage.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), + ), + CrossSpawnSpawner.defaultLayer, + ), +) const awaitDeferred = (deferred: Deferred.Deferred, message: string) => Effect.race( @@ -56,8 +69,6 @@ describe("session.created event", () => { it.instance("session.created event should be emitted before session.updated", () => Effect.gen(function* () { - if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return - const session = yield* SessionNs.Service const events: string[] = [] const received = yield* Deferred.make() From 8d5aa584b451dc7a815659d6d335cc3dd6961536 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 13 May 2026 10:45:43 -0400 Subject: [PATCH 62/98] test(workspace): effectify sync start coverage (#27338) --- .../test/control-plane/workspace.test.ts | 63 +++++++++++-------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 3c4837e31..adac51fe5 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -16,13 +16,14 @@ import { ProjectID } from "@/project/schema" import { ProjectTable } from "@/project/project.sql" import { Instance } from "@/project/instance" import { WithInstance } from "../../src/project/with-instance" +import { InstanceRef } from "@/effect/instance-ref" import { Session as SessionNs } from "@/session/session" import { SessionID } from "@/session/schema" import { SessionTable } from "@/session/session.sql" import { SyncEvent } from "@/sync" import { EventSequenceTable } from "@/sync/event.sql" import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance, TestInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceID } from "../../src/control-plane/schema" @@ -105,7 +106,7 @@ afterEach(async () => { async function withInstance(fn: (dir: string) => T | Promise) { await using tmp = await tmpdir({ git: true }) - return WithInstance.provide({ + return await WithInstance.provide({ directory: tmp.path, fn: () => fn(tmp.path), }) @@ -994,31 +995,43 @@ describe("workspace sync state", () => { }) }) - test("startWorkspaceSyncing starts all workspaces", async () => { - await withInstance(async (dir) => { - const firstType = unique("first") - const secondType = unique("second") - const first = workspaceInfo(Instance.project.id, firstType) - const second = workspaceInfo(Instance.project.id, secondType) - await fs.mkdir(path.join(dir, "first"), { recursive: true }) - await fs.mkdir(path.join(dir, "second"), { recursive: true }) - insertWorkspace(first) - insertWorkspace(second) - registerAdapter(Instance.project.id, firstType, localAdapter(path.join(dir, "first")).adapter) - registerAdapter(Instance.project.id, secondType, localAdapter(path.join(dir, "second")).adapter) + it.instance( + "startWorkspaceSyncing starts all workspaces", + () => + Effect.gen(function* () { + const { directory: dir } = yield* TestInstance + const instance = yield* InstanceRef + if (!instance) return yield* Effect.die(new Error("missing test instance")) + const workspace = yield* Workspace.Service + const projectID = instance.project.id + const firstType = unique("first") + const secondType = unique("second") + const first = workspaceInfo(projectID, firstType) + const second = workspaceInfo(projectID, secondType) + yield* Effect.promise(() => fs.mkdir(path.join(dir, "first"), { recursive: true })) + yield* Effect.promise(() => fs.mkdir(path.join(dir, "second"), { recursive: true })) + yield* Effect.sync(() => { + insertWorkspace(first) + insertWorkspace(second) + registerAdapter(projectID, firstType, localAdapter(path.join(dir, "first")).adapter) + registerAdapter(projectID, secondType, localAdapter(path.join(dir, "second")).adapter) + }) + yield* Effect.addFinalizer(() => + Effect.all([workspace.remove(first.id), workspace.remove(second.id)], { discard: true }).pipe(Effect.ignore), + ) - startWorkspaceSyncing(Instance.project.id) + yield* workspace.startWorkspaceSyncing(projectID) - await eventually(() => - workspaceStatus().then((status) => { - expect(status.find((item) => item.workspaceID === first.id)?.status).toBe("connected") - expect(status.find((item) => item.workspaceID === second.id)?.status).toBe("connected") - }), - ) - await removeWorkspace(first.id) - await removeWorkspace(second.id) - }) - }) + yield* eventuallyEffect( + Effect.gen(function* () { + const status = yield* workspace.status() + expect(status.find((item) => item.workspaceID === first.id)?.status).toBe("connected") + expect(status.find((item) => item.workspaceID === second.id)?.status).toBe("connected") + }), + ) + }), + { git: true }, + ) test("local start reports error when the target directory is missing", async () => { await withInstance(async (dir) => { From 766318a4cf1f972309eed9cca1532a22d365b3cf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 13 May 2026 10:46:14 -0400 Subject: [PATCH 63/98] effect(snapshot): migrate to AppProcess.run (#27189) --- packages/opencode/src/snapshot/index.ts | 56 ++++++++++++++++++------- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 51fd267d5..70b034730 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -2,7 +2,7 @@ import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Context, S import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { formatPatch, structuredPatch } from "diff" import path from "path" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AppProcess } from "@opencode-ai/core/process" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Hash } from "@opencode-ai/core/util/hash" @@ -58,12 +58,12 @@ export class Service extends Context.Service()("@opencode/Sn export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | Config.Service + AppFileSystem.Service | AppProcess.Service | Config.Service > = Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const appProcess = yield* AppProcess.Service const config = yield* Config.Service const locks = new Map() @@ -90,18 +90,20 @@ export const layer: Layer.Layer< const enc = new TextEncoder() const feed = (list: string[]) => Stream.make(enc.encode(list.join("\0") + "\0")) - const git = Effect.fnUntraced( + const gitWithStdin = Effect.fnUntraced( function* ( cmd: string[], - opts?: { cwd?: string; env?: Record; stdin?: ChildProcess.CommandInput }, + opts: { cwd?: string; env?: Record; stdin: ChildProcess.CommandInput }, ) { + // stdin-feed calls still need raw spawn — AppProcess.run does not yet + // expose a stdin Stream API. Tracked as future AppProcess helper. const proc = ChildProcess.make("git", cmd, { - cwd: opts?.cwd, - env: opts?.env, + cwd: opts.cwd, + env: opts.env, extendEnv: true, - stdin: opts?.stdin, + stdin: opts.stdin, }) - const handle = yield* spawner.spawn(proc) + const handle = yield* appProcess.spawn(proc) const [text, stderr] = yield* Effect.all( [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], { concurrency: 2 }, @@ -119,9 +121,33 @@ export const layer: Layer.Layer< ), ) + const git = Effect.fnUntraced( + function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { + const result = yield* appProcess.run( + ChildProcess.make("git", cmd, { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + }), + ) + return { + code: ChildProcessSpawner.ExitCode(result.exitCode), + text: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + } satisfies GitResult + }, + Effect.catch((err) => + Effect.succeed({ + code: ChildProcessSpawner.ExitCode(1), + text: "", + stderr: err instanceof Error ? err.message : String(err), + }), + ), + ) + const ignore = Effect.fnUntraced(function* (files: string[]) { if (!files.length) return new Set() - const check = yield* git( + const check = yield* gitWithStdin( [ ...quote, "--git-dir", @@ -144,7 +170,7 @@ export const layer: Layer.Layer< const drop = Effect.fnUntraced(function* (files: string[]) { if (!files.length) return - yield* git( + yield* gitWithStdin( [ ...cfg, ...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]), @@ -158,7 +184,7 @@ export const layer: Layer.Layer< const stage = Effect.fnUntraced(function* (files: string[]) { if (!files.length) return - const result = yield* git( + const result = yield* gitWithStdin( [...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])], { cwd: state.directory, @@ -565,12 +591,14 @@ export const layer: Layer.Layer< }) if (!refs.length) return new Map() + // cat-file --batch is a stdin-feed call — kept on raw spawn + // until AppProcess.run exposes a stdin Stream API. const proc = ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], { cwd: state.directory, extendEnv: true, stdin: Stream.make(new TextEncoder().encode(refs.map((item) => item.ref).join("\n") + "\n")), }) - const handle = yield* spawner.spawn(proc) + const handle = yield* appProcess.spawn(proc) const [out, err] = yield* Effect.all( [Stream.mkUint8Array(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))], { concurrency: 2 }, @@ -767,7 +795,7 @@ export const layer: Layer.Layer< ) export const defaultLayer = layer.pipe( - Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(AppProcess.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Config.defaultLayer), ) From 5b5376a3fa86aa055d42193697981950c8d0c006 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 13 May 2026 14:47:40 +0000 Subject: [PATCH 64/98] chore: generate --- packages/core/src/plugin/provider/azure.ts | 21 +- packages/core/src/plugin/provider/gitlab.ts | 3 +- .../core/src/plugin/provider/google-vertex.ts | 25 +- packages/core/src/plugin/provider/opencode.ts | 2 +- .../core/src/plugin/provider/sap-ai-core.ts | 6 +- .../v2/plugin/provider-amazon-bedrock.test.ts | 5 +- packages/opencode/src/session/session.ts | 10 +- packages/opencode/src/snapshot/index.ts | 1353 ++++++++--------- packages/sdk/js/src/v2/gen/sdk.gen.ts | 57 +- packages/sdk/js/src/v2/gen/types.gen.ts | 141 +- packages/sdk/openapi.json | 639 ++++++++ specs/v2/provider-model.md | 33 +- 12 files changed, 1558 insertions(+), 737 deletions(-) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 86c3eb924..6c29a1610 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -24,7 +24,11 @@ export const AzurePlugin = PluginV2.define({ "aisdk.sdk": Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/azure") return if (evt.model.providerID === ProviderV2.ID.azure) { - if (!evt.options.resourceName && !evt.options.baseURL && (evt.model.endpoint.type !== "aisdk" || !evt.model.endpoint.url)) { + if ( + !evt.options.resourceName && + !evt.options.baseURL && + (evt.model.endpoint.type !== "aisdk" || !evt.model.endpoint.url) + ) { throw new Error( "AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it", ) @@ -35,11 +39,7 @@ export const AzurePlugin = PluginV2.define({ }), "aisdk.language": Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.azure) return - evt.language = selectLanguage( - evt.sdk, - evt.model.apiID, - Boolean(evt.options.useCompletionUrls), - ) + evt.language = selectLanguage(evt.sdk, evt.model.apiID, Boolean(evt.options.useCompletionUrls)) }), } }), @@ -52,15 +52,12 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({ "provider.update": Effect.fn(function* (evt) { if (evt.provider.id !== ProviderV2.ID.make("azure-cognitive-services")) return const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME - if (resourceName) evt.provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai` + if (resourceName) + evt.provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai` }), "aisdk.language": Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return - evt.language = selectLanguage( - evt.sdk, - evt.model.apiID, - Boolean(evt.options.useCompletionUrls), - ) + evt.language = selectLanguage(evt.sdk, evt.model.apiID, Boolean(evt.options.useCompletionUrls)) }), } }), diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index be923e7cb..226f5a45e 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -32,7 +32,8 @@ export const GitLabPlugin = PluginV2.define({ }), "aisdk.language": Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.gitlab) return - const featureFlags = typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {} + const featureFlags = + typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {} if (evt.model.apiID.startsWith("duo-workflow-")) { const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider")).pipe(Effect.orDie) const workflowRef = diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index f22f79f45..0c335df93 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -15,7 +15,13 @@ function resolveProject(options: Record) { } function resolveLocation(options: Record) { - return options.location ?? process.env.GOOGLE_VERTEX_LOCATION ?? process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "us-central1" + return ( + options.location ?? + process.env.GOOGLE_VERTEX_LOCATION ?? + process.env.GOOGLE_CLOUD_LOCATION ?? + process.env.VERTEX_LOCATION ?? + "us-central1" + ) } function vertexEndpoint(location: string) { @@ -60,7 +66,10 @@ export const GoogleVertexPlugin = PluginV2.define({ if (evt.provider.endpoint.type === "aisdk" && evt.provider.endpoint.url) { evt.provider.endpoint.url = replaceVertexVars(evt.provider.endpoint.url, project, location) } - if (evt.provider.endpoint.type === "aisdk" && evt.provider.endpoint.package.includes("@ai-sdk/openai-compatible")) { + if ( + evt.provider.endpoint.type === "aisdk" && + evt.provider.endpoint.package.includes("@ai-sdk/openai-compatible") + ) { evt.provider.options.aisdk.provider.fetch = authFetch(evt.provider.options.aisdk.provider.fetch) } }), @@ -95,8 +104,16 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({ return { "provider.update": Effect.fn(function* (evt) { if (evt.provider.id !== ProviderV2.ID.make("google-vertex-anthropic")) return - const project = evt.provider.options.aisdk.provider.project ?? process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCP_PROJECT ?? process.env.GCLOUD_PROJECT - const location = evt.provider.options.aisdk.provider.location ?? process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "global" + const project = + evt.provider.options.aisdk.provider.project ?? + process.env.GOOGLE_CLOUD_PROJECT ?? + process.env.GCP_PROJECT ?? + process.env.GCLOUD_PROJECT + const location = + evt.provider.options.aisdk.provider.location ?? + process.env.GOOGLE_CLOUD_LOCATION ?? + process.env.VERTEX_LOCATION ?? + "global" if (project) evt.provider.options.aisdk.provider.project = project evt.provider.options.aisdk.provider.location = location }), diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 44c904aec..10bbb62da 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -10,7 +10,7 @@ export const OpencodePlugin = PluginV2.define({ "provider.update": Effect.fn(function* (evt) { if (evt.provider.id !== ProviderV2.ID.opencode) return hasKey = Boolean( - process.env.OPENCODE_API_KEY || + process.env.OPENCODE_API_KEY || evt.provider.env.some((item) => process.env[item]) || evt.provider.options.aisdk.provider.apiKey || (evt.provider.enabled && evt.provider.enabled.via === "auth"), diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index 619f01eb3..7c57b785b 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -29,7 +29,11 @@ export const SapAICorePlugin = PluginV2.define({ const match = Object.keys(mod).find((name) => name.startsWith("create")) if (!match) throw new Error(`Package ${evt.package} has no provider factory export`) - evt.sdk = mod[match](serviceKey ? { deploymentId: process.env.AICORE_DEPLOYMENT_ID, resourceGroup: process.env.AICORE_RESOURCE_GROUP } : {}) + evt.sdk = mod[match]( + serviceKey + ? { deploymentId: process.env.AICORE_DEPLOYMENT_ID, resourceGroup: process.env.AICORE_RESOURCE_GROUP } + : {}, + ) }), "aisdk.language": Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return diff --git a/packages/core/test/v2/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/v2/plugin/provider-amazon-bedrock.test.ts index e7e53cb8d..c70ada08d 100644 --- a/packages/core/test/v2/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/v2/plugin/provider-amazon-bedrock.test.ts @@ -11,8 +11,9 @@ function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { function bedrockFetch(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID) - return (language as { config: { fetch: (input: Parameters[0], init?: RequestInit) => Promise } }).config - .fetch + return ( + language as { config: { fetch: (input: Parameters[0], init?: RequestInit) => Promise } } + ).config.fetch } describe("AmazonBedrockPlugin", () => { diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index edd4fe119..85486480a 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -507,7 +507,11 @@ export type Patch = Types.DeepMutable["dat const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => Effect.sync(() => Database.use(fn)) -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer< + Service, + never, + Bus.Service | Storage.Service | SyncEvent.Service | RuntimeFlags.Service +> = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service @@ -571,7 +575,9 @@ export const layer: Layer.Layer()("@opencode/Snapshot") {} -export const layer: Layer.Layer< - Service, - never, - AppFileSystem.Service | AppProcess.Service | Config.Service -> = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const appProcess = yield* AppProcess.Service - const config = yield* Config.Service - const locks = new Map() +export const layer: Layer.Layer = + Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const appProcess = yield* AppProcess.Service + const config = yield* Config.Service + const locks = new Map() - const lock = (key: string) => { - const hit = locks.get(key) - if (hit) return hit + const lock = (key: string) => { + const hit = locks.get(key) + if (hit) return hit - const next = Semaphore.makeUnsafe(1) - locks.set(key, next) - return next - } + const next = Semaphore.makeUnsafe(1) + locks.set(key, next) + return next + } - const state = yield* InstanceState.make( - Effect.fn("Snapshot.state")(function* (ctx) { - const state = { - directory: ctx.directory, - worktree: ctx.worktree, - gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)), - vcs: ctx.project.vcs, - } + const state = yield* InstanceState.make( + Effect.fn("Snapshot.state")(function* (ctx) { + const state = { + directory: ctx.directory, + worktree: ctx.worktree, + gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)), + vcs: ctx.project.vcs, + } - const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd] + const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd] - const enc = new TextEncoder() - const feed = (list: string[]) => Stream.make(enc.encode(list.join("\0") + "\0")) + const enc = new TextEncoder() + const feed = (list: string[]) => Stream.make(enc.encode(list.join("\0") + "\0")) - const gitWithStdin = Effect.fnUntraced( - function* ( - cmd: string[], - opts: { cwd?: string; env?: Record; stdin: ChildProcess.CommandInput }, - ) { - // stdin-feed calls still need raw spawn — AppProcess.run does not yet - // expose a stdin Stream API. Tracked as future AppProcess helper. - const proc = ChildProcess.make("git", cmd, { - cwd: opts.cwd, - env: opts.env, - extendEnv: true, - stdin: opts.stdin, + const gitWithStdin = Effect.fnUntraced( + function* ( + cmd: string[], + opts: { cwd?: string; env?: Record; stdin: ChildProcess.CommandInput }, + ) { + // stdin-feed calls still need raw spawn — AppProcess.run does not yet + // expose a stdin Stream API. Tracked as future AppProcess helper. + const proc = ChildProcess.make("git", cmd, { + cwd: opts.cwd, + env: opts.env, + extendEnv: true, + stdin: opts.stdin, + }) + const handle = yield* appProcess.spawn(proc) + const [text, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, text, stderr } satisfies GitResult + }, + Effect.scoped, + Effect.catch((err) => + Effect.succeed({ + code: ChildProcessSpawner.ExitCode(1), + text: "", + stderr: err instanceof Error ? err.message : String(err), + }), + ), + ) + + const git = Effect.fnUntraced( + function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { + const result = yield* appProcess.run( + ChildProcess.make("git", cmd, { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + }), + ) + return { + code: ChildProcessSpawner.ExitCode(result.exitCode), + text: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + } satisfies GitResult + }, + Effect.catch((err) => + Effect.succeed({ + code: ChildProcessSpawner.ExitCode(1), + text: "", + stderr: err instanceof Error ? err.message : String(err), + }), + ), + ) + + const ignore = Effect.fnUntraced(function* (files: string[]) { + if (!files.length) return new Set() + const check = yield* gitWithStdin( + [ + ...quote, + "--git-dir", + path.join(state.worktree, ".git"), + "--work-tree", + state.worktree, + "check-ignore", + "--no-index", + "--stdin", + "-z", + ], + { + cwd: state.directory, + stdin: feed(files), + }, + ) + if (check.code !== 0 && check.code !== 1) return new Set() + return new Set(check.text.split("\0").filter(Boolean)) + }) + + const drop = Effect.fnUntraced(function* (files: string[]) { + if (!files.length) return + yield* gitWithStdin( + [ + ...cfg, + ...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]), + ], + { + cwd: state.directory, + stdin: feed(files), + }, + ) + }) + + const stage = Effect.fnUntraced(function* (files: string[]) { + if (!files.length) return + const result = yield* gitWithStdin( + [...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])], + { + cwd: state.directory, + stdin: feed(files), + }, + ) + if (result.code === 0) return + log.warn("failed to add snapshot files", { + exitCode: result.code, + stderr: result.stderr, }) - const handle = yield* appProcess.spawn(proc) - const [text, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + }) + + const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) + const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) + const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) + const locked = (fx: Effect.Effect) => lock(state.gitdir).withPermits(1)(fx) + + const enabled = Effect.fnUntraced(function* () { + if (state.vcs !== "git") return false + return (yield* config.get()).snapshot !== false + }) + + const excludes = Effect.fnUntraced(function* () { + const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { + cwd: state.worktree, + }) + const file = result.text.trim() + if (!file) return + if (!(yield* exists(file))) return + return file + }) + + const sync = Effect.fnUntraced(function* (list: string[] = []) { + const file = yield* excludes() + const target = path.join(state.gitdir, "info", "exclude") + const text = [ + file ? (yield* read(file)).trimEnd() : "", + ...list.map((item) => `/${item.replaceAll("\\", "/")}`), + ] + .filter(Boolean) + .join("\n") + yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie) + yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie) + }) + + const add = Effect.fnUntraced(function* () { + yield* sync() + const [diff, other] = yield* Effect.all( + [ + git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], { + cwd: state.directory, + }), + git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], { + cwd: state.directory, + }), + ], { concurrency: 2 }, ) - const code = yield* handle.exitCode - return { code, text, stderr } satisfies GitResult - }, - Effect.scoped, - Effect.catch((err) => - Effect.succeed({ - code: ChildProcessSpawner.ExitCode(1), - text: "", - stderr: err instanceof Error ? err.message : String(err), - }), - ), - ) + if (diff.code !== 0 || other.code !== 0) { + log.warn("failed to list snapshot files", { + diffCode: diff.code, + diffStderr: diff.stderr, + otherCode: other.code, + otherStderr: other.stderr, + }) + return + } - const git = Effect.fnUntraced( - function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { - const result = yield* appProcess.run( - ChildProcess.make("git", cmd, { - cwd: opts?.cwd, - env: opts?.env, - extendEnv: true, + const tracked = diff.text.split("\0").filter(Boolean) + const untracked = other.text.split("\0").filter(Boolean) + const all = Array.from(new Set([...tracked, ...untracked])) + if (!all.length) return + + // Resolve source-repo ignore rules against the exact candidate set. + // --no-index keeps this pattern-based even when a path is already tracked. + const ignored = yield* ignore(all) + + // Remove newly-ignored files from snapshot index to prevent re-adding + if (ignored.size > 0) { + const ignoredFiles = Array.from(ignored) + log.info("removing gitignored files from snapshot", { count: ignoredFiles.length }) + yield* drop(ignoredFiles) + } + + const allow = all.filter((item) => !ignored.has(item)) + if (!allow.length) return + + const large = new Set( + (yield* Effect.all( + allow.map((item) => + fs + .stat(path.join(state.directory, item)) + .pipe(Effect.catch(() => Effect.void)) + .pipe( + Effect.map((stat) => { + if (!stat || stat.type !== "File") return + const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size + return size > limit ? item : undefined + }), + ), + ), + { concurrency: 8 }, + )).filter((item): item is string => Boolean(item)), + ) + const block = new Set(untracked.filter((item) => large.has(item))) + yield* sync(Array.from(block)) + // Stage only the allowed candidate paths so snapshot updates stay scoped. + yield* stage(allow.filter((item) => !block.has(item))) + }) + + const cleanup = Effect.fnUntraced(function* () { + return yield* locked( + Effect.gen(function* () { + if (!(yield* enabled())) return + if (!(yield* exists(state.gitdir))) return + const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory }) + if (result.code !== 0) { + log.warn("cleanup failed", { + exitCode: result.code, + stderr: result.stderr, + }) + return + } + log.info("cleanup", { prune }) }), ) - return { - code: ChildProcessSpawner.ExitCode(result.exitCode), - text: result.stdout.toString("utf8"), - stderr: result.stderr.toString("utf8"), - } satisfies GitResult - }, - Effect.catch((err) => - Effect.succeed({ - code: ChildProcessSpawner.ExitCode(1), - text: "", - stderr: err instanceof Error ? err.message : String(err), - }), - ), - ) - - const ignore = Effect.fnUntraced(function* (files: string[]) { - if (!files.length) return new Set() - const check = yield* gitWithStdin( - [ - ...quote, - "--git-dir", - path.join(state.worktree, ".git"), - "--work-tree", - state.worktree, - "check-ignore", - "--no-index", - "--stdin", - "-z", - ], - { - cwd: state.directory, - stdin: feed(files), - }, - ) - if (check.code !== 0 && check.code !== 1) return new Set() - return new Set(check.text.split("\0").filter(Boolean)) - }) - - const drop = Effect.fnUntraced(function* (files: string[]) { - if (!files.length) return - yield* gitWithStdin( - [ - ...cfg, - ...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]), - ], - { - cwd: state.directory, - stdin: feed(files), - }, - ) - }) - - const stage = Effect.fnUntraced(function* (files: string[]) { - if (!files.length) return - const result = yield* gitWithStdin( - [...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])], - { - cwd: state.directory, - stdin: feed(files), - }, - ) - if (result.code === 0) return - log.warn("failed to add snapshot files", { - exitCode: result.code, - stderr: result.stderr, }) - }) - const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) - const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) - const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) - const locked = (fx: Effect.Effect) => lock(state.gitdir).withPermits(1)(fx) - - const enabled = Effect.fnUntraced(function* () { - if (state.vcs !== "git") return false - return (yield* config.get()).snapshot !== false - }) - - const excludes = Effect.fnUntraced(function* () { - const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { - cwd: state.worktree, + const track = Effect.fnUntraced(function* () { + return yield* locked( + Effect.gen(function* () { + if (!(yield* enabled())) return + const existed = yield* exists(state.gitdir) + yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie) + if (!existed) { + yield* git(["init"], { + env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree }, + }) + yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"]) + yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"]) + yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"]) + yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"]) + log.info("initialized") + } + yield* add() + const result = yield* git(args(["write-tree"]), { cwd: state.directory }) + const hash = result.text.trim() + log.info("tracking", { hash, cwd: state.directory, git: state.gitdir }) + return hash + }), + ) }) - const file = result.text.trim() - if (!file) return - if (!(yield* exists(file))) return - return file - }) - const sync = Effect.fnUntraced(function* (list: string[] = []) { - const file = yield* excludes() - const target = path.join(state.gitdir, "info", "exclude") - const text = [ - file ? (yield* read(file)).trimEnd() : "", - ...list.map((item) => `/${item.replaceAll("\\", "/")}`), - ] - .filter(Boolean) - .join("\n") - yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie) - yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie) - }) + const patch = Effect.fnUntraced(function* (hash: string) { + return yield* locked( + Effect.gen(function* () { + yield* add() + const result = yield* git( + [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])], + { + cwd: state.directory, + }, + ) + if (result.code !== 0) { + log.warn("failed to get diff", { hash, exitCode: result.code }) + return { hash, files: [] } + } + const files = result.text + .trim() + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) - const add = Effect.fnUntraced(function* () { - yield* sync() - const [diff, other] = yield* Effect.all( - [ - git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], { - cwd: state.directory, + // Hide ignored-file removals from the user-facing patch output. + const ignored = yield* ignore(files) + + return { + hash, + files: files + .filter((item) => !ignored.has(item)) + .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), + } }), - git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], { - cwd: state.directory, - }), - ], - { concurrency: 2 }, - ) - if (diff.code !== 0 || other.code !== 0) { - log.warn("failed to list snapshot files", { - diffCode: diff.code, - diffStderr: diff.stderr, - otherCode: other.code, - otherStderr: other.stderr, - }) - return - } + ) + }) - const tracked = diff.text.split("\0").filter(Boolean) - const untracked = other.text.split("\0").filter(Boolean) - const all = Array.from(new Set([...tracked, ...untracked])) - if (!all.length) return - - // Resolve source-repo ignore rules against the exact candidate set. - // --no-index keeps this pattern-based even when a path is already tracked. - const ignored = yield* ignore(all) - - // Remove newly-ignored files from snapshot index to prevent re-adding - if (ignored.size > 0) { - const ignoredFiles = Array.from(ignored) - log.info("removing gitignored files from snapshot", { count: ignoredFiles.length }) - yield* drop(ignoredFiles) - } - - const allow = all.filter((item) => !ignored.has(item)) - if (!allow.length) return - - const large = new Set( - (yield* Effect.all( - allow.map((item) => - fs - .stat(path.join(state.directory, item)) - .pipe(Effect.catch(() => Effect.void)) - .pipe( - Effect.map((stat) => { - if (!stat || stat.type !== "File") return - const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size - return size > limit ? item : undefined - }), - ), - ), - { concurrency: 8 }, - )).filter((item): item is string => Boolean(item)), - ) - const block = new Set(untracked.filter((item) => large.has(item))) - yield* sync(Array.from(block)) - // Stage only the allowed candidate paths so snapshot updates stay scoped. - yield* stage(allow.filter((item) => !block.has(item))) - }) - - const cleanup = Effect.fnUntraced(function* () { - return yield* locked( - Effect.gen(function* () { - if (!(yield* enabled())) return - if (!(yield* exists(state.gitdir))) return - const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory }) - if (result.code !== 0) { - log.warn("cleanup failed", { + const restore = Effect.fnUntraced(function* (snapshot: string) { + return yield* locked( + Effect.gen(function* () { + log.info("restore", { commit: snapshot }) + const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree }) + if (result.code === 0) { + const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { + cwd: state.worktree, + }) + if (checkout.code === 0) return + log.error("failed to restore snapshot", { + snapshot, + exitCode: checkout.code, + stderr: checkout.stderr, + }) + return + } + log.error("failed to restore snapshot", { + snapshot, exitCode: result.code, stderr: result.stderr, }) - return - } - log.info("cleanup", { prune }) - }), - ) - }) + }), + ) + }) - const track = Effect.fnUntraced(function* () { - return yield* locked( - Effect.gen(function* () { - if (!(yield* enabled())) return - const existed = yield* exists(state.gitdir) - yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie) - if (!existed) { - yield* git(["init"], { - env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree }, - }) - yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"]) - yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"]) - yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"]) - yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"]) - log.info("initialized") - } - yield* add() - const result = yield* git(args(["write-tree"]), { cwd: state.directory }) - const hash = result.text.trim() - log.info("tracking", { hash, cwd: state.directory, git: state.gitdir }) - return hash - }), - ) - }) - - const patch = Effect.fnUntraced(function* (hash: string) { - return yield* locked( - Effect.gen(function* () { - yield* add() - const result = yield* git( - [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])], - { - cwd: state.directory, - }, - ) - if (result.code !== 0) { - log.warn("failed to get diff", { hash, exitCode: result.code }) - return { hash, files: [] } - } - const files = result.text - .trim() - .split("\n") - .map((x) => x.trim()) - .filter(Boolean) - - // Hide ignored-file removals from the user-facing patch output. - const ignored = yield* ignore(files) - - return { - hash, - files: files - .filter((item) => !ignored.has(item)) - .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), - } - }), - ) - }) - - const restore = Effect.fnUntraced(function* (snapshot: string) { - return yield* locked( - Effect.gen(function* () { - log.info("restore", { commit: snapshot }) - const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree }) - if (result.code === 0) { - const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { - cwd: state.worktree, - }) - if (checkout.code === 0) return - log.error("failed to restore snapshot", { - snapshot, - exitCode: checkout.code, - stderr: checkout.stderr, - }) - return - } - log.error("failed to restore snapshot", { - snapshot, - exitCode: result.code, - stderr: result.stderr, - }) - }), - ) - }) - - const revert = Effect.fnUntraced(function* (patches: Patch[]) { - return yield* locked( - Effect.gen(function* () { - const ops: { hash: string; file: string; rel: string }[] = [] - const seen = new Set() - for (const item of patches) { - for (const file of item.files) { - if (seen.has(file)) continue - seen.add(file) - ops.push({ - hash: item.hash, - file, - rel: path.relative(state.worktree, file).replaceAll("\\", "/"), - }) - } - } - - const single = Effect.fnUntraced(function* (op: (typeof ops)[number]) { - log.info("reverting", { file: op.file, hash: op.hash }) - const result = yield* git([...core, ...args(["checkout", op.hash, "--", op.file])], { - cwd: state.worktree, - }) - if (result.code === 0) return - const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], { - cwd: state.worktree, - }) - if (tree.code === 0 && tree.text.trim()) { - log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash }) - return - } - log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) - yield* remove(op.file) - }) - - const clash = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`) - - for (let i = 0; i < ops.length; ) { - const first = ops[i]! - const run = [first] - let j = i + 1 - // Only batch adjacent files when their paths cannot affect each other. - while (j < ops.length && run.length < 100) { - const next = ops[j]! - if (next.hash !== first.hash) break - if (run.some((item) => clash(item.rel, next.rel))) break - run.push(next) - j += 1 - } - - if (run.length === 1) { - yield* single(first) - i = j - continue - } - - const tree = yield* git( - [...core, ...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)])], - { - cwd: state.worktree, - }, - ) - - if (tree.code !== 0) { - log.info("batched ls-tree failed, falling back to single-file revert", { - hash: first.hash, - files: run.length, - }) - for (const op of run) { - yield* single(op) + const revert = Effect.fnUntraced(function* (patches: Patch[]) { + return yield* locked( + Effect.gen(function* () { + const ops: { hash: string; file: string; rel: string }[] = [] + const seen = new Set() + for (const item of patches) { + for (const file of item.files) { + if (seen.has(file)) continue + seen.add(file) + ops.push({ + hash: item.hash, + file, + rel: path.relative(state.worktree, file).replaceAll("\\", "/"), + }) } - i = j - continue } - const have = new Set( - tree.text - .trim() - .split("\n") - .map((item) => item.trim()) - .filter(Boolean), - ) - const list = run.filter((item) => have.has(item.rel)) - if (list.length) { - log.info("reverting", { hash: first.hash, files: list.length }) - const result = yield* git( - [...core, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])], + const single = Effect.fnUntraced(function* (op: (typeof ops)[number]) { + log.info("reverting", { file: op.file, hash: op.hash }) + const result = yield* git([...core, ...args(["checkout", op.hash, "--", op.file])], { + cwd: state.worktree, + }) + if (result.code === 0) return + const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], { + cwd: state.worktree, + }) + if (tree.code === 0 && tree.text.trim()) { + log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash }) + return + } + log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) + yield* remove(op.file) + }) + + const clash = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`) + + for (let i = 0; i < ops.length; ) { + const first = ops[i]! + const run = [first] + let j = i + 1 + // Only batch adjacent files when their paths cannot affect each other. + while (j < ops.length && run.length < 100) { + const next = ops[j]! + if (next.hash !== first.hash) break + if (run.some((item) => clash(item.rel, next.rel))) break + run.push(next) + j += 1 + } + + if (run.length === 1) { + yield* single(first) + i = j + continue + } + + const tree = yield* git( + [...core, ...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)])], { cwd: state.worktree, }, ) - if (result.code !== 0) { - log.info("batched checkout failed, falling back to single-file revert", { + + if (tree.code !== 0) { + log.info("batched ls-tree failed, falling back to single-file revert", { hash: first.hash, - files: list.length, + files: run.length, }) for (const op of run) { yield* single(op) @@ -499,300 +468,330 @@ export const layer: Layer.Layer< i = j continue } - } - for (const op of run) { - if (have.has(op.rel)) continue - log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) - yield* remove(op.file) - } - - i = j - } - }), - ) - }) - - const diff = Effect.fnUntraced(function* (hash: string) { - return yield* locked( - Effect.gen(function* () { - yield* add() - const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], { - cwd: state.worktree, - }) - if (result.code !== 0) { - log.warn("failed to get diff", { - hash, - exitCode: result.code, - stderr: result.stderr, - }) - return "" - } - return result.text.trim() - }), - ) - }) - - const diffFull = Effect.fnUntraced(function* (from: string, to: string) { - return yield* locked( - Effect.gen(function* () { - type Row = { - file: string - status: "added" | "deleted" | "modified" - binary: boolean - additions: number - deletions: number - } - - type Ref = { - file: string - side: "before" | "after" - ref: string - } - - const show = Effect.fnUntraced(function* (row: Row) { - if (row.binary) return ["", ""] - if (row.status === "added") { - return [ - "", - yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)), - ] - } - if (row.status === "deleted") { - return [ - yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe( - Effect.map((item) => item.text), - ), - "", - ] - } - return yield* Effect.all( - [ - git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)), - git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)), - ], - { concurrency: 2 }, - ) - }) - - const load = Effect.fnUntraced( - function* (rows: Row[]) { - const refs = rows.flatMap((row) => { - if (row.binary) return [] - if (row.status === "added") - return [{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref] - if (row.status === "deleted") { - return [{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref] - } - return [ - { file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref, - { file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref, - ] - }) - if (!refs.length) return new Map() - - // cat-file --batch is a stdin-feed call — kept on raw spawn - // until AppProcess.run exposes a stdin Stream API. - const proc = ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], { - cwd: state.directory, - extendEnv: true, - stdin: Stream.make(new TextEncoder().encode(refs.map((item) => item.ref).join("\n") + "\n")), - }) - const handle = yield* appProcess.spawn(proc) - const [out, err] = yield* Effect.all( - [Stream.mkUint8Array(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, + const have = new Set( + tree.text + .trim() + .split("\n") + .map((item) => item.trim()) + .filter(Boolean), ) - const code = yield* handle.exitCode - if (code !== 0) { - log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", { - stderr: err, - refs: refs.length, - }) - return - } - - const fail = (msg: string, extra?: Record) => { - log.info(msg, { ...extra, refs: refs.length }) - return undefined - } - - const map = new Map() - const dec = new TextDecoder() - let i = 0 - for (const ref of refs) { - let end = i - while (end < out.length && out[end] !== 10) end += 1 - if (end >= out.length) { - return fail( - "git cat-file --batch returned a truncated header during snapshot diff, falling back to per-file git show", - ) - } - - const head = dec.decode(out.slice(i, end)) - i = end + 1 - const hit = map.get(ref.file) ?? { before: "", after: "" } - if (head.endsWith(" missing")) { - map.set(ref.file, hit) + const list = run.filter((item) => have.has(item.rel)) + if (list.length) { + log.info("reverting", { hash: first.hash, files: list.length }) + const result = yield* git( + [...core, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])], + { + cwd: state.worktree, + }, + ) + if (result.code !== 0) { + log.info("batched checkout failed, falling back to single-file revert", { + hash: first.hash, + files: list.length, + }) + for (const op of run) { + yield* single(op) + } + i = j continue } - - const match = head.match(/^[0-9a-f]+ blob (\d+)$/) - if (!match) { - return fail( - "git cat-file --batch returned an unexpected header during snapshot diff, falling back to per-file git show", - { head }, - ) - } - - const size = Number(match[1]) - if (!Number.isInteger(size) || size < 0 || i + size >= out.length || out[i + size] !== 10) { - return fail( - "git cat-file --batch returned truncated content during snapshot diff, falling back to per-file git show", - { head }, - ) - } - - const text = dec.decode(out.slice(i, i + size)) - if (ref.side === "before") hit.before = text - if (ref.side === "after") hit.after = text - map.set(ref.file, hit) - i += size + 1 } - if (i !== out.length) { - return fail( - "git cat-file --batch returned trailing data during snapshot diff, falling back to per-file git show", - ) + for (const op of run) { + if (have.has(op.rel)) continue + log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) + yield* remove(op.file) } - return map - }, - Effect.scoped, - Effect.catch(() => - Effect.succeed | undefined>(undefined), - ), - ) + i = j + } + }), + ) + }) - const result: FileDiff[] = [] - const status = new Map() + const diff = Effect.fnUntraced(function* (hash: string) { + return yield* locked( + Effect.gen(function* () { + yield* add() + const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], { + cwd: state.worktree, + }) + if (result.code !== 0) { + log.warn("failed to get diff", { + hash, + exitCode: result.code, + stderr: result.stderr, + }) + return "" + } + return result.text.trim() + }), + ) + }) - const statuses = yield* git( - [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])], - { cwd: state.directory }, - ) + const diffFull = Effect.fnUntraced(function* (from: string, to: string) { + return yield* locked( + Effect.gen(function* () { + type Row = { + file: string + status: "added" | "deleted" | "modified" + binary: boolean + additions: number + deletions: number + } - for (const line of statuses.text.trim().split("\n")) { - if (!line) continue - const [code, file] = line.split("\t") - if (!code || !file) continue - status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified") - } + type Ref = { + file: string + side: "before" | "after" + ref: string + } - const numstat = yield* git( - [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], - { - cwd: state.directory, - }, - ) - - const rows = numstat.text - .trim() - .split("\n") - .filter(Boolean) - .flatMap((line) => { - const [adds, dels, file] = line.split("\t") - if (!file) return [] - const binary = adds === "-" && dels === "-" - const additions = binary ? 0 : parseInt(adds) - const deletions = binary ? 0 : parseInt(dels) - return [ - { - file, - status: status.get(file) ?? "modified", - binary, - additions: Number.isFinite(additions) ? additions : 0, - deletions: Number.isFinite(deletions) ? deletions : 0, - } satisfies Row, - ] + const show = Effect.fnUntraced(function* (row: Row) { + if (row.binary) return ["", ""] + if (row.status === "added") { + return [ + "", + yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe( + Effect.map((item) => item.text), + ), + ] + } + if (row.status === "deleted") { + return [ + yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe( + Effect.map((item) => item.text), + ), + "", + ] + } + return yield* Effect.all( + [ + git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)), + git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)), + ], + { concurrency: 2 }, + ) }) - // Hide ignored-file removals from the user-facing diff output. - const ignored = yield* ignore(rows.map((r) => r.file)) - if (ignored.size > 0) { - const filtered = rows.filter((r) => !ignored.has(r.file)) - rows.length = 0 - rows.push(...filtered) - } + const load = Effect.fnUntraced( + function* (rows: Row[]) { + const refs = rows.flatMap((row) => { + if (row.binary) return [] + if (row.status === "added") + return [{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref] + if (row.status === "deleted") { + return [{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref] + } + return [ + { file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref, + { file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref, + ] + }) + if (!refs.length) return new Map() - const step = 100 - const patch = (file: string, before: string, after: string) => - formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) + // cat-file --batch is a stdin-feed call — kept on raw spawn + // until AppProcess.run exposes a stdin Stream API. + const proc = ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], { + cwd: state.directory, + extendEnv: true, + stdin: Stream.make(new TextEncoder().encode(refs.map((item) => item.ref).join("\n") + "\n")), + }) + const handle = yield* appProcess.spawn(proc) + const [out, err] = yield* Effect.all( + [Stream.mkUint8Array(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + if (code !== 0) { + log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", { + stderr: err, + refs: refs.length, + }) + return + } - for (let i = 0; i < rows.length; i += step) { - const run = rows.slice(i, i + step) - const text = yield* load(run) + const fail = (msg: string, extra?: Record) => { + log.info(msg, { ...extra, refs: refs.length }) + return undefined + } - for (const row of run) { - const hit = text?.get(row.file) ?? { before: "", after: "" } - const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row) - result.push({ - file: row.file, - patch: row.binary ? "" : patch(row.file, before, after), - additions: row.additions, - deletions: row.deletions, - status: row.status, - }) + const map = new Map() + const dec = new TextDecoder() + let i = 0 + for (const ref of refs) { + let end = i + while (end < out.length && out[end] !== 10) end += 1 + if (end >= out.length) { + return fail( + "git cat-file --batch returned a truncated header during snapshot diff, falling back to per-file git show", + ) + } + + const head = dec.decode(out.slice(i, end)) + i = end + 1 + const hit = map.get(ref.file) ?? { before: "", after: "" } + if (head.endsWith(" missing")) { + map.set(ref.file, hit) + continue + } + + const match = head.match(/^[0-9a-f]+ blob (\d+)$/) + if (!match) { + return fail( + "git cat-file --batch returned an unexpected header during snapshot diff, falling back to per-file git show", + { head }, + ) + } + + const size = Number(match[1]) + if (!Number.isInteger(size) || size < 0 || i + size >= out.length || out[i + size] !== 10) { + return fail( + "git cat-file --batch returned truncated content during snapshot diff, falling back to per-file git show", + { head }, + ) + } + + const text = dec.decode(out.slice(i, i + size)) + if (ref.side === "before") hit.before = text + if (ref.side === "after") hit.after = text + map.set(ref.file, hit) + i += size + 1 + } + + if (i !== out.length) { + return fail( + "git cat-file --batch returned trailing data during snapshot diff, falling back to per-file git show", + ) + } + + return map + }, + Effect.scoped, + Effect.catch(() => + Effect.succeed | undefined>(undefined), + ), + ) + + const result: FileDiff[] = [] + const status = new Map() + + const statuses = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])], + { cwd: state.directory }, + ) + + for (const line of statuses.text.trim().split("\n")) { + if (!line) continue + const [code, file] = line.split("\t") + if (!code || !file) continue + status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified") } - } - return result + const numstat = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], + { + cwd: state.directory, + }, + ) + + const rows = numstat.text + .trim() + .split("\n") + .filter(Boolean) + .flatMap((line) => { + const [adds, dels, file] = line.split("\t") + if (!file) return [] + const binary = adds === "-" && dels === "-" + const additions = binary ? 0 : parseInt(adds) + const deletions = binary ? 0 : parseInt(dels) + return [ + { + file, + status: status.get(file) ?? "modified", + binary, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + } satisfies Row, + ] + }) + + // Hide ignored-file removals from the user-facing diff output. + const ignored = yield* ignore(rows.map((r) => r.file)) + if (ignored.size > 0) { + const filtered = rows.filter((r) => !ignored.has(r.file)) + rows.length = 0 + rows.push(...filtered) + } + + const step = 100 + const patch = (file: string, before: string, after: string) => + formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) + + for (let i = 0; i < rows.length; i += step) { + const run = rows.slice(i, i + step) + const text = yield* load(run) + + for (const row of run) { + const hit = text?.get(row.file) ?? { before: "", after: "" } + const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row) + result.push({ + file: row.file, + patch: row.binary ? "" : patch(row.file, before, after), + additions: row.additions, + deletions: row.deletions, + status: row.status, + }) + } + } + + return result + }), + ) + }) + + yield* cleanup().pipe( + Effect.catchCause((cause) => { + log.error("cleanup loop failed", { cause: Cause.pretty(cause) }) + return Effect.void }), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.delay(Duration.minutes(1)), + Effect.forkScoped, ) - }) - yield* cleanup().pipe( - Effect.catchCause((cause) => { - log.error("cleanup loop failed", { cause: Cause.pretty(cause) }) - return Effect.void - }), - Effect.repeat(Schedule.spaced(Duration.hours(1))), - Effect.delay(Duration.minutes(1)), - Effect.forkScoped, - ) + return { cleanup, track, patch, restore, revert, diff, diffFull } + }), + ) - return { cleanup, track, patch, restore, revert, diff, diffFull } - }), - ) - - return Service.of({ - init: Effect.fn("Snapshot.init")(function* () { - yield* InstanceState.get(state) - }), - cleanup: Effect.fn("Snapshot.cleanup")(function* () { - return yield* InstanceState.useEffect(state, (s) => s.cleanup()) - }), - track: Effect.fn("Snapshot.track")(function* () { - return yield* InstanceState.useEffect(state, (s) => s.track()) - }), - patch: Effect.fn("Snapshot.patch")(function* (hash: string) { - return yield* InstanceState.useEffect(state, (s) => s.patch(hash)) - }), - restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) { - return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot)) - }), - revert: Effect.fn("Snapshot.revert")(function* (patches: Patch[]) { - return yield* InstanceState.useEffect(state, (s) => s.revert(patches)) - }), - diff: Effect.fn("Snapshot.diff")(function* (hash: string) { - return yield* InstanceState.useEffect(state, (s) => s.diff(hash)) - }), - diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) { - return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to)) - }), - }) - }), -) + return Service.of({ + init: Effect.fn("Snapshot.init")(function* () { + yield* InstanceState.get(state) + }), + cleanup: Effect.fn("Snapshot.cleanup")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.cleanup()) + }), + track: Effect.fn("Snapshot.track")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.track()) + }), + patch: Effect.fn("Snapshot.patch")(function* (hash: string) { + return yield* InstanceState.useEffect(state, (s) => s.patch(hash)) + }), + restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) { + return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot)) + }), + revert: Effect.fn("Snapshot.revert")(function* (patches: Patch[]) { + return yield* InstanceState.useEffect(state, (s) => s.revert(patches)) + }), + diff: Effect.fn("Snapshot.diff")(function* (hash: string) { + return yield* InstanceState.useEffect(state, (s) => s.diff(hash)) + }), + diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) { + return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to)) + }), + }) + }), + ) export const defaultLayer = layer.pipe( Layer.provide(AppProcess.defaultLayer), diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index e6e0c4638..37b938574 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -198,6 +198,9 @@ import type { TuiShowToastResponses, TuiSubmitPromptResponses, V2ModelListResponses, + V2ProviderGetErrors, + V2ProviderGetResponses, + V2ProviderListResponses, V2SessionCompactResponses, V2SessionContextResponses, V2SessionListErrors, @@ -4382,26 +4385,41 @@ export class Model extends HeyApiClient { * * Retrieve available v2 models ordered by release date. */ - public list( - parameters?: { - directory?: string - workspace?: string + public list(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/api/model", + ...options, + }) + } +} + +export class Provider2 extends HeyApiClient { + /** + * List v2 providers + * + * Retrieve active v2 AI providers so clients can show provider availability and configuration. + */ + public list(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/api/provider", + ...options, + }) + } + + /** + * Get v2 provider + * + * Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings. + */ + public get( + parameters: { + providerID: string }, options?: Options, ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/api/model", + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "providerID" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/provider/{providerID}", ...options, ...params, }) @@ -4418,6 +4436,11 @@ export class V2 extends HeyApiClient { get model(): Model { return (this._model ??= new Model({ client: this.client })) } + + private _provider?: Provider2 + get provider(): Provider2 { + return (this._provider ??= new Provider2({ client: this.client })) + } } export class Control extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 99bbfd5ec..014a5fbab 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3380,10 +3380,14 @@ export type SessionMessage = export type ModelV2Info = { id: string + apiID: string providerID: string family?: string name: string endpoint: + | { + type: "unknown" + } | { type: "openai/responses" url: string @@ -3404,6 +3408,11 @@ export type ModelV2Info = { type: "anthropic/messages" url: string } + | { + type: "aisdk" + package: string + url?: string + } capabilities: { tools: boolean input: Array @@ -3416,6 +3425,14 @@ export type ModelV2Info = { body: { [key: string]: unknown } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } variant?: string } variants: Array<{ @@ -3426,6 +3443,14 @@ export type ModelV2Info = { body: { [key: string]: unknown } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } }> time: { released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" @@ -3443,6 +3468,7 @@ export type ModelV2Info = { } }> status: "alpha" | "beta" | "deprecated" | "active" + enabled: boolean limit: { context: number input?: number @@ -3450,6 +3476,73 @@ export type ModelV2Info = { } } +export type ProviderV2Info = { + id: string + name: string + enabled: + | false + | { + via: "env" + name: string + } + | { + via: "auth" + service: string + } + | { + via: "custom" + data: { + [key: string]: unknown + } + } + env: Array + endpoint: + | { + type: "unknown" + } + | { + type: "openai/responses" + url: string + websocket?: boolean + } + | { + type: "openai/completions" + url: string + reasoning?: + | { + type: "reasoning_content" + } + | { + type: "reasoning_details" + } + } + | { + type: "anthropic/messages" + url: string + } + | { + type: "aisdk" + package: string + url?: string + } + options: { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + } +} + export type EventTuiToastShow1 = { id: string type: "tui.toast.show" @@ -6580,10 +6673,7 @@ export type V2SessionMessagesResponse2 = V2SessionMessagesResponses[keyof V2Sess export type V2ModelListData = { body?: never path?: never - query?: { - directory?: string - workspace?: string - } + query?: never url: "/api/model" } @@ -6596,6 +6686,49 @@ export type V2ModelListResponses = { export type V2ModelListResponse = V2ModelListResponses[keyof V2ModelListResponses] +export type V2ProviderListData = { + body?: never + path?: never + query?: never + url: "/api/provider" +} + +export type V2ProviderListResponses = { + /** + * Success + */ + 200: Array +} + +export type V2ProviderListResponse = V2ProviderListResponses[keyof V2ProviderListResponses] + +export type V2ProviderGetData = { + body?: never + path: { + providerID: string + } + query?: never + url: "/api/provider/{providerID}" +} + +export type V2ProviderGetErrors = { + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type V2ProviderGetError = V2ProviderGetErrors[keyof V2ProviderGetErrors] + +export type V2ProviderGetResponses = { + /** + * ProviderV2.Info + */ + 200: ProviderV2Info +} + +export type V2ProviderGetResponse = V2ProviderGetResponses[keyof V2ProviderGetResponses] + export type TuiAppendPromptData = { body?: { text: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 97890a5dc..114db9cd7 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7606,6 +7606,112 @@ ] } }, + "/api/model": { + "get": { + "tags": ["v2 models"], + "operationId": "v2.model.list", + "parameters": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelV2Info" + } + } + } + } + } + }, + "description": "Retrieve available v2 models ordered by release date.", + "summary": "List v2 models", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.model.list({\n ...\n})" + } + ] + } + }, + "/api/provider": { + "get": { + "tags": ["v2 providers"], + "operationId": "v2.provider.list", + "parameters": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderV2Info" + } + } + } + } + } + }, + "description": "Retrieve active v2 AI providers so clients can show provider availability and configuration.", + "summary": "List v2 providers", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.provider.list({\n ...\n})" + } + ] + } + }, + "/api/provider/{providerID}": { + "get": { + "tags": ["v2 providers"], + "operationId": "v2.provider.get", + "parameters": [ + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "ProviderV2.Info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderV2Info" + } + } + } + }, + "404": { + "description": "NotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings.", + "summary": "Get v2 provider", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.provider.get({\n ...\n})" + } + ] + } + }, "/tui/append-prompt": { "post": { "tags": ["tui"], @@ -18991,6 +19097,531 @@ } ] }, + "ModelV2Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "apiID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "family": { + "type": "string" + }, + "name": { + "type": "string" + }, + "endpoint": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/responses"] + }, + "url": { + "type": "string" + }, + "websocket": { + "type": "boolean" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/completions"] + }, + "url": { + "type": "string" + }, + "reasoning": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_content"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_details"] + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["anthropic/messages"] + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "package"], + "additionalProperties": false + } + ] + }, + "capabilities": { + "type": "object", + "properties": { + "tools": { + "type": "boolean" + }, + "input": { + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["tools", "input", "output"], + "additionalProperties": false + }, + "options": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false + }, + "variant": { + "type": "string" + } + }, + "required": ["headers", "body", "aisdk"], + "additionalProperties": false + }, + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false + } + }, + "required": ["id", "headers", "body", "aisdk"], + "additionalProperties": false + } + }, + "time": { + "type": "object", + "properties": { + "released": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["released"], + "additionalProperties": false + }, + "cost": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tier": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["context"] + }, + "size": { + "type": "integer" + } + }, + "required": ["type", "size"], + "additionalProperties": false + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "cache"], + "additionalProperties": false + } + }, + "status": { + "type": "string", + "enum": ["alpha", "beta", "deprecated", "active"] + }, + "enabled": { + "type": "boolean" + }, + "limit": { + "type": "object", + "properties": { + "context": { + "type": "integer" + }, + "input": { + "type": "integer" + }, + "output": { + "type": "integer" + } + }, + "required": ["context", "output"], + "additionalProperties": false + } + }, + "required": [ + "id", + "apiID", + "providerID", + "name", + "endpoint", + "capabilities", + "options", + "variants", + "time", + "cost", + "status", + "enabled", + "limit" + ], + "additionalProperties": false + }, + "ProviderV2Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean", + "enum": [false] + }, + { + "type": "object", + "properties": { + "via": { + "type": "string", + "enum": ["env"] + }, + "name": { + "type": "string" + } + }, + "required": ["via", "name"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "via": { + "type": "string", + "enum": ["auth"] + }, + "service": { + "type": "string" + } + }, + "required": ["via", "service"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "via": { + "type": "string", + "enum": ["custom"] + }, + "data": { + "type": "object" + } + }, + "required": ["via", "data"], + "additionalProperties": false + } + ] + }, + "env": { + "type": "array", + "items": { + "type": "string" + } + }, + "endpoint": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/responses"] + }, + "url": { + "type": "string" + }, + "websocket": { + "type": "boolean" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/completions"] + }, + "url": { + "type": "string" + }, + "reasoning": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_content"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_details"] + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["anthropic/messages"] + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "package"], + "additionalProperties": false + } + ] + }, + "options": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false + } + }, + "required": ["headers", "body", "aisdk"], + "additionalProperties": false + } + }, + "required": ["id", "name", "enabled", "env", "endpoint", "options"], + "additionalProperties": false + }, "EventTuiToastShow1": { "type": "object", "properties": { @@ -19121,6 +19752,14 @@ "name": "v2 messages", "description": "Experimental v2 message routes." }, + { + "name": "v2 models", + "description": "Experimental v2 model routes." + }, + { + "name": "v2 providers", + "description": "Experimental v2 provider routes." + }, { "name": "tui", "description": "Experimental HttpApi TUI routes." diff --git a/specs/v2/provider-model.md b/specs/v2/provider-model.md index fe5a98bdd..fb4598b58 100644 --- a/specs/v2/provider-model.md +++ b/specs/v2/provider-model.md @@ -55,9 +55,13 @@ const UnknownEndpoint = Schema.Struct({ type: Schema.Literal("unknown"), }) -export const Endpoint = Schema.Union([UnknownEndpoint, OpenAIResponses, OpenAICompletions, AnthropicMessages, AISDK]).pipe( - Schema.toTaggedUnion("type"), -) +export const Endpoint = Schema.Union([ + UnknownEndpoint, + OpenAIResponses, + OpenAICompletions, + AnthropicMessages, + AISDK, +]).pipe(Schema.toTaggedUnion("type")) export type Endpoint = typeof Endpoint.Type export const Options = Schema.Struct({ @@ -198,7 +202,6 @@ export class Info extends Schema.Class("ModelV2.Info")({ }) } } - ``` ## Catalog Interface @@ -253,23 +256,21 @@ const available = provider.enabled && model.status !== "deprecated" ## Plugin Interface ```ts -export type Definition = Effect.Effect<{ - readonly order: number - readonly hooks: HookFunctions -}, never, R> +export type Definition = Effect.Effect< + { + readonly order: number + readonly hooks: HookFunctions + }, + never, + R +> export interface Interface { - readonly add: (input: { - id: ID - definition: Definition - }) => Effect.Effect + readonly add: (input: { id: ID; definition: Definition }) => Effect.Effect readonly remove: (id: ID) => Effect.Effect - readonly trigger: ( - name: Name, - input: HookInput, - ) => Effect.Effect> + readonly trigger: (name: Name, input: HookInput) => Effect.Effect> } ``` From e28ef7b57c71aa056d127642209c73e3958cf22c Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 20:18:06 +0530 Subject: [PATCH 65/98] refactor(flags): route sync workspaces through runtime flags (#27336) --- packages/opencode/src/sync/index.ts | 14 ++++++++------ packages/opencode/test/sync/index.test.ts | 18 ++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 5c29101b6..e0ec2d345 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -7,11 +7,11 @@ import type { InstanceContext } from "@/project/instance" import { EventSequenceTable, EventTable } from "./event.sql" import type { WorkspaceID } from "@/control-plane/schema" import { EventID } from "./schema" -import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Effect, Layer, Schema as EffectSchema } from "effect" import type { DeepMutable } from "@opencode-ai/core/schema" import { serviceUse } from "@/effect/service-use" import { InstanceState } from "@/effect/instance-state" +import { RuntimeFlags } from "@/effect/runtime-flags" // Keep `Event["data"]` mutable because projectors mutate the persisted shape // when writing to the database. Bus payloads (`Properties`) stay readonly — @@ -69,6 +69,8 @@ export class Service extends Context.Service()("@opencode/Sy export const layer = Layer.effect(Service)( Effect.gen(function* () { + const flags = yield* RuntimeFlags.Service + const replay: Interface["replay"] = Effect.fn("SyncEvent.replay")(function* (event, options) { const def = registry.get(event.type) if (!def) { @@ -104,7 +106,7 @@ export const layer = Layer.effect(Service)( workspace: yield* InstanceState.workspaceID, } : undefined - process(def, event, { publish, context, ownerID: options?.ownerID }) + process(def, event, { publish, context, ownerID: options?.ownerID, experimentalWorkspaces: flags.experimentalWorkspaces }) }) const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) { @@ -160,7 +162,7 @@ export const layer = Layer.effect(Service)( const seq = row?.seq != null ? row.seq + 1 : 0 const event = { id, seq, aggregateID: agg, data } - process(def, event, { publish, context }) + process(def, event, { publish, context, experimentalWorkspaces: flags.experimentalWorkspaces }) }, { behavior: "immediate", @@ -197,7 +199,7 @@ export const layer = Layer.effect(Service)( }), ) -export const defaultLayer = layer +export const defaultLayer = layer.pipe(Layer.provide(RuntimeFlags.defaultLayer)) export const use = serviceUse(Service) @@ -279,7 +281,7 @@ export function project( function process( def: Def, event: Event, - options: { publish: boolean; context?: PublishContext; ownerID?: string }, + options: { publish: boolean; context?: PublishContext; ownerID?: string; experimentalWorkspaces: boolean }, ) { if (projectors == null) { throw new Error("No projectors available. Call `SyncEvent.init` to install projectors") @@ -293,7 +295,7 @@ function process( Database.transaction((tx) => { projector(tx, event.data, event) - if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + if (options.experimentalWorkspaces) { tx.insert(EventSequenceTable) .values({ aggregate_id: event.aggregateID, diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 10f593a57..c4e5b8606 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, beforeEach, afterEach, afterAll } from "bun:test" +import { describe, expect, beforeEach, afterAll } from "bun:test" import { provideTmpdirInstance } from "../fixture/fixture" import { Effect, Layer, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -7,21 +7,19 @@ import { SyncEvent } from "../../src/sync" import { Database, eq } from "@/storage/db" import { EventSequenceTable, EventTable } from "../../src/sync/event.sql" import { MessageID } from "../../src/session/schema" -import { Flag } from "@opencode-ai/core/flag/flag" import { initProjectors } from "../../src/server/projectors" import { testEffect } from "../lib/effect" +import { RuntimeFlags } from "@/effect/runtime-flags" -const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES -const it = testEffect(Layer.mergeAll(SyncEvent.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect( + Layer.mergeAll( + SyncEvent.layer.pipe(Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true }))), + CrossSpawnSpawner.defaultLayer, + ), +) beforeEach(() => { Database.close() - - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true -}) - -afterEach(() => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original }) describe("SyncEvent", () => { From 72acdf050598e84725e4720180b8c91e649e2b3c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 13 May 2026 14:50:34 +0000 Subject: [PATCH 66/98] chore: generate --- packages/opencode/src/sync/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index e0ec2d345..7f9b8eeef 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -106,7 +106,12 @@ export const layer = Layer.effect(Service)( workspace: yield* InstanceState.workspaceID, } : undefined - process(def, event, { publish, context, ownerID: options?.ownerID, experimentalWorkspaces: flags.experimentalWorkspaces }) + process(def, event, { + publish, + context, + ownerID: options?.ownerID, + experimentalWorkspaces: flags.experimentalWorkspaces, + }) }) const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) { From 268d7581309dffa408b9979d7f0d3078d5aea5a6 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 20:22:30 +0530 Subject: [PATCH 67/98] refactor(flags): route control-plane workspaces through runtime flags (#27337) --- .../opencode/src/control-plane/workspace.ts | 6 ++- .../test/control-plane/workspace.test.ts | 45 ++++++++++++++----- .../test/plugin/workspace-adapter.test.ts | 16 ++++++- 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index e7e65f890..4a21e2e65 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -10,8 +10,8 @@ import { GlobalBus } from "@/bus/global" import { Auth } from "@/auth" import { SyncEvent } from "@/sync" import { EventSequenceTable, EventTable } from "@/sync/event.sql" -import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" +import { RuntimeFlags } from "@/effect/runtime-flags" import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" import { Slug } from "@opencode-ai/core/util/slug" @@ -175,6 +175,7 @@ export const layer = Layer.effect( const http = yield* HttpClient.HttpClient const sync = yield* SyncEvent.Service const vcs = yield* Vcs.Service + const flags = yield* RuntimeFlags.Service const connections = new Map() const syncFibers = yield* FiberMap.make() @@ -482,7 +483,7 @@ export const layer = Layer.effect( }) const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) { - if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return + if (!flags.experimentalWorkspaces) return const adapter = getAdapter(space.projectID, space.type) const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe( @@ -1040,6 +1041,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Project.defaultLayer), Layer.provide(Vcs.defaultLayer), Layer.provide(FetchHttpClient.layer), + Layer.provide(RuntimeFlags.defaultLayer), ) const TIMEOUT = 5000 diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index adac51fe5..01304e805 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -6,7 +6,7 @@ import path from "node:path" import { setTimeout as delay } from "node:timers/promises" import { NodeHttpServer } from "@effect/platform-node" import { Effect, Layer, Schema } from "effect" -import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { FetchHttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" @@ -33,24 +33,43 @@ import * as Workspace from "../../src/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" +import { Auth } from "@/auth" +import { SessionPrompt } from "@/session/prompt" +import { Project } from "@/project/project" +import { Vcs } from "@/project/vcs" +import { RuntimeFlags } from "@/effect/runtime-flags" void Log.init({ print: false }) -const testServerLayer = Layer.mergeAll( - NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), - Workspace.defaultLayer.pipe(Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer)), - SessionNs.defaultLayer, -) -const it = testEffect(testServerLayer) - const originalWorkspacesFlag = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const originalEnv = { OPENCODE_AUTH_CONTENT: process.env.OPENCODE_AUTH_CONTENT, + OPENCODE_EXPERIMENTAL_WORKSPACES: process.env.OPENCODE_EXPERIMENTAL_WORKSPACES, OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, } +const workspaceLayer = (experimentalWorkspaces: boolean) => + Workspace.layer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide(SessionNs.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + Layer.provide(SessionPrompt.defaultLayer), + Layer.provide(Project.defaultLayer), + Layer.provide(Vcs.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })), + Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))), + ) + +const testServerLayer = Layer.mergeAll( + NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), + workspaceLayer(true), + SessionNs.defaultLayer, +) +const it = testEffect(testServerLayer) + type RecordedCreate = { info: WorkspaceInfo env: Record @@ -94,6 +113,7 @@ beforeEach(() => { Database.close() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true restoreEnv() + process.env.OPENCODE_EXPERIMENTAL_WORKSPACES = "true" }) afterEach(async () => { @@ -141,6 +161,12 @@ const isWorkspaceSyncing = (id: WorkspaceID) => const startWorkspaceSyncing = (projectID: ProjectID) => { void runWorkspace(Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID))) } +const startWorkspaceSyncingWithFlag = (projectID: ProjectID, experimentalWorkspaces: boolean) => + Effect.runPromise( + Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID)).pipe( + Effect.provide(workspaceLayer(experimentalWorkspaces)), + ), + ) const waitForWorkspaceSync = (workspaceID: WorkspaceID, state: Record, signal?: AbortSignal) => runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))) @@ -980,7 +1006,6 @@ describe("workspace CRUD", () => { describe("workspace sync state", () => { test("startWorkspaceSyncing is disabled by the experimental workspace flag", async () => { await withInstance(async (dir) => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false const type = unique("flag-disabled") const info = workspaceInfo(Instance.project.id, type) const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) @@ -988,7 +1013,7 @@ describe("workspace sync state", () => { insertWorkspace(info) registerAdapter(Instance.project.id, type, localAdapter(path.join(dir, "flag-disabled")).adapter) - startWorkspaceSyncing(Instance.project.id) + await startWorkspaceSyncingWithFlag(Instance.project.id, false) await delay(25) expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined() diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index bef860432..0cf603fa3 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -1,5 +1,6 @@ import { afterAll, afterEach, describe, expect } from "bun:test" import { Effect, Layer, Option } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" @@ -17,6 +18,11 @@ import { Plugin } from "../../src/plugin/index" import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { Instance } from "../../src/project/instance" import { InstanceStore } from "../../src/project/instance-store" +import { Project } from "../../src/project/project" +import { Vcs } from "../../src/project/vcs" +import { Session } from "../../src/session/session" +import { SessionPrompt } from "../../src/session/prompt" +import { SyncEvent } from "../../src/sync" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { NpmTest } from "../fake/npm" @@ -42,8 +48,16 @@ const pluginLayer = Plugin.layer.pipe( Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), ) const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const workspaceLayer = Workspace.defaultLayer.pipe( +const workspaceLayer = Workspace.layer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + Layer.provide(SessionPrompt.defaultLayer), + Layer.provide(Project.defaultLayer), + Layer.provide(Vcs.defaultLayer), + Layer.provide(FetchHttpClient.layer), Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))), + Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })), ) const it = testEffect(Layer.mergeAll(pluginLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer)) From 0b112e5bcf10a0ff0b0118a359a6c9bd80d48899 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 13 May 2026 10:56:26 -0400 Subject: [PATCH 68/98] test: migrate permission task config tests (#27343) --- .../opencode/test/permission-task.test.ts | 188 +++++++++--------- 1 file changed, 89 insertions(+), 99 deletions(-) diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index 64b93bb8b..f2084b095 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -1,16 +1,12 @@ -import { afterEach, describe, test, expect } from "bun:test" +import { describe, test, expect } from "bun:test" +import { Effect } from "effect" import { Permission } from "../src/permission" import { Config } from "@/config/config" -import { Instance } from "../src/project/instance" -import { WithInstance } from "../src/project/with-instance" -import { disposeAllInstances, tmpdir } from "./fixture/fixture" -import { AppRuntime } from "../src/effect/app-runtime" +import { testEffect } from "./lib/effect" -const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) +const it = testEffect(Config.defaultLayer) -afterEach(async () => { - await disposeAllInstances() -}) +const load = Config.Service.use((svc) => svc.get()) describe("Permission.evaluate for permission.task", () => { const createRuleset = (rules: Record): Permission.Ruleset => @@ -147,8 +143,18 @@ describe("Permission.disabled for task tool", () => { // Integration tests that load permissions from real config files describe("permission.task with real config files", () => { - test("loads task permissions from opencode.json config", async () => { - await using tmp = await tmpdir({ + it.instance( + "loads task permissions from opencode.json config", + () => + Effect.gen(function* () { + const config = yield* load + 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") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + }), + { git: true, config: { permission: { @@ -158,22 +164,21 @@ describe("permission.task with real config files", () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - 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") - expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") - }, - }) - }) + }, + ) - test("loads task permissions with wildcard patterns from config", async () => { - await using tmp = await tmpdir({ + it.instance( + "loads task permissions with wildcard patterns from config", + () => + Effect.gen(function* () { + const config = yield* load + 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") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") + }), + { git: true, config: { permission: { @@ -183,22 +188,21 @@ describe("permission.task with real config files", () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - 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") - expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") - }, - }) - }) + }, + ) - test("evaluate respects task permission from config", async () => { - await using tmp = await tmpdir({ + it.instance( + "evaluate respects task permission from config", + () => + Effect.gen(function* () { + const config = yield* load + 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" + expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask") + }), + { git: true, config: { permission: { @@ -208,38 +212,14 @@ describe("permission.task with real config files", () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - 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" - expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask") - }, - }) - }) + }, + ) - test("mixed permission config with task and other tools", async () => { - await using tmp = await tmpdir({ - git: true, - config: { - permission: { - bash: "allow", - edit: "ask", - task: { - "*": "deny", - general: "allow", - }, - }, - }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() + it.instance( + "mixed permission config with task and other tools", + () => + Effect.gen(function* () { + const config = yield* load const ruleset = Permission.fromConfig(config.permission ?? {}) // Verify task permissions @@ -257,27 +237,27 @@ describe("permission.task with real config files", () => { // task is NOT disabled because disabled() uses findLast, and the last rule // matching "task" permission is {pattern: "general", action: "allow"}, not pattern: "*" expect(disabled.has("task")).toBe(false) - }, - }) - }) - - test("task tool disabled when global deny comes last in config", async () => { - await using tmp = await tmpdir({ + }), + { git: true, config: { permission: { + bash: "allow", + edit: "ask", task: { - general: "allow", - "code-reviewer": "allow", "*": "deny", + general: "allow", }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() + }, + ) + + it.instance( + "task tool disabled when global deny comes last in config", + () => + Effect.gen(function* () { + const config = yield* load const ruleset = Permission.fromConfig(config.permission ?? {}) // Last matching rule wins - "*" deny is last, so all agents are denied @@ -289,26 +269,26 @@ describe("permission.task with real config files", () => { // and sees pattern: "*" with action: "deny", so task is disabled const disabled = Permission.disabled(["task"], ruleset) expect(disabled.has("task")).toBe(true) - }, - }) - }) - - test("task tool NOT disabled when specific allow comes last in config", async () => { - await using tmp = await tmpdir({ + }), + { git: true, config: { permission: { task: { - "*": "deny", general: "allow", + "code-reviewer": "allow", + "*": "deny", }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() + }, + ) + + it.instance( + "task tool NOT disabled when specific allow comes last in config", + () => + Effect.gen(function* () { + const config = yield* load const ruleset = Permission.fromConfig(config.permission ?? {}) // Evaluate uses findLast - "general" allow comes after "*" deny @@ -321,7 +301,17 @@ describe("permission.task with real config files", () => { // So the task tool is NOT disabled (even though most subagents are denied) const disabled = Permission.disabled(["task"], ruleset) expect(disabled.has("task")).toBe(false) + }), + { + git: true, + config: { + permission: { + task: { + "*": "deny", + general: "allow", + }, + }, }, - }) - }) + }, + ) }) From 74046648271cf5f6229b61f281dd38cc3f4c0cd7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 13 May 2026 10:56:51 -0400 Subject: [PATCH 69/98] refactor: migrate installation tests to testEffect (#27342) --- .../test/installation/installation.test.ts | 177 +++++++++--------- 1 file changed, 88 insertions(+), 89 deletions(-) diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index 5b26b0565..9ca38e968 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { Effect, Layer, Stream } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { Installation } from "../../src/installation" import { InstallationChannel } from "@opencode-ai/core/installation/version" +import { testEffect } from "../lib/effect" const encoder = new TextEncoder() @@ -51,86 +52,84 @@ function testLayer( describe("installation", () => { describe("latest", () => { - test("reads release version from GitHub releases", async () => { - const layer = testLayer(() => jsonResponse({ tag_name: "v1.2.3" })) + testEffect(testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))).effect( + "reads release version from GitHub releases", + () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("unknown")) + expect(result).toBe("1.2.3") + }), + ) - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("unknown")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("1.2.3") - }) + testEffect(testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))).effect( + "strips v prefix from GitHub release tag", + () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("curl")) + expect(result).toBe("4.0.0-beta.1") + }), + ) - test("strips v prefix from GitHub release tag", async () => { - const layer = testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" })) - - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("curl")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("4.0.0-beta.1") - }) - - test("reads npm versions via registry", async () => { - const calls: string[] = [] - const layer = testLayer((request) => { - calls.push(request.url) + const npmCalls: string[] = [] + testEffect( + testLayer((request) => { + npmCalls.push(request.url) return jsonResponse({ version: "1.5.0" }) - }) + }), + ).effect("reads npm versions via registry", () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("npm")) + expect(result).toBe("1.5.0") + expect(npmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) + }), + ) - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("1.5.0") - expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) - }) - - test("reads bun versions via registry", async () => { - const calls: string[] = [] - const layer = testLayer((request) => { - calls.push(request.url) + const bunCalls: string[] = [] + testEffect( + testLayer((request) => { + bunCalls.push(request.url) return jsonResponse({ version: "1.6.0" }) - }) + }), + ).effect("reads bun versions via registry", () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("bun")) + expect(result).toBe("1.6.0") + expect(bunCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) + }), + ) - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("1.6.0") - expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) - }) - - test("reads pnpm versions via registry", async () => { - const calls: string[] = [] - const layer = testLayer((request) => { - calls.push(request.url) + const pnpmCalls: string[] = [] + testEffect( + testLayer((request) => { + pnpmCalls.push(request.url) return jsonResponse({ version: "1.7.0" }) - }) + }), + ).effect("reads pnpm versions via registry", () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("pnpm")) + expect(result).toBe("1.7.0") + expect(pnpmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) + }), + ) - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("pnpm")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("1.7.0") - expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) - }) + testEffect(testLayer(() => jsonResponse({ version: "2.3.4" }))).effect("reads scoop manifest versions", () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("scoop")) + expect(result).toBe("2.3.4") + }), + ) - test("reads scoop manifest versions", async () => { - const layer = testLayer(() => jsonResponse({ version: "2.3.4" })) + testEffect(testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } }))).effect( + "reads chocolatey feed versions", + () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("choco")) + expect(result).toBe("3.4.5") + }), + ) - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("scoop")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("2.3.4") - }) - - test("reads chocolatey feed versions", async () => { - const layer = testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } })) - - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("choco")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("3.4.5") - }) - - test("reads brew formulae API versions", async () => { - const layer = testLayer( + testEffect( + testLayer( () => jsonResponse({ versions: { stable: "2.0.0" } }), (cmd, args) => { // getBrewFormula: return core formula (no tap) @@ -138,31 +137,31 @@ describe("installation", () => { if (cmd === "brew" && args.includes("--formula") && args.includes("opencode")) return "opencode" return "" }, - ) + ), + ).effect("reads brew formulae API versions", () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("brew")) + expect(result).toBe("2.0.0") + }), + ) - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("2.0.0") + const brewInfoJson = JSON.stringify({ + formulae: [{ versions: { stable: "2.1.0" } }], }) - - test("reads brew tap info JSON via CLI", async () => { - const brewInfoJson = JSON.stringify({ - formulae: [{ versions: { stable: "2.1.0" } }], - }) - const layer = testLayer( + testEffect( + testLayer( () => jsonResponse({}), // HTTP not used for tap formula (cmd, args) => { if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) return "opencode" if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson return "" }, - ) - - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("2.1.0") - }) + ), + ).effect("reads brew tap info JSON via CLI", () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("brew")) + expect(result).toBe("2.1.0") + }), + ) }) }) From d43124abe029adfd2fbf7d516a1c6dfa7a0844b0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 13 May 2026 10:57:22 -0400 Subject: [PATCH 70/98] ignore: notes --- specs/v2/api.html | 781 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 781 insertions(+) create mode 100644 specs/v2/api.html diff --git a/specs/v2/api.html b/specs/v2/api.html new file mode 100644 index 000000000..c23d7d4f0 --- /dev/null +++ b/specs/v2/api.html @@ -0,0 +1,781 @@ + + + + + + opencode v2 API + + + + + + From e7aed649493a668b5b09baac4251969667cf7a20 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 13 May 2026 14:59:13 +0000 Subject: [PATCH 71/98] chore: generate --- specs/v2/api.html | 750 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 565 insertions(+), 185 deletions(-) diff --git a/specs/v2/api.html b/specs/v2/api.html index c23d7d4f0..147d24f58 100644 --- a/specs/v2/api.html +++ b/specs/v2/api.html @@ -17,7 +17,13 @@ --accent: #496b5a; --accent-soft: #dce7dc; font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; } * { @@ -34,8 +40,7 @@ background: radial-gradient(circle at 12% 0%, rgba(73, 107, 90, 0.12), transparent 34rem), linear-gradient(90deg, rgba(38, 52, 47, 0.055) 1px, transparent 1px), - linear-gradient(rgba(38, 52, 47, 0.045) 1px, transparent 1px), - var(--bg); + linear-gradient(rgba(38, 52, 47, 0.045) 1px, transparent 1px), var(--bg); background-size: 72px 72px; color: var(--fg); line-height: 1.5; @@ -231,7 +236,13 @@ .diagram text { fill: var(--fg); font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; } .diagram .box { @@ -412,7 +423,10 @@
- Everything has one canonical route. Some routes are server-scoped; runtime routes use context; session item routes use the session. + Everything has one canonical route. Some routes are server-scoped; runtime routes use context; session item + routes use the session.

Server-scoped routes manage the whole server: projects, workspace lifecycle, and auth accounts. Runtime context is for anything resolved from an active directory, including config, provider capabilities, tools, @@ -432,7 +446,9 @@

Context Model

API context resolution - Non-session routes resolve from request context, session item routes resolve from session storage. + + Non-session routes resolve from request context, session item routes resolve from session storage. + @@ -468,8 +484,8 @@

Request-context calls

- These calls operate against a directory, optionally through a workspace. Simple clients omit context and - use the default runtime. + These calls operate against a directory, optionally through a workspace. Simple clients omit context and use + the default runtime.

GET /api/fs/tree?path=.&directory=/repo/app&workspace=ws_123
@@ -491,187 +507,551 @@ sessionID -> { directory, workspaceID? }

Operation Inventory

- The SDK is the source of truth. HTTP routes are mounts for RPC-style operations. server operations do not use runtime context. request operations use request/default runtime context from directory and workspace query parameters. session operations use pinned session context and should not accept context input. + The SDK is the source of truth. HTTP routes are mounts for RPC-style operations. + server operations do not use runtime context. + request operations use request/default runtime context from + directory and workspace query parameters. + session operations use pinned session context and should not accept + context input.

- + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OperationInputContextHTTP mountPurpose
OperationInputContextHTTP mountPurpose
agent.list{}requestGET /api/agentAvailable agents.
auth.activate{ accountID: AccountID }serverPOST /api/auth/:accountID/activateSet the account as active for its service.
auth.create{ - serviceID: ServiceID - credential: - | { type: "oauth", refresh: string, access: string, expires: number } - | { type: "api", key: string, metadata?: Record<string, string> } - description?: string - active?: boolean -}serverPOST /api/authCreate an auth account.
auth.delete{ accountID: AccountID }serverDELETE /api/auth/:accountIDRemove an auth account.
auth.get{ accountID: AccountID }serverGET /api/auth/:accountIDGet one auth account.
auth.list{ serviceID?: ServiceID }serverGET /api/authList saved auth accounts. Response includes active account mapping.
auth.update{ - accountID: AccountID - description?: string - credential?: - | { type: "oauth", refresh: string, access: string, expires: number } - | { type: "api", key: string, metadata?: Record<string, string> } -}serverPATCH /api/auth/:accountIDUpdate account description or credential.
catalog.model.get{ - providerID: ProviderID - modelID: ModelID -}serverGET /api/catalog/model/:providerID/:modelIDGet one catalog model.
catalog.model.list{}serverGET /api/catalog/modelList flattened catalog models.
command.list{}requestGET /api/commandAvailable commands.
config.get{}requestGET /api/configResolved config.
config.update{ config: Config }requestPATCH /api/configUpdate config.
event.subscribe{}requestGET /api/eventServer-sent events for the resolved runtime context.
formatter.status{}requestGET /api/formatterFormatter status.
fs.file{ path: string }requestGET /api/fs/fileRead one file.
fs.grep{ - pattern: string - include?: string - limit?: number -}requestPOST /api/fs/grepSearch file contents.
fs.search{ - query: string - type?: "file" | "directory" - limit?: number -}requestPOST /api/fs/searchSearch paths by name.
fs.tree{ path: string }requestGET /api/fs/treeBrowse a directory.
lsp.status{}requestGET /api/lspLSP status.
mcp.prompt.list{}requestGET /api/mcp/promptList MCP prompts.
mcp.prompt.render{ - server: string - name: string - arguments?: Record<string, string> -}requestPOST /api/mcp/prompt/renderRender one MCP prompt.
mcp.resource.list{}requestGET /api/mcp/resourceList MCP resources.
mcp.resource.read{ - server: string - uri: string -}requestGET /api/mcp/resource/readRead one MCP resource.
mcp.server.create{ - name: string - config: - | { type: "local", command: string, arguments?: string[], environment?: Record<string, string> } - | { type: "remote", url: string, headers?: Record<string, string>, oauth?: boolean | object } -}requestPOST /api/mcp/serverAdd an MCP server to runtime config.
mcp.server.list{}requestGET /api/mcp/serverList MCP servers with status and auth state.
mcp.server.oauth.callback{ - name: string - code: string -}requestPOST /api/mcp/server/:name/oauth/callbackComplete MCP OAuth.
mcp.server.oauth.delete{ name: string }requestDELETE /api/mcp/server/:name/oauthRemove MCP OAuth credentials.
mcp.server.oauth.start{ name: string }requestPOST /api/mcp/server/:name/oauthStart MCP OAuth.
permission.list{}requestGET /api/permissionPending permission requests.
permission.reply{ - permissionID: PermissionID - response: PermissionReply -}requestPOST /api/permission/:permissionID/replyReply to a permission request.
project.get{ projectID: ProjectID }serverGET /api/project/:projectIDGet project metadata.
project.list{}serverGET /api/projectList projects known to this server.
project.update{ - projectID: ProjectID - name?: string - icon?: string - commands?: Array<{ - name: string - command: string - }> -}serverPATCH /api/project/:projectIDUpdate project metadata.
provider.list{}requestGET /api/providerProvider inventory for the runtime context.
pty.create{ - command?: string - cwd?: string - shell?: string -}requestPOST /api/ptyCreate PTY in the runtime context.
pty.delete{ ptyID: PtyID }requestDELETE /api/pty/:ptyIDDelete PTY.
pty.get{ ptyID: PtyID }requestGET /api/pty/:ptyIDGet PTY info.
pty.list{}requestGET /api/ptyList PTYs for the runtime.
pty.update{ - ptyID: PtyID - title?: string - size?: { columns: number, rows: number } -}requestPATCH /api/pty/:ptyIDUpdate PTY.
question.list{}requestGET /api/questionPending user questions.
question.reject{ questionID: QuestionID }requestPOST /api/question/:questionID/rejectReject a question.
question.reply{ - questionID: QuestionID - response: QuestionResponse -}requestPOST /api/question/:questionID/replyReply to a question.
session.compact{ sessionID: SessionID }sessionPOST /api/session/:sessionID/compactCompact the session conversation.
session.context{ sessionID: SessionID }sessionGET /api/session/:sessionID/contextReturn active context messages after the last compaction.
session.create{ - title?: string - agent?: string - model?: { providerID: ProviderID, modelID: ModelID } - permission?: PermissionRule[] -}requestPOST /api/sessionCreate a session pinned to resolved runtime context.
session.delete{ sessionID: SessionID }sessionDELETE /api/session/:sessionIDDelete a session.
session.diff{ sessionID: SessionID }sessionGET /api/session/:sessionID/diffReturn session diff summary.
session.get{ sessionID: SessionID }sessionGET /api/session/:sessionIDGet one session.
session.list{ - limit?: number - order?: "asc" | "desc" - path?: string - roots?: boolean - start?: number - search?: string - cursor?: string -}requestGET /api/sessionList sessions for the current runtime context by default.
session.message.list{ - sessionID: SessionID - limit?: number - order?: "asc" | "desc" - cursor?: string -}sessionGET /api/session/:sessionID/messagePage through session messages.
session.prompt{ - sessionID: SessionID - prompt: Prompt - delivery?: "immediate" | "deferred" -}sessionPOST /api/session/:sessionID/promptCreate a user message and queue the agent loop.
session.todo{ sessionID: SessionID }sessionGET /api/session/:sessionID/todoReturn todos associated with the session.
session.update{ - sessionID: SessionID - title?: string - archived?: number - permission?: PermissionRule[] -}sessionPATCH /api/session/:sessionIDUpdate title, archival state, or session metadata.
session.wait{ sessionID: SessionID }sessionPOST /api/session/:sessionID/waitWait until the session is idle.
skill.list{}requestGET /api/skillAvailable skills.
vcs.diff{ - format?: "json" | "patch" - mode?: "worktree" | "default" -}requestGET /api/vcs/diffDiff for the runtime directory.
vcs.get{}requestGET /api/vcsVCS metadata.
vcs.patch{ patch: string }requestPOST /api/vcs/patchApply a patch to the runtime directory.
vcs.status{}requestGET /api/vcs/statusChanged files.
workspace.create{ - projectID?: ProjectID - name?: string - directory?: string - type: string - metadata?: Record<string, unknown> -}serverPOST /api/workspaceCreate or register a workspace.
workspace.delete{ workspaceID: WorkspaceID }serverDELETE /api/workspace/:workspaceIDRemove a workspace registration.
workspace.get{ workspaceID: WorkspaceID }serverGET /api/workspace/:workspaceIDGet workspace metadata.
workspace.list{ projectID?: ProjectID }serverGET /api/workspaceList workspaces, optionally filtered by project.
workspace.status{}serverGET /api/workspace/statusConnection/lifecycle status for all workspaces. Needs team discussion.
workspace.sync{}serverPOST /api/workspace/syncSync workspace metadata from adapters. Needs team discussion.
workspace.update{ - workspaceID: WorkspaceID - name?: string - metadata?: Record<string, unknown> - archived?: boolean -}serverPATCH /api/workspace/:workspaceIDUpdate workspace metadata or lifecycle state.
workspace.warp{ - workspaceID?: WorkspaceID - sessionID: SessionID - copyChanges: boolean -}serverPOST /api/workspace/warpMove a session into or out of a workspace. Needs team discussion.
agent.list{}requestGET /api/agentAvailable agents.
auth.activate{ accountID: AccountID }serverPOST /api/auth/:accountID/activateSet the account as active for its service.
auth.create + { serviceID: ServiceID credential: | { type: "oauth", refresh: string, access: string, expires: + number } | { type: "api", key: string, metadata?: Record<string, string> } description?: + string active?: boolean } + serverPOST /api/authCreate an auth account.
auth.delete{ accountID: AccountID }serverDELETE /api/auth/:accountIDRemove an auth account.
auth.get{ accountID: AccountID }serverGET /api/auth/:accountIDGet one auth account.
auth.list{ serviceID?: ServiceID }serverGET /api/authList saved auth accounts. Response includes active account mapping.
auth.update + { accountID: AccountID description?: string credential?: | { type: "oauth", refresh: string, + access: string, expires: number } | { type: "api", key: string, metadata?: Record<string, + string> } } + serverPATCH /api/auth/:accountIDUpdate account description or credential.
catalog.model.get{ providerID: ProviderID modelID: ModelID }serverGET /api/catalog/model/:providerID/:modelIDGet one catalog model.
catalog.model.list{}serverGET /api/catalog/modelList flattened catalog models.
command.list{}requestGET /api/commandAvailable commands.
config.get{}requestGET /api/configResolved config.
config.update{ config: Config }requestPATCH /api/configUpdate config.
event.subscribe{}requestGET /api/eventServer-sent events for the resolved runtime context.
formatter.status{}requestGET /api/formatterFormatter status.
fs.file{ path: string }requestGET /api/fs/fileRead one file.
fs.grep{ pattern: string include?: string limit?: number }requestPOST /api/fs/grepSearch file contents.
fs.search{ query: string type?: "file" | "directory" limit?: number }requestPOST /api/fs/searchSearch paths by name.
fs.tree{ path: string }requestGET /api/fs/treeBrowse a directory.
lsp.status{}requestGET /api/lspLSP status.
mcp.prompt.list{}requestGET /api/mcp/promptList MCP prompts.
mcp.prompt.render + { server: string name: string arguments?: Record<string, string> } + requestPOST /api/mcp/prompt/renderRender one MCP prompt.
mcp.resource.list{}requestGET /api/mcp/resourceList MCP resources.
mcp.resource.read{ server: string uri: string }requestGET /api/mcp/resource/readRead one MCP resource.
mcp.server.create + { name: string config: | { type: "local", command: string, arguments?: string[], environment?: + Record<string, string> } | { type: "remote", url: string, headers?: Record<string, + string>, oauth?: boolean | object } } + requestPOST /api/mcp/serverAdd an MCP server to runtime config.
mcp.server.list{}requestGET /api/mcp/serverList MCP servers with status and auth state.
mcp.server.oauth.callback{ name: string code: string }requestPOST /api/mcp/server/:name/oauth/callbackComplete MCP OAuth.
mcp.server.oauth.delete{ name: string }requestDELETE /api/mcp/server/:name/oauthRemove MCP OAuth credentials.
mcp.server.oauth.start{ name: string }requestPOST /api/mcp/server/:name/oauthStart MCP OAuth.
permission.list{}requestGET /api/permissionPending permission requests.
permission.reply{ permissionID: PermissionID response: PermissionReply }requestPOST /api/permission/:permissionID/replyReply to a permission request.
project.get{ projectID: ProjectID }serverGET /api/project/:projectIDGet project metadata.
project.list{}serverGET /api/projectList projects known to this server.
project.update + { projectID: ProjectID name?: string icon?: string commands?: Array<{ name: string command: + string }> } + serverPATCH /api/project/:projectIDUpdate project metadata.
provider.list{}requestGET /api/providerProvider inventory for the runtime context.
pty.create{ command?: string cwd?: string shell?: string }requestPOST /api/ptyCreate PTY in the runtime context.
pty.delete{ ptyID: PtyID }requestDELETE /api/pty/:ptyIDDelete PTY.
pty.get{ ptyID: PtyID }requestGET /api/pty/:ptyIDGet PTY info.
pty.list{}requestGET /api/ptyList PTYs for the runtime.
pty.update + { ptyID: PtyID title?: string size?: { columns: number, rows: number } } + requestPATCH /api/pty/:ptyIDUpdate PTY.
question.list{}requestGET /api/questionPending user questions.
question.reject{ questionID: QuestionID }requestPOST /api/question/:questionID/rejectReject a question.
question.reply{ questionID: QuestionID response: QuestionResponse }requestPOST /api/question/:questionID/replyReply to a question.
session.compact{ sessionID: SessionID }sessionPOST /api/session/:sessionID/compactCompact the session conversation.
session.context{ sessionID: SessionID }sessionGET /api/session/:sessionID/contextReturn active context messages after the last compaction.
session.create + { title?: string agent?: string model?: { providerID: ProviderID, modelID: ModelID } permission?: + PermissionRule[] } + requestPOST /api/sessionCreate a session pinned to resolved runtime context.
session.delete{ sessionID: SessionID }sessionDELETE /api/session/:sessionIDDelete a session.
session.diff{ sessionID: SessionID }sessionGET /api/session/:sessionID/diffReturn session diff summary.
session.get{ sessionID: SessionID }sessionGET /api/session/:sessionIDGet one session.
session.list + { limit?: number order?: "asc" | "desc" path?: string roots?: boolean start?: number search?: + string cursor?: string } + requestGET /api/sessionList sessions for the current runtime context by default.
session.message.list + { sessionID: SessionID limit?: number order?: "asc" | "desc" cursor?: string } + sessionGET /api/session/:sessionID/messagePage through session messages.
session.prompt + { sessionID: SessionID prompt: Prompt delivery?: "immediate" | "deferred" } + sessionPOST /api/session/:sessionID/promptCreate a user message and queue the agent loop.
session.todo{ sessionID: SessionID }sessionGET /api/session/:sessionID/todoReturn todos associated with the session.
session.update + { sessionID: SessionID title?: string archived?: number permission?: PermissionRule[] } + sessionPATCH /api/session/:sessionIDUpdate title, archival state, or session metadata.
session.wait{ sessionID: SessionID }sessionPOST /api/session/:sessionID/waitWait until the session is idle.
skill.list{}requestGET /api/skillAvailable skills.
vcs.diff{ format?: "json" | "patch" mode?: "worktree" | "default" }requestGET /api/vcs/diffDiff for the runtime directory.
vcs.get{}requestGET /api/vcsVCS metadata.
vcs.patch{ patch: string }requestPOST /api/vcs/patchApply a patch to the runtime directory.
vcs.status{}requestGET /api/vcs/statusChanged files.
workspace.create + { projectID?: ProjectID name?: string directory?: string type: string metadata?: Record<string, + unknown> } + serverPOST /api/workspaceCreate or register a workspace.
workspace.delete{ workspaceID: WorkspaceID }serverDELETE /api/workspace/:workspaceIDRemove a workspace registration.
workspace.get{ workspaceID: WorkspaceID }serverGET /api/workspace/:workspaceIDGet workspace metadata.
workspace.list{ projectID?: ProjectID }serverGET /api/workspaceList workspaces, optionally filtered by project.
workspace.status{}serverGET /api/workspace/statusConnection/lifecycle status for all workspaces. Needs team discussion.
workspace.sync{}serverPOST /api/workspace/syncSync workspace metadata from adapters. Needs team discussion.
workspace.update + { workspaceID: WorkspaceID name?: string metadata?: Record<string, unknown> archived?: + boolean } + serverPATCH /api/workspace/:workspaceIDUpdate workspace metadata or lifecycle state.
workspace.warp + { workspaceID?: WorkspaceID sessionID: SessionID copyChanges: boolean } + serverPOST /api/workspace/warpMove a session into or out of a workspace. Needs team discussion.
@@ -681,8 +1061,8 @@ sessionID -> { directory, workspaceID? }

Event Envelope

- Every event uses the same envelope. Resource identity belongs in payload. Runtime identity belongs - in context. + Every event uses the same envelope. Resource identity belongs in payload. Runtime identity + belongs in context.

type ApiEvent<Payload> = {

From ca17ca85cd3ca2fd12e4926b27788e9921acd2c4 Mon Sep 17 00:00:00 2001
From: "opencode-agent[bot]" 
Date: Wed, 13 May 2026 15:02:16 +0000
Subject: [PATCH 72/98] chore: update nix node_modules hashes

---
 nix/hashes.json | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/nix/hashes.json b/nix/hashes.json
index 0bba38a2c..876b96960 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-xZyIgqow1wVh0Kfpb5GLUUHsE3jyfqJfrZ9Qykml008=",
-    "aarch64-linux": "sha256-tbbne63KImq4EQrPi45l9YG1dY/SO7b1ZKkLjDfZhWg=",
-    "aarch64-darwin": "sha256-PYsiSMkASbcZxqMXb7UfbkRTiQae6xzseMNhDP+/y5g=",
-    "x86_64-darwin": "sha256-Qnj9FAgXWyiB6U5NyIsRw7aNVNexAagETr07Jwde908="
+    "x86_64-linux": "sha256-cRhvzZoW6gBbE0sQm1+e+6/WgajuA6MSIL5iroFsfqs=",
+    "aarch64-linux": "sha256-0knZfxBULqkt5u6sXFx+a/vqw2rc6IC1+LeAd4TNFhM=",
+    "aarch64-darwin": "sha256-jL4tO+EHSmUF+gQGEaLzAbTxxjkL8OyhTk13vsbomgM=",
+    "x86_64-darwin": "sha256-bsa7IpS3GaxagcigTa0yqZTkf4e/nbcTQ9aZeb+5eHQ="
   }
 }

From 76c91c6e331a4f9027e3250e485629c79fbcb880 Mon Sep 17 00:00:00 2001
From: Kit Langton 
Date: Wed, 13 May 2026 11:04:02 -0400
Subject: [PATCH 73/98] test: migrate mcp oauth browser tests (#27345)

---
 .../opencode/test/mcp/oauth-browser.test.ts   | 253 ++++++++----------
 1 file changed, 109 insertions(+), 144 deletions(-)

diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts
index 20cb90a18..8c8c6ca3f 100644
--- a/packages/opencode/test/mcp/oauth-browser.test.ts
+++ b/packages/opencode/test/mcp/oauth-browser.test.ts
@@ -1,15 +1,19 @@
-import { test, expect, mock, beforeEach } from "bun:test"
+import { expect, mock, beforeEach } from "bun:test"
 import { EventEmitter } from "events"
-import { Effect } from "effect"
+import { Deferred, Effect, Layer, Option } from "effect"
+import type { Duration } from "effect"
+import { testEffect } from "../lib/effect"
 import type { MCP as MCPNS } from "../../src/mcp/index"
 
 // Track open() calls and control failure behavior
 let openShouldFail = false
 let openCalledWith: string | undefined
+let openDeferred: Deferred.Deferred | undefined
 
 void mock.module("open", () => ({
   default: async (url: string) => {
     openCalledWith = url
+    if (openDeferred) Effect.runSync(Deferred.succeed(openDeferred, url).pipe(Effect.ignore))
 
     // Return a mock subprocess that emits an error if openShouldFail is true
     const subprocess = new EventEmitter()
@@ -97,173 +101,134 @@ void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
 beforeEach(() => {
   openShouldFail = false
   openCalledWith = undefined
+  openDeferred = undefined
   transportCalls.length = 0
 })
 
 // Import modules after mocking
 const { MCP } = await import("../../src/mcp/index")
-const { AppRuntime } = await import("../../src/effect/app-runtime")
 const { Bus } = await import("../../src/bus")
+const { Config } = await import("../../src/config/config")
+const { McpAuth } = await import("../../src/mcp/auth")
 const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
-const { Instance } = await import("../../src/project/instance")
-const { WithInstance } = await import("../../src/project/with-instance")
-const { tmpdir } = await import("../fixture/fixture")
+const { AppFileSystem } = await import("@opencode-ai/core/filesystem")
+const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
+const mcpTest = testEffect(
+  MCP.layer.pipe(
+    Layer.provide(McpAuth.defaultLayer),
+    Layer.provideMerge(Bus.layer),
+    Layer.provide(Config.defaultLayer),
+    Layer.provide(CrossSpawnSpawner.defaultLayer),
+    Layer.provide(AppFileSystem.defaultLayer),
+  ),
+)
 const service = MCP.Service as unknown as Effect.Effect
 
-test("BrowserOpenFailed event is published when open() throws", async () => {
-  await using tmp = await tmpdir({
-    init: async (dir) => {
-      await Bun.write(
-        `${dir}/opencode.json`,
-        JSON.stringify({
-          $schema: "https://opencode.ai/config.json",
-          mcp: {
-            "test-oauth-server": {
-              type: "remote",
-              url: "https://example.com/mcp",
-            },
-          },
-        }),
-      )
+const config = (name: string) => ({
+  mcp: {
+    [name]: {
+      type: "remote" as const,
+      url: "https://example.com/mcp",
     },
+  },
+})
+
+const withCallbackStop = Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
+
+const awaitWithTimeout = (
+  self: Effect.Effect,
+  message: string,
+  duration: Duration.Input = "5 seconds",
+) =>
+  self.pipe(
+    Effect.timeoutOrElse({
+      duration,
+      orElse: () => Effect.fail(new Error(message)),
+    }),
+  )
+
+const trackBrowserOpen = Effect.gen(function* () {
+  const opened = yield* Deferred.make()
+  openDeferred = opened
+  yield* Effect.addFinalizer(() => Effect.sync(() => (openDeferred = undefined)))
+  return opened
+})
+
+const trackBrowserOpenFailed = Effect.gen(function* () {
+  const bus = yield* Bus.Service
+  const event = yield* Deferred.make<{ mcpName: string; url: string }>()
+  const unsubscribe = yield* bus.subscribeCallback(MCP.BrowserOpenFailed, (evt) => {
+    Effect.runSync(Deferred.succeed(event, evt.properties).pipe(Effect.ignore))
+  })
+  yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
+  return event
+})
+
+const authenticateScoped = (name: string) =>
+  Effect.gen(function* () {
+    const mcp = yield* service
+    yield* mcp.authenticate(name).pipe(Effect.ignore, Effect.catchCause(() => Effect.void), Effect.forkScoped)
   })
 
-  await WithInstance.provide({
-    directory: tmp.path,
-    fn: async () => {
+mcpTest.instance(
+  "BrowserOpenFailed event is published when open() throws",
+  () =>
+    Effect.gen(function* () {
+      yield* withCallbackStop
       openShouldFail = true
 
-      const events: Array<{ mcpName: string; url: string }> = []
-      const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => {
-        events.push(evt.properties)
-      })
+      const event = yield* trackBrowserOpenFailed
+      yield* authenticateScoped("test-oauth-server")
 
-      // Run authenticate with a timeout to avoid waiting forever for the callback
-      // Attach a handler immediately so callback shutdown rejections
-      // don't show up as unhandled between tests.
-      const authPromise = AppRuntime.runPromise(
-        Effect.gen(function* () {
-          const mcp = yield* service
-          return yield* mcp.authenticate("test-oauth-server")
-        }),
-      ).catch(() => undefined)
-
-      // Config.get() can be slow in tests, so give it plenty of time.
-      await new Promise((resolve) => setTimeout(resolve, 2_000))
-
-      // Stop the callback server and cancel any pending auth
-      await McpOAuthCallback.stop()
-
-      await authPromise
-
-      unsubscribe()
-
-      // Verify the BrowserOpenFailed event was published
-      expect(events.length).toBe(1)
-      expect(events[0].mcpName).toBe("test-oauth-server")
-      expect(events[0].url).toContain("https://")
-    },
-  })
-})
-
-test("BrowserOpenFailed event is NOT published when open() succeeds", async () => {
-  await using tmp = await tmpdir({
-    init: async (dir) => {
-      await Bun.write(
-        `${dir}/opencode.json`,
-        JSON.stringify({
-          $schema: "https://opencode.ai/config.json",
-          mcp: {
-            "test-oauth-server-2": {
-              type: "remote",
-              url: "https://example.com/mcp",
-            },
-          },
-        }),
+      const failure = yield* awaitWithTimeout(
+        Deferred.await(event),
+        "Timed out waiting for BrowserOpenFailed event",
       )
-    },
-  })
 
-  await WithInstance.provide({
-    directory: tmp.path,
-    fn: async () => {
+      expect(failure.mcpName).toBe("test-oauth-server")
+      expect(failure.url).toContain("https://")
+    }),
+  { config: config("test-oauth-server") },
+)
+
+mcpTest.instance(
+  "BrowserOpenFailed event is NOT published when open() succeeds",
+  () =>
+    Effect.gen(function* () {
+      yield* withCallbackStop
       openShouldFail = false
 
-      const events: Array<{ mcpName: string; url: string }> = []
-      const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => {
-        events.push(evt.properties)
-      })
+      const opened = yield* trackBrowserOpen
+      const event = yield* trackBrowserOpenFailed
+      yield* authenticateScoped("test-oauth-server-2")
 
-      // Run authenticate with a timeout to avoid waiting forever for the callback
-      const authPromise = AppRuntime.runPromise(
-        Effect.gen(function* () {
-          const mcp = yield* service
-          return yield* mcp.authenticate("test-oauth-server-2")
-        }),
-      ).catch(() => undefined)
+      yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()")
+      const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis"))
 
-      // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
-      await new Promise((resolve) => setTimeout(resolve, 2_000))
-
-      // Stop the callback server and cancel any pending auth
-      await McpOAuthCallback.stop()
-
-      await authPromise
-
-      unsubscribe()
-
-      // Verify NO BrowserOpenFailed event was published
-      expect(events.length).toBe(0)
-      // Verify open() was still called
+      expect(failure).toEqual(Option.none())
       expect(openCalledWith).toBeDefined()
-    },
-  })
-})
+    }),
+  { config: config("test-oauth-server-2") },
+)
 
-test("open() is called with the authorization URL", async () => {
-  await using tmp = await tmpdir({
-    init: async (dir) => {
-      await Bun.write(
-        `${dir}/opencode.json`,
-        JSON.stringify({
-          $schema: "https://opencode.ai/config.json",
-          mcp: {
-            "test-oauth-server-3": {
-              type: "remote",
-              url: "https://example.com/mcp",
-            },
-          },
-        }),
-      )
-    },
-  })
-
-  await WithInstance.provide({
-    directory: tmp.path,
-    fn: async () => {
+mcpTest.instance(
+  "open() is called with the authorization URL",
+  () =>
+    Effect.gen(function* () {
+      yield* withCallbackStop
       openShouldFail = false
       openCalledWith = undefined
 
-      // Run authenticate with a timeout to avoid waiting forever for the callback
-      const authPromise = AppRuntime.runPromise(
-        Effect.gen(function* () {
-          const mcp = yield* service
-          return yield* mcp.authenticate("test-oauth-server-3")
-        }),
-      ).catch(() => undefined)
+      const opened = yield* trackBrowserOpen
+      const event = yield* trackBrowserOpenFailed
+      yield* authenticateScoped("test-oauth-server-3")
 
-      // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
-      await new Promise((resolve) => setTimeout(resolve, 2_000))
+      const url = yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()")
+      const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis"))
 
-      // Stop the callback server and cancel any pending auth
-      await McpOAuthCallback.stop()
-
-      await authPromise
-
-      // Verify open was called with a URL
-      expect(openCalledWith).toBeDefined()
-      expect(typeof openCalledWith).toBe("string")
-      expect(openCalledWith!).toContain("https://")
-    },
-  })
-})
+      expect(failure).toEqual(Option.none())
+      expect(typeof url).toBe("string")
+      expect(url).toContain("https://")
+    }),
+  { config: config("test-oauth-server-3") },
+)

From 02b8b0ff930f67d7aa128766995c6570ac63add5 Mon Sep 17 00:00:00 2001
From: Kit Langton 
Date: Wed, 13 May 2026 11:04:30 -0400
Subject: [PATCH 74/98] test: migrate file watcher test to Effect (#27346)

---
 packages/opencode/test/file/watcher.test.ts | 289 ++++++++++----------
 1 file changed, 148 insertions(+), 141 deletions(-)

diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts
index 7e47c5135..1da896cc1 100644
--- a/packages/opencode/test/file/watcher.test.ts
+++ b/packages/opencode/test/file/watcher.test.ts
@@ -1,15 +1,13 @@
-import { $ } from "bun"
-import { afterEach, describe, expect, test } from "bun:test"
-import fs from "fs/promises"
+import { describe, expect } from "bun:test"
 import path from "path"
-import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect"
-import { disposeAllInstances, tmpdir } from "../fixture/fixture"
-import { Bus } from "../../src/bus"
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { ConfigProvider, Deferred, Effect, Layer, Option } from "effect"
+import { TestInstance, provideInstance } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
 import { Config } from "@/config/config"
 import { FileWatcher } from "../../src/file/watcher"
 import { Git } from "../../src/git"
-import { Instance } from "../../src/project/instance"
-import { WithInstance } from "../../src/project/with-instance"
 
 // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
 const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@@ -25,43 +23,43 @@ const watcherConfigLayer = ConfigProvider.layer(
   }),
 )
 
+const watcherLayer = FileWatcher.layer.pipe(
+  Layer.provide(Config.defaultLayer),
+  Layer.provide(Git.defaultLayer),
+  Layer.provide(watcherConfigLayer),
+)
+
+const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, Git.defaultLayer))
+
 type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
 
 /** Run `body` with a live FileWatcher service. */
-function withWatcher(directory: string, body: Effect.Effect) {
-  return WithInstance.provide({
-    directory,
-    fn: async () => {
-      const layer: Layer.Layer = FileWatcher.layer.pipe(
-        Layer.provide(Config.defaultLayer),
-        Layer.provide(Git.defaultLayer),
-        Layer.provide(watcherConfigLayer),
-      )
-      const rt = ManagedRuntime.make(layer)
-      try {
-        await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
-        await Effect.runPromise(ready(directory))
-        await Effect.runPromise(body)
-      } finally {
-        await rt.dispose()
-      }
-    },
-  })
+function withWatcher(directory: string, body: Effect.Effect) {
+  return Effect.gen(function* () {
+    const watcher = yield* FileWatcher.Service
+    yield* watcher.init()
+    yield* ready(directory)
+    return yield* body
+  }).pipe(Effect.provide(watcherLayer), provideInstance(directory), Effect.scoped)
 }
 
 function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
   let done = false
 
-  const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
+  const on = (evt: GlobalEvent) => {
     if (done) return
-    if (!check(evt.properties)) return
-    hit(evt.properties)
-  })
+    if (evt.directory !== directory) return
+    if (evt.payload.type !== FileWatcher.Event.Updated.type) return
+    if (!check(evt.payload.properties)) return
+    hit(evt.payload.properties)
+  }
+
+  GlobalBus.on("event", on)
 
   return () => {
     if (done) return
     done = true
-    unsub()
+    GlobalBus.off("event", on)
   }
 }
 
@@ -72,7 +70,7 @@ function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
       let off = () => {}
       off = listen(directory, check, (evt) => {
         off()
-        Deferred.doneUnsafe(deferred, Effect.succeed(evt))
+        Effect.runFork(Deferred.succeed(deferred, evt))
       })
       return off
     })
@@ -86,7 +84,12 @@ function nextUpdate(directory: string, check: (evt: WatcherEvent) => boolean,
     ({ deferred }) =>
       Effect.gen(function* () {
         yield* trigger
-        return yield* Deferred.await(deferred).pipe(Effect.timeout("5 seconds"))
+        return yield* Deferred.await(deferred).pipe(
+          Effect.timeoutOrElse({
+            duration: "5 seconds",
+            orElse: () => Effect.fail(new Error("timed out waiting for file watcher update")),
+          }),
+        )
       }),
     ({ cleanup }) => Effect.sync(cleanup),
   )
@@ -104,7 +107,11 @@ function noUpdate(
     ({ deferred }) =>
       Effect.gen(function* () {
         yield* trigger
-        expect(yield* Deferred.await(deferred).pipe(Effect.timeoutOption(`${ms} millis`))).toEqual(Option.none())
+        const result = yield* Deferred.await(deferred).pipe(
+          Effect.map((evt) => Option.some(evt)),
+          Effect.timeoutOrElse({ duration: `${ms} millis`, orElse: () => Effect.succeed(Option.none()) }),
+        )
+        expect(result).toEqual(Option.none())
       }),
     ({ cleanup }) => Effect.sync(cleanup),
   )
@@ -115,29 +122,25 @@ function ready(directory: string) {
   const head = path.join(directory, ".git", "HEAD")
 
   return Effect.gen(function* () {
+    const fs = yield* AppFileSystem.Service
+    const git = yield* Git.Service
+
     yield* nextUpdate(
       directory,
       (evt) => evt.file === file && evt.event === "add",
-      Effect.promise(() => fs.writeFile(file, "ready")),
-    ).pipe(Effect.ensuring(Effect.promise(() => fs.rm(file, { force: true }).catch(() => undefined))), Effect.asVoid)
+      fs.writeFileString(file, "ready"),
+    ).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
 
-    const git = yield* Effect.promise(() =>
-      fs
-        .stat(head)
-        .then(() => true)
-        .catch(() => false),
-    )
-    if (!git) return
+    if (!(yield* fs.existsSafe(head))) return
 
     const branch = `watch-${Math.random().toString(36).slice(2)}`
-    const hash = yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(directory).quiet().text())
+    const hash = (yield* git.run(["rev-parse", "HEAD"], { cwd: directory })).text()
     yield* nextUpdate(
       directory,
       (evt) => evt.file === head && evt.event !== "unlink",
-      Effect.promise(async () => {
-        await fs.writeFile(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
-        await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
-      }),
+      fs
+        .writeFileString(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
+        .pipe(Effect.andThen(fs.writeFileString(head, `ref: refs/heads/${branch}\n`))),
     ).pipe(Effect.asVoid)
   })
 }
@@ -147,104 +150,108 @@ function ready(directory: string) {
 // ---------------------------------------------------------------------------
 
 describeWatcher("FileWatcher", () => {
-  afterEach(async () => {
-    await disposeAllInstances()
-  })
+  it.instance("publishes root create, update, and delete events", () =>
+    Effect.gen(function* () {
+      const test = yield* TestInstance
+      const fs = yield* AppFileSystem.Service
+      const file = path.join(test.directory, "watch.txt")
+      const cases = [
+        { event: "add" as const, trigger: fs.writeFileString(file, "a") },
+        { event: "change" as const, trigger: fs.writeFileString(file, "b") },
+        { event: "unlink" as const, trigger: fs.remove(file) },
+      ]
 
-  test("publishes root create, update, and delete events", async () => {
-    await using tmp = await tmpdir({ git: true })
-    const file = path.join(tmp.path, "watch.txt")
-    const dir = tmp.path
-    const cases = [
-      { event: "add" as const, trigger: Effect.promise(() => fs.writeFile(file, "a")) },
-      { event: "change" as const, trigger: Effect.promise(() => fs.writeFile(file, "b")) },
-      { event: "unlink" as const, trigger: Effect.promise(() => fs.unlink(file)) },
-    ]
-
-    await withWatcher(
-      dir,
-      Effect.forEach(cases, ({ event, trigger }) =>
-        nextUpdate(dir, (evt) => evt.file === file && evt.event === event, trigger).pipe(
-          Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))),
-        ),
-      ),
-    )
-  })
-
-  test("watches non-git roots", async () => {
-    await using tmp = await tmpdir()
-    const file = path.join(tmp.path, "plain.txt")
-    const dir = tmp.path
-
-    await withWatcher(
-      dir,
-      nextUpdate(
-        dir,
-        (e) => e.file === file && e.event === "add",
-        Effect.promise(() => fs.writeFile(file, "plain")),
-      ).pipe(Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" })))),
-    )
-  })
-
-  test("cleanup stops publishing events", async () => {
-    await using tmp = await tmpdir({ git: true })
-    const file = path.join(tmp.path, "after-dispose.txt")
-
-    // Start and immediately stop the watcher (withWatcher disposes on exit)
-    await withWatcher(tmp.path, Effect.void)
-
-    // Now write a file — no watcher should be listening
-    await WithInstance.provide({
-      directory: tmp.path,
-      fn: () =>
-        Effect.runPromise(
-          noUpdate(
-            tmp.path,
-            (e) => e.file === file,
-            Effect.promise(() => fs.writeFile(file, "gone")),
+      yield* withWatcher(
+        test.directory,
+        Effect.forEach(cases, ({ event, trigger }) =>
+          nextUpdate(test.directory, (evt) => evt.file === file && evt.event === event, trigger).pipe(
+            Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))),
           ),
         ),
-    })
-  })
+      )
+    }),
+    { git: true },
+  )
 
-  test("ignores .git/index changes", async () => {
-    await using tmp = await tmpdir({ git: true })
-    const gitIndex = path.join(tmp.path, ".git", "index")
-    const edit = path.join(tmp.path, "tracked.txt")
+  it.instance("watches non-git roots", () =>
+    Effect.gen(function* () {
+      const test = yield* TestInstance
+      const fs = yield* AppFileSystem.Service
+      const file = path.join(test.directory, "plain.txt")
 
-    await withWatcher(
-      tmp.path,
-      noUpdate(
-        tmp.path,
-        (e) => e.file === gitIndex,
-        Effect.promise(async () => {
-          await fs.writeFile(edit, "a")
-          await $`git add .`.cwd(tmp.path).quiet().nothrow()
-        }),
-      ),
-    )
-  })
+      yield* withWatcher(
+        test.directory,
+        nextUpdate(
+          test.directory,
+          (e) => e.file === file && e.event === "add",
+          fs.writeFileString(file, "plain"),
+        ).pipe(Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" })))),
+      )
+    }),
+  )
 
-  test("publishes .git/HEAD events", async () => {
-    await using tmp = await tmpdir({ git: true })
-    const head = path.join(tmp.path, ".git", "HEAD")
-    const branch = `watch-${Math.random().toString(36).slice(2)}`
-    await $`git branch ${branch}`.cwd(tmp.path).quiet()
+  it.instance("cleanup stops publishing events", () =>
+    Effect.gen(function* () {
+      const test = yield* TestInstance
+      const fs = yield* AppFileSystem.Service
+      const file = path.join(test.directory, "after-dispose.txt")
 
-    await withWatcher(
-      tmp.path,
-      nextUpdate(
-        tmp.path,
-        (evt) => evt.file === head && evt.event !== "unlink",
-        Effect.promise(() => fs.writeFile(head, `ref: refs/heads/${branch}\n`)),
-      ).pipe(
-        Effect.tap((evt) =>
-          Effect.sync(() => {
-            expect(evt.file).toBe(head)
-            expect(["add", "change"]).toContain(evt.event)
-          }),
+      // Start and immediately stop the watcher (withWatcher disposes on exit).
+      yield* withWatcher(test.directory, Effect.void)
+
+      // Now write a file - no watcher should be listening.
+      yield* noUpdate(test.directory, (e) => e.file === file, fs.writeFileString(file, "gone")).pipe(
+        provideInstance(test.directory),
+      )
+    }),
+    { git: true },
+  )
+
+  it.instance("ignores .git/index changes", () =>
+    Effect.gen(function* () {
+      const test = yield* TestInstance
+      const fs = yield* AppFileSystem.Service
+      const git = yield* Git.Service
+      const gitIndex = path.join(test.directory, ".git", "index")
+      const edit = path.join(test.directory, "tracked.txt")
+
+      yield* withWatcher(
+        test.directory,
+        noUpdate(
+          test.directory,
+          (e) => e.file === gitIndex,
+          fs.writeFileString(edit, "a").pipe(Effect.andThen(git.run(["add", "."], { cwd: test.directory }))),
         ),
-      ),
-    )
-  })
+      )
+    }),
+    { git: true },
+  )
+
+  it.instance("publishes .git/HEAD events", () =>
+    Effect.gen(function* () {
+      const test = yield* TestInstance
+      const fs = yield* AppFileSystem.Service
+      const git = yield* Git.Service
+      const head = path.join(test.directory, ".git", "HEAD")
+      const branch = `watch-${Math.random().toString(36).slice(2)}`
+      yield* git.run(["branch", branch], { cwd: test.directory })
+
+      yield* withWatcher(
+        test.directory,
+        nextUpdate(
+          test.directory,
+          (evt) => evt.file === head && evt.event !== "unlink",
+          fs.writeFileString(head, `ref: refs/heads/${branch}\n`),
+        ).pipe(
+          Effect.tap((evt) =>
+            Effect.sync(() => {
+              expect(evt.file).toBe(head)
+              expect(["add", "change"]).toContain(evt.event)
+            }),
+          ),
+        ),
+      )
+    }),
+    { git: true },
+  )
 })

From 50dccac915e7d4a216238d085146a0745716f120 Mon Sep 17 00:00:00 2001
From: "opencode-agent[bot]" 
Date: Wed, 13 May 2026 15:06:14 +0000
Subject: [PATCH 75/98] chore: generate

---
 packages/opencode/test/file/watcher.test.ts   | 166 +++++++++---------
 .../opencode/test/mcp/oauth-browser.test.ts   |  11 +-
 2 files changed, 92 insertions(+), 85 deletions(-)

diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts
index 1da896cc1..6276e58f2 100644
--- a/packages/opencode/test/file/watcher.test.ts
+++ b/packages/opencode/test/file/watcher.test.ts
@@ -150,26 +150,28 @@ function ready(directory: string) {
 // ---------------------------------------------------------------------------
 
 describeWatcher("FileWatcher", () => {
-  it.instance("publishes root create, update, and delete events", () =>
-    Effect.gen(function* () {
-      const test = yield* TestInstance
-      const fs = yield* AppFileSystem.Service
-      const file = path.join(test.directory, "watch.txt")
-      const cases = [
-        { event: "add" as const, trigger: fs.writeFileString(file, "a") },
-        { event: "change" as const, trigger: fs.writeFileString(file, "b") },
-        { event: "unlink" as const, trigger: fs.remove(file) },
-      ]
+  it.instance(
+    "publishes root create, update, and delete events",
+    () =>
+      Effect.gen(function* () {
+        const test = yield* TestInstance
+        const fs = yield* AppFileSystem.Service
+        const file = path.join(test.directory, "watch.txt")
+        const cases = [
+          { event: "add" as const, trigger: fs.writeFileString(file, "a") },
+          { event: "change" as const, trigger: fs.writeFileString(file, "b") },
+          { event: "unlink" as const, trigger: fs.remove(file) },
+        ]
 
-      yield* withWatcher(
-        test.directory,
-        Effect.forEach(cases, ({ event, trigger }) =>
-          nextUpdate(test.directory, (evt) => evt.file === file && evt.event === event, trigger).pipe(
-            Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))),
+        yield* withWatcher(
+          test.directory,
+          Effect.forEach(cases, ({ event, trigger }) =>
+            nextUpdate(test.directory, (evt) => evt.file === file && evt.event === event, trigger).pipe(
+              Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))),
+            ),
           ),
-        ),
-      )
-    }),
+        )
+      }),
     { git: true },
   )
 
@@ -181,77 +183,81 @@ describeWatcher("FileWatcher", () => {
 
       yield* withWatcher(
         test.directory,
-        nextUpdate(
-          test.directory,
-          (e) => e.file === file && e.event === "add",
-          fs.writeFileString(file, "plain"),
-        ).pipe(Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" })))),
-      )
-    }),
-  )
-
-  it.instance("cleanup stops publishing events", () =>
-    Effect.gen(function* () {
-      const test = yield* TestInstance
-      const fs = yield* AppFileSystem.Service
-      const file = path.join(test.directory, "after-dispose.txt")
-
-      // Start and immediately stop the watcher (withWatcher disposes on exit).
-      yield* withWatcher(test.directory, Effect.void)
-
-      // Now write a file - no watcher should be listening.
-      yield* noUpdate(test.directory, (e) => e.file === file, fs.writeFileString(file, "gone")).pipe(
-        provideInstance(test.directory),
-      )
-    }),
-    { git: true },
-  )
-
-  it.instance("ignores .git/index changes", () =>
-    Effect.gen(function* () {
-      const test = yield* TestInstance
-      const fs = yield* AppFileSystem.Service
-      const git = yield* Git.Service
-      const gitIndex = path.join(test.directory, ".git", "index")
-      const edit = path.join(test.directory, "tracked.txt")
-
-      yield* withWatcher(
-        test.directory,
-        noUpdate(
-          test.directory,
-          (e) => e.file === gitIndex,
-          fs.writeFileString(edit, "a").pipe(Effect.andThen(git.run(["add", "."], { cwd: test.directory }))),
+        nextUpdate(test.directory, (e) => e.file === file && e.event === "add", fs.writeFileString(file, "plain")).pipe(
+          Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" }))),
         ),
       )
     }),
+  )
+
+  it.instance(
+    "cleanup stops publishing events",
+    () =>
+      Effect.gen(function* () {
+        const test = yield* TestInstance
+        const fs = yield* AppFileSystem.Service
+        const file = path.join(test.directory, "after-dispose.txt")
+
+        // Start and immediately stop the watcher (withWatcher disposes on exit).
+        yield* withWatcher(test.directory, Effect.void)
+
+        // Now write a file - no watcher should be listening.
+        yield* noUpdate(test.directory, (e) => e.file === file, fs.writeFileString(file, "gone")).pipe(
+          provideInstance(test.directory),
+        )
+      }),
     { git: true },
   )
 
-  it.instance("publishes .git/HEAD events", () =>
-    Effect.gen(function* () {
-      const test = yield* TestInstance
-      const fs = yield* AppFileSystem.Service
-      const git = yield* Git.Service
-      const head = path.join(test.directory, ".git", "HEAD")
-      const branch = `watch-${Math.random().toString(36).slice(2)}`
-      yield* git.run(["branch", branch], { cwd: test.directory })
+  it.instance(
+    "ignores .git/index changes",
+    () =>
+      Effect.gen(function* () {
+        const test = yield* TestInstance
+        const fs = yield* AppFileSystem.Service
+        const git = yield* Git.Service
+        const gitIndex = path.join(test.directory, ".git", "index")
+        const edit = path.join(test.directory, "tracked.txt")
 
-      yield* withWatcher(
-        test.directory,
-        nextUpdate(
+        yield* withWatcher(
           test.directory,
-          (evt) => evt.file === head && evt.event !== "unlink",
-          fs.writeFileString(head, `ref: refs/heads/${branch}\n`),
-        ).pipe(
-          Effect.tap((evt) =>
-            Effect.sync(() => {
-              expect(evt.file).toBe(head)
-              expect(["add", "change"]).toContain(evt.event)
-            }),
+          noUpdate(
+            test.directory,
+            (e) => e.file === gitIndex,
+            fs.writeFileString(edit, "a").pipe(Effect.andThen(git.run(["add", "."], { cwd: test.directory }))),
           ),
-        ),
-      )
-    }),
+        )
+      }),
+    { git: true },
+  )
+
+  it.instance(
+    "publishes .git/HEAD events",
+    () =>
+      Effect.gen(function* () {
+        const test = yield* TestInstance
+        const fs = yield* AppFileSystem.Service
+        const git = yield* Git.Service
+        const head = path.join(test.directory, ".git", "HEAD")
+        const branch = `watch-${Math.random().toString(36).slice(2)}`
+        yield* git.run(["branch", branch], { cwd: test.directory })
+
+        yield* withWatcher(
+          test.directory,
+          nextUpdate(
+            test.directory,
+            (evt) => evt.file === head && evt.event !== "unlink",
+            fs.writeFileString(head, `ref: refs/heads/${branch}\n`),
+          ).pipe(
+            Effect.tap((evt) =>
+              Effect.sync(() => {
+                expect(evt.file).toBe(head)
+                expect(["add", "change"]).toContain(evt.event)
+              }),
+            ),
+          ),
+        )
+      }),
     { git: true },
   )
 })
diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts
index 8c8c6ca3f..f6222de43 100644
--- a/packages/opencode/test/mcp/oauth-browser.test.ts
+++ b/packages/opencode/test/mcp/oauth-browser.test.ts
@@ -167,7 +167,11 @@ const trackBrowserOpenFailed = Effect.gen(function* () {
 const authenticateScoped = (name: string) =>
   Effect.gen(function* () {
     const mcp = yield* service
-    yield* mcp.authenticate(name).pipe(Effect.ignore, Effect.catchCause(() => Effect.void), Effect.forkScoped)
+    yield* mcp.authenticate(name).pipe(
+      Effect.ignore,
+      Effect.catchCause(() => Effect.void),
+      Effect.forkScoped,
+    )
   })
 
 mcpTest.instance(
@@ -180,10 +184,7 @@ mcpTest.instance(
       const event = yield* trackBrowserOpenFailed
       yield* authenticateScoped("test-oauth-server")
 
-      const failure = yield* awaitWithTimeout(
-        Deferred.await(event),
-        "Timed out waiting for BrowserOpenFailed event",
-      )
+      const failure = yield* awaitWithTimeout(Deferred.await(event), "Timed out waiting for BrowserOpenFailed event")
 
       expect(failure.mcpName).toBe("test-oauth-server")
       expect(failure.url).toContain("https://")

From 650f67a05aad178ae3f18903fca6b3bf6d8d101d Mon Sep 17 00:00:00 2001
From: Kit Langton 
Date: Wed, 13 May 2026 11:08:35 -0400
Subject: [PATCH 76/98] chore: delete unused util/lock module (#27223)

---
 packages/opencode/src/util/lock.ts       | 98 ------------------------
 packages/opencode/test/util/lock.test.ts | 72 -----------------
 2 files changed, 170 deletions(-)
 delete mode 100644 packages/opencode/src/util/lock.ts
 delete mode 100644 packages/opencode/test/util/lock.test.ts

diff --git a/packages/opencode/src/util/lock.ts b/packages/opencode/src/util/lock.ts
deleted file mode 100644
index 15635996e..000000000
--- a/packages/opencode/src/util/lock.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-const locks = new Map<
-  string,
-  {
-    readers: number
-    writer: boolean
-    waitingReaders: (() => void)[]
-    waitingWriters: (() => void)[]
-  }
->()
-
-function get(key: string) {
-  if (!locks.has(key)) {
-    locks.set(key, {
-      readers: 0,
-      writer: false,
-      waitingReaders: [],
-      waitingWriters: [],
-    })
-  }
-  return locks.get(key)!
-}
-
-function process(key: string) {
-  const lock = locks.get(key)
-  if (!lock || lock.writer || lock.readers > 0) return
-
-  // Prioritize writers to prevent starvation
-  if (lock.waitingWriters.length > 0) {
-    const nextWriter = lock.waitingWriters.shift()!
-    nextWriter()
-    return
-  }
-
-  // Wake up all waiting readers
-  while (lock.waitingReaders.length > 0) {
-    const nextReader = lock.waitingReaders.shift()!
-    nextReader()
-  }
-
-  // Clean up empty locks
-  if (lock.readers === 0 && !lock.writer && lock.waitingReaders.length === 0 && lock.waitingWriters.length === 0) {
-    locks.delete(key)
-  }
-}
-
-export async function read(key: string): Promise {
-  const lock = get(key)
-
-  return new Promise((resolve) => {
-    if (!lock.writer && lock.waitingWriters.length === 0) {
-      lock.readers++
-      resolve({
-        [Symbol.dispose]: () => {
-          lock.readers--
-          process(key)
-        },
-      })
-    } else {
-      lock.waitingReaders.push(() => {
-        lock.readers++
-        resolve({
-          [Symbol.dispose]: () => {
-            lock.readers--
-            process(key)
-          },
-        })
-      })
-    }
-  })
-}
-
-export async function write(key: string): Promise {
-  const lock = get(key)
-
-  return new Promise((resolve) => {
-    if (!lock.writer && lock.readers === 0) {
-      lock.writer = true
-      resolve({
-        [Symbol.dispose]: () => {
-          lock.writer = false
-          process(key)
-        },
-      })
-    } else {
-      lock.waitingWriters.push(() => {
-        lock.writer = true
-        resolve({
-          [Symbol.dispose]: () => {
-            lock.writer = false
-            process(key)
-          },
-        })
-      })
-    }
-  })
-}
-
-export * as Lock from "./lock"
diff --git a/packages/opencode/test/util/lock.test.ts b/packages/opencode/test/util/lock.test.ts
deleted file mode 100644
index 79fbb5831..000000000
--- a/packages/opencode/test/util/lock.test.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { Lock } from "@/util/lock"
-
-function tick() {
-  return new Promise((r) => queueMicrotask(r))
-}
-
-async function flush(n = 5) {
-  for (let i = 0; i < n; i++) await tick()
-}
-
-describe("util.lock", () => {
-  test("writer exclusivity: blocks reads and other writes while held", async () => {
-    const key = "lock:" + Math.random().toString(36).slice(2)
-
-    const state = {
-      writer2: false,
-      reader: false,
-      writers: 0,
-    }
-
-    // Acquire writer1
-    using writer1 = await Lock.write(key)
-    state.writers++
-    expect(state.writers).toBe(1)
-
-    // Start writer2 candidate (should block)
-    const writer2Task = (async () => {
-      const w = await Lock.write(key)
-      state.writers++
-      expect(state.writers).toBe(1)
-      state.writer2 = true
-      // Hold for a tick so reader cannot slip in
-      await tick()
-      return w
-    })()
-
-    // Start reader candidate (should block)
-    const readerTask = (async () => {
-      const r = await Lock.read(key)
-      state.reader = true
-      return r
-    })()
-
-    // Flush microtasks and assert neither acquired
-    await flush()
-    expect(state.writer2).toBe(false)
-    expect(state.reader).toBe(false)
-
-    // Release writer1
-    writer1[Symbol.dispose]()
-    state.writers--
-
-    // writer2 should acquire next
-    const writer2 = await writer2Task
-    expect(state.writer2).toBe(true)
-
-    // Reader still blocked while writer2 held
-    await flush()
-    expect(state.reader).toBe(false)
-
-    // Release writer2
-    writer2[Symbol.dispose]()
-    state.writers--
-
-    // Reader should now acquire
-    const reader = await readerTask
-    expect(state.reader).toBe(true)
-
-    reader[Symbol.dispose]()
-  })
-})

From ca723f1cbc6fc4244ae57e61e9de8c4e37380ed4 Mon Sep 17 00:00:00 2001
From: Kit Langton 
Date: Wed, 13 May 2026 11:10:23 -0400
Subject: [PATCH 77/98] effect(core): add stdin option to AppProcess.run;
 migrate snapshot+clipboard (#27224)

---
 packages/core/src/process.ts                  |  29 ++++-
 packages/core/test/process/process.test.ts    | 105 +++++++++++++++---
 .../src/cli/cmd/tui/util/clipboard.ts         |  60 +++-------
 packages/opencode/src/snapshot/index.ts       |  74 +++---------
 4 files changed, 150 insertions(+), 118 deletions(-)

diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts
index 2da8eb834..76ea9cf3f 100644
--- a/packages/core/src/process.ts
+++ b/packages/core/src/process.ts
@@ -16,6 +16,7 @@ export interface RunOptions {
   readonly maxErrorBytes?: number
   readonly signal?: AbortSignal
   readonly timeout?: Duration.Input
+  readonly stdin?: string | Uint8Array | Stream.Stream
 }
 
 export interface RunStreamOptions {
@@ -96,6 +97,15 @@ const waitForAbort = (signal: AbortSignal) =>
     return Effect.sync(() => signal.removeEventListener("abort", onabort))
   })
 
+const normalizeStdin = (
+  input: string | Uint8Array | Stream.Stream,
+): Stream.Stream =>
+  typeof input === "string"
+    ? Stream.make(new TextEncoder().encode(input))
+    : input instanceof Uint8Array
+      ? Stream.make(input)
+      : input
+
 const collectStream = (stream: Stream.Stream, maxOutputBytes: number | undefined) =>
   Stream.runFold(
     stream,
@@ -119,7 +129,7 @@ export const layer = Layer.effect(
   Effect.gen(function* () {
     const spawner = yield* ChildProcessSpawner
 
-    const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) {
+    const runCommand = (command: ChildProcess.Command, options?: RunOptions) => {
       const description = describeCommand(command)
       const collect = Effect.scoped(
         Effect.gen(function* () {
@@ -154,7 +164,22 @@ export const layer = Layer.effect(
             ),
           )
         : timed
-      return yield* aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause))))
+      return aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause))))
+    }
+
+    const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) {
+      if (options?.stdin === undefined) return yield* runCommand(command, options)
+      if (command._tag !== "StandardCommand") {
+        return yield* new AppProcessError({
+          command: describeCommand(command),
+          cause: new Error("stdin option only supports StandardCommand; received PipedCommand"),
+        })
+      }
+      const next = ChildProcess.make(command.command, command.args, {
+        ...command.options,
+        stdin: normalizeStdin(options.stdin),
+      })
+      return yield* runCommand(next, options)
     })
 
     const runStream = (
diff --git a/packages/core/test/process/process.test.ts b/packages/core/test/process/process.test.ts
index 726c3c4d8..5cc73e616 100644
--- a/packages/core/test/process/process.test.ts
+++ b/packages/core/test/process/process.test.ts
@@ -1,4 +1,6 @@
 import { describe, expect } from "bun:test"
+import { realpathSync } from "node:fs"
+import { tmpdir } from "node:os"
 import { Effect, Exit, Stream } from "effect"
 import { ChildProcess } from "effect/unstable/process"
 import { AppProcess } from "@opencode-ai/core/process"
@@ -123,6 +125,82 @@ describe("AppProcess", () => {
     )
   })
 
+  describe("run with stdin option", () => {
+    const echoStdin = "process.stdin.on('data', c => process.stdout.write(c))"
+
+    it.effect(
+      "feeds a string to stdin and returns it on stdout",
+      Effect.gen(function* () {
+        const svc = yield* AppProcess.Service
+        const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "hello" })
+        expect(result.exitCode).toBe(0)
+        expect(result.stdout.toString("utf8")).toBe("hello")
+      }),
+    )
+
+    it.effect(
+      "feeds a Uint8Array to stdin",
+      Effect.gen(function* () {
+        const svc = yield* AppProcess.Service
+        const bytes = new TextEncoder().encode("bytes")
+        const result = yield* svc.run(cmd("-e", echoStdin), { stdin: bytes })
+        expect(result.exitCode).toBe(0)
+        expect(result.stdout.toString("utf8")).toBe("bytes")
+      }),
+    )
+
+    it.effect(
+      "feeds a Stream of Uint8Array chunks to stdin",
+      Effect.gen(function* () {
+        const svc = yield* AppProcess.Service
+        const enc = new TextEncoder()
+        const stream = Stream.fromIterable([enc.encode("one"), enc.encode("-two"), enc.encode("-three")])
+        const result = yield* svc.run(cmd("-e", echoStdin), { stdin: stream })
+        expect(result.exitCode).toBe(0)
+        expect(result.stdout.toString("utf8")).toBe("one-two-three")
+      }),
+    )
+
+    it.effect(
+      "completes correctly with empty input",
+      Effect.gen(function* () {
+        const svc = yield* AppProcess.Service
+        const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "" })
+        expect(result.exitCode).toBe(0)
+        expect(result.stdout.toString("utf8")).toBe("")
+      }),
+    )
+
+    it.effect(
+      "carries existing Command options like env",
+      Effect.gen(function* () {
+        const svc = yield* AppProcess.Service
+        const script =
+          "process.stdout.write(process.env.FEED + ':'); process.stdin.on('data', c => process.stdout.write(c))"
+        const command = ChildProcess.make(NODE, ["-e", script], { env: { FEED: "envset" }, extendEnv: true })
+        const result = yield* svc.run(command, { stdin: "payload" })
+        expect(result.exitCode).toBe(0)
+        expect(result.stdout.toString("utf8")).toBe("envset:payload")
+      }),
+    )
+
+    it.effect(
+      "carries existing Command options like cwd",
+      Effect.gen(function* () {
+        const svc = yield* AppProcess.Service
+        const dir = realpathSync(tmpdir())
+        const script =
+          "process.stdout.write(process.cwd() + '|'); process.stdin.on('data', c => process.stdout.write(c))"
+        const command = ChildProcess.make(NODE, ["-e", script], { cwd: dir })
+        const result = yield* svc.run(command, { stdin: "ok" })
+        expect(result.exitCode).toBe(0)
+        const [cwd, stdin] = result.stdout.toString("utf8").split("|")
+        expect(realpathSync(cwd)).toBe(dir)
+        expect(stdin).toBe("ok")
+      }),
+    )
+  })
+
   describe("runStream", () => {
     it.live(
       "emits lines incrementally and ends cleanly on exit 0",
@@ -136,11 +214,17 @@ describe("AppProcess", () => {
     )
 
     it.live(
-      "fails with AppProcessError when exit not in okExitCodes",
+      "okExitCodes determines whether a non-zero exit fails the stream",
       Effect.gen(function* () {
         const svc = yield* AppProcess.Service
+        const allowed = yield* svc
+          .runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
+          .pipe(Stream.runCollect)
+        expect(Array.from(allowed)).toEqual(["only"])
         const exit = yield* Effect.exit(
-          svc.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0] }).pipe(Stream.runCollect),
+          svc
+            .runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
+            .pipe(Stream.runCollect),
         )
         expect(Exit.isFailure(exit)).toBe(true)
         if (Exit.isFailure(exit)) {
@@ -152,17 +236,6 @@ describe("AppProcess", () => {
       }),
     )
 
-    it.live(
-      "okExitCodes allowlist treats non-zero as success",
-      Effect.gen(function* () {
-        const svc = yield* AppProcess.Service
-        const result = yield* svc
-          .runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
-          .pipe(Stream.runCollect)
-        expect(Array.from(result)).toEqual(["only"])
-      }),
-    )
-
     it.live(
       "without okExitCodes, never fails on exit code",
       Effect.gen(function* () {
@@ -177,12 +250,10 @@ describe("AppProcess", () => {
       Effect.gen(function* () {
         const svc = yield* AppProcess.Service
         const controller = new AbortController()
-        setTimeout(() => controller.abort(), 50)
+        controller.abort()
         const exit = yield* Effect.exit(
           svc
-            .runStream(cmd("-e", "setInterval(() => console.log('tick'), 100); setTimeout(() => {}, 60_000)"), {
-              signal: controller.signal,
-            })
+            .runStream(cmd("-e", "setInterval(() => {}, 60_000)"), { signal: controller.signal })
             .pipe(Stream.runCollect),
         )
         expect(Exit.isFailure(exit)).toBe(true)
diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts
index 3a9996902..be3cec14c 100644
--- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts
+++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts
@@ -3,9 +3,21 @@ import { lazy } from "../../../../util/lazy.js"
 import { tmpdir } from "os"
 import path from "path"
 import fs from "fs/promises"
+import { Effect } from "effect"
+import { ChildProcess } from "effect/unstable/process"
+import { AppProcess } from "@opencode-ai/core/process"
 import * as Filesystem from "../../../../util/filesystem"
 import * as Process from "../../../../util/process"
 
+const writeWithStdin = (cmd: string[], text: string): Promise =>
+  Effect.runPromise(
+    AppProcess.Service.use((svc) => svc.run(ChildProcess.make(cmd[0]!, cmd.slice(1)), { stdin: text })).pipe(
+      Effect.provide(AppProcess.defaultLayer),
+      Effect.catch(() => Effect.void),
+      Effect.asVoid,
+    ),
+  ).catch(() => undefined)
+
 // Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
 const getWhich = lazy(async () => {
   const { which } = await import("../../../../util/which")
@@ -125,49 +137,23 @@ const getCopyMethod = lazy(async () => {
   if (os === "linux") {
     if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
       console.log("clipboard: using wl-copy")
-      return async (text: string) => {
-        const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
-        if (!proc.stdin) return
-        proc.stdin.write(text)
-        proc.stdin.end()
-        await proc.exited.catch(() => {})
-      }
+      return (text: string) => writeWithStdin(["wl-copy"], text)
     }
     if (which("xclip")) {
       console.log("clipboard: using xclip")
-      return async (text: string) => {
-        const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
-          stdin: "pipe",
-          stdout: "ignore",
-          stderr: "ignore",
-        })
-        if (!proc.stdin) return
-        proc.stdin.write(text)
-        proc.stdin.end()
-        await proc.exited.catch(() => {})
-      }
+      return (text: string) => writeWithStdin(["xclip", "-selection", "clipboard"], text)
     }
     if (which("xsel")) {
       console.log("clipboard: using xsel")
-      return async (text: string) => {
-        const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
-          stdin: "pipe",
-          stdout: "ignore",
-          stderr: "ignore",
-        })
-        if (!proc.stdin) return
-        proc.stdin.write(text)
-        proc.stdin.end()
-        await proc.exited.catch(() => {})
-      }
+      return (text: string) => writeWithStdin(["xsel", "--clipboard", "--input"], text)
     }
   }
 
   if (os === "win32") {
     console.log("clipboard: using powershell")
-    return async (text: string) => {
+    return (text: string) =>
       // Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
-      const proc = Process.spawn(
+      writeWithStdin(
         [
           "powershell.exe",
           "-NonInteractive",
@@ -175,18 +161,8 @@ const getCopyMethod = lazy(async () => {
           "-Command",
           "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
         ],
-        {
-          stdin: "pipe",
-          stdout: "ignore",
-          stderr: "ignore",
-        },
+        text,
       )
-
-      if (!proc.stdin) return
-      proc.stdin.write(text)
-      proc.stdin.end()
-      await proc.exited.catch(() => {})
-    }
   }
 
   console.log("clipboard: no native support")
diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts
index a5f080069..f974a457a 100644
--- a/packages/opencode/src/snapshot/index.ts
+++ b/packages/opencode/src/snapshot/index.ts
@@ -1,4 +1,4 @@
-import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Context, Stream } from "effect"
+import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Context } from "effect"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import { formatPatch, structuredPatch } from "diff"
 import path from "path"
@@ -84,48 +84,13 @@ export const layer: Layer.Layer ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
 
-          const enc = new TextEncoder()
-          const feed = (list: string[]) => Stream.make(enc.encode(list.join("\0") + "\0"))
-
-          const gitWithStdin = Effect.fnUntraced(
-            function* (
-              cmd: string[],
-              opts: { cwd?: string; env?: Record; stdin: ChildProcess.CommandInput },
-            ) {
-              // stdin-feed calls still need raw spawn — AppProcess.run does not yet
-              // expose a stdin Stream API. Tracked as future AppProcess helper.
-              const proc = ChildProcess.make("git", cmd, {
-                cwd: opts.cwd,
-                env: opts.env,
-                extendEnv: true,
-                stdin: opts.stdin,
-              })
-              const handle = yield* appProcess.spawn(proc)
-              const [text, stderr] = yield* Effect.all(
-                [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
-                { concurrency: 2 },
-              )
-              const code = yield* handle.exitCode
-              return { code, text, stderr } satisfies GitResult
-            },
-            Effect.scoped,
-            Effect.catch((err) =>
-              Effect.succeed({
-                code: ChildProcessSpawner.ExitCode(1),
-                text: "",
-                stderr: err instanceof Error ? err.message : String(err),
-              }),
-            ),
-          )
+          const feed = (list: string[]) => list.join("\0") + "\0"
 
           const git = Effect.fnUntraced(
-            function* (cmd: string[], opts?: { cwd?: string; env?: Record }) {
+            function* (cmd: string[], opts?: { cwd?: string; env?: Record; stdin?: string }) {
               const result = yield* appProcess.run(
-                ChildProcess.make("git", cmd, {
-                  cwd: opts?.cwd,
-                  env: opts?.env,
-                  extendEnv: true,
-                }),
+                ChildProcess.make("git", cmd, { cwd: opts?.cwd, env: opts?.env, extendEnv: true }),
+                { stdin: opts?.stdin },
               )
               return {
                 code: ChildProcessSpawner.ExitCode(result.exitCode),
@@ -144,7 +109,7 @@ export const layer: Layer.Layer()
-            const check = yield* gitWithStdin(
+            const check = yield* git(
               [
                 ...quote,
                 "--git-dir",
@@ -167,7 +132,7 @@ export const layer: Layer.Layer()
 
-                    // cat-file --batch is a stdin-feed call — kept on raw spawn
-                    // until AppProcess.run exposes a stdin Stream API.
-                    const proc = ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], {
-                      cwd: state.directory,
-                      extendEnv: true,
-                      stdin: Stream.make(new TextEncoder().encode(refs.map((item) => item.ref).join("\n") + "\n")),
-                    })
-                    const handle = yield* appProcess.spawn(proc)
-                    const [out, err] = yield* Effect.all(
-                      [Stream.mkUint8Array(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))],
-                      { concurrency: 2 },
+                    const batch = yield* appProcess.run(
+                      ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], {
+                        cwd: state.directory,
+                        extendEnv: true,
+                      }),
+                      { stdin: refs.map((item) => item.ref).join("\n") + "\n" },
                     )
-                    const code = yield* handle.exitCode
-                    if (code !== 0) {
+                    if (batch.exitCode !== 0) {
                       log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", {
-                        stderr: err,
+                        stderr: batch.stderr.toString("utf8"),
                         refs: refs.length,
                       })
                       return
                     }
+                    const out = batch.stdout
 
                     const fail = (msg: string, extra?: Record) => {
                       log.info(msg, { ...extra, refs: refs.length })

From 6e25720307400a93ebf1e4f22412443cd4b38087 Mon Sep 17 00:00:00 2001
From: Kit Langton 
Date: Wed, 13 May 2026 11:18:35 -0400
Subject: [PATCH 78/98] test(tool): use Effect sleep in edit concurrency test
 (#27349)

---
 packages/opencode/test/tool/edit.test.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts
index 6a1682826..3f644ed53 100644
--- a/packages/opencode/test/tool/edit.test.ts
+++ b/packages/opencode/test/tool/edit.test.ts
@@ -500,7 +500,7 @@ describe("tool.edit", () => {
               asks++
               if (asks !== 1) return
               yield* Deferred.succeed(firstAsk, undefined)
-              yield* Effect.promise(() => Bun.sleep(50))
+              yield* Effect.sleep("50 millis")
             }),
         }
 

From 6c7f35b49e66e684bd53b4c834dc3932db2cf63b Mon Sep 17 00:00:00 2001
From: Kit Langton 
Date: Wed, 13 May 2026 11:26:42 -0400
Subject: [PATCH 79/98] effect(format): migrate to AppProcess (#27185)

---
 packages/opencode/src/format/index.ts | 24 ++++++++++--------------
 1 file changed, 10 insertions(+), 14 deletions(-)

diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts
index b6eb9dfd0..d8365e0fa 100644
--- a/packages/opencode/src/format/index.ts
+++ b/packages/opencode/src/format/index.ts
@@ -1,6 +1,6 @@
 import { Effect, Layer, Context, Schema } from "effect"
-import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
-import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
+import { ChildProcess } from "effect/unstable/process"
+import { AppProcess } from "@opencode-ai/core/process"
 import { InstanceState } from "@/effect/instance-state"
 import path from "path"
 import { mergeDeep } from "remeda"
@@ -29,7 +29,7 @@ export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
     const config = yield* Config.Service
-    const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+    const appProcess = yield* AppProcess.Service
 
     const state = yield* InstanceState.make(
       Effect.fn("Format.state")(function* (ctx) {
@@ -81,8 +81,8 @@ export const layer = Layer.effect(
               log.info("running", { command: cmd })
               const replaced = cmd.map((x) => x.replace("$FILE", filepath))
               const dir = yield* InstanceState.directory
-              const code = yield* spawner
-                .spawn(
+              const result = yield* appProcess
+                .run(
                   ChildProcess.make(replaced[0]!, replaced.slice(1), {
                     cwd: dir,
                     env: item.environment,
@@ -93,21 +93,20 @@ export const layer = Layer.effect(
                   }),
                 )
                 .pipe(
-                  Effect.flatMap((handle) => handle.exitCode),
-                  Effect.scoped,
-                  Effect.catch(() =>
+                  Effect.catch((error) =>
                     Effect.sync(() => {
                       log.error("failed to format file", {
                         error: "spawn failed",
                         command: cmd,
                         ...item.environment,
                         file: filepath,
+                        cause: error.message,
                       })
-                      return ChildProcessSpawner.ExitCode(1)
+                      return undefined
                     }),
                   ),
                 )
-              if (code !== 0) {
+              if (result && result.exitCode !== 0) {
                 log.error("failed", {
                   command: cmd,
                   ...item.environment,
@@ -198,9 +197,6 @@ export const layer = Layer.effect(
   }),
 )
 
-export const defaultLayer = layer.pipe(
-  Layer.provide(Config.defaultLayer),
-  Layer.provide(CrossSpawnSpawner.defaultLayer),
-)
+export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(AppProcess.defaultLayer))
 
 export * as Format from "."

From 832aa949771530e74d85b9b751a0f679dc1069a2 Mon Sep 17 00:00:00 2001
From: Kit Langton 
Date: Wed, 13 May 2026 11:39:35 -0400
Subject: [PATCH 80/98] effect(worktree): migrate to AppProcess.run (#27186)

---
 packages/opencode/src/worktree/index.ts | 50 ++++++++++---------------
 1 file changed, 20 insertions(+), 30 deletions(-)

diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index 93e56605b..8f7910208 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/packages/opencode/src/worktree/index.ts
@@ -12,12 +12,12 @@ import { errorMessage } from "../util/error"
 import { BusEvent } from "@/bus/bus-event"
 import { GlobalBus } from "@/bus/global"
 import { Git } from "@/git"
-import { Effect, Layer, Path, Schema, Scope, Context, Stream } from "effect"
-import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import { Effect, Layer, Path, Schema, Scope, Context } from "effect"
+import { ChildProcess } from "effect/unstable/process"
 import { NodePath } from "@effect/platform-node"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
-import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
+import { AppProcess } from "@opencode-ai/core/process"
 import { InstanceState } from "@/effect/instance-state"
 
 const log = Log.create({ service: "worktree" })
@@ -150,38 +150,35 @@ type GitResult = { code: number; text: string; stderr: string }
 export const layer: Layer.Layer<
   Service,
   never,
-  | AppFileSystem.Service
-  | Path.Path
-  | ChildProcessSpawner.ChildProcessSpawner
-  | Git.Service
-  | Project.Service
-  | InstanceStore.Service
+  AppFileSystem.Service | Path.Path | AppProcess.Service | Git.Service | Project.Service | InstanceStore.Service
 > = Layer.effect(
   Service,
   Effect.gen(function* () {
     const scope = yield* Scope.Scope
     const fs = yield* AppFileSystem.Service
     const pathSvc = yield* Path.Path
-    const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+    const appProcess = yield* AppProcess.Service
     const gitSvc = yield* Git.Service
     const project = yield* Project.Service
     const store = yield* InstanceStore.Service
 
     const git = Effect.fnUntraced(
       function* (args: string[], opts?: { cwd?: string }) {
-        const handle = yield* spawner.spawn(
+        const result = yield* appProcess.run(
           ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
         )
-        const [text, stderr] = yield* Effect.all(
-          [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
-          { concurrency: 2 },
-        )
-        const code = yield* handle.exitCode
-        return { code, text, stderr } satisfies GitResult
+        return {
+          code: result.exitCode,
+          text: result.stdout.toString("utf8"),
+          stderr: result.stderr.toString("utf8"),
+        } satisfies GitResult
       },
-      Effect.scoped,
       Effect.catch((e) =>
-        Effect.succeed({ code: 1, text: "", stderr: e instanceof Error ? e.message : String(e) } satisfies GitResult),
+        Effect.succeed({
+          code: 1,
+          text: "",
+          stderr: e instanceof Error ? e.message : String(e),
+        } satisfies GitResult),
       ),
     )
 
@@ -461,18 +458,11 @@ export const layer: Layer.Layer<
     const runStartCommand = Effect.fnUntraced(
       function* (directory: string, cmd: string) {
         const [shell, args] = process.platform === "win32" ? ["cmd", ["/c", cmd]] : ["bash", ["-lc", cmd]]
-        const handle = yield* spawner.spawn(
-          ChildProcess.make(shell, args, { cwd: directory, extendEnv: true, stdin: "ignore" }),
+        const result = yield* appProcess.run(
+          ChildProcess.make(shell, args as string[], { cwd: directory, extendEnv: true, stdin: "ignore" }),
         )
-        // Drain stdout, capture stderr for error reporting
-        const [, stderr] = yield* Effect.all(
-          [Stream.runDrain(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))],
-          { concurrency: 2 },
-        ).pipe(Effect.orDie)
-        const code = yield* handle.exitCode
-        return { code, stderr }
+        return { code: result.exitCode, stderr: result.stderr.toString("utf8") }
       },
-      Effect.scoped,
       Effect.catch(() => Effect.succeed({ code: 1, stderr: "" })),
     )
 
@@ -620,7 +610,7 @@ export const layer: Layer.Layer<
 
 export const appLayer = layer.pipe(
   Layer.provide(Git.defaultLayer),
-  Layer.provide(CrossSpawnSpawner.defaultLayer),
+  Layer.provide(AppProcess.defaultLayer),
   Layer.provide(Project.defaultLayer),
   Layer.provide(AppFileSystem.defaultLayer),
   Layer.provide(NodePath.layer),

From e5319846add53292a258e197650d1bf75182cd40 Mon Sep 17 00:00:00 2001
From: Kit Langton 
Date: Wed, 13 May 2026 11:43:09 -0400
Subject: [PATCH 81/98] test(server): migrate pty websocket input test (#27348)

---
 .../test/server/httpapi-pty-websocket.test.ts | 21 +++++++++++--------
 1 file changed, 12 insertions(+), 9 deletions(-)

diff --git a/packages/opencode/test/server/httpapi-pty-websocket.test.ts b/packages/opencode/test/server/httpapi-pty-websocket.test.ts
index 81ee952d9..19d97ef09 100644
--- a/packages/opencode/test/server/httpapi-pty-websocket.test.ts
+++ b/packages/opencode/test/server/httpapi-pty-websocket.test.ts
@@ -1,16 +1,19 @@
-import { describe, expect, test } from "bun:test"
+import { describe, expect } from "bun:test"
 import { Effect } from "effect"
 import { handlePtyInput } from "../../src/pty/input"
+import { it } from "../lib/effect"
 
 describe("pty HttpApi websocket input", () => {
-  test("does not forward invalid binary frames to the PTY handler", async () => {
-    const messages: Array = []
-    const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) }
+  it.effect("does not forward invalid binary frames to the PTY handler", () =>
+    Effect.gen(function* () {
+      const messages: Array = []
+      const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) }
 
-    await Effect.runPromise(handlePtyInput(handler, "ready"))
-    await Effect.runPromise(handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd])))
-    await Effect.runPromise(handlePtyInput(handler, new TextEncoder().encode("hello")))
+      yield* handlePtyInput(handler, "ready")
+      yield* handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd]))
+      yield* handlePtyInput(handler, new TextEncoder().encode("hello"))
 
-    expect(messages).toEqual(["ready", "hello"])
-  })
+      expect(messages).toEqual(["ready", "hello"])
+    }),
+  )
 })

From 5cdbb7505efd09dfd588b732118e6f4c970c4a3d Mon Sep 17 00:00:00 2001
From: Kit Langton 
Date: Wed, 13 May 2026 11:54:29 -0400
Subject: [PATCH 82/98] effect(installation): migrate to AppProcess.run
 (#27188)

---
 packages/opencode/src/installation/index.ts   | 413 +++++++++---------
 .../test/installation/installation.test.ts    |   4 +-
 2 files changed, 204 insertions(+), 213 deletions(-)

diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts
index e8c434276..cc0b06e8e 100644
--- a/packages/opencode/src/installation/index.ts
+++ b/packages/opencode/src/installation/index.ts
@@ -1,8 +1,8 @@
 import { Effect, Layer, Schema, Context, Stream } from "effect"
 import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
-import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { withTransientReadRetry } from "@/util/effect-http-client"
-import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import { ChildProcess } from "effect/unstable/process"
+import { AppProcess } from "@opencode-ai/core/process"
 import path from "path"
 import { BusEvent } from "@/bus/bus-event"
 import { Flag } from "@opencode-ai/core/flag/flag"
@@ -85,246 +85,235 @@ export interface Interface {
 
 export class Service extends Context.Service()("@opencode/Installation") {}
 
-export const layer: Layer.Layer =
-  Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const http = yield* HttpClient.HttpClient
-      const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http))
-      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+export const layer: Layer.Layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const http = yield* HttpClient.HttpClient
+    const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http))
+    const appProcess = yield* AppProcess.Service
 
-      const text = Effect.fnUntraced(
-        function* (cmd: string[], opts?: { cwd?: string; env?: Record }) {
-          const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
+    const text = Effect.fnUntraced(
+      function* (cmd: string[], opts?: { cwd?: string; env?: Record }) {
+        const result = yield* appProcess.run(
+          ChildProcess.make(cmd[0], cmd.slice(1), {
             cwd: opts?.cwd,
             env: opts?.env,
             extendEnv: true,
-          })
-          const handle = yield* spawner.spawn(proc)
-          const out = yield* Stream.mkString(Stream.decodeText(handle.stdout))
-          yield* handle.exitCode
-          return out
-        },
-        Effect.scoped,
-        Effect.catch(() => Effect.succeed("")),
-      )
+          }),
+        )
+        return result.stdout.toString("utf8")
+      },
+      Effect.catch(() => Effect.succeed("")),
+    )
 
-      const run = Effect.fnUntraced(
-        function* (cmd: string[], opts?: { cwd?: string; env?: Record }) {
-          const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
+    const run = Effect.fnUntraced(
+      function* (cmd: string[], opts?: { cwd?: string; env?: Record }) {
+        const result = yield* appProcess.run(
+          ChildProcess.make(cmd[0], cmd.slice(1), {
             cwd: opts?.cwd,
             env: opts?.env,
             extendEnv: true,
-          })
-          const handle = yield* spawner.spawn(proc)
-          const [stdout, stderr] = yield* Effect.all(
-            [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
-            { concurrency: 2 },
-          )
-          const code = yield* handle.exitCode
-          return { code, stdout, stderr }
-        },
-        Effect.scoped,
-        Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
-      )
+          }),
+        )
+        return {
+          code: result.exitCode,
+          stdout: result.stdout.toString("utf8"),
+          stderr: result.stderr.toString("utf8"),
+        }
+      },
+      Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })),
+    )
 
-      const getBrewFormula = Effect.fnUntraced(function* () {
-        const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
-        if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
-        const coreFormula = yield* text(["brew", "list", "--formula", "opencode"])
-        if (coreFormula.includes("opencode")) return "opencode"
-        return "opencode"
-      })
+    const getBrewFormula = Effect.fnUntraced(function* () {
+      const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
+      if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
+      const coreFormula = yield* text(["brew", "list", "--formula", "opencode"])
+      if (coreFormula.includes("opencode")) return "opencode"
+      return "opencode"
+    })
 
-      const upgradeCurl = Effect.fnUntraced(
-        function* (target: string) {
-          const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
-          const body = yield* response.text
-          const bodyBytes = new TextEncoder().encode(body)
-          const proc = ChildProcess.make("bash", [], {
-            stdin: Stream.make(bodyBytes),
-            env: { VERSION: target },
-            extendEnv: true,
-          })
-          const handle = yield* spawner.spawn(proc)
-          const [stdout, stderr] = yield* Effect.all(
-            [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
-            { concurrency: 2 },
-          )
-          const code = yield* handle.exitCode
-          return { code, stdout, stderr }
-        },
-        Effect.scoped,
-        Effect.orDie,
-      )
-
-      const result: Interface = {
-        info: Effect.fn("Installation.info")(function* () {
-          return {
-            version: InstallationVersion,
-            latest: yield* result.latest(),
-          }
+    const upgradeCurl = Effect.fnUntraced(function* (target: string) {
+      const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
+      const body = yield* response.text
+      const bodyBytes = new TextEncoder().encode(body)
+      const result = yield* appProcess.run(
+        ChildProcess.make("bash", [], {
+          stdin: Stream.make(bodyBytes),
+          env: { VERSION: target },
+          extendEnv: true,
         }),
-        method: Effect.fn("Installation.method")(function* () {
-          if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
-          if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
-          const exec = process.execPath.toLowerCase()
+      )
+      return {
+        code: result.exitCode,
+        stdout: result.stdout.toString("utf8"),
+        stderr: result.stderr.toString("utf8"),
+      }
+    }, Effect.orDie)
 
-          const checks: Array<{ name: Method; command: () => Effect.Effect }> = [
-            { name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
-            { name: "yarn", command: () => text(["yarn", "global", "list"]) },
-            { name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
-            { name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
-            { name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
-            { name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
-            { name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
-          ]
+    const result: Interface = {
+      info: Effect.fn("Installation.info")(function* () {
+        return {
+          version: InstallationVersion,
+          latest: yield* result.latest(),
+        }
+      }),
+      method: Effect.fn("Installation.method")(function* () {
+        if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
+        if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
+        const exec = process.execPath.toLowerCase()
 
-          checks.sort((a, b) => {
-            const aMatches = exec.includes(a.name)
-            const bMatches = exec.includes(b.name)
-            if (aMatches && !bMatches) return -1
-            if (!aMatches && bMatches) return 1
-            return 0
-          })
+        const checks: Array<{ name: Method; command: () => Effect.Effect }> = [
+          { name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
+          { name: "yarn", command: () => text(["yarn", "global", "list"]) },
+          { name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
+          { name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
+          { name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
+          { name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
+          { name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
+        ]
 
-          for (const check of checks) {
-            const output = yield* check.command()
-            const installedName =
-              check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
-            if (output.includes(installedName)) {
-              return check.name
-            }
+        checks.sort((a, b) => {
+          const aMatches = exec.includes(a.name)
+          const bMatches = exec.includes(b.name)
+          if (aMatches && !bMatches) return -1
+          if (!aMatches && bMatches) return 1
+          return 0
+        })
+
+        for (const check of checks) {
+          const output = yield* check.command()
+          const installedName =
+            check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
+          if (output.includes(installedName)) {
+            return check.name
           }
+        }
 
-          return "unknown" as Method
-        }),
-        latest: Effect.fn("Installation.latest")(function* (installMethod?: Method) {
-          const detectedMethod = installMethod || (yield* result.method())
+        return "unknown" as Method
+      }),
+      latest: Effect.fn("Installation.latest")(function* (installMethod?: Method) {
+        const detectedMethod = installMethod || (yield* result.method())
 
-          if (detectedMethod === "brew") {
-            const formula = yield* getBrewFormula()
-            if (formula.includes("/")) {
-              const infoJson = yield* text(["brew", "info", "--json=v2", formula])
-              const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
-              return info.formulae[0].versions.stable
-            }
-            const response = yield* httpOk.execute(
-              HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
-                HttpClientRequest.acceptJson,
-              ),
-            )
-            const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
-            return data.versions.stable
+        if (detectedMethod === "brew") {
+          const formula = yield* getBrewFormula()
+          if (formula.includes("/")) {
+            const infoJson = yield* text(["brew", "info", "--json=v2", formula])
+            const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
+            return info.formulae[0].versions.stable
           }
-
-          if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
-            const response = yield* httpOk.execute(
-              HttpClientRequest.get(
-                `${yield* NpmConfig.registry(process.cwd())}/opencode-ai/${InstallationChannel}`,
-              ).pipe(HttpClientRequest.acceptJson),
-            )
-            const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
-            return data.version
-          }
-
-          if (detectedMethod === "choco") {
-            const response = yield* httpOk.execute(
-              HttpClientRequest.get(
-                "https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
-              ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
-            )
-            const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
-            return data.d.results[0].Version
-          }
-
-          if (detectedMethod === "scoop") {
-            const response = yield* httpOk.execute(
-              HttpClientRequest.get(
-                "https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
-              ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
-            )
-            const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
-            return data.version
-          }
-
           const response = yield* httpOk.execute(
-            HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
+            HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
               HttpClientRequest.acceptJson,
             ),
           )
-          const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
-          return data.tag_name.replace(/^v/, "")
-        }, Effect.orDie),
-        upgrade: Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
-          let upgradeResult: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
-          switch (m) {
-            case "curl":
-              upgradeResult = yield* upgradeCurl(target)
-              break
-            case "npm":
-              upgradeResult = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
-              break
-            case "pnpm":
-              upgradeResult = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
-              break
-            case "bun":
-              upgradeResult = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
-              break
-            case "brew": {
-              const formula = yield* getBrewFormula()
-              const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
-              if (formula.includes("/")) {
-                const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
-                if (tap.code !== 0) {
-                  upgradeResult = tap
+          const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
+          return data.versions.stable
+        }
+
+        if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
+          const response = yield* httpOk.execute(
+            HttpClientRequest.get(
+              `${yield* NpmConfig.registry(process.cwd())}/opencode-ai/${InstallationChannel}`,
+            ).pipe(HttpClientRequest.acceptJson),
+          )
+          const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
+          return data.version
+        }
+
+        if (detectedMethod === "choco") {
+          const response = yield* httpOk.execute(
+            HttpClientRequest.get(
+              "https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
+            ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
+          )
+          const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
+          return data.d.results[0].Version
+        }
+
+        if (detectedMethod === "scoop") {
+          const response = yield* httpOk.execute(
+            HttpClientRequest.get(
+              "https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
+            ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
+          )
+          const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
+          return data.version
+        }
+
+        const response = yield* httpOk.execute(
+          HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
+            HttpClientRequest.acceptJson,
+          ),
+        )
+        const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
+        return data.tag_name.replace(/^v/, "")
+      }, Effect.orDie),
+      upgrade: Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
+        let upgradeResult: { code: number; stdout: string; stderr: string } | undefined
+        switch (m) {
+          case "curl":
+            upgradeResult = yield* upgradeCurl(target)
+            break
+          case "npm":
+            upgradeResult = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
+            break
+          case "pnpm":
+            upgradeResult = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
+            break
+          case "bun":
+            upgradeResult = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
+            break
+          case "brew": {
+            const formula = yield* getBrewFormula()
+            const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
+            if (formula.includes("/")) {
+              const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
+              if (tap.code !== 0) {
+                upgradeResult = tap
+                break
+              }
+              const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
+              const dir = repo.trim()
+              if (dir) {
+                const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
+                if (pull.code !== 0) {
+                  upgradeResult = pull
                   break
                 }
-                const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
-                const dir = repo.trim()
-                if (dir) {
-                  const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
-                  if (pull.code !== 0) {
-                    upgradeResult = pull
-                    break
-                  }
-                }
               }
-              upgradeResult = yield* run(["brew", "upgrade", formula], { env })
-              break
             }
-            case "choco":
-              upgradeResult = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
-              break
-            case "scoop":
-              upgradeResult = yield* run(["scoop", "install", `opencode@${target}`])
-              break
-            default:
-              return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
+            upgradeResult = yield* run(["brew", "upgrade", formula], { env })
+            break
           }
-          if (!upgradeResult || upgradeResult.code !== 0) {
-            const stderr = m === "choco" ? "not running from an elevated command shell" : upgradeResult?.stderr || ""
-            return yield* new UpgradeFailedError({ stderr })
-          }
-          log.info("upgraded", {
-            method: m,
-            target,
-            stdout: upgradeResult.stdout,
-            stderr: upgradeResult.stderr,
-          })
-          yield* text([process.execPath, "--version"])
-        }),
-      }
+          case "choco":
+            upgradeResult = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
+            break
+          case "scoop":
+            upgradeResult = yield* run(["scoop", "install", `opencode@${target}`])
+            break
+          default:
+            return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
+        }
+        if (!upgradeResult || upgradeResult.code !== 0) {
+          const stderr = m === "choco" ? "not running from an elevated command shell" : upgradeResult?.stderr || ""
+          return yield* new UpgradeFailedError({ stderr })
+        }
+        log.info("upgraded", {
+          method: m,
+          target,
+          stdout: upgradeResult.stdout,
+          stderr: upgradeResult.stderr,
+        })
+        yield* text([process.execPath, "--version"])
+      }),
+    }
 
-      return Service.of(result)
-    }),
-  )
-
-export const defaultLayer = layer.pipe(
-  Layer.provide(FetchHttpClient.layer),
-  Layer.provide(CrossSpawnSpawner.defaultLayer),
+    return Service.of(result)
+  }),
 )
 
+export const defaultLayer = layer.pipe(Layer.provide(FetchHttpClient.layer), Layer.provide(AppProcess.defaultLayer))
+
 const { runPromise } = makeRuntime(Service, defaultLayer)
 
 export const latest = (...args: Parameters) => runPromise((s) => s.latest(...args))
diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts
index 9ca38e968..8193ab8d1 100644
--- a/packages/opencode/test/installation/installation.test.ts
+++ b/packages/opencode/test/installation/installation.test.ts
@@ -4,6 +4,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import { Installation } from "../../src/installation"
 import { InstallationChannel } from "@opencode-ai/core/installation/version"
+import { AppProcess } from "@opencode-ai/core/process"
 import { testEffect } from "../lib/effect"
 
 const encoder = new TextEncoder()
@@ -47,7 +48,8 @@ function testLayer(
   httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response,
   spawnHandler?: (cmd: string, args: readonly string[]) => string,
 ) {
-  return Installation.layer.pipe(Layer.provide(mockHttpClient(httpHandler)), Layer.provide(mockSpawner(spawnHandler)))
+  const appProcess = AppProcess.layer.pipe(Layer.provide(mockSpawner(spawnHandler)))
+  return Installation.layer.pipe(Layer.provide(mockHttpClient(httpHandler)), Layer.provide(appProcess))
 }
 
 describe("installation", () => {

From e5d13d9519ac7c8d9d1a32c31b33b0c8a31b7af2 Mon Sep 17 00:00:00 2001
From: Kit Langton 
Date: Wed, 13 May 2026 12:04:51 -0400
Subject: [PATCH 83/98] effect(git): migrate to AppProcess.run (#27190)

---
 packages/opencode/src/git/index.ts | 58 +++++++++++-------------------
 1 file changed, 20 insertions(+), 38 deletions(-)

diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts
index 349bbad46..4c88edaea 100644
--- a/packages/opencode/src/git/index.ts
+++ b/packages/opencode/src/git/index.ts
@@ -1,6 +1,6 @@
-import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
+import { AppProcess } from "@opencode-ai/core/process"
 import { Effect, Layer, Context, Stream } from "effect"
-import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import { ChildProcess } from "effect/unstable/process"
 
 const cfg = [
   "--no-optional-locks",
@@ -102,49 +102,31 @@ export class Service extends Context.Service()("@opencode/Gi
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+    const appProcess = yield* AppProcess.Service
     const encoder = new TextEncoder()
     const stdin = (text: string) => Stream.make(encoder.encode(text))
 
     const run = Effect.fn("Git.run")(
       function* (args: string[], opts: Options) {
-        const proc = ChildProcess.make("git", [...cfg, ...args], {
-          cwd: opts.cwd,
-          env: opts.env,
-          extendEnv: true,
-          stdin: opts.stdin ?? "ignore",
-          stdout: "pipe",
-          stderr: "pipe",
-        })
-        const handle = yield* spawner.spawn(proc)
-        const collect = (stream: typeof handle.stdout) =>
-          Stream.runFold(
-            stream,
-            () => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }),
-            (acc, chunk) => {
-              if (opts.maxOutputBytes === undefined) {
-                acc.chunks.push(chunk)
-                acc.bytes += chunk.length
-                return acc
-              }
-
-              const remaining = opts.maxOutputBytes - acc.bytes
-              if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining))
-              acc.bytes += chunk.length
-              acc.truncated = acc.truncated || acc.bytes > opts.maxOutputBytes
-              return acc
-            },
-          ).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated })))
-        const [stdout, stderr] = yield* Effect.all([collect(handle.stdout), collect(handle.stderr)], { concurrency: 2 })
+        const result = yield* appProcess.run(
+          ChildProcess.make("git", [...cfg, ...args], {
+            cwd: opts.cwd,
+            env: opts.env,
+            extendEnv: true,
+            stdin: opts.stdin ?? "ignore",
+            stdout: "pipe",
+            stderr: "pipe",
+          }),
+          { maxOutputBytes: opts.maxOutputBytes },
+        )
         return {
-          exitCode: yield* handle.exitCode,
-          text: () => stdout.buffer.toString("utf8"),
-          stdout: stdout.buffer,
-          stderr: stderr.buffer,
-          truncated: stdout.truncated || stderr.truncated,
+          exitCode: result.exitCode,
+          text: () => result.stdout.toString("utf8"),
+          stdout: result.stdout,
+          stderr: result.stderr,
+          truncated: result.truncated,
         } satisfies Result
       },
-      Effect.scoped,
       Effect.catch((err) => Effect.succeed(fail(err))),
     )
 
@@ -360,6 +342,6 @@ export const layer = Layer.effect(
   }),
 )
 
-export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
+export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer))
 
 export * as Git from "."

From 655b25bccf3f31dfd14a8626bf8119b6905275ea Mon Sep 17 00:00:00 2001
From: Frank 
Date: Wed, 13 May 2026 12:05:28 -0400
Subject: [PATCH 84/98] sync

---
 packages/console/function/src/stat.ts | 23 +++++++++++++----------
 1 file changed, 13 insertions(+), 10 deletions(-)

diff --git a/packages/console/function/src/stat.ts b/packages/console/function/src/stat.ts
index 9a1a1cc14..54d38bc31 100644
--- a/packages/console/function/src/stat.ts
+++ b/packages/console/function/src/stat.ts
@@ -1,17 +1,16 @@
 import { and, Database, inArray } from "@opencode-ai/console-core/drizzle/index.js"
 import { ModelTpsRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
 
-type Entry = { provider: string; model: string; tps: number }
-type Result = Record
+type Result = Record>
 
 export default {
   async fetch(request: Request) {
     if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 })
 
-    const entries = (await request.json()) as Entry[]
-    if (!Array.isArray(entries) || entries.length === 0) return Response.json({} satisfies Result)
+    const body = (await request.json()) as { ids: string[] }
+    const ids = body.ids
 
-    const ids = entries.map((e) => `${e.provider}/${e.model}/${e.tps}`)
+    if (ids.length === 0) return Response.json({} satisfies Result)
 
     const toInterval = (date: Date) =>
       parseInt(
@@ -21,19 +20,23 @@ export default {
           .substring(0, 12),
       )
     const now = Date.now()
-    const intervals = Array.from({ length: 5 }, (_, i) => toInterval(new Date(now - i * 60 * 1000)))
+    const intervals = Array.from({ length: 30 }, (_, i) => toInterval(new Date(now - i * 60 * 1000)))
 
     const rows = await Database.use((tx) =>
       tx
         .select()
         .from(ModelTpsRateLimitTable)
-        .where(and(inArray(ModelTpsRateLimitTable.id, ids), inArray(ModelTpsRateLimitTable.interval, intervals))),
+        .where(and(inArray(ModelTpsRateLimitTable.id, body.ids), inArray(ModelTpsRateLimitTable.interval, intervals))),
     )
 
-    const result: Result = Object.fromEntries(ids.map((id) => [id, { qualify: 0, unqualify: 0 }]))
+    const result: Result = Object.fromEntries(
+      body.ids.map((id) => [
+        id,
+        Object.fromEntries(intervals.map((interval) => [interval, { qualify: 0, unqualify: 0 }])),
+      ]),
+    )
     for (const row of rows) {
-      result[row.id].qualify += row.qualify
-      result[row.id].unqualify += row.unqualify
+      result[row.id][row.interval] = { qualify: row.qualify, unqualify: row.unqualify }
     }
     return Response.json(result)
   },

From 25de3e407b15636f58cc43d29e0e3b7fba6f641a Mon Sep 17 00:00:00 2001
From: Kit Langton 
Date: Wed, 13 May 2026 12:30:13 -0400
Subject: [PATCH 85/98] test(acp): use shared instance fixture for event tests
 (#27351)

---
 .../test/acp/event-subscription.test.ts       | 27 +++++++++----------
 1 file changed, 13 insertions(+), 14 deletions(-)

diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts
index 791b5c578..2c9371d74 100644
--- a/packages/opencode/test/acp/event-subscription.test.ts
+++ b/packages/opencode/test/acp/event-subscription.test.ts
@@ -8,8 +8,7 @@ import type {
   ToolStatePending,
   ToolStateRunning,
 } from "@opencode-ai/sdk/v2"
-import { WithInstance } from "../../src/project/with-instance"
-import { tmpdir } from "../fixture/fixture"
+import { provideTestInstance, tmpdir } from "../fixture/fixture"
 
 type SessionUpdateParams = Parameters[0]
 type RequestPermissionParams = Parameters[0]
@@ -317,7 +316,7 @@ function createFakeAgent() {
 describe("acp.agent event subscription", () => {
   test("routes message.part.delta by the event sessionID (no cross-session pollution)", async () => {
     await using tmp = await tmpdir()
-    await WithInstance.provide({
+    await provideTestInstance({
       directory: tmp.path,
       fn: async () => {
         const { agent, controller, updates, stop } = createFakeAgent()
@@ -352,7 +351,7 @@ describe("acp.agent event subscription", () => {
 
   test("does not emit user_message_chunk for live prompt parts", async () => {
     await using tmp = await tmpdir()
-    await WithInstance.provide({
+    await provideTestInstance({
       directory: tmp.path,
       fn: async () => {
         const { agent, controller, sessionUpdates, stop } = createFakeAgent()
@@ -392,7 +391,7 @@ describe("acp.agent event subscription", () => {
 
   test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => {
     await using tmp = await tmpdir()
-    await WithInstance.provide({
+    await provideTestInstance({
       directory: tmp.path,
       fn: async () => {
         const { agent, controller, chunks, stop } = createFakeAgent()
@@ -444,7 +443,7 @@ describe("acp.agent event subscription", () => {
 
   test("does not create additional event subscriptions on repeated loadSession()", async () => {
     await using tmp = await tmpdir()
-    await WithInstance.provide({
+    await provideTestInstance({
       directory: tmp.path,
       fn: async () => {
         const { agent, calls, stop } = createFakeAgent()
@@ -466,7 +465,7 @@ describe("acp.agent event subscription", () => {
 
   test("permission.asked events are handled and replied", async () => {
     await using tmp = await tmpdir()
-    await WithInstance.provide({
+    await provideTestInstance({
       directory: tmp.path,
       fn: async () => {
         const permissionReplies: string[] = []
@@ -505,7 +504,7 @@ describe("acp.agent event subscription", () => {
 
   test("permission prompt on session A does not block message updates for session B", async () => {
     await using tmp = await tmpdir()
-    await WithInstance.provide({
+    await provideTestInstance({
       directory: tmp.path,
       fn: async () => {
         const permissionReplies: string[] = []
@@ -592,7 +591,7 @@ describe("acp.agent event subscription", () => {
 
   test("streams running bash output snapshots and de-dupes identical snapshots", async () => {
     await using tmp = await tmpdir()
-    await WithInstance.provide({
+    await provideTestInstance({
       directory: tmp.path,
       fn: async () => {
         const { agent, controller, sessionUpdates, stop } = createFakeAgent()
@@ -626,7 +625,7 @@ describe("acp.agent event subscription", () => {
 
   test("emits synthetic pending before first running update for any tool", async () => {
     await using tmp = await tmpdir()
-    await WithInstance.provide({
+    await provideTestInstance({
       directory: tmp.path,
       fn: async () => {
         const { agent, controller, sessionUpdates, stop } = createFakeAgent()
@@ -671,7 +670,7 @@ describe("acp.agent event subscription", () => {
 
   test("emits image attachments as ACP tool content blocks on live completed tool updates", async () => {
     await using tmp = await tmpdir()
-    await WithInstance.provide({
+    await provideTestInstance({
       directory: tmp.path,
       fn: async () => {
         const { agent, controller, sessionUpdates, stop } = createFakeAgent()
@@ -728,7 +727,7 @@ describe("acp.agent event subscription", () => {
 
   test("replays completed tool image attachments as ACP tool content blocks", async () => {
     await using tmp = await tmpdir()
-    await WithInstance.provide({
+    await provideTestInstance({
       directory: tmp.path,
       fn: async () => {
         const { agent, sessionUpdates, stop, sdk } = createFakeAgent()
@@ -795,7 +794,7 @@ describe("acp.agent event subscription", () => {
 
   test("does not emit duplicate synthetic pending after replayed running tool", async () => {
     await using tmp = await tmpdir()
-    await WithInstance.provide({
+    await provideTestInstance({
       directory: tmp.path,
       fn: async () => {
         const { agent, controller, sessionUpdates, stop, sdk } = createFakeAgent()
@@ -854,7 +853,7 @@ describe("acp.agent event subscription", () => {
 
   test("clears bash snapshot marker on pending state", async () => {
     await using tmp = await tmpdir()
-    await WithInstance.provide({
+    await provideTestInstance({
       directory: tmp.path,
       fn: async () => {
         const { agent, controller, sessionUpdates, stop } = createFakeAgent()

From f0635e365fc7d49261d8f7d1e006c51a9b340732 Mon Sep 17 00:00:00 2001
From: Kit Langton 
Date: Wed, 13 May 2026 12:33:19 -0400
Subject: [PATCH 86/98] test(session): use Effect polling in processor tests
 (#27354)

---
 .../test/session/processor-effect.test.ts     | 36 ++++++++++---------
 1 file changed, 19 insertions(+), 17 deletions(-)

diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts
index 537665b9a..61c566eae 100644
--- a/packages/opencode/test/session/processor-effect.test.ts
+++ b/packages/opencode/test/session/processor-effect.test.ts
@@ -105,6 +105,17 @@ function defer() {
   return { promise, resolve }
 }
 
+const waitFor = (check: Effect.Effect, message: string) =>
+  Effect.gen(function* () {
+    const stop = Date.now() + 500
+    while (Date.now() < stop) {
+      const value = yield* check
+      if (value !== undefined) return value
+      yield* Effect.sleep("10 millis")
+    }
+    return yield* Effect.fail(new Error(message))
+  })
+
 const user = Effect.fn("TestSession.user")(function* (sessionID: SessionID, text: string) {
   const session = yield* Session.Service
   const msg = yield* session.updateMessage({
@@ -301,15 +312,10 @@ it.live("session.processor effect tests preserve text start time", () =>
           })
           .pipe(Effect.forkChild)
 
-        yield* Effect.promise(async () => {
-          const stop = Date.now() + 500
-          while (Date.now() < stop) {
-            const text = MessageV2.parts(msg.id).find((part): part is MessageV2.TextPart => part.type === "text")
-            if (text?.time?.start) return
-            await Bun.sleep(10)
-          }
-          throw new Error("timed out waiting for text part")
-        })
+        yield* waitFor(
+          Effect.sync(() => MessageV2.parts(msg.id).find((part): part is MessageV2.TextPart => part.type === "text")),
+          "timed out waiting for text part",
+        )
         yield* Effect.sleep("20 millis")
         gate.resolve()
 
@@ -691,14 +697,10 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup
           .pipe(Effect.forkChild)
 
         yield* llm.wait(1)
-        yield* Effect.promise(async () => {
-          const end = Date.now() + 500
-          while (Date.now() < end) {
-            const parts = await MessageV2.parts(msg.id)
-            if (parts.some((part) => part.type === "tool")) return
-            await Bun.sleep(10)
-          }
-        })
+        yield* waitFor(
+          Effect.sync(() => MessageV2.parts(msg.id).find((part): part is MessageV2.ToolPart => part.type === "tool")),
+          "timed out waiting for tool part",
+        )
         yield* Fiber.interrupt(run)
 
         const exit = yield* Fiber.await(run)

From 533495ae20ae154f5f8bcb0764636979a4427c77 Mon Sep 17 00:00:00 2001
From: Kit Langton 
Date: Wed, 13 May 2026 12:38:37 -0400
Subject: [PATCH 87/98] test(mcp): migrate OAuth auto-connect tests (#27356)

---
 .../test/mcp/oauth-auto-connect.test.ts       | 262 ++++++++----------
 1 file changed, 108 insertions(+), 154 deletions(-)

diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts
index 3cf677421..6fb15c459 100644
--- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts
+++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts
@@ -1,5 +1,6 @@
-import { test, expect, mock, beforeEach } from "bun:test"
-import { Effect } from "effect"
+import { expect, mock, beforeEach } from "bun:test"
+import { Effect, Layer } from "effect"
+import { testEffect } from "../lib/effect"
 
 // Mock UnauthorizedError to match the SDK's class
 class MockUnauthorizedError extends Error {
@@ -111,172 +112,125 @@ beforeEach(() => {
 
 // Import modules after mocking
 const { MCP } = await import("../../src/mcp/index")
-const { Instance } = await import("../../src/project/instance")
-const { WithInstance } = await import("../../src/project/with-instance")
-const { tmpdir } = await import("../fixture/fixture")
+const { Bus } = await import("../../src/bus")
+const { Config } = await import("../../src/config/config")
+const { McpAuth } = await import("../../src/mcp/auth")
+const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
+const { AppFileSystem } = await import("@opencode-ai/core/filesystem")
+const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
 
-test("first connect to OAuth server shows needs_auth instead of failed", async () => {
-  await using tmp = await tmpdir({
-    init: async (dir) => {
-      await Bun.write(
-        `${dir}/opencode.json`,
-        JSON.stringify({
-          $schema: "https://opencode.ai/config.json",
-          mcp: {
-            "test-oauth": {
-              type: "remote",
-              url: "https://example.com/mcp",
-            },
-          },
-        }),
-      )
+const mcpTest = testEffect(
+  Layer.mergeAll(
+    MCP.layer.pipe(
+      Layer.provide(McpAuth.defaultLayer),
+      Layer.provideMerge(Bus.layer),
+      Layer.provide(Config.defaultLayer),
+      Layer.provide(CrossSpawnSpawner.defaultLayer),
+      Layer.provide(AppFileSystem.defaultLayer),
+    ),
+    McpAuth.defaultLayer,
+  ),
+)
+
+const config = (name: string) => ({
+  mcp: {
+    [name]: {
+      type: "remote" as const,
+      url: "https://example.com/mcp",
     },
-  })
-
-  await WithInstance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const result = await Effect.runPromise(
-        MCP.Service.use((mcp) =>
-          mcp.add("test-oauth", {
-            type: "remote",
-            url: "https://example.com/mcp",
-          }),
-        ).pipe(Effect.provide(MCP.defaultLayer)),
-      )
-
-      const serverStatus = result.status as Record
-
-      // The server should be detected as needing auth, NOT as failed.
-      // Before the fix, provider.state() would throw a plain Error
-      // ("No OAuth state saved for MCP server: test-oauth") which was
-      // not caught as UnauthorizedError, causing status to be "failed".
-      expect(serverStatus["test-oauth"]).toBeDefined()
-      expect(serverStatus["test-oauth"].status).toBe("needs_auth")
-    },
-  })
+  },
 })
 
-test("state() generates a new state when none is saved", async () => {
-  const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
-  const { McpAuth } = await import("../../src/mcp/auth")
+mcpTest.instance(
+  "first connect to OAuth server shows needs_auth instead of failed",
+  () =>
+    MCP.Service.use((mcp) =>
+      Effect.gen(function* () {
+        const result = yield* mcp.add("test-oauth", {
+          type: "remote",
+          url: "https://example.com/mcp",
+        })
 
-  await using tmp = await tmpdir()
+        const serverStatus = result.status as Record
 
-  await WithInstance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const auth = await Effect.runPromise(
-        Effect.gen(function* () {
-          return yield* McpAuth.Service
-        }).pipe(Effect.provide(McpAuth.defaultLayer)),
-      )
-      const provider = new McpOAuthProvider(
-        "test-state-gen",
-        "https://example.com/mcp",
-        {},
-        { onRedirect: async () => {} },
-        auth,
-      )
+        // The server should be detected as needing auth, NOT as failed.
+        // Before the fix, provider.state() would throw a plain Error
+        // ("No OAuth state saved for MCP server: test-oauth") which was
+        // not caught as UnauthorizedError, causing status to be "failed".
+        expect(serverStatus["test-oauth"]).toBeDefined()
+        expect(serverStatus["test-oauth"].status).toBe("needs_auth")
+      }),
+    ),
+  { config: config("test-oauth") },
+)
 
-      const entryBefore = await Effect.runPromise(
-        McpAuth.Service.use((auth) => auth.get("test-state-gen")).pipe(Effect.provide(McpAuth.defaultLayer)),
-      )
-      expect(entryBefore?.oauthState).toBeUndefined()
+mcpTest.instance("state() generates a new state when none is saved", () =>
+  Effect.gen(function* () {
+    const auth = yield* McpAuth.Service
+    const provider = new McpOAuthProvider(
+      "test-state-gen",
+      "https://example.com/mcp",
+      {},
+      { onRedirect: async () => {} },
+      auth,
+    )
 
-      // state() should generate and return a new state, not throw
-      const state = await provider.state()
-      expect(typeof state).toBe("string")
-      expect(state.length).toBe(64) // 32 bytes as hex
+    const entryBefore = yield* McpAuth.Service.use((auth) => auth.get("test-state-gen"))
+    expect(entryBefore?.oauthState).toBeUndefined()
 
-      // The generated state should be persisted
-      const entryAfter = await Effect.runPromise(
-        McpAuth.Service.use((auth) => auth.get("test-state-gen")).pipe(Effect.provide(McpAuth.defaultLayer)),
-      )
-      expect(entryAfter?.oauthState).toBe(state)
-    },
-  })
-})
+    // state() should generate and return a new state, not throw
+    const state = yield* Effect.promise(() => provider.state())
+    expect(typeof state).toBe("string")
+    expect(state.length).toBe(64) // 32 bytes as hex
 
-test("state() returns existing state when one is saved", async () => {
-  const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
-  const { McpAuth } = await import("../../src/mcp/auth")
+    // The generated state should be persisted
+    const entryAfter = yield* McpAuth.Service.use((auth) => auth.get("test-state-gen"))
+    expect(entryAfter?.oauthState).toBe(state)
+  }),
+)
 
-  await using tmp = await tmpdir()
+mcpTest.instance("state() returns existing state when one is saved", () =>
+  Effect.gen(function* () {
+    const auth = yield* McpAuth.Service
+    const provider = new McpOAuthProvider(
+      "test-state-existing",
+      "https://example.com/mcp",
+      {},
+      { onRedirect: async () => {} },
+      auth,
+    )
 
-  await WithInstance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const auth = await Effect.runPromise(
-        Effect.gen(function* () {
-          return yield* McpAuth.Service
-        }).pipe(Effect.provide(McpAuth.defaultLayer)),
-      )
-      const provider = new McpOAuthProvider(
-        "test-state-existing",
-        "https://example.com/mcp",
-        {},
-        { onRedirect: async () => {} },
-        auth,
-      )
+    // Pre-save a state
+    const existingState = "pre-saved-state-value"
+    yield* McpAuth.Service.use((auth) => auth.updateOAuthState("test-state-existing", existingState))
 
-      // Pre-save a state
-      const existingState = "pre-saved-state-value"
-      await Effect.runPromise(
-        McpAuth.Service.use((auth) => auth.updateOAuthState("test-state-existing", existingState)).pipe(
-          Effect.provide(McpAuth.defaultLayer),
-        ),
-      )
+    // state() should return the existing state
+    const state = yield* Effect.promise(() => provider.state())
+    expect(state).toBe(existingState)
+  }),
+)
 
-      // state() should return the existing state
-      const state = await provider.state()
-      expect(state).toBe(existingState)
-    },
-  })
-})
+mcpTest.instance(
+  "authenticate() stores a connected client when auth completes without redirect",
+  () =>
+    MCP.Service.use((mcp) =>
+      Effect.gen(function* () {
+        const added = yield* mcp.add("test-oauth-connect", {
+          type: "remote",
+          url: "https://example.com/mcp",
+        })
+        const before = added.status as Record
+        expect(before["test-oauth-connect"]?.status).toBe("needs_auth")
 
-test("authenticate() stores a connected client when auth completes without redirect", async () => {
-  await using tmp = await tmpdir({
-    init: async (dir) => {
-      await Bun.write(
-        `${dir}/opencode.json`,
-        JSON.stringify({
-          $schema: "https://opencode.ai/config.json",
-          mcp: {
-            "test-oauth-connect": {
-              type: "remote",
-              url: "https://example.com/mcp",
-            },
-          },
-        }),
-      )
-    },
-  })
+        simulateAuthFlow = false
+        connectSucceedsImmediately = true
 
-  await WithInstance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      await Effect.runPromise(
-        MCP.Service.use((mcp) =>
-          Effect.gen(function* () {
-            const added = yield* mcp.add("test-oauth-connect", {
-              type: "remote",
-              url: "https://example.com/mcp",
-            })
-            const before = added.status as Record
-            expect(before["test-oauth-connect"]?.status).toBe("needs_auth")
+        const result = yield* mcp.authenticate("test-oauth-connect")
+        expect(result.status).toBe("connected")
 
-            simulateAuthFlow = false
-            connectSucceedsImmediately = true
-
-            const result = yield* mcp.authenticate("test-oauth-connect")
-            expect(result.status).toBe("connected")
-
-            const after = yield* mcp.status()
-            expect(after["test-oauth-connect"]?.status).toBe("connected")
-          }),
-        ).pipe(Effect.provide(MCP.defaultLayer)),
-      )
-    },
-  })
-})
+        const after = yield* mcp.status()
+        expect(after["test-oauth-connect"]?.status).toBe("connected")
+      }),
+    ),
+  { config: config("test-oauth-connect") },
+)

From 8ad3a4b2170033a7bbabe98fa6dd82fe44d9cfaf Mon Sep 17 00:00:00 2001
From: Kit Langton 
Date: Wed, 13 May 2026 12:43:23 -0400
Subject: [PATCH 88/98] test(util): migrate log cleanup test to Effect (#27357)

---
 packages/opencode/test/util/log.test.ts | 63 +++++++++++++------------
 1 file changed, 34 insertions(+), 29 deletions(-)

diff --git a/packages/opencode/test/util/log.test.ts b/packages/opencode/test/util/log.test.ts
index 486ca0f3b..defd8c981 100644
--- a/packages/opencode/test/util/log.test.ts
+++ b/packages/opencode/test/util/log.test.ts
@@ -1,44 +1,49 @@
-import { afterEach, expect, test } from "bun:test"
+import { expect } from "bun:test"
+import { Effect } from "effect"
 import fs from "fs/promises"
 import path from "path"
+import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { Global } from "@opencode-ai/core/global"
 import * as Log from "@opencode-ai/core/util/log"
-import { tmpdir } from "../fixture/fixture"
+import { tmpdirScoped } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
 
-const log = Global.Path.log
+const it = testEffect(CrossSpawnSpawner.defaultLayer)
 
-afterEach(() => {
-  Global.Path.log = log
-})
+function files(dir: string) {
+  return Effect.gen(function* () {
+    let last = ""
+    let same = 0
 
-async function files(dir: string) {
-  let last = ""
-  let same = 0
+    for (let i = 0; i < 50; i++) {
+      const list = yield* Effect.promise(() => fs.readdir(dir).then((files) => files.sort()))
+      const next = JSON.stringify(list)
+      same = next === last ? same + 1 : 0
+      if (same >= 2 && list.length === 11) return list
+      last = next
+      yield* Effect.sleep("10 millis")
+    }
 
-  for (let i = 0; i < 50; i++) {
-    const list = (await fs.readdir(dir)).sort()
-    const next = JSON.stringify(list)
-    same = next === last ? same + 1 : 0
-    if (same >= 2 && list.length === 11) return list
-    last = next
-    await Bun.sleep(10)
-  }
-
-  return (await fs.readdir(dir)).sort()
+    return yield* Effect.promise(() => fs.readdir(dir).then((files) => files.sort()))
+  })
 }
 
-test("init cleanup keeps the newest timestamped logs", async () => {
-  await using tmp = await tmpdir()
-  Global.Path.log = tmp.path
+it.live("init cleanup keeps the newest timestamped logs", () =>
+  Effect.gen(function* () {
+    const log = Global.Path.log
+    yield* Effect.addFinalizer(() => Effect.sync(() => (Global.Path.log = log)))
+    const dir = yield* tmpdirScoped()
+    Global.Path.log = dir
 
-  const list = Array.from({ length: 12 }, (_, i) => `2000-01-${String(i + 1).padStart(2, "0")}T000000.log`)
+    const list = Array.from({ length: 12 }, (_, i) => `2000-01-${String(i + 1).padStart(2, "0")}T000000.log`)
 
-  await Promise.all(list.map((file) => fs.writeFile(path.join(tmp.path, file), file)))
+    yield* Effect.all(list.map((file) => Effect.promise(() => fs.writeFile(path.join(dir, file), file))))
 
-  await Log.init({ print: false, dev: false })
+    yield* Effect.promise(() => Log.init({ print: false, dev: false }))
 
-  const next = await files(tmp.path)
+    const next = yield* files(dir)
 
-  expect(next).not.toContain(list[0]!)
-  expect(next).toContain(list.at(-1)!)
-})
+    expect(next).not.toContain(list[0]!)
+    expect(next).toContain(list.at(-1)!)
+  }),
+)

From fa077b92b17df16824262efa72345a9b71d4d1ad Mon Sep 17 00:00:00 2001
From: Frank 
Date: Wed, 13 May 2026 12:45:08 -0400
Subject: [PATCH 89/98] zen: update sticky session logic

---
 .../app/src/routes/zen/util/handler.ts        |    4 +-
 .../routes/zen/util/stickyProviderTracker.ts  |   36 +-
 .../migration.sql                             |    7 +
 .../snapshot.json                             | 2821 +++++++++++++++++
 packages/console/core/src/schema/ip.sql.ts    |   10 +
 5 files changed, 2871 insertions(+), 7 deletions(-)
 create mode 100644 packages/console/core/migrations/20260513163955_tearful_whistler/migration.sql
 create mode 100644 packages/console/core/migrations/20260513163955_tearful_whistler/snapshot.json

diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts
index a550a6e4c..d9e564169 100644
--- a/packages/console/app/src/routes/zen/util/handler.ts
+++ b/packages/console/app/src/routes/zen/util/handler.ts
@@ -123,7 +123,7 @@ export async function handler(
       ? createIpRateLimiter(modelInfo.id, modelInfo.rateLimit, ip, input.request)
       : createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request)
     await rateLimiter?.check()
-    const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
+    const stickyTracker = createStickyTracker(modelInfo.id, modelInfo.stickyProvider, sessionId)
     const stickyProvider = await stickyTracker?.get()
     const authInfo = await authenticate(modelInfo, zenApiKey)
     const billingSource = validateBilling(authInfo, modelInfo)
@@ -238,7 +238,7 @@ export async function handler(
     dataDumper?.provideRequest(reqBody)
 
     // Store sticky provider
-    await stickyTracker?.set(providerInfo.id)
+    if (res.status === 200) await stickyTracker?.set(providerInfo.id)
 
     // Temporarily change 404 to 400 status code b/c solid start automatically override 404 response
     const resStatus = res.status === 404 ? 400 : res.status
diff --git a/packages/console/app/src/routes/zen/util/stickyProviderTracker.ts b/packages/console/app/src/routes/zen/util/stickyProviderTracker.ts
index 8029757c5..fae0cdf03 100644
--- a/packages/console/app/src/routes/zen/util/stickyProviderTracker.ts
+++ b/packages/console/app/src/routes/zen/util/stickyProviderTracker.ts
@@ -1,16 +1,42 @@
-import { Resource } from "@opencode-ai/console-resource"
+import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
+import { ModelStickyProviderTable } from "@opencode-ai/console-core/schema/ip.sql.js"
 
-export function createStickyTracker(stickyProvider: "strict" | "prefer" | undefined, session: string) {
+export function createStickyTracker(modelId: string, stickyProvider: "strict" | "prefer" | undefined, session: string) {
   if (!stickyProvider) return
   if (!session) return
-  const key = `sticky:${session}`
+  const id = `${modelId}/${session}`
+  let _providerId: string | undefined
 
   return {
     get: async () => {
-      return await Resource.GatewayKv.get(key)
+      const data = await Database.use((tx) =>
+        tx
+          .select({
+            providerId: ModelStickyProviderTable.providerId,
+          })
+          .from(ModelStickyProviderTable)
+          .where(eq(ModelStickyProviderTable.id, id))
+          .limit(1),
+      )
+      _providerId = data[0]?.providerId
+      return _providerId
     },
     set: async (providerId: string) => {
-      await Resource.GatewayKv.put(key, providerId, { expirationTtl: 86400 })
+      if (_providerId === providerId) return
+
+      await Database.use((tx) =>
+        tx
+          .insert(ModelStickyProviderTable)
+          .values({
+            id,
+            providerId,
+          })
+          .onDuplicateKeyUpdate({
+            set: {
+              providerId,
+            },
+          }),
+      )
     },
   }
 }
diff --git a/packages/console/core/migrations/20260513163955_tearful_whistler/migration.sql b/packages/console/core/migrations/20260513163955_tearful_whistler/migration.sql
new file mode 100644
index 000000000..a2bcc164c
--- /dev/null
+++ b/packages/console/core/migrations/20260513163955_tearful_whistler/migration.sql
@@ -0,0 +1,7 @@
+CREATE TABLE `model_sticky_provider` (
+	`id` varchar(255) PRIMARY KEY,
+	`time_created` timestamp(3) NOT NULL DEFAULT (now()),
+	`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+	`time_deleted` timestamp(3),
+	`provider_id` varchar(255) NOT NULL
+);
diff --git a/packages/console/core/migrations/20260513163955_tearful_whistler/snapshot.json b/packages/console/core/migrations/20260513163955_tearful_whistler/snapshot.json
new file mode 100644
index 000000000..7810ff281
--- /dev/null
+++ b/packages/console/core/migrations/20260513163955_tearful_whistler/snapshot.json
@@ -0,0 +1,2821 @@
+{
+  "version": "6",
+  "dialect": "mysql",
+  "id": "1f04bd59-35b0-493b-9d55-cfa08207ba8e",
+  "prevIds": [
+    "c742e0f2-5d89-4216-b843-059d00680f13"
+  ],
+  "ddl": [
+    {
+      "name": "account",
+      "entityType": "tables"
+    },
+    {
+      "name": "auth",
+      "entityType": "tables"
+    },
+    {
+      "name": "benchmark",
+      "entityType": "tables"
+    },
+    {
+      "name": "billing",
+      "entityType": "tables"
+    },
+    {
+      "name": "coupon",
+      "entityType": "tables"
+    },
+    {
+      "name": "lite",
+      "entityType": "tables"
+    },
+    {
+      "name": "payment",
+      "entityType": "tables"
+    },
+    {
+      "name": "subscription",
+      "entityType": "tables"
+    },
+    {
+      "name": "usage",
+      "entityType": "tables"
+    },
+    {
+      "name": "ip_rate_limit",
+      "entityType": "tables"
+    },
+    {
+      "name": "ip",
+      "entityType": "tables"
+    },
+    {
+      "name": "key_rate_limit",
+      "entityType": "tables"
+    },
+    {
+      "name": "model_sticky_provider",
+      "entityType": "tables"
+    },
+    {
+      "name": "model_tpm_rate_limit",
+      "entityType": "tables"
+    },
+    {
+      "name": "model_tps_rate_limit",
+      "entityType": "tables"
+    },
+    {
+      "name": "key",
+      "entityType": "tables"
+    },
+    {
+      "name": "model",
+      "entityType": "tables"
+    },
+    {
+      "name": "provider",
+      "entityType": "tables"
+    },
+    {
+      "name": "user",
+      "entityType": "tables"
+    },
+    {
+      "name": "workspace",
+      "entityType": "tables"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "auth"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "auth"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "auth"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "auth"
+    },
+    {
+      "type": "enum('email','github','google')",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "provider",
+      "entityType": "columns",
+      "table": "auth"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "subject",
+      "entityType": "columns",
+      "table": "auth"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "account_id",
+      "entityType": "columns",
+      "table": "auth"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "benchmark"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "benchmark"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "benchmark"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "benchmark"
+    },
+    {
+      "type": "varchar(64)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "model",
+      "entityType": "columns",
+      "table": "benchmark"
+    },
+    {
+      "type": "varchar(64)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "agent",
+      "entityType": "columns",
+      "table": "benchmark"
+    },
+    {
+      "type": "mediumtext",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "result",
+      "entityType": "columns",
+      "table": "benchmark"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "customer_id",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "payment_method_id",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(32)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "payment_method_type",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(4)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "payment_method_last4",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "bigint",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "balance",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "monthly_limit",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "bigint",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "monthly_usage",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_monthly_usage_updated",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "boolean",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "reload",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "reload_trigger",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "reload_amount",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "reload_error",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_reload_error",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_reload_locked_till",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "json",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "subscription",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(28)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "subscription_id",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "enum('20','100','200')",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "subscription_plan",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_subscription_booked",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_subscription_selected",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(28)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "lite_subscription_id",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "json",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "lite",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "email",
+      "entityType": "columns",
+      "table": "coupon"
+    },
+    {
+      "type": "enum('BUILDATHON','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100')",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "coupon"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_redeemed",
+      "entityType": "columns",
+      "table": "coupon"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "user_id",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "bigint",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "rolling_usage",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "bigint",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "weekly_usage",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "bigint",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "monthly_usage",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_rolling_updated",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_weekly_updated",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_monthly_updated",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "customer_id",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "invoice_id",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "payment_id",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "bigint",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "amount",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_refunded",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "json",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "enrichment",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "user_id",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "bigint",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "rolling_usage",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "bigint",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "fixed_usage",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_rolling_updated",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_fixed_updated",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "model",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "provider",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "int",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "input_tokens",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "int",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "output_tokens",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "reasoning_tokens",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "cache_read_tokens",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "cache_write_5m_tokens",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "cache_write_1h_tokens",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "bigint",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "cost",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "key_id",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "json",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "enrichment",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "varchar(45)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "ip",
+      "entityType": "columns",
+      "table": "ip_rate_limit"
+    },
+    {
+      "type": "varchar(10)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "interval",
+      "entityType": "columns",
+      "table": "ip_rate_limit"
+    },
+    {
+      "type": "int",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "count",
+      "entityType": "columns",
+      "table": "ip_rate_limit"
+    },
+    {
+      "type": "varchar(45)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "ip",
+      "entityType": "columns",
+      "table": "ip"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "ip"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "ip"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "ip"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "usage",
+      "entityType": "columns",
+      "table": "ip"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "key",
+      "entityType": "columns",
+      "table": "key_rate_limit"
+    },
+    {
+      "type": "varchar(40)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "interval",
+      "entityType": "columns",
+      "table": "key_rate_limit"
+    },
+    {
+      "type": "int",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "count",
+      "entityType": "columns",
+      "table": "key_rate_limit"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "model_sticky_provider"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "model_sticky_provider"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "model_sticky_provider"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "model_sticky_provider"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "provider_id",
+      "entityType": "columns",
+      "table": "model_sticky_provider"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "model_tpm_rate_limit"
+    },
+    {
+      "type": "bigint",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "interval",
+      "entityType": "columns",
+      "table": "model_tpm_rate_limit"
+    },
+    {
+      "type": "int",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "count",
+      "entityType": "columns",
+      "table": "model_tpm_rate_limit"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "model_tps_rate_limit"
+    },
+    {
+      "type": "bigint",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "interval",
+      "entityType": "columns",
+      "table": "model_tps_rate_limit"
+    },
+    {
+      "type": "int",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "qualify",
+      "entityType": "columns",
+      "table": "model_tps_rate_limit"
+    },
+    {
+      "type": "int",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "unqualify",
+      "entityType": "columns",
+      "table": "model_tps_rate_limit"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "key",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "user_id",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_used",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "model"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "model"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "model"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "model"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "model"
+    },
+    {
+      "type": "varchar(64)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "model",
+      "entityType": "columns",
+      "table": "model"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "provider"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "provider"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "provider"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "provider"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "provider"
+    },
+    {
+      "type": "varchar(64)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "provider",
+      "entityType": "columns",
+      "table": "provider"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "credentials",
+      "entityType": "columns",
+      "table": "provider"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "account_id",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "email",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_seen",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "color",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "enum('admin','member')",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "role",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "monthly_limit",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "bigint",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "monthly_usage",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_monthly_usage_updated",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "slug",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "name": "PRIMARY",
+      "table": "account",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "name": "PRIMARY",
+      "table": "auth",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "name": "PRIMARY",
+      "table": "benchmark",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "workspace_id",
+        "id"
+      ],
+      "name": "PRIMARY",
+      "table": "billing",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "email",
+        "type"
+      ],
+      "name": "PRIMARY",
+      "table": "coupon",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "workspace_id",
+        "id"
+      ],
+      "name": "PRIMARY",
+      "table": "lite",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "workspace_id",
+        "id"
+      ],
+      "name": "PRIMARY",
+      "table": "payment",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "workspace_id",
+        "id"
+      ],
+      "name": "PRIMARY",
+      "table": "subscription",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "workspace_id",
+        "id"
+      ],
+      "name": "PRIMARY",
+      "table": "usage",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "ip",
+        "interval"
+      ],
+      "name": "PRIMARY",
+      "table": "ip_rate_limit",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "ip"
+      ],
+      "name": "PRIMARY",
+      "table": "ip",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "key",
+        "interval"
+      ],
+      "name": "PRIMARY",
+      "table": "key_rate_limit",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "name": "PRIMARY",
+      "table": "model_sticky_provider",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id",
+        "interval"
+      ],
+      "name": "PRIMARY",
+      "table": "model_tpm_rate_limit",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id",
+        "interval"
+      ],
+      "name": "PRIMARY",
+      "table": "model_tps_rate_limit",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "workspace_id",
+        "id"
+      ],
+      "name": "PRIMARY",
+      "table": "key",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "workspace_id",
+        "id"
+      ],
+      "name": "PRIMARY",
+      "table": "model",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "workspace_id",
+        "id"
+      ],
+      "name": "PRIMARY",
+      "table": "provider",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "workspace_id",
+        "id"
+      ],
+      "name": "PRIMARY",
+      "table": "user",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "name": "PRIMARY",
+      "table": "workspace",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        {
+          "value": "provider",
+          "isExpression": false
+        },
+        {
+          "value": "subject",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "provider",
+      "entityType": "indexes",
+      "table": "auth"
+    },
+    {
+      "columns": [
+        {
+          "value": "account_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "account_id",
+      "entityType": "indexes",
+      "table": "auth"
+    },
+    {
+      "columns": [
+        {
+          "value": "time_created",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "time_created",
+      "entityType": "indexes",
+      "table": "benchmark"
+    },
+    {
+      "columns": [
+        {
+          "value": "customer_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "global_customer_id",
+      "entityType": "indexes",
+      "table": "billing"
+    },
+    {
+      "columns": [
+        {
+          "value": "subscription_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "global_subscription_id",
+      "entityType": "indexes",
+      "table": "billing"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        },
+        {
+          "value": "user_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "workspace_user_id",
+      "entityType": "indexes",
+      "table": "lite"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        },
+        {
+          "value": "user_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "workspace_user_id",
+      "entityType": "indexes",
+      "table": "subscription"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        },
+        {
+          "value": "time_created",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "usage_time_created",
+      "entityType": "indexes",
+      "table": "usage"
+    },
+    {
+      "columns": [
+        {
+          "value": "key",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "global_key",
+      "entityType": "indexes",
+      "table": "key"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        },
+        {
+          "value": "model",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "model_workspace_model",
+      "entityType": "indexes",
+      "table": "model"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        },
+        {
+          "value": "provider",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "workspace_provider",
+      "entityType": "indexes",
+      "table": "provider"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        },
+        {
+          "value": "account_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "user_account_id",
+      "entityType": "indexes",
+      "table": "user"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        },
+        {
+          "value": "email",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "user_email",
+      "entityType": "indexes",
+      "table": "user"
+    },
+    {
+      "columns": [
+        {
+          "value": "account_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "global_account_id",
+      "entityType": "indexes",
+      "table": "user"
+    },
+    {
+      "columns": [
+        {
+          "value": "email",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "global_email",
+      "entityType": "indexes",
+      "table": "user"
+    },
+    {
+      "columns": [
+        {
+          "value": "slug",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "slug",
+      "entityType": "indexes",
+      "table": "workspace"
+    }
+  ],
+  "renames": []
+}
\ No newline at end of file
diff --git a/packages/console/core/src/schema/ip.sql.ts b/packages/console/core/src/schema/ip.sql.ts
index 975dcfa18..a80fa474c 100644
--- a/packages/console/core/src/schema/ip.sql.ts
+++ b/packages/console/core/src/schema/ip.sql.ts
@@ -51,3 +51,13 @@ export const ModelTpsRateLimitTable = mysqlTable(
   },
   (table) => [primaryKey({ columns: [table.id, table.interval] })],
 )
+
+export const ModelStickyProviderTable = mysqlTable(
+  "model_sticky_provider",
+  {
+    id: varchar("id", { length: 255 }).notNull(),
+    ...timestamps,
+    providerId: varchar("provider_id", { length: 255 }).notNull(),
+  },
+  (table) => [primaryKey({ columns: [table.id] })],
+)

From 7cc968b05d120b4225e550104132f9cba3bd05f2 Mon Sep 17 00:00:00 2001
From: "opencode-agent[bot]" 
Date: Wed, 13 May 2026 16:47:21 +0000
Subject: [PATCH 90/98] chore: generate

---
 .../snapshot.json                             | 100 ++++--------------
 1 file changed, 22 insertions(+), 78 deletions(-)

diff --git a/packages/console/core/migrations/20260513163955_tearful_whistler/snapshot.json b/packages/console/core/migrations/20260513163955_tearful_whistler/snapshot.json
index 7810ff281..b79173522 100644
--- a/packages/console/core/migrations/20260513163955_tearful_whistler/snapshot.json
+++ b/packages/console/core/migrations/20260513163955_tearful_whistler/snapshot.json
@@ -2,9 +2,7 @@
   "version": "6",
   "dialect": "mysql",
   "id": "1f04bd59-35b0-493b-9d55-cfa08207ba8e",
-  "prevIds": [
-    "c742e0f2-5d89-4216-b843-059d00680f13"
-  ],
+  "prevIds": ["c742e0f2-5d89-4216-b843-059d00680f13"],
   "ddl": [
     {
       "name": "account",
@@ -2355,175 +2353,121 @@
       "table": "workspace"
     },
     {
-      "columns": [
-        "id"
-      ],
+      "columns": ["id"],
       "name": "PRIMARY",
       "table": "account",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "id"
-      ],
+      "columns": ["id"],
       "name": "PRIMARY",
       "table": "auth",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "id"
-      ],
+      "columns": ["id"],
       "name": "PRIMARY",
       "table": "benchmark",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "workspace_id",
-        "id"
-      ],
+      "columns": ["workspace_id", "id"],
       "name": "PRIMARY",
       "table": "billing",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "email",
-        "type"
-      ],
+      "columns": ["email", "type"],
       "name": "PRIMARY",
       "table": "coupon",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "workspace_id",
-        "id"
-      ],
+      "columns": ["workspace_id", "id"],
       "name": "PRIMARY",
       "table": "lite",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "workspace_id",
-        "id"
-      ],
+      "columns": ["workspace_id", "id"],
       "name": "PRIMARY",
       "table": "payment",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "workspace_id",
-        "id"
-      ],
+      "columns": ["workspace_id", "id"],
       "name": "PRIMARY",
       "table": "subscription",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "workspace_id",
-        "id"
-      ],
+      "columns": ["workspace_id", "id"],
       "name": "PRIMARY",
       "table": "usage",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "ip",
-        "interval"
-      ],
+      "columns": ["ip", "interval"],
       "name": "PRIMARY",
       "table": "ip_rate_limit",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "ip"
-      ],
+      "columns": ["ip"],
       "name": "PRIMARY",
       "table": "ip",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "key",
-        "interval"
-      ],
+      "columns": ["key", "interval"],
       "name": "PRIMARY",
       "table": "key_rate_limit",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "id"
-      ],
+      "columns": ["id"],
       "name": "PRIMARY",
       "table": "model_sticky_provider",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "id",
-        "interval"
-      ],
+      "columns": ["id", "interval"],
       "name": "PRIMARY",
       "table": "model_tpm_rate_limit",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "id",
-        "interval"
-      ],
+      "columns": ["id", "interval"],
       "name": "PRIMARY",
       "table": "model_tps_rate_limit",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "workspace_id",
-        "id"
-      ],
+      "columns": ["workspace_id", "id"],
       "name": "PRIMARY",
       "table": "key",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "workspace_id",
-        "id"
-      ],
+      "columns": ["workspace_id", "id"],
       "name": "PRIMARY",
       "table": "model",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "workspace_id",
-        "id"
-      ],
+      "columns": ["workspace_id", "id"],
       "name": "PRIMARY",
       "table": "provider",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "workspace_id",
-        "id"
-      ],
+      "columns": ["workspace_id", "id"],
       "name": "PRIMARY",
       "table": "user",
       "entityType": "pks"
     },
     {
-      "columns": [
-        "id"
-      ],
+      "columns": ["id"],
       "name": "PRIMARY",
       "table": "workspace",
       "entityType": "pks"
@@ -2818,4 +2762,4 @@
     }
   ],
   "renames": []
-}
\ No newline at end of file
+}

From a4ebb07c25ce58ecc5599555267ea196ea8f21a1 Mon Sep 17 00:00:00 2001
From: Shoubhit Dash 
Date: Thu, 14 May 2026 00:09:53 +0530
Subject: [PATCH 91/98] refactor(flags): route llm client through runtime flags
 (#27368)

---
 packages/opencode/src/session/llm.ts | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts
index c31545a3d..116254a81 100644
--- a/packages/opencode/src/session/llm.ts
+++ b/packages/opencode/src/session/llm.ts
@@ -12,7 +12,6 @@ import type { Agent } from "@/agent/agent"
 import type { MessageV2 } from "./message-v2"
 import { Plugin } from "@/plugin"
 import { SystemPrompt } from "./system"
-import { Flag } from "@opencode-ai/core/flag/flag"
 import { Permission } from "@/permission"
 import { PermissionID } from "@/permission/schema"
 import { Bus } from "@/bus"
@@ -21,6 +20,7 @@ import { SessionID } from "@/session/schema"
 import { Auth } from "@/auth"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import { EffectBridge } from "@/effect/bridge"
+import { RuntimeFlags } from "@/effect/runtime-flags"
 import * as Option from "effect/Option"
 import * as OtelTracer from "@effect/opentelemetry/Tracer"
 
@@ -62,7 +62,7 @@ export class Service extends Context.Service()("@opencode/LL
 const live: Layer.Layer<
   Service,
   never,
-  Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service
+  Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service | RuntimeFlags.Service
 > = Layer.effect(
   Service,
   Effect.gen(function* () {
@@ -71,6 +71,7 @@ const live: Layer.Layer<
     const provider = yield* Provider.Service
     const plugin = yield* Plugin.Service
     const perm = yield* Permission.Service
+    const flags = yield* RuntimeFlags.Service
 
     const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
       const l = log
@@ -375,7 +376,7 @@ const live: Layer.Layer<
                 "x-opencode-project": opencodeProjectID,
                 "x-opencode-session": input.sessionID,
                 "x-opencode-request": input.user.id,
-                "x-opencode-client": Flag.OPENCODE_CLIENT,
+                "x-opencode-client": flags.client,
                 "User-Agent": `opencode/${InstallationVersion}`,
               }
             : {
@@ -443,6 +444,7 @@ export const defaultLayer = Layer.suspend(() =>
     Layer.provide(Config.defaultLayer),
     Layer.provide(Provider.defaultLayer),
     Layer.provide(Plugin.defaultLayer),
+    Layer.provide(RuntimeFlags.defaultLayer),
   ),
 )
 

From 52f9bcbb8204c5bb459ec9227c64dd392a551c41 Mon Sep 17 00:00:00 2001
From: Shoubhit Dash 
Date: Thu, 14 May 2026 00:10:31 +0530
Subject: [PATCH 92/98] refactor(flags): route installation client through
 runtime flags (#27369)

---
 packages/opencode/src/installation/index.ts    |  7 +++++--
 packages/opencode/src/provider/models.ts       |  9 +++++++--
 packages/opencode/test/provider/models.test.ts | 10 ++++++++--
 3 files changed, 20 insertions(+), 6 deletions(-)

diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts
index cc0b06e8e..00fbabe35 100644
--- a/packages/opencode/src/installation/index.ts
+++ b/packages/opencode/src/installation/index.ts
@@ -5,7 +5,6 @@ import { ChildProcess } from "effect/unstable/process"
 import { AppProcess } from "@opencode-ai/core/process"
 import path from "path"
 import { BusEvent } from "@/bus/bus-event"
-import { Flag } from "@opencode-ai/core/flag/flag"
 import * as Log from "@opencode-ai/core/util/log"
 import { makeRuntime } from "@opencode-ai/core/effect/runtime"
 import semver from "semver"
@@ -50,7 +49,11 @@ export const Info = Schema.Struct({
 }).annotate({ identifier: "InstallationInfo" })
 export type Info = Schema.Schema.Type
 
-export const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
+export function userAgent(client = "cli") {
+  return `opencode/${InstallationChannel}/${InstallationVersion}/${client}`
+}
+
+export const USER_AGENT = userAgent()
 
 export function isPreview() {
   return InstallationChannel !== "latest"
diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts
index 9f88f9db1..8d88e0fce 100644
--- a/packages/opencode/src/provider/models.ts
+++ b/packages/opencode/src/provider/models.ts
@@ -9,6 +9,7 @@ import { Hash } from "@opencode-ai/core/util/hash"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { withTransientReadRetry } from "@/util/effect-http-client"
 import { CatalogModelStatus } from "./model-status"
+import { RuntimeFlags } from "@/effect/runtime-flags"
 
 const CostTier = Schema.Struct({
   input: Schema.Finite,
@@ -109,11 +110,14 @@ export interface Interface {
 
 export class Service extends Context.Service()("@opencode/ModelsDev") {}
 
-export const layer: Layer.Layer = Layer.effect(
+type Requirements = AppFileSystem.Service | HttpClient.HttpClient | RuntimeFlags.Service
+
+export const layer: Layer.Layer = Layer.effect(
   Service,
   Effect.gen(function* () {
     const fs = yield* AppFileSystem.Service
     const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
+    const flags = yield* RuntimeFlags.Service
 
     const source = Flag.OPENCODE_MODELS_URL || "https://models.dev"
     const filepath = path.join(
@@ -132,7 +136,7 @@ export const layer: Layer.Layer res.text),
         Effect.timeout("10 seconds"),
@@ -208,6 +212,7 @@ export const layer: Layer.Layer = layer.pipe(
   Layer.provide(FetchHttpClient.layer),
   Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(RuntimeFlags.defaultLayer),
 )
 
 export * as ModelsDev from "./models"
diff --git a/packages/opencode/test/provider/models.test.ts b/packages/opencode/test/provider/models.test.ts
index 7ccf126a9..c0dbd5caa 100644
--- a/packages/opencode/test/provider/models.test.ts
+++ b/packages/opencode/test/provider/models.test.ts
@@ -8,6 +8,7 @@ import { ModelsDev } from "../../src/provider/models"
 import { it } from "../lib/effect"
 import { rm, writeFile, utimes, mkdir } from "fs/promises"
 import path from "path"
+import { RuntimeFlags } from "@/effect/runtime-flags"
 
 // test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can
 // resolve providers without network. These tests need to drive the on-disk
@@ -70,13 +71,16 @@ const fixture2: Record = {
 interface MockState {
   body: string
   status: number
-  calls: Array<{ url: string }>
+  calls: Array<{ url: string; userAgent: string | null }>
 }
 
 const makeMockClient = (state: Ref.Ref) =>
   HttpClient.make((request) =>
     Effect.gen(function* () {
-      yield* Ref.update(state, (s) => ({ ...s, calls: [...s.calls, { url: request.url }] }))
+      yield* Ref.update(state, (s) => ({
+        ...s,
+        calls: [...s.calls, { url: request.url, userAgent: request.headers["user-agent"] ?? null }],
+      }))
       const s = yield* Ref.get(state)
       return HttpClientResponse.fromWeb(request, new Response(s.body, { status: s.status }))
     }),
@@ -89,6 +93,7 @@ const buildLayer = (state: Ref.Ref) =>
   Layer.fresh(ModelsDev.layer).pipe(
     Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
     Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(RuntimeFlags.layer({ client: "test-client" })),
   )
 
 const writeCache = (data: object, mtimeMs?: number) =>
@@ -202,6 +207,7 @@ describe("ModelsDev Service", () => {
       const final = yield* Ref.get(state)
       expect(final.calls.length).toBe(1)
       expect(final.calls[0].url).toContain("/api.json")
+      expect(final.calls[0].userAgent).toContain("/test-client")
     }),
   )
 

From 22a5e6cc50fa4c9f52014a989bf4b66776eaab85 Mon Sep 17 00:00:00 2001
From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Date: Wed, 13 May 2026 13:45:34 -0500
Subject: [PATCH 93/98] fix(run): restore non-interactive exit behavior
 (#27371)

---
 packages/opencode/src/cli/cmd/run.ts | 26 +++++++++++++++++++-------
 1 file changed, 19 insertions(+), 7 deletions(-)

diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts
index 7011b51eb..33dff2ab5 100644
--- a/packages/opencode/src/cli/cmd/run.ts
+++ b/packages/opencode/src/cli/cmd/run.ts
@@ -24,6 +24,7 @@ import { Filesystem } from "@/util/filesystem"
 import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
 import { Agent } from "@/agent/agent"
 import { Permission } from "@/permission"
+import { FormatError, FormatUnknownError } from "../error"
 import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
 
 const runtimeTask = import("./run/runtime")
@@ -82,6 +83,10 @@ function block(info: Inline, output?: string) {
   UI.empty()
 }
 
+function formatRunError(error: unknown) {
+  return FormatError(error) ?? FormatUnknownError(error)
+}
+
 async function tool(part: ToolPart) {
   try {
     const { toolInlineInfo } = await import("./run/tool")
@@ -731,10 +736,13 @@ export const RunCommand = effectCmd({
 
         if (!args.interactive) {
           const events = await client.event.subscribe()
-          const completed = loop(client, events)
+          loop(client, events).catch((e) => {
+            console.error(e)
+            process.exit(1)
+          })
 
           if (args.command) {
-            await client.session.command({
+            const result = await client.session.command({
               sessionID,
               agent,
               model: args.model,
@@ -742,21 +750,25 @@ export const RunCommand = effectCmd({
               arguments: message,
               variant: args.variant,
             })
-            const error = await completed
-            if (error) process.exitCode = 1
+            if (result.error) {
+              if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))
+              process.exitCode = 1
+            }
             return
           }
 
           const model = pick(args.model)
-          await client.session.prompt({
+          const result = await client.session.prompt({
             sessionID,
             agent,
             model,
             variant: args.variant,
             parts: [...files, { type: "text", text: message }],
           })
-          const error = await completed
-          if (error) process.exitCode = 1
+          if (result.error) {
+            if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))
+            process.exitCode = 1
+          }
           return
         }
 

From 20cec9155021c37fe557eb17559a35dc0c2b692c Mon Sep 17 00:00:00 2001
From: Shoubhit Dash 
Date: Thu, 14 May 2026 00:38:38 +0530
Subject: [PATCH 94/98] fix(provider): restore model suggestions (#27372)

---
 packages/opencode/src/provider/provider.ts    | 53 ++++++++++++++++---
 .../opencode/test/provider/provider.test.ts   | 27 ++++++++++
 2 files changed, 73 insertions(+), 7 deletions(-)

diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index ca87c40b7..0f757caf1 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -979,6 +979,7 @@ export interface Interface {
 interface State {
   models: Map
   providers: Record
+  catalog: Record
   sdk: Map
   modelLoaders: Record
   varsLoaders: Record
@@ -1104,6 +1105,38 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
   }
 }
 
+function suggestionModelIDs(provider: Info | undefined) {
+  if (!provider) return []
+  return Object.keys(provider.models).filter((id) => {
+    const model = provider.models[id]
+    if (model.status === "deprecated") return false
+    if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) return false
+    return true
+  })
+}
+
+function modelSuggestions(provider: Info | undefined, modelID: ModelID) {
+  const available = suggestionModelIDs(provider)
+  const fuzzy = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 }).map((m) => m.target)
+  if (fuzzy.length) return fuzzy
+  const query = modelID
+    .toLowerCase()
+    .split(/[^a-z0-9]+/)
+    .filter((part) => part.length > 1)
+  return sortBy(
+    available
+      .map((id) => ({
+        id,
+        score: query.filter((part) => id.toLowerCase().includes(part)).length,
+      }))
+      .filter((item) => item.score > 0),
+    [(item) => item.score, "desc"],
+    [(item) => item.id, "asc"],
+  )
+    .slice(0, 3)
+    .map((item) => item.id)
+}
+
 const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
@@ -1120,7 +1153,8 @@ const layer = Layer.effect(
         const bridge = yield* EffectBridge.make()
         const cfg = yield* config.get()
         const modelsDev = yield* modelsDevSvc.get()
-        const database = mapValues(modelsDev, fromModelsDevProvider)
+        const catalog = mapValues(modelsDev, fromModelsDevProvider)
+        const database = mapValues(catalog, toPublicInfo)
 
         const providers: Record = {} as Record
         const languages = new Map()
@@ -1437,6 +1471,7 @@ const layer = Layer.effect(
         return {
           models: languages,
           providers,
+          catalog,
           sdk,
           modelLoaders,
           varsLoaders,
@@ -1597,16 +1632,20 @@ const layer = Layer.effect(
       const s = yield* InstanceState.get(state)
       const provider = s.providers[providerID]
       if (!provider) {
-        const available = Object.keys(s.providers)
-        const matches = fuzzysort.go(providerID, available, { limit: 3, threshold: -10000 })
-        throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
+        const catalogProvider = s.catalog[providerID]
+        const suggestions = catalogProvider
+          ? modelSuggestions(catalogProvider, modelID)
+          : fuzzysort
+              .go(providerID, Object.keys({ ...s.catalog, ...s.providers }), { limit: 3, threshold: -10000 })
+              .map((m) => m.target)
+        throw new ModelNotFoundError({ providerID, modelID, suggestions })
       }
 
       const info = provider.models[modelID]
       if (!info) {
-        const available = Object.keys(provider.models)
-        const matches = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 })
-        throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
+        const current = modelSuggestions(provider, modelID)
+        const suggestions = current.length ? current : modelSuggestions(s.catalog[providerID], modelID)
+        throw new ModelNotFoundError({ providerID, modelID, suggestions })
       }
       return info
     })
diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts
index 2270418be..ab39c1902 100644
--- a/packages/opencode/test/provider/provider.test.ts
+++ b/packages/opencode/test/provider/provider.test.ts
@@ -19,6 +19,7 @@ import { testEffect } from "../lib/effect"
 
 const env = makeRuntime(Env.Service, Env.defaultLayer)
 const set = (k: string, v: string) => env.runSync((svc) => svc.set(k, v))
+const remove = (k: string) => env.runSync((svc) => svc.remove(k))
 
 async function run(fn: (provider: Provider.Interface) => Effect.Effect) {
   return AppRuntime.runPromise(
@@ -1604,6 +1605,32 @@ test("ModelNotFoundError for provider includes suggestions", async () => {
   })
 })
 
+test("ModelNotFoundError suggests catalog models for unloaded providers", 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 () => {
+      remove("OPENCODE_API_KEY")
+      try {
+        await getModel(ProviderID.opencode, ModelID.make("claude-haiku-fake-model"))
+        throw new Error("expected model lookup to fail")
+      } catch (e) {
+        if (!Provider.ModelNotFoundError.isInstance(e)) throw e
+        expect(e.data.suggestions).toContain("claude-haiku-4-5")
+      }
+    },
+  })
+})
+
 test("getProvider returns undefined for nonexistent provider", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {

From 9ee1f6ceba8964c7db34fb9429533f67269ef089 Mon Sep 17 00:00:00 2001
From: Shoubhit Dash 
Date: Thu, 14 May 2026 01:02:07 +0530
Subject: [PATCH 95/98] fix(server): map busy sessions in http handlers
 (#27375)

---
 .../routes/instance/httpapi/handlers/session.ts  | 16 ++++++++++++----
 .../routes/instance/httpapi/middleware/error.ts  |  9 ---------
 2 files changed, 12 insertions(+), 13 deletions(-)

diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
index b12be2cfc..df2b40de5 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
@@ -52,6 +52,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
     const bus = yield* Bus.Service
     const scope = yield* Scope.Scope
 
+    const mapBusy = (effect: Effect.Effect): Effect.Effect =>
+      effect.pipe(
+        Effect.catchCause((cause): Effect.Effect => {
+          if (Cause.squash(cause) instanceof Session.BusyError) return Effect.fail(new HttpApiError.BadRequest({}))
+          return Effect.failCause(cause)
+        }),
+      )
+
     const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
       return yield* session.list({
         directory: ctx.query.scope === "project" ? undefined : ctx.query.directory,
@@ -329,7 +337,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
       payload: typeof ShellPayload.Type
     }) {
       yield* requireSession(ctx.params.sessionID)
-      return yield* promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID })
+      return yield* mapBusy(promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID }))
     })
 
     const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: {
@@ -337,12 +345,12 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
       payload: typeof RevertPayload.Type
     }) {
       yield* requireSession(ctx.params.sessionID)
-      return yield* revertSvc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload })
+      return yield* mapBusy(revertSvc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload }))
     })
 
     const unrevert = Effect.fn("SessionHttpApi.unrevert")(function* (ctx: { params: { sessionID: SessionID } }) {
       yield* requireSession(ctx.params.sessionID)
-      return yield* revertSvc.unrevert({ sessionID: ctx.params.sessionID })
+      return yield* mapBusy(revertSvc.unrevert({ sessionID: ctx.params.sessionID }))
     })
 
     const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: {
@@ -358,7 +366,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
       params: { sessionID: SessionID; messageID: MessageID }
     }) {
       yield* requireSession(ctx.params.sessionID)
-      yield* runState.assertNotBusy(ctx.params.sessionID)
+      yield* mapBusy(runState.assertNotBusy(ctx.params.sessionID))
       yield* session.removeMessage(ctx.params)
       return true
     })
diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts
index bb75f6602..589641037 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts
@@ -1,5 +1,4 @@
 import { Provider } from "@/provider/provider"
-import { Session } from "@/session/session"
 import { iife } from "@/util/iife"
 import { NamedError } from "@opencode-ai/core/util/error"
 import * as Log from "@opencode-ai/core/util/log"
@@ -33,14 +32,6 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
           }),
         )
       }
-      if (error instanceof Session.BusyError) {
-        return Effect.succeed(
-          HttpServerResponse.jsonUnsafe(new NamedError.Unknown({ message: error.message }).toObject(), {
-            status: 400,
-          }),
-        )
-      }
-
       return Effect.succeed(
         HttpServerResponse.jsonUnsafe(
           new NamedError.Unknown({

From c197fd92b7f41919f5b5f249a830ca7bfea603a7 Mon Sep 17 00:00:00 2001
From: Frank 
Date: Wed, 13 May 2026 15:58:32 -0400
Subject: [PATCH 96/98] sync

---
 packages/console/function/src/stat.ts | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/packages/console/function/src/stat.ts b/packages/console/function/src/stat.ts
index 54d38bc31..957adf491 100644
--- a/packages/console/function/src/stat.ts
+++ b/packages/console/function/src/stat.ts
@@ -1,7 +1,7 @@
 import { and, Database, inArray } from "@opencode-ai/console-core/drizzle/index.js"
 import { ModelTpsRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
 
-type Result = Record>
+type Result = Record
 
 export default {
   async fetch(request: Request) {
@@ -9,7 +9,6 @@ export default {
 
     const body = (await request.json()) as { ids: string[] }
     const ids = body.ids
-
     if (ids.length === 0) return Response.json({} satisfies Result)
 
     const toInterval = (date: Date) =>
@@ -26,18 +25,19 @@ export default {
       tx
         .select()
         .from(ModelTpsRateLimitTable)
-        .where(and(inArray(ModelTpsRateLimitTable.id, body.ids), inArray(ModelTpsRateLimitTable.interval, intervals))),
+        .where(and(inArray(ModelTpsRateLimitTable.id, ids), inArray(ModelTpsRateLimitTable.interval, intervals))),
     )
 
+    const rowsByKey = new Map(rows.map((row) => [`${row.id}:${row.interval}`, row]))
     const result: Result = Object.fromEntries(
-      body.ids.map((id) => [
+      ids.map((id) => [
         id,
-        Object.fromEntries(intervals.map((interval) => [interval, { qualify: 0, unqualify: 0 }])),
+        intervals.map((interval) => {
+          const row = rowsByKey.get(`${id}:${interval}`)
+          return { interval, qualify: row?.qualify ?? 0, unqualify: row?.unqualify ?? 0 }
+        }),
       ]),
     )
-    for (const row of rows) {
-      result[row.id][row.interval] = { qualify: row.qualify, unqualify: row.unqualify }
-    }
     return Response.json(result)
   },
 }

From 3b7a5e783d59e8986dca6e5df48663613fa80722 Mon Sep 17 00:00:00 2001
From: Sebastian 
Date: Wed, 13 May 2026 23:00:48 +0200
Subject: [PATCH 97/98] fix keymap fallback priority and TUI config diagnostics
 (#27384)

---
 bun.lock                                      | 30 +++++------
 package.json                                  |  6 +--
 packages/core/src/util/log.ts                 |  6 ++-
 .../opencode/src/cli/cmd/tui/config/tui.ts    | 52 ++++++++++++++-----
 packages/opencode/test/config/tui.test.ts     | 19 +++++++
 packages/opencode/test/util/log.test.ts       | 28 ++++++++++
 packages/plugin/package.json                  |  6 +--
 7 files changed, 111 insertions(+), 36 deletions(-)

diff --git a/bun.lock b/bun.lock
index 2a79552b9..dc715b527 100644
--- a/bun.lock
+++ b/bun.lock
@@ -536,9 +536,9 @@
         "typescript": "catalog:",
       },
       "peerDependencies": {
-        "@opentui/core": ">=0.2.8",
-        "@opentui/keymap": ">=0.2.8",
-        "@opentui/solid": ">=0.2.8",
+        "@opentui/core": ">=0.2.9",
+        "@opentui/keymap": ">=0.2.9",
+        "@opentui/solid": ">=0.2.9",
       },
       "optionalPeers": [
         "@opentui/core",
@@ -721,9 +721,9 @@
     "@npmcli/arborist": "9.4.0",
     "@octokit/rest": "22.0.0",
     "@openauthjs/openauth": "0.0.0-20250322224806",
-    "@opentui/core": "0.2.8",
-    "@opentui/keymap": "0.2.8",
-    "@opentui/solid": "0.2.8",
+    "@opentui/core": "0.2.9",
+    "@opentui/keymap": "0.2.9",
+    "@opentui/solid": "0.2.9",
     "@pierre/diffs": "1.1.0-beta.18",
     "@playwright/test": "1.59.1",
     "@sentry/solid": "10.36.0",
@@ -1590,23 +1590,23 @@
 
     "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
 
-    "@opentui/core": ["@opentui/core@0.2.8", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.8", "@opentui/core-darwin-x64": "0.2.8", "@opentui/core-linux-arm64": "0.2.8", "@opentui/core-linux-x64": "0.2.8", "@opentui/core-win32-arm64": "0.2.8", "@opentui/core-win32-x64": "0.2.8" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-bRRiCXuwjS8/6mN1oA5iVaf55z9APyalm7FnoxkLkEyIU1VDaQeTpYtElBbfo1rxtcO6Rj53XywH9oW8auNO9A=="],
+    "@opentui/core": ["@opentui/core@0.2.9", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.9", "@opentui/core-darwin-x64": "0.2.9", "@opentui/core-linux-arm64": "0.2.9", "@opentui/core-linux-x64": "0.2.9", "@opentui/core-win32-arm64": "0.2.9", "@opentui/core-win32-x64": "0.2.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Kmeqi+yiDau+P45xDeX08GS50FK917qVwuPTN7HGxsQ9Byt7Iifq/6OMiSnFULBzoZtECdKLgQF1XwLsNm1wig=="],
 
-    "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Qh6VCMQgW3hWh/7MR51y+XuQezh8NOLwKS8EQSoKzAr4VOc/W5P0/DvgMKgwaqXw2Mz0AIba/BvZ6by20yc4zA=="],
+    "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-D2ne8Xgyrg71L/9lF7vPh30Sxz6+3yAqpT0m87WiI+040J7sQEyK3YM/7w5JKuVemQ4H54HSPjofrUHjfibjoQ=="],
 
-    "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-wQjJ38C3IiVx/gwwBYxnCarzgD75FdS7IyUErt3lhn57XriNiCbb7ScphWnRMwwtL8CI+bBGzClroDRA2lCfvg=="],
+    "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ymbbt/wN/vgB8g+kbHospJclVKHq6cdgfEYg9qgsSHp2vqMFBqlQQ692MS3BcZfX9jrKROK7NvC6Hj37X5K/7Q=="],
 
-    "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-fx4ADeWSSSVU1O/MkMnklCRxtWRy6CLeAvktLlNdPb+BhmQIDg1kpZcdv7m/3cgD1/ksFEXIwO6VTvfKYE0umw=="],
+    "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-0RIrVe4+42oELHtSJBaaYhngUeMKwSeqfdtKeSwEFwCzrqrNXxCpXQdOo8QvjOKGgng4Smn6O6KM8sgCj4SSPQ=="],
 
-    "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.8", "", { "os": "linux", "cpu": "x64" }, "sha512-4ekUyzopBj2ClsUbneLnUOrmZtvU67FCVFLgmBfKL4IvVl/P0YobGNg71gN1JNiYpY7hK77qOpidVLHcNMIE7w=="],
+    "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-fjCZP1IOLWm68FYl2PRzFg1vfu226FPfiJsdNtLbhaYF2uEZOB/v1BQph21OKnB7GC7X8GQatvhM5sS3DQ2MSQ=="],
 
-    "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-63K046wpzTzQOLOG9LTsp3+Ld0TNTxeQczexkg0pKSBxZFhws+/9YIGjTctZmJUfE1g1X4tI31dO+KNRpXRHQw=="],
+    "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-u8SP3u2QEJqcGIULYZ7Lkht9ss7wcN4/LnMuqt9rPOiCduFn/VW4r8lQCftZ6DRSqyoP9mJ1xLzOSFl98UYyEw=="],
 
-    "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.8", "", { "os": "win32", "cpu": "x64" }, "sha512-+WDiTlTyDpgkis8rPAhW1fS7TwXJih+fk+RYXS2bC3tAKsRD+O3PRSkVABRbjkuXbtfJZf2cjOHZFGN4Vf5qDg=="],
+    "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-un7iSy9XHLwa6ouVpUj3eEGnXfPG50OMUJ2Dt30Jvn2vhNwIU2VO4RGx06l5OUD6GGVpHb0RqmG/384oo9i+HA=="],
 
-    "@opentui/keymap": ["@opentui/keymap@0.2.8", "", { "dependencies": { "@opentui/core": "0.2.8" }, "peerDependencies": { "@opentui/react": "0.2.8", "@opentui/solid": "0.2.8", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-/H9j8fP64cf3/nFDCvVP8+7cwU/oRh4sgfQH2NhcPp8illgBb/e9pG5x3vM0nK4RVyTqUvkPXsOeIX5u7vltlg=="],
+    "@opentui/keymap": ["@opentui/keymap@0.2.9", "", { "dependencies": { "@opentui/core": "0.2.9" }, "peerDependencies": { "@opentui/react": "0.2.9", "@opentui/solid": "0.2.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-yCc6L0Jqa8aVaNAVniTV5bNygJayUE6mxWfaBQY5VV5QwsZemXSeQQc4vP2eetH4Rrm1gGA59gLP+zh6+s5fvw=="],
 
-    "@opentui/solid": ["@opentui/solid@0.2.8", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.8", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-f2g0riBuzk4/ZmcJnp1k13odUmNZcfA3nF7RzdSlEfpkwNDfc4xqnRAwYbNNDwGNrJX0JDCTEZY5ZEhuL155MQ=="],
+    "@opentui/solid": ["@opentui/solid@0.2.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-qpNSCELxRvBAx8Zneqz46FYYTvJNFjDvhqzAAZRNoaHathfU6X6iPxWMUqP/9ls5VcHFW1TDJdgtpsq1N/nHMQ=="],
 
     "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
 
diff --git a/package.json b/package.json
index f1cc7da5c..50406e1f4 100644
--- a/package.json
+++ b/package.json
@@ -35,9 +35,9 @@
       "@types/cross-spawn": "6.0.6",
       "@octokit/rest": "22.0.0",
       "@hono/zod-validator": "0.4.2",
-      "@opentui/core": "0.2.8",
-      "@opentui/keymap": "0.2.8",
-      "@opentui/solid": "0.2.8",
+      "@opentui/core": "0.2.9",
+      "@opentui/keymap": "0.2.9",
+      "@opentui/solid": "0.2.9",
       "ulid": "3.0.1",
       "@kobalte/core": "0.13.11",
       "@types/luxon": "3.7.1",
diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts
index 83060b29c..3b5249cdc 100644
--- a/packages/core/src/util/log.ts
+++ b/packages/core/src/util/log.ts
@@ -20,6 +20,7 @@ const levelPriority: Record = {
   ERROR: 3,
 }
 const keep = 10
+const initializedRunID = "OPENCODE_LOG_INITIALIZED_RUN_ID"
 
 let level: Level = "INFO"
 
@@ -70,7 +71,10 @@ export async function init(options: Options) {
     Global.Path.log,
     options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
   )
-  await fs.truncate(logpath).catch(() => {})
+  const runID = process.env.OPENCODE_RUN_ID
+  const shouldTruncate = !options.dev || !runID || process.env[initializedRunID] !== runID
+  if (shouldTruncate) await fs.truncate(logpath).catch(() => {})
+  if (options.dev && runID) process.env[initializedRunID] = runID
   const stream = createWriteStream(logpath, { flags: "a" })
   write = async (msg: any) => {
     return new Promise((resolve, reject) => {
diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts
index 562b369db..0d4be41df 100644
--- a/packages/opencode/src/cli/cmd/tui/config/tui.ts
+++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts
@@ -3,9 +3,8 @@ export * as TuiConfig from "./tui"
 import path from "path"
 import { createBindingLookup } from "@opentui/keymap/extras"
 import { mergeDeep, unique } from "remeda"
-import { Context, Effect, Fiber, Layer, Schema } from "effect"
+import { Cause, Context, Effect, Fiber, Layer, Schema } from "effect"
 import { ConfigParse } from "@/config/parse"
-import { InvalidError } from "@/config/error"
 import * as ConfigPaths from "@/config/paths"
 import { migrateTuiConfig } from "./tui-migrate"
 import { KeymapLeaderTimeoutDefault, resolveAttentionSoundPaths, TuiInfo } from "./tui-schema"
@@ -24,6 +23,7 @@ import { ConfigVariable } from "@/config/variable"
 import { Npm } from "@opencode-ai/core/npm"
 import type { DeepMutable } from "@opencode-ai/core/schema"
 import type { TuiAttentionSoundName } from "@opencode-ai/plugin/tui"
+import { FormatError, FormatUnknownError } from "@/cli/error"
 
 const log = Log.create({ service: "tui.config" })
 
@@ -79,8 +79,26 @@ function normalize(raw: Record) {
   }
 }
 
+function dropUnknownKeybinds(input: Record, configFilepath: string) {
+  if (!isRecord(input.keybinds)) return input
+
+  const invalid = TuiKeybind.unknownKeys(input.keybinds)
+  if (!invalid.length) return input
+
+  log.warn("ignored unknown tui keybinds", {
+    path: configFilepath,
+    keybinds: invalid,
+    hint: "Remove these entries or rename them to keys from the tui.json schema.",
+  })
+  return {
+    ...input,
+    keybinds: Object.fromEntries(Object.entries(input.keybinds).filter(([key]) => !invalid.includes(key))),
+  }
+}
+
 const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
   const afs = yield* AppFileSystem.Service
+  let appliedOrder = 0
 
   const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect =>
     Effect.gen(function* () {
@@ -101,16 +119,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
       if (!isRecord(data)) return {} as Info
       // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
       // (mirroring the old opencode.json shape) still get their settings applied.
-      const normalized = normalize(data)
-      if (isRecord(normalized.keybinds)) {
-        const invalid = TuiKeybind.unknownKeys(normalized.keybinds)
-        if (invalid.length) {
-          throw new InvalidError({
-            path: configFilepath,
-            message: `Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`,
-          })
-        }
-      }
+      const normalized = dropUnknownKeybinds(normalize(data), configFilepath)
       const parsed = ConfigParse.schema(Info, normalized, configFilepath)
       const validated = parsed.attention?.sounds
         ? {
@@ -127,7 +136,12 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
       // can sync-throw — those become defects, which orElseSucceed wouldn't catch.
       Effect.catchCause((cause) =>
         Effect.sync(() => {
-          log.warn("invalid tui config", { path: configFilepath, cause })
+          const error = Cause.squash(cause)
+          const reason = FormatError(error) ?? FormatUnknownError(error)
+          log.warn("skipping invalid tui config", {
+            path: configFilepath,
+            reason,
+          })
           return {} as Info
         }),
       ),
@@ -141,18 +155,28 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
       const text = yield* afs.readFileStringSafe(filepath).pipe(
         Effect.catchCause((cause) =>
           Effect.sync(() => {
-            log.warn("failed to read tui config", { path: filepath, cause })
+            const error = Cause.squash(cause)
+            const reason = FormatError(error) ?? FormatUnknownError(error)
+            log.warn("failed to read tui config", {
+              path: filepath,
+              reason,
+            })
             return undefined
           }),
         ),
       )
       if (!text) return {} as Info
+      log.info("loading tui config", { path: filepath })
       return yield* load(text, filepath)
     })
 
   const mergeFile = (acc: Acc, file: string) =>
     Effect.gen(function* () {
       const data = yield* loadFile(file)
+      if (Object.keys(data).length) {
+        appliedOrder += 1
+        log.info("applying tui config", { path: file, order: appliedOrder })
+      }
       acc.result = mergeDeep(acc.result, data)
       if (!data.plugin?.length) return
 
diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts
index 30c5a65fb..4eb96b957 100644
--- a/packages/opencode/test/config/tui.test.ts
+++ b/packages/opencode/test/config/tui.test.ts
@@ -439,6 +439,25 @@ it.instance("merges keybind overrides across precedence layers", () =>
   ),
 )
 
+it.instance("ignores unknown keybind names without dropping valid overrides from the same file", () =>
+  withCleanState(
+    Effect.gen(function* () {
+      const fs = yield* AppFileSystem.Service
+      const test = yield* TestInstance
+      yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
+        keybinds: {
+          session_delete: "ctrl+d",
+          not_a_real_keybind: "ctrl+q",
+        },
+      })
+
+      const config = yield* getTuiConfig(test.directory)
+      expect(config.keybinds.get("session.delete")?.[0]?.key).toBe("ctrl+d")
+      expect(config.keybinds.get("not_a_real_keybind")).toEqual([])
+    }),
+  ),
+)
+
 it.instance("resolves keybind lookup from canonical keybinds", () =>
   withCleanState(
     Effect.gen(function* () {
diff --git a/packages/opencode/test/util/log.test.ts b/packages/opencode/test/util/log.test.ts
index defd8c981..62dc1d61c 100644
--- a/packages/opencode/test/util/log.test.ts
+++ b/packages/opencode/test/util/log.test.ts
@@ -47,3 +47,31 @@ it.live("init cleanup keeps the newest timestamped logs", () =>
     expect(next).toContain(list.at(-1)!)
   }),
 )
+
+it.live("local dev log is not truncated twice for the same run", () =>
+  Effect.gen(function* () {
+    const log = Global.Path.log
+    const runID = process.env.OPENCODE_RUN_ID
+    const initialized = process.env.OPENCODE_LOG_INITIALIZED_RUN_ID
+    yield* Effect.addFinalizer(() =>
+      Effect.sync(() => {
+        Global.Path.log = log
+        if (runID === undefined) delete process.env.OPENCODE_RUN_ID
+        else process.env.OPENCODE_RUN_ID = runID
+        if (initialized === undefined) delete process.env.OPENCODE_LOG_INITIALIZED_RUN_ID
+        else process.env.OPENCODE_LOG_INITIALIZED_RUN_ID = initialized
+      }),
+    )
+
+    const dir = yield* tmpdirScoped()
+    Global.Path.log = dir
+    process.env.OPENCODE_RUN_ID = "run-1"
+    delete process.env.OPENCODE_LOG_INITIALIZED_RUN_ID
+
+    yield* Effect.promise(() => Log.init({ print: false, dev: true }))
+    yield* Effect.promise(() => fs.writeFile(path.join(dir, "dev.log"), "main startup\n"))
+    yield* Effect.promise(() => Log.init({ print: false, dev: true }))
+
+    expect(yield* Effect.promise(() => fs.readFile(path.join(dir, "dev.log"), "utf8"))).toContain("main startup")
+  }),
+)
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index baff4cddb..82fdc0ab8 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -22,9 +22,9 @@
     "zod": "catalog:"
   },
   "peerDependencies": {
-    "@opentui/core": ">=0.2.8",
-    "@opentui/keymap": ">=0.2.8",
-    "@opentui/solid": ">=0.2.8"
+    "@opentui/core": ">=0.2.9",
+    "@opentui/keymap": ">=0.2.9",
+    "@opentui/solid": ">=0.2.9"
   },
   "peerDependenciesMeta": {
     "@opentui/core": {

From 0d074492dfaaa90db68cfc9593cf5a48e74848e2 Mon Sep 17 00:00:00 2001
From: "opencode-agent[bot]" 
Date: Wed, 13 May 2026 21:20:10 +0000
Subject: [PATCH 98/98] chore: update nix node_modules hashes

---
 nix/hashes.json | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/nix/hashes.json b/nix/hashes.json
index 876b96960..73a76a1d5 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-cRhvzZoW6gBbE0sQm1+e+6/WgajuA6MSIL5iroFsfqs=",
-    "aarch64-linux": "sha256-0knZfxBULqkt5u6sXFx+a/vqw2rc6IC1+LeAd4TNFhM=",
-    "aarch64-darwin": "sha256-jL4tO+EHSmUF+gQGEaLzAbTxxjkL8OyhTk13vsbomgM=",
-    "x86_64-darwin": "sha256-bsa7IpS3GaxagcigTa0yqZTkf4e/nbcTQ9aZeb+5eHQ="
+    "x86_64-linux": "sha256-QIj9PhOXR/GngV/dPjCF7n5rKro2fcTNGzJ47a41Z2Q=",
+    "aarch64-linux": "sha256-fQl7BjjTYtRKT3HRVhubaIVww/puUFSTzVV5bTy8II8=",
+    "aarch64-darwin": "sha256-81IAmdjiYZz8IgMJt0+VxzdOS80gTHc5SendwEW/vD4=",
+    "x86_64-darwin": "sha256-5OMX4VVBMfEmkYvzd09oksAt5hKkxDs84miO804LBI8="
   }
 }
+
+
+
opencode v2
+

API map

+
+
+

+ A single /api route surface for simple clients and multi-directory frontends. The important + design question is not route nesting; it is where runtime context comes from. +

+
+ Server scoped + Request context + Session pinned +
+
+
+ +
+
+ Everything has one canonical route. Some routes are server-scoped; runtime routes use context; session item routes use the session. +

+ Server-scoped routes manage the whole server: projects, workspace lifecycle, and auth accounts. Runtime + context is for anything resolved from an active directory, including config, provider capabilities, tools, + files, and VCS. +

+
+
+
+ +
+
+

Context Model

+ + API context resolution + Non-session routes resolve from request context, session item routes resolve from session storage. + + + + + + + + Non-session route + /api/file, /api/vcs/status + + + Request context + query params or default runtime + + + Runtime context + directory + workspaceID? + + + Session item route + /api/session/:id/prompt + + + Session row + contains pinned context + + + Runtime context + directory + workspaceID? + +
+ +
+

Request-context calls

+

+ These calls operate against a directory, optionally through a workspace. Simple clients omit context and + use the default runtime. +

+
GET /api/fs/tree?path=.&directory=/repo/app&workspace=ws_123
+
+ +
+

Session-pinned calls

+

+ These calls never take request context. The session is already pinned to the directory and workspace it was + created in. +

+
POST /api/session/ses_123/prompt
+
+// server resolves
+sessionID -> { directory, workspaceID? }
+
+
+ +
+
+

Operation Inventory

+

+ The SDK is the source of truth. HTTP routes are mounts for RPC-style operations. server operations do not use runtime context. request operations use request/default runtime context from directory and workspace query parameters. session operations use pinned session context and should not accept context input. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OperationInputContextHTTP mountPurpose
agent.list{}requestGET /api/agentAvailable agents.
auth.activate{ accountID: AccountID }serverPOST /api/auth/:accountID/activateSet the account as active for its service.
auth.create{ + serviceID: ServiceID + credential: + | { type: "oauth", refresh: string, access: string, expires: number } + | { type: "api", key: string, metadata?: Record<string, string> } + description?: string + active?: boolean +}serverPOST /api/authCreate an auth account.
auth.delete{ accountID: AccountID }serverDELETE /api/auth/:accountIDRemove an auth account.
auth.get{ accountID: AccountID }serverGET /api/auth/:accountIDGet one auth account.
auth.list{ serviceID?: ServiceID }serverGET /api/authList saved auth accounts. Response includes active account mapping.
auth.update{ + accountID: AccountID + description?: string + credential?: + | { type: "oauth", refresh: string, access: string, expires: number } + | { type: "api", key: string, metadata?: Record<string, string> } +}serverPATCH /api/auth/:accountIDUpdate account description or credential.
catalog.model.get{ + providerID: ProviderID + modelID: ModelID +}serverGET /api/catalog/model/:providerID/:modelIDGet one catalog model.
catalog.model.list{}serverGET /api/catalog/modelList flattened catalog models.
command.list{}requestGET /api/commandAvailable commands.
config.get{}requestGET /api/configResolved config.
config.update{ config: Config }requestPATCH /api/configUpdate config.
event.subscribe{}requestGET /api/eventServer-sent events for the resolved runtime context.
formatter.status{}requestGET /api/formatterFormatter status.
fs.file{ path: string }requestGET /api/fs/fileRead one file.
fs.grep{ + pattern: string + include?: string + limit?: number +}requestPOST /api/fs/grepSearch file contents.
fs.search{ + query: string + type?: "file" | "directory" + limit?: number +}requestPOST /api/fs/searchSearch paths by name.
fs.tree{ path: string }requestGET /api/fs/treeBrowse a directory.
lsp.status{}requestGET /api/lspLSP status.
mcp.prompt.list{}requestGET /api/mcp/promptList MCP prompts.
mcp.prompt.render{ + server: string + name: string + arguments?: Record<string, string> +}requestPOST /api/mcp/prompt/renderRender one MCP prompt.
mcp.resource.list{}requestGET /api/mcp/resourceList MCP resources.
mcp.resource.read{ + server: string + uri: string +}requestGET /api/mcp/resource/readRead one MCP resource.
mcp.server.create{ + name: string + config: + | { type: "local", command: string, arguments?: string[], environment?: Record<string, string> } + | { type: "remote", url: string, headers?: Record<string, string>, oauth?: boolean | object } +}requestPOST /api/mcp/serverAdd an MCP server to runtime config.
mcp.server.list{}requestGET /api/mcp/serverList MCP servers with status and auth state.
mcp.server.oauth.callback{ + name: string + code: string +}requestPOST /api/mcp/server/:name/oauth/callbackComplete MCP OAuth.
mcp.server.oauth.delete{ name: string }requestDELETE /api/mcp/server/:name/oauthRemove MCP OAuth credentials.
mcp.server.oauth.start{ name: string }requestPOST /api/mcp/server/:name/oauthStart MCP OAuth.
permission.list{}requestGET /api/permissionPending permission requests.
permission.reply{ + permissionID: PermissionID + response: PermissionReply +}requestPOST /api/permission/:permissionID/replyReply to a permission request.
project.get{ projectID: ProjectID }serverGET /api/project/:projectIDGet project metadata.
project.list{}serverGET /api/projectList projects known to this server.
project.update{ + projectID: ProjectID + name?: string + icon?: string + commands?: Array<{ + name: string + command: string + }> +}serverPATCH /api/project/:projectIDUpdate project metadata.
provider.list{}requestGET /api/providerProvider inventory for the runtime context.
pty.create{ + command?: string + cwd?: string + shell?: string +}requestPOST /api/ptyCreate PTY in the runtime context.
pty.delete{ ptyID: PtyID }requestDELETE /api/pty/:ptyIDDelete PTY.
pty.get{ ptyID: PtyID }requestGET /api/pty/:ptyIDGet PTY info.
pty.list{}requestGET /api/ptyList PTYs for the runtime.
pty.update{ + ptyID: PtyID + title?: string + size?: { columns: number, rows: number } +}requestPATCH /api/pty/:ptyIDUpdate PTY.
question.list{}requestGET /api/questionPending user questions.
question.reject{ questionID: QuestionID }requestPOST /api/question/:questionID/rejectReject a question.
question.reply{ + questionID: QuestionID + response: QuestionResponse +}requestPOST /api/question/:questionID/replyReply to a question.
session.compact{ sessionID: SessionID }sessionPOST /api/session/:sessionID/compactCompact the session conversation.
session.context{ sessionID: SessionID }sessionGET /api/session/:sessionID/contextReturn active context messages after the last compaction.
session.create{ + title?: string + agent?: string + model?: { providerID: ProviderID, modelID: ModelID } + permission?: PermissionRule[] +}requestPOST /api/sessionCreate a session pinned to resolved runtime context.
session.delete{ sessionID: SessionID }sessionDELETE /api/session/:sessionIDDelete a session.
session.diff{ sessionID: SessionID }sessionGET /api/session/:sessionID/diffReturn session diff summary.
session.get{ sessionID: SessionID }sessionGET /api/session/:sessionIDGet one session.
session.list{ + limit?: number + order?: "asc" | "desc" + path?: string + roots?: boolean + start?: number + search?: string + cursor?: string +}requestGET /api/sessionList sessions for the current runtime context by default.
session.message.list{ + sessionID: SessionID + limit?: number + order?: "asc" | "desc" + cursor?: string +}sessionGET /api/session/:sessionID/messagePage through session messages.
session.prompt{ + sessionID: SessionID + prompt: Prompt + delivery?: "immediate" | "deferred" +}sessionPOST /api/session/:sessionID/promptCreate a user message and queue the agent loop.
session.todo{ sessionID: SessionID }sessionGET /api/session/:sessionID/todoReturn todos associated with the session.
session.update{ + sessionID: SessionID + title?: string + archived?: number + permission?: PermissionRule[] +}sessionPATCH /api/session/:sessionIDUpdate title, archival state, or session metadata.
session.wait{ sessionID: SessionID }sessionPOST /api/session/:sessionID/waitWait until the session is idle.
skill.list{}requestGET /api/skillAvailable skills.
vcs.diff{ + format?: "json" | "patch" + mode?: "worktree" | "default" +}requestGET /api/vcs/diffDiff for the runtime directory.
vcs.get{}requestGET /api/vcsVCS metadata.
vcs.patch{ patch: string }requestPOST /api/vcs/patchApply a patch to the runtime directory.
vcs.status{}requestGET /api/vcs/statusChanged files.
workspace.create{ + projectID?: ProjectID + name?: string + directory?: string + type: string + metadata?: Record<string, unknown> +}serverPOST /api/workspaceCreate or register a workspace.
workspace.delete{ workspaceID: WorkspaceID }serverDELETE /api/workspace/:workspaceIDRemove a workspace registration.
workspace.get{ workspaceID: WorkspaceID }serverGET /api/workspace/:workspaceIDGet workspace metadata.
workspace.list{ projectID?: ProjectID }serverGET /api/workspaceList workspaces, optionally filtered by project.
workspace.status{}serverGET /api/workspace/statusConnection/lifecycle status for all workspaces. Needs team discussion.
workspace.sync{}serverPOST /api/workspace/syncSync workspace metadata from adapters. Needs team discussion.
workspace.update{ + workspaceID: WorkspaceID + name?: string + metadata?: Record<string, unknown> + archived?: boolean +}serverPATCH /api/workspace/:workspaceIDUpdate workspace metadata or lifecycle state.
workspace.warp{ + workspaceID?: WorkspaceID + sessionID: SessionID + copyChanges: boolean +}serverPOST /api/workspace/warpMove a session into or out of a workspace. Needs team discussion.
+
+
+ +
+
+

Event Envelope

+

+ Every event uses the same envelope. Resource identity belongs in payload. Runtime identity belongs + in context. +

+
+
type ApiEvent<Payload> = {
+  id: string
+  type: string
+  time: number
+  context: {
+    directory: string
+    workspaceID?: string
+  }
+  payload: Payload
+}
+
{
+  "id": "evt_01",
+  "type": "message.part.delta",
+  "time": 1760000000000,
+  "context": {
+    "directory": "/repo/app",
+    "workspaceID": "ws_123"
+  },
+  "payload": {
+    "sessionID": "ses_123",
+    "messageID": "msg_456",
+    "partID": "part_789",
+    "field": "text",
+    "delta": "hello"
+  }
+}
+
+
+
+ +
+
+

Frontend Sync Store

+

+ A frontend can keep one giant store like the current TUI. Runtime data is partitioned by + contextKey. Durable entities such as sessions and messages are keyed by their own IDs. +

+
type RuntimeContext = {
+  directory: string
+  workspaceID?: string
+}
+
+type ContextKey = string
+type SessionID = string
+type MessageID = string
+
+type SyncStore = {
+  status: "loading" | "partial" | "complete"
+
+  shared: {
+    provider: Provider[]
+    provider_default: Record<string, string>
+    provider_next: ProviderListResponse
+    provider_auth: Record<string, ProviderAuthMethod[]>
+    console_state: ConsoleState
+  }
+
+  contexts: Record<
+    ContextKey,
+    {
+      context: RuntimeContext
+
+      config: Config
+      agent: Agent[]
+      command: Command[]
+      lsp: LspStatus[]
+      formatter: FormatterStatus[]
+      vcs: VcsInfo | undefined
+      mcp: Record<string, McpStatus>
+      mcp_resource: Record<string, McpResource>
+
+      session: SessionID[]
+      session_status: Record<SessionID, SessionStatus>
+    }
+  >
+
+  session: Record<SessionID, Session & { context: RuntimeContext }>
+  session_diff: Record<SessionID, Snapshot.FileDiff[]>
+  todo: Record<SessionID, Todo[]>
+  permission: Record<SessionID, PermissionRequest[]>
+  question: Record<SessionID, QuestionRequest[]>
+
+  message: Record<SessionID, Message[]>
+  part: Record<MessageID, Part[]>
+}
+
+function contextKey(context: RuntimeContext) {
+  return `${context.workspaceID ?? "local"}:${context.directory}`
+}
+
+
+