diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/opencode/src/cli/cmd/run/runtime.boot.ts index 3ff9801c6a..5256be8b8b 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.boot.ts @@ -9,7 +9,6 @@ import { Context, Effect, Layer } from "effect" import { stringifyKeyStroke } from "@opentui/keymap" import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { TuiKeybind } from "@/cli/cmd/tui/config/keybind" -import { makeRuntime } from "@/effect/run-service" import { reusePendingTask } from "./runtime.shared" import { resolveSession, sessionHistory } from "./session.shared" import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt, RunProvider } from "./types" @@ -28,6 +27,8 @@ const DEFAULT_KEYBINDS: FooterKeybinds = { inputNewline: [{ key: "shift+return,ctrl+return,alt+return,ctrl+j" }], } +export const defaultKeybinds: FooterKeybinds = DEFAULT_KEYBINDS + export type ModelInfo = { providers: RunProvider[] variants: string[] @@ -41,7 +42,8 @@ export type SessionInfo = { } type Config = Awaited> -type BootService = { + +export interface Interface { readonly resolveModelInfo: ( sdk: RunInput["sdk"], directory: string, @@ -58,13 +60,13 @@ type BootService = { const configTask: { current?: Promise } = {} -class Service extends Context.Service()("@opencode/RunBoot") {} +export class Service extends Context.Service()("@opencode/RunBoot") {} function loadConfig() { return reusePendingTask(configTask, () => TuiConfig.get()) } -function emptyModelInfo(): ModelInfo { +export function emptyModelInfo(): ModelInfo { return { providers: [], variants: [], @@ -72,7 +74,7 @@ function emptyModelInfo(): ModelInfo { } } -function emptySessionInfo(): SessionInfo { +export function emptySessionInfo(): SessionInfo { return { first: true, history: [], @@ -105,7 +107,7 @@ function footerKeybinds(config: Config | undefined): FooterKeybinds { } } -const layer = Layer.effect( +export const layer = Layer.effect( Service, Effect.gen(function* () { const config = Effect.fn("RunBoot.config")(() => Effect.promise(() => loadConfig().catch(() => undefined))) @@ -192,31 +194,6 @@ const layer = Layer.effect( }), ) -const runtime = makeRuntime(Service, layer) +export const defaultLayer = layer -// Fetches available variants and context limits for every provider/model pair. -export async function resolveModelInfo( - sdk: RunInput["sdk"], - directory: string, - model: RunInput["model"], -): Promise { - return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, directory, model)).catch(() => emptyModelInfo()) -} - -// Fetches session messages to determine if this is the first turn and build prompt history. -export async function resolveSessionInfo( - sdk: RunInput["sdk"], - sessionID: string, - model: RunInput["model"], -): Promise { - return runtime.runPromise((svc) => svc.resolveSessionInfo(sdk, sessionID, model)).catch(() => emptySessionInfo()) -} - -// Reads keybind overrides from TUI config and merges them with defaults. -export async function resolveFooterKeybinds(): Promise { - return runtime.runPromise((svc) => svc.resolveFooterKeybinds()).catch(() => DEFAULT_KEYBINDS) -} - -export async function resolveDiffStyle(): Promise { - return runtime.runPromise((svc) => svc.resolveDiffStyle()).catch(() => "auto") -} +export * as RunBoot from "./runtime.boot" diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 882ff2e6c7..4697bcaf91 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -14,13 +14,33 @@ // 4. runs the prompt queue until the footer closes. import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Flag } from "@opencode-ai/core/flag/flag" +import { Effect, Layer } from "effect" +import { AppRuntime } from "@/effect/app-runtime" import { createRunDemo } from "./demo" -import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot" +import { + RunBoot, + defaultKeybinds, + emptyModelInfo, + emptySessionInfo, + type ModelInfo, + type SessionInfo, +} from "./runtime.boot" import { createRuntimeLifecycle } from "./runtime.lifecycle" import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel" import { trace } from "./trace" -import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared" -import type { RunInput, RunPrompt, RunProvider } from "./types" +import { Variant, cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared" +import type { RunDiffStyle, RunInput, RunPrompt, RunProvider } from "./types" + +const BootLayer = Layer.mergeAll(RunBoot.defaultLayer, Variant.defaultLayer) + +function persistVariant(model: RunInput["model"], variant: string | undefined) { + AppRuntime.runFork( + Effect.gen(function* () { + const variantSvc = yield* Variant.Service + yield* variantSvc.saveVariant(model, variant).pipe(Effect.orElseSucceed(() => undefined)) + }).pipe(Effect.provide(BootLayer)), + ) +} /** @internal Exported for testing */ export { pickVariant, resolveVariant } from "./variant.shared" @@ -169,25 +189,44 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { async (span) => { const start = performance.now() const log = trace() - const keybindTask = resolveFooterKeybinds() - const diffTask = resolveDiffStyle() + const earlyTask = AppRuntime.runPromise( + Effect.gen(function* () { + const boot = yield* RunBoot.Service + return yield* Effect.all( + { + keybinds: boot.resolveFooterKeybinds().pipe(Effect.orElseSucceed(() => defaultKeybinds)), + diffStyle: boot.resolveDiffStyle().pipe(Effect.orElseSucceed((): RunDiffStyle => "auto")), + }, + { concurrency: "unbounded" }, + ) + }).pipe(Effect.provide(BootLayer)), + ) const ctx = await input.boot() - const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model) - const sessionTask = - ctx.resume === true - ? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model) - : Promise.resolve({ - first: true, - history: [], - variant: undefined, - }) - const savedTask = resolveSavedVariant(ctx.model) - const [keybinds, diffStyle, session, savedVariant] = await Promise.all([ - keybindTask, - diffTask, - sessionTask, - savedTask, - ]) + const modelTask = AppRuntime.runPromise( + Effect.gen(function* () { + const boot = yield* RunBoot.Service + return yield* boot.resolveModelInfo(ctx.sdk, ctx.directory, ctx.model) + }).pipe(Effect.provide(BootLayer)), + ).catch((): ModelInfo => emptyModelInfo()) + const sessionAndSavedTask = AppRuntime.runPromise( + Effect.gen(function* () { + const boot = yield* RunBoot.Service + const variantSvc = yield* Variant.Service + return yield* Effect.all( + { + session: + ctx.resume === true + ? boot + .resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model) + .pipe(Effect.orElseSucceed((): SessionInfo => emptySessionInfo())) + : Effect.succeed({ first: true, history: [], variant: undefined }), + savedVariant: variantSvc.resolveSavedVariant(ctx.model).pipe(Effect.orElseSucceed(() => undefined)), + }, + { concurrency: "unbounded" }, + ) + }).pipe(Effect.provide(BootLayer)), + ) + const [{ keybinds, diffStyle }, { session, savedVariant }] = await Promise.all([earlyTask, sessionAndSavedTask]) const state: RuntimeState = { shown: !session.first, aborting: false, @@ -280,7 +319,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { } state.activeVariant = cycleVariant(state.activeVariant, state.variants) - saveVariant(state.model, state.activeVariant) + persistVariant(state.model, state.activeVariant) setRunSpanAttributes(span, { "opencode.model.variant": state.activeVariant, }) @@ -298,7 +337,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { state.model = model state.activeVariant = undefined state.variants = variantsFor(state.providers, model) - const switching = resolveSavedVariant(model).then((saved) => { + const switching = AppRuntime.runPromise( + Effect.gen(function* () { + const variantSvc = yield* Variant.Service + return yield* variantSvc.resolveSavedVariant(model).pipe(Effect.orElseSucceed(() => undefined)) + }).pipe(Effect.provide(BootLayer)), + ).then((saved) => { const current = state.model if (!current || current.providerID !== model.providerID || current.modelID !== model.modelID) { return @@ -343,7 +387,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { } state.activeVariant = variant - saveVariant(state.model, state.activeVariant) + persistVariant(state.model, state.activeVariant) setRunSpanAttributes(span, { "opencode.model.variant": state.activeVariant, }) diff --git a/packages/opencode/src/cli/cmd/run/variant.shared.ts b/packages/opencode/src/cli/cmd/run/variant.shared.ts index 9e95413e0b..b1fb567a0a 100644 --- a/packages/opencode/src/cli/cmd/run/variant.shared.ts +++ b/packages/opencode/src/cli/cmd/run/variant.shared.ts @@ -9,7 +9,6 @@ import path from "path" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Context, Effect, Layer } from "effect" -import { makeRuntime } from "@/effect/run-service" import { Global } from "@opencode-ai/core/global" import { isRecord } from "@/util/record" import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared" @@ -20,16 +19,13 @@ const MODEL_FILE = path.join(Global.Path.state, "model.json") type ModelState = Record & { variant?: Record } -type VariantService = { + +export interface Interface { readonly resolveSavedVariant: (model: RunInput["model"]) => Effect.Effect readonly saveVariant: (model: RunInput["model"], variant: string | undefined) => Effect.Effect } -type VariantRuntime = { - resolveSavedVariant(model: RunInput["model"]): Promise - saveVariant(model: RunInput["model"], variant: string | undefined): Promise -} -class Service extends Context.Service()("@opencode/RunVariant") {} +export class Service extends Context.Service()("@opencode/RunVariant") {} function modelKey(provider: string, model: string): string { return `${provider}/${model}` @@ -135,81 +131,62 @@ function state(value: unknown): ModelState { } } -function createLayer(fs = AppFileSystem.defaultLayer) { - return Layer.fresh( - Layer.effect( - Service, - Effect.gen(function* () { - const file = yield* AppFileSystem.Service +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const file = yield* AppFileSystem.Service - const read = Effect.fn("RunVariant.read")(function* () { - return yield* file.readJson(MODEL_FILE).pipe( - Effect.map(state), - Effect.catchCause(() => Effect.succeed(state(undefined))), - ) + const read = Effect.fn("RunVariant.read")(function* () { + return yield* file.readJson(MODEL_FILE).pipe( + Effect.map(state), + Effect.catchCause(() => Effect.succeed(state(undefined))), + ) + }) + + const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) { + if (!model) { + return undefined + } + + return (yield* read()).variant?.[variantKey(model)] + }) + + const saveVariant = Effect.fn("RunVariant.saveVariant")(function* ( + model: RunInput["model"], + variant: string | undefined, + ) { + if (!model) { + return + } + + const current = yield* read() + const next = { + ...current.variant, + } + const key = variantKey(model) + if (variant) { + next[key] = variant + } + + if (!variant) { + delete next[key] + } + + yield* file + .writeJson(MODEL_FILE, { + ...current, + variant: next, }) + .pipe(Effect.orElseSucceed(() => undefined)) + }) - const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) { - if (!model) { - return undefined - } + return Service.of({ + resolveSavedVariant, + saveVariant, + }) + }), +) - return (yield* read()).variant?.[variantKey(model)] - }) +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) - const saveVariant = Effect.fn("RunVariant.saveVariant")(function* ( - model: RunInput["model"], - variant: string | undefined, - ) { - if (!model) { - return - } - - const current = yield* read() - const next = { - ...current.variant, - } - const key = variantKey(model) - if (variant) { - next[key] = variant - } - - if (!variant) { - delete next[key] - } - - yield* file - .writeJson(MODEL_FILE, { - ...current, - variant: next, - }) - .pipe(Effect.orElseSucceed(() => undefined)) - }) - - return Service.of({ - resolveSavedVariant, - saveVariant, - }) - }), - ).pipe(Layer.provide(fs)), - ) -} - -/** @internal Exported for testing. */ -export function createVariantRuntime(fs = AppFileSystem.defaultLayer): VariantRuntime { - const runtime = makeRuntime(Service, createLayer(fs)) - return { - resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined), - saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}), - } -} - -const runtime = createVariantRuntime() - -export async function resolveSavedVariant(model: RunInput["model"]): Promise { - return runtime.resolveSavedVariant(model) -} - -export function saveVariant(model: RunInput["model"], variant: string | undefined): void { - void runtime.saveVariant(model, variant) -} +export * as Variant from "./variant.shared" diff --git a/packages/opencode/test/cli/run/runtime.boot.test.ts b/packages/opencode/test/cli/run/runtime.boot.test.ts index e2569b0ac6..77af2c17e7 100644 --- a/packages/opencode/test/cli/run/runtime.boot.test.ts +++ b/packages/opencode/test/cli/run/runtime.boot.test.ts @@ -1,12 +1,14 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { afterEach, describe, expect, mock, spyOn } from "bun:test" import type { KeyEvent, Renderable } from "@opentui/core" import type { Binding } from "@opentui/keymap" import { createBindingLookup } from "@opentui/keymap/extras" import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2" +import { Effect } from "effect" import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui" import { formatBindings } from "@/cli/cmd/run/keymap.shared" import { TuiKeybind } from "@/cli/cmd/tui/config/keybind" -import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot" +import { RunBoot } from "@/cli/cmd/run/runtime.boot" +import { testEffect } from "../../lib/effect" type RunBinding = Binding @@ -102,186 +104,206 @@ function config(input?: { } } +const it = testEffect(RunBoot.defaultLayer) + describe("run runtime boot", () => { afterEach(() => { mock.restore() }) - test("reads footer keybinds from resolved keybind config", async () => { - spyOn(TuiConfig, "get").mockResolvedValue( - config({ - leader: "ctrl+g", - bindings: { - commandList: bindings("ctrl+p"), - variantCycle: bindings("ctrl+t", "alt+t"), - interrupt: bindings("ctrl+c"), - historyPrevious: bindings("k"), - historyNext: bindings("j"), - inputClear: bindings("ctrl+l"), - inputSubmit: bindings("ctrl+s"), - inputNewline: bindings("alt+return"), - }, - }), - ) - - const result = await resolveFooterKeybinds() - - expect(result.leader).toBe("ctrl+g") - expect(result.leaderTimeout).toBe(2000) - expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p") - expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t, alt+t") - expect(formatBindings(result.interrupt, result.leader)).toBe("ctrl+c") - expect(formatBindings(result.historyPrevious, result.leader)).toBe("k") - expect(formatBindings(result.historyNext, result.leader)).toBe("j") - expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+l") - expect(formatBindings(result.inputSubmit, result.leader)).toBe("ctrl+s") - expect(formatBindings(result.inputNewline, result.leader)).toBe("alt+return") - }) - - test("falls back to default keybinds when config load fails", async () => { - spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom")) - - const result = await resolveFooterKeybinds() - - expect(result.leader).toBe("ctrl+x") - expect(result.leaderTimeout).toBe(2000) - expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p") - expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t") - expect(formatBindings(result.interrupt, result.leader)).toBe("esc") - expect(formatBindings(result.historyPrevious, result.leader)).toBe("up") - expect(formatBindings(result.historyNext, result.leader)).toBe("down") - expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+c") - expect(formatBindings(result.inputSubmit, result.leader)).toBe("return") - expect(formatBindings(result.inputNewline, result.leader)).toBe("shift+return, ctrl+return, alt+return, ctrl+j") - }) - - test("reads diff style and falls back to auto", async () => { - spyOn(TuiConfig, "get").mockResolvedValue(config({ diff_style: "stacked" })) - await expect(resolveDiffStyle()).resolves.toBe("stacked") - - mock.restore() - spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom")) - await expect(resolveDiffStyle()).resolves.toBe("auto") - }) - - test("prefers configured providers for model selector data", async () => { - const sdk = new OpencodeClient() - const data: { - all: Provider[] - default: Record - connected: string[] - } = { - all: [ - { - id: "openai", - name: "OpenAI", - source: "api", - env: [], - options: {}, - models: { - "gpt-5": model("gpt-5", "openai", 128000, { - high: {}, - minimal: {}, - }), + it.live("reads footer keybinds from resolved keybind config", () => + Effect.gen(function* () { + spyOn(TuiConfig, "get").mockResolvedValue( + config({ + leader: "ctrl+g", + bindings: { + commandList: bindings("ctrl+p"), + variantCycle: bindings("ctrl+t", "alt+t"), + interrupt: bindings("ctrl+c"), + historyPrevious: bindings("k"), + historyNext: bindings("j"), + inputClear: bindings("ctrl+l"), + inputSubmit: bindings("ctrl+s"), + inputNewline: bindings("alt+return"), }, - }, - { - id: "anthropic", - name: "Anthropic", - source: "api", - env: [], - options: {}, - models: { - sonnet: model("sonnet", "anthropic", 200000), - }, - }, - ], - default: {}, - connected: [], - } - const configured = { - providers: [data.all[0]!], - default: {}, - } - const list = spyOn(sdk.provider, "list").mockImplementation(() => - Promise.resolve({ - data, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }), - ) - spyOn(sdk.config, "providers").mockImplementation(() => - Promise.resolve({ - data: configured, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }), - ) + }), + ) - await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({ - providers: configured.providers, - variants: ["high", "minimal"], - limits: { - "openai/gpt-5": 128000, - }, - }) - expect(list).not.toHaveBeenCalled() - }) + const boot = yield* RunBoot.Service + const result = yield* boot.resolveFooterKeybinds() - test("falls back to provider list when configured providers are unavailable", async () => { - const sdk = new OpencodeClient() - const data: { - all: Provider[] - default: Record - connected: string[] - } = { - all: [ - { - id: "openai", - name: "OpenAI", - source: "api", - env: [], - options: {}, - models: { - "gpt-5": model("gpt-5", "openai", 128000, { - high: {}, - minimal: {}, - }), - }, - }, - { - id: "anthropic", - name: "Anthropic", - source: "api", - env: [], - options: {}, - models: { - sonnet: model("sonnet", "anthropic", 200000), - }, - }, - ], - default: {}, - connected: [], - } - spyOn(sdk.config, "providers").mockRejectedValue(new Error("boom")) - spyOn(sdk.provider, "list").mockImplementation(() => - Promise.resolve({ - data, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }), - ) + expect(result.leader).toBe("ctrl+g") + expect(result.leaderTimeout).toBe(2000) + expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p") + expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t, alt+t") + expect(formatBindings(result.interrupt, result.leader)).toBe("ctrl+c") + expect(formatBindings(result.historyPrevious, result.leader)).toBe("k") + expect(formatBindings(result.historyNext, result.leader)).toBe("j") + expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+l") + expect(formatBindings(result.inputSubmit, result.leader)).toBe("ctrl+s") + expect(formatBindings(result.inputNewline, result.leader)).toBe("alt+return") + }), + ) - await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({ - providers: data.all, - variants: ["high", "minimal"], - limits: { - "openai/gpt-5": 128000, - "anthropic/sonnet": 200000, - }, - }) - }) + it.live("falls back to default keybinds when config load fails", () => + Effect.gen(function* () { + spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom")) + + const boot = yield* RunBoot.Service + const result = yield* boot.resolveFooterKeybinds() + + expect(result.leader).toBe("ctrl+x") + expect(result.leaderTimeout).toBe(2000) + expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p") + expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t") + expect(formatBindings(result.interrupt, result.leader)).toBe("esc") + expect(formatBindings(result.historyPrevious, result.leader)).toBe("up") + expect(formatBindings(result.historyNext, result.leader)).toBe("down") + expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+c") + expect(formatBindings(result.inputSubmit, result.leader)).toBe("return") + expect(formatBindings(result.inputNewline, result.leader)).toBe("shift+return, ctrl+return, alt+return, ctrl+j") + }), + ) + + it.live("reads diff style and falls back to auto", () => + Effect.gen(function* () { + spyOn(TuiConfig, "get").mockResolvedValue(config({ diff_style: "stacked" })) + const boot = yield* RunBoot.Service + expect(yield* boot.resolveDiffStyle()).toBe("stacked") + + mock.restore() + spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom")) + expect(yield* boot.resolveDiffStyle()).toBe("auto") + }), + ) + + it.live("prefers configured providers for model selector data", () => + Effect.gen(function* () { + const sdk = new OpencodeClient() + const data: { + all: Provider[] + default: Record + connected: string[] + } = { + all: [ + { + id: "openai", + name: "OpenAI", + source: "api", + env: [], + options: {}, + models: { + "gpt-5": model("gpt-5", "openai", 128000, { + high: {}, + minimal: {}, + }), + }, + }, + { + id: "anthropic", + name: "Anthropic", + source: "api", + env: [], + options: {}, + models: { + sonnet: model("sonnet", "anthropic", 200000), + }, + }, + ], + default: {}, + connected: [], + } + const configured = { + providers: [data.all[0]!], + default: {}, + } + const list = spyOn(sdk.provider, "list").mockImplementation(() => + Promise.resolve({ + data, + error: undefined, + request: new Request("https://opencode.test"), + response: new Response(), + }), + ) + spyOn(sdk.config, "providers").mockImplementation(() => + Promise.resolve({ + data: configured, + error: undefined, + request: new Request("https://opencode.test"), + response: new Response(), + }), + ) + + const boot = yield* RunBoot.Service + const result = yield* boot.resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" }) + expect(result).toEqual({ + providers: configured.providers, + variants: ["high", "minimal"], + limits: { + "openai/gpt-5": 128000, + }, + }) + expect(list).not.toHaveBeenCalled() + }), + ) + + it.live("falls back to provider list when configured providers are unavailable", () => + Effect.gen(function* () { + const sdk = new OpencodeClient() + const data: { + all: Provider[] + default: Record + connected: string[] + } = { + all: [ + { + id: "openai", + name: "OpenAI", + source: "api", + env: [], + options: {}, + models: { + "gpt-5": model("gpt-5", "openai", 128000, { + high: {}, + minimal: {}, + }), + }, + }, + { + id: "anthropic", + name: "Anthropic", + source: "api", + env: [], + options: {}, + models: { + sonnet: model("sonnet", "anthropic", 200000), + }, + }, + ], + default: {}, + connected: [], + } + spyOn(sdk.config, "providers").mockRejectedValue(new Error("boom")) + spyOn(sdk.provider, "list").mockImplementation(() => + Promise.resolve({ + data, + error: undefined, + request: new Request("https://opencode.test"), + response: new Response(), + }), + ) + + const boot = yield* RunBoot.Service + const result = yield* boot.resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" }) + expect(result).toEqual({ + providers: data.all, + variants: ["high", "minimal"], + limits: { + "openai/gpt-5": 128000, + "anthropic/sonnet": 200000, + }, + }) + }), + ) }) + diff --git a/packages/opencode/test/cli/run/variant.shared.test.ts b/packages/opencode/test/cli/run/variant.shared.test.ts index 9fa41be320..a6c988bb1c 100644 --- a/packages/opencode/test/cli/run/variant.shared.test.ts +++ b/packages/opencode/test/cli/run/variant.shared.test.ts @@ -4,13 +4,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { describe, expect, test } from "bun:test" import { Effect, FileSystem, Layer } from "effect" import { Global } from "@opencode-ai/core/global" -import { - createVariantRuntime, - cycleVariant, - formatModelLabel, - pickVariant, - resolveVariant, -} from "@/cli/cmd/run/variant.shared" +import { Variant, cycleVariant, formatModelLabel, pickVariant, resolveVariant } from "@/cli/cmd/run/variant.shared" import type { SessionMessages } from "@/cli/cmd/run/session.shared" import type { RunProvider } from "@/cli/cmd/run/types" import { testEffect } from "../../lib/effect" @@ -171,26 +165,27 @@ describe("run variant shared", () => { }, }) - const svc = createVariantRuntime(remappedFs(root)) + yield* Effect.gen(function* () { + const svc = yield* Variant.Service + yield* svc.saveVariant(model, "high") + expect(yield* svc.resolveSavedVariant(model)).toBe("high") + expect(yield* fs.readJson(file)).toEqual({ + recent: [{ providerID: "anthropic", modelID: "sonnet" }], + variant: { + "openai/gpt-4.1": "low", + "openai/gpt-5": "high", + }, + }) - yield* Effect.promise(() => svc.saveVariant(model, "high")) - expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high") - expect(yield* fs.readJson(file)).toEqual({ - recent: [{ providerID: "anthropic", modelID: "sonnet" }], - variant: { - "openai/gpt-4.1": "low", - "openai/gpt-5": "high", - }, - }) - - yield* Effect.promise(() => svc.saveVariant(model, undefined)) - expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBeUndefined() - expect(yield* fs.readJson(file)).toEqual({ - recent: [{ providerID: "anthropic", modelID: "sonnet" }], - variant: { - "openai/gpt-4.1": "low", - }, - }) + yield* svc.saveVariant(model, undefined) + expect(yield* svc.resolveSavedVariant(model)).toBeUndefined() + expect(yield* fs.readJson(file)).toEqual({ + recent: [{ providerID: "anthropic", modelID: "sonnet" }], + variant: { + "openai/gpt-4.1": "low", + }, + }) + }).pipe(Effect.provide(Variant.layer.pipe(Layer.provide(remappedFs(root))))) }), ) @@ -203,15 +198,16 @@ describe("run variant shared", () => { yield* filesys.writeFileString(file, "{") - const svc = createVariantRuntime(remappedFs(root)) - - yield* Effect.promise(() => svc.saveVariant(model, "high")) - expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high") - expect(yield* fs.readJson(file)).toEqual({ - variant: { - "openai/gpt-5": "high", - }, - }) + yield* Effect.gen(function* () { + const svc = yield* Variant.Service + yield* svc.saveVariant(model, "high") + expect(yield* svc.resolveSavedVariant(model)).toBe("high") + expect(yield* fs.readJson(file)).toEqual({ + variant: { + "openai/gpt-5": "high", + }, + }) + }).pipe(Effect.provide(Variant.layer.pipe(Layer.provide(remappedFs(root))))) }), ) })