Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 84a1b0280e effect(cli): unwrap run/runtime.boot and variant.shared facades 2026-05-12 16:26:22 -04:00
5 changed files with 363 additions and 347 deletions
@@ -9,7 +9,6 @@ import { Context, Effect, Layer } from "effect"
import { stringifyKeyStroke } from "@opentui/keymap" import { stringifyKeyStroke } from "@opentui/keymap"
import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind" import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
import { makeRuntime } from "@/effect/run-service"
import { reusePendingTask } from "./runtime.shared" import { reusePendingTask } from "./runtime.shared"
import { resolveSession, sessionHistory } from "./session.shared" import { resolveSession, sessionHistory } from "./session.shared"
import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt, RunProvider } from "./types" 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" }], inputNewline: [{ key: "shift+return,ctrl+return,alt+return,ctrl+j" }],
} }
export const defaultKeybinds: FooterKeybinds = DEFAULT_KEYBINDS
export type ModelInfo = { export type ModelInfo = {
providers: RunProvider[] providers: RunProvider[]
variants: string[] variants: string[]
@@ -41,7 +42,8 @@ export type SessionInfo = {
} }
type Config = Awaited<ReturnType<typeof TuiConfig.get>> type Config = Awaited<ReturnType<typeof TuiConfig.get>>
type BootService = {
export interface Interface {
readonly resolveModelInfo: ( readonly resolveModelInfo: (
sdk: RunInput["sdk"], sdk: RunInput["sdk"],
directory: string, directory: string,
@@ -58,13 +60,13 @@ type BootService = {
const configTask: { current?: Promise<Config> } = {} const configTask: { current?: Promise<Config> } = {}
class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {} export class Service extends Context.Service<Service, Interface>()("@opencode/RunBoot") {}
function loadConfig() { function loadConfig() {
return reusePendingTask(configTask, () => TuiConfig.get()) return reusePendingTask(configTask, () => TuiConfig.get())
} }
function emptyModelInfo(): ModelInfo { export function emptyModelInfo(): ModelInfo {
return { return {
providers: [], providers: [],
variants: [], variants: [],
@@ -72,7 +74,7 @@ function emptyModelInfo(): ModelInfo {
} }
} }
function emptySessionInfo(): SessionInfo { export function emptySessionInfo(): SessionInfo {
return { return {
first: true, first: true,
history: [], history: [],
@@ -105,7 +107,7 @@ function footerKeybinds(config: Config | undefined): FooterKeybinds {
} }
} }
const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const config = Effect.fn("RunBoot.config")(() => Effect.promise(() => loadConfig().catch(() => undefined))) 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 * as RunBoot from "./runtime.boot"
export async function resolveModelInfo(
sdk: RunInput["sdk"],
directory: string,
model: RunInput["model"],
): Promise<ModelInfo> {
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<SessionInfo> {
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<FooterKeybinds> {
return runtime.runPromise((svc) => svc.resolveFooterKeybinds()).catch(() => DEFAULT_KEYBINDS)
}
export async function resolveDiffStyle(): Promise<RunDiffStyle> {
return runtime.runPromise((svc) => svc.resolveDiffStyle()).catch(() => "auto")
}
+68 -24
View File
@@ -14,13 +14,33 @@
// 4. runs the prompt queue until the footer closes. // 4. runs the prompt queue until the footer closes.
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Effect, Layer } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { createRunDemo } from "./demo" 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 { createRuntimeLifecycle } from "./runtime.lifecycle"
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel" import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
import { trace } from "./trace" import { trace } from "./trace"
import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared" import { Variant, cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared"
import type { RunInput, RunPrompt, RunProvider } from "./types" 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 */ /** @internal Exported for testing */
export { pickVariant, resolveVariant } from "./variant.shared" export { pickVariant, resolveVariant } from "./variant.shared"
@@ -169,25 +189,44 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
async (span) => { async (span) => {
const start = performance.now() const start = performance.now()
const log = trace() const log = trace()
const keybindTask = resolveFooterKeybinds() const earlyTask = AppRuntime.runPromise(
const diffTask = resolveDiffStyle() 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 ctx = await input.boot()
const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model) const modelTask = AppRuntime.runPromise(
const sessionTask = Effect.gen(function* () {
ctx.resume === true const boot = yield* RunBoot.Service
? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model) return yield* boot.resolveModelInfo(ctx.sdk, ctx.directory, ctx.model)
: Promise.resolve({ }).pipe(Effect.provide(BootLayer)),
first: true, ).catch((): ModelInfo => emptyModelInfo())
history: [], const sessionAndSavedTask = AppRuntime.runPromise(
variant: undefined, Effect.gen(function* () {
}) const boot = yield* RunBoot.Service
const savedTask = resolveSavedVariant(ctx.model) const variantSvc = yield* Variant.Service
const [keybinds, diffStyle, session, savedVariant] = await Promise.all([ return yield* Effect.all(
keybindTask, {
diffTask, session:
sessionTask, ctx.resume === true
savedTask, ? boot
]) .resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
.pipe(Effect.orElseSucceed((): SessionInfo => emptySessionInfo()))
: Effect.succeed<SessionInfo>({ 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 = { const state: RuntimeState = {
shown: !session.first, shown: !session.first,
aborting: false, aborting: false,
@@ -280,7 +319,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
} }
state.activeVariant = cycleVariant(state.activeVariant, state.variants) state.activeVariant = cycleVariant(state.activeVariant, state.variants)
saveVariant(state.model, state.activeVariant) persistVariant(state.model, state.activeVariant)
setRunSpanAttributes(span, { setRunSpanAttributes(span, {
"opencode.model.variant": state.activeVariant, "opencode.model.variant": state.activeVariant,
}) })
@@ -298,7 +337,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
state.model = model state.model = model
state.activeVariant = undefined state.activeVariant = undefined
state.variants = variantsFor(state.providers, model) 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 const current = state.model
if (!current || current.providerID !== model.providerID || current.modelID !== model.modelID) { if (!current || current.providerID !== model.providerID || current.modelID !== model.modelID) {
return return
@@ -343,7 +387,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
} }
state.activeVariant = variant state.activeVariant = variant
saveVariant(state.model, state.activeVariant) persistVariant(state.model, state.activeVariant)
setRunSpanAttributes(span, { setRunSpanAttributes(span, {
"opencode.model.variant": state.activeVariant, "opencode.model.variant": state.activeVariant,
}) })
@@ -9,7 +9,6 @@
import path from "path" import path from "path"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared" 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<string, unknown> & { type ModelState = Record<string, unknown> & {
variant?: Record<string, string | undefined> variant?: Record<string, string | undefined>
} }
type VariantService = {
export interface Interface {
readonly resolveSavedVariant: (model: RunInput["model"]) => Effect.Effect<string | undefined> readonly resolveSavedVariant: (model: RunInput["model"]) => Effect.Effect<string | undefined>
readonly saveVariant: (model: RunInput["model"], variant: string | undefined) => Effect.Effect<void> readonly saveVariant: (model: RunInput["model"], variant: string | undefined) => Effect.Effect<void>
} }
type VariantRuntime = {
resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined>
saveVariant(model: RunInput["model"], variant: string | undefined): Promise<void>
}
class Service extends Context.Service<Service, VariantService>()("@opencode/RunVariant") {} export class Service extends Context.Service<Service, Interface>()("@opencode/RunVariant") {}
function modelKey(provider: string, model: string): string { function modelKey(provider: string, model: string): string {
return `${provider}/${model}` return `${provider}/${model}`
@@ -135,81 +131,62 @@ function state(value: unknown): ModelState {
} }
} }
function createLayer(fs = AppFileSystem.defaultLayer) { export const layer = Layer.effect(
return Layer.fresh( Service,
Layer.effect( Effect.gen(function* () {
Service, const file = yield* AppFileSystem.Service
Effect.gen(function* () {
const file = yield* AppFileSystem.Service
const read = Effect.fn("RunVariant.read")(function* () { const read = Effect.fn("RunVariant.read")(function* () {
return yield* file.readJson(MODEL_FILE).pipe( return yield* file.readJson(MODEL_FILE).pipe(
Effect.map(state), Effect.map(state),
Effect.catchCause(() => Effect.succeed(state(undefined))), 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"]) { return Service.of({
if (!model) { resolveSavedVariant,
return undefined saveVariant,
} })
}),
)
return (yield* read()).variant?.[variantKey(model)] export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
})
const saveVariant = Effect.fn("RunVariant.saveVariant")(function* ( export * as Variant from "./variant.shared"
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<string | undefined> {
return runtime.resolveSavedVariant(model)
}
export function saveVariant(model: RunInput["model"], variant: string | undefined): void {
void runtime.saveVariant(model, variant)
}
@@ -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 { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap" import type { Binding } from "@opentui/keymap"
import { createBindingLookup } from "@opentui/keymap/extras" import { createBindingLookup } from "@opentui/keymap/extras"
import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2" import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui" import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui"
import { formatBindings } from "@/cli/cmd/run/keymap.shared" import { formatBindings } from "@/cli/cmd/run/keymap.shared"
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind" 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<Renderable, KeyEvent> type RunBinding = Binding<Renderable, KeyEvent>
@@ -102,186 +104,206 @@ function config(input?: {
} }
} }
const it = testEffect(RunBoot.defaultLayer)
describe("run runtime boot", () => { describe("run runtime boot", () => {
afterEach(() => { afterEach(() => {
mock.restore() mock.restore()
}) })
test("reads footer keybinds from resolved keybind config", async () => { it.live("reads footer keybinds from resolved keybind config", () =>
spyOn(TuiConfig, "get").mockResolvedValue( Effect.gen(function* () {
config({ spyOn(TuiConfig, "get").mockResolvedValue(
leader: "ctrl+g", config({
bindings: { leader: "ctrl+g",
commandList: bindings("ctrl+p"), bindings: {
variantCycle: bindings("ctrl+t", "alt+t"), commandList: bindings("ctrl+p"),
interrupt: bindings("ctrl+c"), variantCycle: bindings("ctrl+t", "alt+t"),
historyPrevious: bindings("k"), interrupt: bindings("ctrl+c"),
historyNext: bindings("j"), historyPrevious: bindings("k"),
inputClear: bindings("ctrl+l"), historyNext: bindings("j"),
inputSubmit: bindings("ctrl+s"), inputClear: bindings("ctrl+l"),
inputNewline: bindings("alt+return"), 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<string, string>
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(),
}),
)
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({ const boot = yield* RunBoot.Service
providers: configured.providers, const result = yield* boot.resolveFooterKeybinds()
variants: ["high", "minimal"],
limits: {
"openai/gpt-5": 128000,
},
})
expect(list).not.toHaveBeenCalled()
})
test("falls back to provider list when configured providers are unavailable", async () => { expect(result.leader).toBe("ctrl+g")
const sdk = new OpencodeClient() expect(result.leaderTimeout).toBe(2000)
const data: { expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p")
all: Provider[] expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t, alt+t")
default: Record<string, string> expect(formatBindings(result.interrupt, result.leader)).toBe("ctrl+c")
connected: string[] expect(formatBindings(result.historyPrevious, result.leader)).toBe("k")
} = { expect(formatBindings(result.historyNext, result.leader)).toBe("j")
all: [ expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+l")
{ expect(formatBindings(result.inputSubmit, result.leader)).toBe("ctrl+s")
id: "openai", expect(formatBindings(result.inputNewline, result.leader)).toBe("alt+return")
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(),
}),
)
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({ it.live("falls back to default keybinds when config load fails", () =>
providers: data.all, Effect.gen(function* () {
variants: ["high", "minimal"], spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
limits: {
"openai/gpt-5": 128000, const boot = yield* RunBoot.Service
"anthropic/sonnet": 200000, 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<string, string>
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<string, string>
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,
},
})
}),
)
}) })
@@ -4,13 +4,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect, FileSystem, Layer } from "effect" import { Effect, FileSystem, Layer } from "effect"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { import { Variant, cycleVariant, formatModelLabel, pickVariant, resolveVariant } from "@/cli/cmd/run/variant.shared"
createVariantRuntime,
cycleVariant,
formatModelLabel,
pickVariant,
resolveVariant,
} from "@/cli/cmd/run/variant.shared"
import type { SessionMessages } from "@/cli/cmd/run/session.shared" import type { SessionMessages } from "@/cli/cmd/run/session.shared"
import type { RunProvider } from "@/cli/cmd/run/types" import type { RunProvider } from "@/cli/cmd/run/types"
import { testEffect } from "../../lib/effect" 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")) yield* svc.saveVariant(model, undefined)
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high") expect(yield* svc.resolveSavedVariant(model)).toBeUndefined()
expect(yield* fs.readJson(file)).toEqual({ expect(yield* fs.readJson(file)).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }], recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: { variant: {
"openai/gpt-4.1": "low", "openai/gpt-4.1": "low",
"openai/gpt-5": "high", },
}, })
}) }).pipe(Effect.provide(Variant.layer.pipe(Layer.provide(remappedFs(root)))))
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",
},
})
}), }),
) )
@@ -203,15 +198,16 @@ describe("run variant shared", () => {
yield* filesys.writeFileString(file, "{") yield* filesys.writeFileString(file, "{")
const svc = createVariantRuntime(remappedFs(root)) yield* Effect.gen(function* () {
const svc = yield* Variant.Service
yield* Effect.promise(() => svc.saveVariant(model, "high")) yield* svc.saveVariant(model, "high")
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high") expect(yield* svc.resolveSavedVariant(model)).toBe("high")
expect(yield* fs.readJson(file)).toEqual({ expect(yield* fs.readJson(file)).toEqual({
variant: { variant: {
"openai/gpt-5": "high", "openai/gpt-5": "high",
}, },
}) })
}).pipe(Effect.provide(Variant.layer.pipe(Layer.provide(remappedFs(root)))))
}), }),
) )
}) })