From 7dc365a9f8e921daa9148d1e836d458c4403df8e Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sat, 18 Apr 2026 19:31:35 +0200 Subject: [PATCH] migrate local IO helpers to a module --- .../opencode/src/cli/cmd/run/runtime.boot.ts | 241 +++++++++++------- .../src/cli/cmd/run/variant.shared.ts | 142 ++++++++--- .../test/cli/run/variant.shared.test.ts | 100 +++++++- 3 files changed, 355 insertions(+), 128 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/opencode/src/cli/cmd/run/runtime.boot.ts index ba1f6f0e4d..74e0224d4d 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.boot.ts @@ -5,7 +5,9 @@ // model variant list with context limits, and session history for the prompt // history ring. All are async because they read config or hit the SDK, but // none block each other. -import { TuiConfig } from "../tui/config/tui" +import { Context, Effect, Layer } from "effect" +import { TuiConfig } from "@/cli/cmd/tui/config/tui" +import { makeRuntime } from "@/effect/run-service" import { reusePendingTask } from "./runtime.shared" import { resolveSession, sessionHistory } from "./session.shared" import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt } from "./types" @@ -21,12 +23,6 @@ const DEFAULT_KEYBINDS: FooterKeybinds = { inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j", } -const configTask: { current?: ReturnType } = {} - -function loadConfig() { - return reusePendingTask(configTask, () => TuiConfig.get()) -} - export type ModelInfo = { variants: string[] limits: Record @@ -38,45 +34,152 @@ export type SessionInfo = { variant: string | undefined } -function modelKey(provider: string, model: string): string { - return `${provider}/${model}` +type Config = Awaited> +type BootService = { + readonly resolveModelInfo: (sdk: RunInput["sdk"], model: RunInput["model"]) => Effect.Effect + readonly resolveSessionInfo: ( + sdk: RunInput["sdk"], + sessionID: string, + model: RunInput["model"], + ) => Effect.Effect + readonly resolveFooterKeybinds: () => Effect.Effect + readonly resolveDiffStyle: () => Effect.Effect } +const configTask: { current?: Promise } = {} + +class Service extends Context.Service()("@opencode/RunBoot") {} + +function loadConfig() { + return reusePendingTask(configTask, () => TuiConfig.get()) +} + +function emptyModelInfo(): ModelInfo { + return { + variants: [], + limits: {}, + } +} + +function emptySessionInfo(): SessionInfo { + return { + first: true, + history: [], + variant: undefined, + } +} + +function footerKeybinds(config: Config | undefined): FooterKeybinds { + const leader = config?.keybinds?.leader?.trim() || DEFAULT_KEYBINDS.leader + const cycle = config?.keybinds?.variant_cycle?.trim() || "ctrl+t" + const interrupt = config?.keybinds?.session_interrupt?.trim() || DEFAULT_KEYBINDS.interrupt + const previous = config?.keybinds?.history_previous?.trim() || DEFAULT_KEYBINDS.historyPrevious + const next = config?.keybinds?.history_next?.trim() || DEFAULT_KEYBINDS.historyNext + const submit = config?.keybinds?.input_submit?.trim() || DEFAULT_KEYBINDS.inputSubmit + const newline = config?.keybinds?.input_newline?.trim() || DEFAULT_KEYBINDS.inputNewline + + const bindings = cycle + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0) + + if (!bindings.some((binding) => binding.toLowerCase() === "t")) { + bindings.push("t") + } + + return { + leader, + variantCycle: bindings.join(","), + interrupt, + historyPrevious: previous, + historyNext: next, + inputSubmit: submit, + inputNewline: newline, + } +} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = Effect.fn("RunBoot.config")(() => + Effect.promise(loadConfig).pipe( + Effect.orElseSucceed(() => undefined), + ), + ) + + const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* (sdk: RunInput["sdk"], model: RunInput["model"]) { + const providers = yield* Effect.promise(() => sdk.provider.list()).pipe( + Effect.map((item) => item.data?.all ?? []), + Effect.orElseSucceed(() => []), + ) + const limits = Object.fromEntries( + providers.flatMap((provider) => + Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => { + const limit = info?.limit?.context + if (typeof limit !== "number" || limit <= 0) { + return [] + } + + return [[`${provider.id}/${modelID}`, limit] as const] + }), + ), + ) + + if (!model) { + return { + variants: [], + limits, + } + } + + const info = providers.find((item) => item.id === model.providerID)?.models?.[model.modelID] + return { + variants: Object.keys(info?.variants ?? {}), + limits, + } + }) + + const resolveSessionInfo = Effect.fn("RunBoot.resolveSessionInfo")(function* ( + sdk: RunInput["sdk"], + sessionID: string, + model: RunInput["model"], + ) { + const session = yield* Effect.promise(() => resolveSession(sdk, sessionID)).pipe( + Effect.orElseSucceed(() => undefined), + ) + if (!session) { + return emptySessionInfo() + } + + return { + first: session.first, + history: sessionHistory(session), + variant: pickVariant(model, session), + } + }) + + const resolveFooterKeybinds = Effect.fn("RunBoot.resolveFooterKeybinds")(function* () { + return footerKeybinds(yield* config()) + }) + + const resolveDiffStyle = Effect.fn("RunBoot.resolveDiffStyle")(function* () { + return (yield* config())?.diff_style ?? "auto" + }) + + return Service.of({ + resolveModelInfo, + resolveSessionInfo, + resolveFooterKeybinds, + resolveDiffStyle, + }) + }), +) + +const runtime = makeRuntime(Service, layer) + // Fetches available variants and context limits for every provider/model pair. export async function resolveModelInfo(sdk: RunInput["sdk"], model: RunInput["model"]): Promise { - try { - const response = await sdk.provider.list() - const providers = response.data?.all ?? [] - const limits: Record = {} - - for (const provider of providers) { - for (const [modelID, info] of Object.entries(provider.models ?? {})) { - const limit = info?.limit?.context - if (typeof limit === "number" && limit > 0) { - limits[modelKey(provider.id, modelID)] = limit - } - } - } - - if (!model) { - return { - variants: [], - limits, - } - } - - const provider = providers.find((item) => item.id === model.providerID) - const modelInfo = provider?.models?.[model.modelID] - return { - variants: Object.keys(modelInfo?.variants ?? {}), - limits, - } - } catch { - return { - variants: [], - limits: {}, - } - } + return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, model)).catch(() => emptyModelInfo()) } // Fetches session messages to determine if this is the first turn and build prompt history. @@ -85,63 +188,15 @@ export async function resolveSessionInfo( sessionID: string, model: RunInput["model"], ): Promise { - try { - const session = await resolveSession(sdk, sessionID) - return { - first: session.first, - history: sessionHistory(session), - variant: pickVariant(model, session), - } - } catch { - return { - first: true, - history: [], - variant: undefined, - } - } + return runtime.runPromise((svc) => svc.resolveSessionInfo(sdk, sessionID, model)).catch(() => emptySessionInfo()) } // Reads keybind overrides from TUI config and merges them with defaults. // Always ensures t is present in the variant cycle binding. export async function resolveFooterKeybinds(): Promise { - try { - const config = await loadConfig() - const configuredLeader = config.keybinds?.leader?.trim() || DEFAULT_KEYBINDS.leader - const configuredVariantCycle = config.keybinds?.variant_cycle?.trim() || "ctrl+t" - const configuredInterrupt = config.keybinds?.session_interrupt?.trim() || DEFAULT_KEYBINDS.interrupt - const configuredHistoryPrevious = config.keybinds?.history_previous?.trim() || DEFAULT_KEYBINDS.historyPrevious - const configuredHistoryNext = config.keybinds?.history_next?.trim() || DEFAULT_KEYBINDS.historyNext - const configuredSubmit = config.keybinds?.input_submit?.trim() || DEFAULT_KEYBINDS.inputSubmit - const configuredNewline = config.keybinds?.input_newline?.trim() || DEFAULT_KEYBINDS.inputNewline - - const variantBindings = configuredVariantCycle - .split(",") - .map((item) => item.trim()) - .filter((item) => item.length > 0) - - if (!variantBindings.some((binding) => binding.toLowerCase() === "t")) { - variantBindings.push("t") - } - - return { - leader: configuredLeader, - variantCycle: variantBindings.join(","), - interrupt: configuredInterrupt, - historyPrevious: configuredHistoryPrevious, - historyNext: configuredHistoryNext, - inputSubmit: configuredSubmit, - inputNewline: configuredNewline, - } - } catch { - return DEFAULT_KEYBINDS - } + return runtime.runPromise((svc) => svc.resolveFooterKeybinds()).catch(() => DEFAULT_KEYBINDS) } export async function resolveDiffStyle(): Promise { - try { - const config = await loadConfig() - return config.diff_style ?? "auto" - } catch { - return "auto" - } + return runtime.runPromise((svc) => svc.resolveDiffStyle()).catch(() => "auto") } diff --git a/packages/opencode/src/cli/cmd/run/variant.shared.ts b/packages/opencode/src/cli/cmd/run/variant.shared.ts index f0dc8d3f69..7ed8c35289 100644 --- a/packages/opencode/src/cli/cmd/run/variant.shared.ts +++ b/packages/opencode/src/cli/cmd/run/variant.shared.ts @@ -7,16 +7,29 @@ // so your last-used variant sticks. Cycling (ctrl+t) updates both the active // variant and the persisted file. import path from "path" -import { Global } from "../../../global" -import * as Filesystem from "../../../util/filesystem" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Context, Effect, Layer } from "effect" +import { makeRuntime } from "@/effect/run-service" +import { Global } from "@/global" +import { isRecord } from "@/util/record" import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared" import type { RunInput } from "./types" const MODEL_FILE = path.join(Global.Path.state, "model.json") -type ModelState = { +type ModelState = Record & { variant?: Record } +type VariantService = { + 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") {} function modelKey(provider: string, model: string): string { return `${provider}/${model}` @@ -86,41 +99,102 @@ export function resolveVariant( return fallback } -export async function resolveSavedVariant(model: RunInput["model"]): Promise { - if (!model) { - return undefined +function state(value: unknown): ModelState { + if (!isRecord(value)) { + return {} } - try { - const state = await Filesystem.readJson(MODEL_FILE) - return state.variant?.[variantKey(model)] - } catch { - return undefined + const variant = isRecord(value.variant) + ? Object.fromEntries( + Object.entries(value.variant).flatMap(([key, item]) => { + if (typeof item !== "string") { + return [] + } + + return [[key, item] as const] + }), + ) + : undefined + + return { + ...value, + variant, } } +function createLayer(fs = AppFileSystem.defaultLayer) { + return Layer.fresh( + 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({})), + ) + }) + + 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)) + }) + + 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 { - if (!model) { - return - } - - void (async () => { - const state = await Filesystem.readJson(MODEL_FILE).catch(() => ({}) as ModelState) - const map = { - ...(state.variant ?? {}), - } - const key = variantKey(model) - if (variant) { - map[key] = variant - } - - if (!variant) { - delete map[key] - } - - await Filesystem.writeJson(MODEL_FILE, { - ...state, - variant: map, - }) - })().catch(() => {}) + void runtime.saveVariant(model, variant) } diff --git a/packages/opencode/test/cli/run/variant.shared.test.ts b/packages/opencode/test/cli/run/variant.shared.test.ts index ffacbd3958..b74abd12f5 100644 --- a/packages/opencode/test/cli/run/variant.shared.test.ts +++ b/packages/opencode/test/cli/run/variant.shared.test.ts @@ -1,12 +1,52 @@ +import path from "path" +import { NodeFileSystem } from "@effect/platform-node" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { describe, expect, test } from "bun:test" -import { cycleVariant, formatModelLabel, pickVariant, resolveVariant } from "../../../src/cli/cmd/run/variant.shared" +import { Effect, FileSystem, Layer } from "effect" +import { Global } from "../../../src/global" +import { + createVariantRuntime, + cycleVariant, + formatModelLabel, + pickVariant, + resolveVariant, +} from "../../../src/cli/cmd/run/variant.shared" import type { SessionMessages } from "../../../src/cli/cmd/run/session.shared" +import { testEffect } from "../../lib/effect" const model = { providerID: "openai", modelID: "gpt-5", } +const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, NodeFileSystem.layer)) + +function remap(root: string, file: string) { + if (file === Global.Path.state) { + return root + } + + if (file.startsWith(Global.Path.state + path.sep)) { + return path.join(root, path.relative(Global.Path.state, file)) + } + + return file +} + +function remappedFs(root: string) { + return Layer.effect( + AppFileSystem.Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + return AppFileSystem.Service.of({ + ...fs, + readJson: (file) => fs.readJson(remap(root, file)), + writeJson: (file, data, mode) => fs.writeJson(remap(root, file), data, mode), + }) + }), + ).pipe(Layer.provide(AppFileSystem.defaultLayer)) +} + describe("run variant shared", () => { test("prefers cli then session then saved variants", () => { expect(resolveVariant("max", "high", "low", ["low", "high"])).toBe("max") @@ -65,4 +105,62 @@ describe("run variant shared", () => { expect(pickVariant(model, msgs)).toBe("minimal") }) + + it.live("reads and writes saved variants through a runtime-backed app fs layer", () => + Effect.gen(function* () { + const filesys = yield* FileSystem.FileSystem + const fs = yield* AppFileSystem.Service + const root = yield* filesys.makeTempDirectoryScoped() + const file = path.join(root, "model.json") + + yield* fs.writeJson(file, { + recent: [{ providerID: "anthropic", modelID: "sonnet" }], + variant: { + "openai/gpt-4.1": "low", + }, + }) + + 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({ + 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", + }, + }) + }), + ) + + it.live("repairs malformed saved variant state on the next write", () => + Effect.gen(function* () { + const filesys = yield* FileSystem.FileSystem + const fs = yield* AppFileSystem.Service + const root = yield* filesys.makeTempDirectoryScoped() + const file = path.join(root, "model.json") + + 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", + }, + }) + }), + ) })