diff --git a/packages/opencode/src/cli/cmd/run/catalog.shared.ts b/packages/opencode/src/cli/cmd/run/catalog.shared.ts index e4c8277fd9..e873071f69 100644 --- a/packages/opencode/src/cli/cmd/run/catalog.shared.ts +++ b/packages/opencode/src/cli/cmd/run/catalog.shared.ts @@ -4,7 +4,9 @@ import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types" type CurrentAgent = NonNullable>["data"]>["data"][number] type CurrentCommand = NonNullable>["data"]>["data"][number] type CurrentSkill = NonNullable>["data"]>["data"][number] -type CurrentProvider = NonNullable>["data"]>["data"][number] +type CurrentProvider = NonNullable< + Awaited>["data"] +>["data"][number] type CurrentModel = NonNullable>["data"]>["data"][number] function location(directory: string) { @@ -105,6 +107,22 @@ export async function waitForCatalogReady(input: { } } +export async function waitForDefaultModel(input: { + sdk: OpencodeClient + directory: string + timeoutMs?: number +}): Promise<{ providerID: string; modelID: string } | undefined> { + const deadline = Date.now() + (input.timeoutMs ?? 5_000) + while (Date.now() < deadline) { + const model = await input.sdk.v2.model + .default(location(input.directory), { throwOnError: true }) + .then((result) => result.data?.data) + .catch(() => undefined) + if (model) return { providerID: model.providerID, modelID: model.id } + await new Promise((resolve) => setTimeout(resolve, 25)) + } +} + export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise { const result = await sdk.v2.agent.list(location(directory), { throwOnError: true }) return (result.data?.data ?? []).map(runAgent) diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index 6a4c03e797..2432c29689 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -390,6 +390,10 @@ export class RunFooter implements FooterApi { } public event(next: FooterEvent): void { + if (next.type === "model") { + this.setCurrentModel(next.selection) + } + if (next.type === "turn.duration") { const current = this.currentModel() this.flush() diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/opencode/src/cli/cmd/run/runtime.boot.ts index 2933ce5fdd..eae1db77e8 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.boot.ts @@ -26,6 +26,7 @@ export type ModelInfo = { export type SessionInfo = { first: boolean history: RunPrompt[] + model?: NonNullable variant: string | undefined } @@ -141,7 +142,8 @@ const layer = Layer.effect( return { first: session.first, history: sessionHistory(session), - variant: pickVariant(model, session), + model: session.model, + variant: pickVariant(model ?? session.model, session), } }) diff --git a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts index a25b293883..d9dab9f309 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts @@ -124,17 +124,9 @@ function splashInfo(title: string | undefined, history: RunPrompt[]) { function footerLabels(input: Pick): FooterLabels { const agentLabel = Locale.titlecase(input.agent ?? "build") - - if (!input.model) { - return { - agentLabel, - modelLabel: "Model default", - } - } - return { agentLabel, - modelLabel: formatModelLabel(input.model, input.variant), + modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "", } } diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 5c6ede1582..092bda3096 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -15,7 +15,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Flag } from "@opencode-ai/core/flag/flag" import { MessageID } from "@/session/schema" -import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared" +import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared" import { createRunDemo } from "./demo" import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot" import { createRuntimeLifecycle } from "./runtime.lifecycle" @@ -181,13 +181,13 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep const log = trace() const tuiConfigTask = resolveRunTuiConfig() 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: [], + model: undefined, variant: undefined, }) const savedTask = resolveSavedVariant(ctx.model) @@ -195,7 +195,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep const state: RuntimeState = { shown: !session.first, aborting: false, - model: ctx.model, + model: ctx.model ?? session.model, providers: [], variants: [], limits: {}, @@ -206,6 +206,38 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep sessionTitle: ctx.sessionTitle, agent: ctx.agent, } + const modelTask = (async () => { + if (state.model) { + return { + model: state.model, + savedVariant, + boot: true, + info: await resolveModelInfo(ctx.sdk, ctx.directory, state.model), + } + } + + const model = await waitForDefaultModel({ sdk: ctx.sdk, directory: ctx.directory }) + const [fallbackSavedVariant, info] = await Promise.all([ + resolveSavedVariant(model), + resolveModelInfo(ctx.sdk, ctx.directory, model), + ]) + if (!model || state.model) { + return { + model: state.model, + savedVariant: undefined, + boot: false, + info, + } + } + + state.model = model + return { + model, + savedVariant: fallbackSavedVariant, + boot: true, + info, + } + })() const ensureSession = () => { if (!input.resolveSession || state.sessionID) { return Promise.resolve() @@ -345,9 +377,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep } state.aborting = true - void (state.stream - ? state.stream.then((item) => item.handle.interruptActiveTurn()) - : ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID })) + void ( + state.stream + ? state.stream.then((item) => item.handle.interruptActiveTurn()) + : ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID }) + ) .catch(() => {}) .finally(() => { state.aborting = false @@ -417,12 +451,13 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep info: Awaited>, current: string | undefined, boot = false, + saved = savedVariant, ) => { state.providers = info.providers state.variants = variantsFor(state.providers, state.model) state.limits = info.limits state.activeVariant = boot - ? resolveVariant(ctx.variant, current, savedVariant, state.variants) + ? resolveVariant(ctx.variant, current, saved, state.variants) : current && !state.variants.includes(current) ? undefined : current @@ -430,7 +465,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep footer.event({ type: "models", providers: info.providers }) footer.event({ type: "variants", variants: state.variants, current: state.activeVariant }) if (state.model) - footer.event({ type: "model", model: formatModelLabel(state.model, state.activeVariant, state.providers) }) + footer.event({ + type: "model", + model: formatModelLabel(state.model, state.activeVariant, state.providers), + selection: state.model, + }) } let catalogRefresh: Promise | undefined @@ -485,7 +524,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep void Promise.resolve(input.afterPaint(ctx)).catch(() => {}) } - void modelTask.then((info) => applyModelInfo(info, session.variant, true)) + void modelTask.then((result) => { + const current = state.model + const boot = + result.boot && + !!current && + current.providerID === result.model?.providerID && + current.modelID === result.model.modelID + applyModelInfo(result.info, boot ? session.variant : state.activeVariant, boot, result.savedVariant) + }) const streamTask = deps.streamTransport ?? import("./stream-v2.transport") const ensureStream = () => { diff --git a/packages/opencode/src/cli/cmd/run/session.shared.ts b/packages/opencode/src/cli/cmd/run/session.shared.ts index 49dece5a89..62d27ab37d 100644 --- a/packages/opencode/src/cli/cmd/run/session.shared.ts +++ b/packages/opencode/src/cli/cmd/run/session.shared.ts @@ -20,6 +20,8 @@ type Turn = { export type RunSession = { first: boolean turns: Turn[] + model?: NonNullable + variant?: string } function fileName(url: string, filename?: string) { @@ -197,6 +199,13 @@ export async function resolveCurrentSession( }, ] }), + ...(session.data.data.model && { + model: { + providerID: session.data.data.model.providerID, + modelID: session.data.data.model.id, + }, + variant: session.data.data.model.variant, + }), } } @@ -223,6 +232,10 @@ export function sessionVariant(session: RunSession, model: RunInput["model"]): s return undefined } + if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) { + return session.variant + } + for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) { const turn = session.turns[idx] if (turn.provider !== model.providerID || turn.model !== model.modelID) { diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index f311febb26..2c489e2626 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -312,6 +312,7 @@ export type FooterEvent = | { type: "model" model: string + selection: NonNullable } | { type: "turn.send" diff --git a/packages/opencode/test/cli/run/catalog.shared.test.ts b/packages/opencode/test/cli/run/catalog.shared.test.ts index bdf57ef7d1..847eceebec 100644 --- a/packages/opencode/test/cli/run/catalog.shared.test.ts +++ b/packages/opencode/test/cli/run/catalog.shared.test.ts @@ -1,12 +1,37 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { OpencodeClient } from "@opencode-ai/sdk/v2" -import { loadRunReferences, runProviders } from "@/cli/cmd/run/catalog.shared" +import { loadRunReferences, runProviders, waitForDefaultModel } from "@/cli/cmd/run/catalog.shared" afterEach(() => { mock.restore() }) describe("run catalog shared", () => { + test("resolves the catalog-selected model for the footer", async () => { + const client = new OpencodeClient() + const selected = spyOn(client.v2.model, "default").mockImplementation( + () => + Promise.resolve({ + data: { + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: { + id: "gpt-5", + providerID: "openai", + }, + }, + error: undefined, + request: new Request("https://opencode.test"), + response: new Response(), + }) as never, + ) + + await expect(waitForDefaultModel({ sdk: client, directory: "/tmp" })).resolves.toEqual({ + providerID: "openai", + modelID: "gpt-5", + }) + expect(selected).toHaveBeenCalledWith({ location: { directory: "/tmp" } }, { throwOnError: true }) + }) + test("loads visible project references from the current reference catalog", async () => { const client = new OpencodeClient() const list = spyOn(client.v2.reference, "list").mockImplementation( diff --git a/packages/opencode/test/cli/run/runtime.test.ts b/packages/opencode/test/cli/run/runtime.test.ts index 702d82d769..0a933c0a5f 100644 --- a/packages/opencode/test/cli/run/runtime.test.ts +++ b/packages/opencode/test/cli/run/runtime.test.ts @@ -113,6 +113,7 @@ describe("run interactive runtime", () => { test("waits for provider metadata before eager replay transport bootstrap", async () => { const providersStarted = defer() const providers = defer() + const lifecycleModels: unknown[] = [] const sdk = new OpencodeClient() const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused")) @@ -249,23 +250,23 @@ describe("run interactive runtime", () => { replay: true, replayLimit: 100, agent: "build", - model: { - providerID: "openai", - modelID: "gpt-5", - }, + model: undefined, variant: undefined, files: [], thinking: true, backgroundSubagents: false, }, { - createRuntimeLifecycle: async () => ({ - footer: footer(), - onResize: () => () => {}, - refreshTheme: () => {}, - resetForReplay: () => Promise.resolve(), - close: () => Promise.resolve(), - }), + createRuntimeLifecycle: async (input) => { + lifecycleModels.push(input.model) + return { + footer: footer(), + onResize: () => () => {}, + refreshTheme: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, streamTransport: Promise.resolve({ createSessionTransport: async (input: { providers?: () => RunProvider[]; footer: FooterApi }) => { transportProviders.push(input.providers?.() ?? []) @@ -293,12 +294,107 @@ describe("run interactive runtime", () => { await task + expect(lifecycleModels).toEqual([{ providerID: "openai", modelID: "gpt-5" }]) expect(transportProviders).toEqual([[provider]]) expect(legacyProviders).not.toHaveBeenCalled() expect(legacyAgents).not.toHaveBeenCalled() expect(legacyCommands).not.toHaveBeenCalled() }) + test("paints before resolving the catalog-selected model", async () => { + const sdk = new OpencodeClient() + const defaultStarted = defer() + const releaseDefault = defer() + const lifecycleStarted = defer() + const modelShown = defer() + const events: FooterEvent[] = [] + const api = footer(events) + const event = api.event + api.event = (value) => { + event(value) + if (value.type !== "model") return + modelShown.resolve() + api.close() + } + + spyOn(sdk.v2.model, "default").mockImplementation(async () => { + defaultStarted.resolve() + await releaseDefault.promise + return ok({ + location: { directory: "/tmp" }, + data: { id: "gpt-5", providerID: "openai" }, + }) as never + }) + spyOn(sdk.v2.provider, "list").mockImplementation(() => + ok({ location: { directory: "/tmp" }, data: [] }) as never, + ) + spyOn(sdk.v2.model, "list").mockImplementation(() => + ok({ location: { directory: "/tmp" }, data: [] }) as never, + ) + spyOn(sdk.v2.agent, "list").mockImplementation(() => + ok({ location: { directory: "/tmp" }, data: [] }) as never, + ) + spyOn(sdk.v2.reference, "list").mockImplementation(() => + ok({ location: { directory: "/tmp" }, data: [] }) as never, + ) + spyOn(sdk.v2.command, "list").mockImplementation(() => + ok({ location: { directory: "/tmp" }, data: [] }) as never, + ) + spyOn(sdk.v2.skill, "list").mockImplementation(() => + ok({ location: { directory: "/tmp" }, data: [] }) as never, + ) + + const task = runInteractiveMode( + { + sdk, + directory: "/tmp", + sessionID: "ses-fresh", + resume: false, + agent: "build", + model: undefined, + variant: undefined, + files: [], + thinking: false, + backgroundSubagents: false, + }, + { + createRuntimeLifecycle: async (input) => { + expect(input.model).toBeUndefined() + lifecycleStarted.resolve() + return { + footer: api, + onResize: () => () => {}, + refreshTheme: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, + streamTransport: Promise.resolve({ + createSessionTransport: async () => ({ + runPromptTurn: async () => {}, + interruptActiveTurn: async () => {}, + selectSubagent: () => {}, + replayOnResize: async () => false, + close: async () => {}, + }), + formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)), + }), + }, + ) + + await defaultStarted.promise + await lifecycleStarted.promise + releaseDefault.resolve() + await modelShown.promise + await task + + expect(events.find((event) => event.type === "model")).toEqual({ + type: "model", + model: "gpt-5 ยท openai", + selection: { providerID: "openai", modelID: "gpt-5" }, + }) + }) + test("retains last-known-good state across failed coalesced refreshes and retries later", async () => { const sdk = new OpencodeClient() const refreshGate = defer() diff --git a/packages/opencode/test/cli/run/session.shared.test.ts b/packages/opencode/test/cli/run/session.shared.test.ts index a470f51014..102a53ba50 100644 --- a/packages/opencode/test/cli/run/session.shared.test.ts +++ b/packages/opencode/test/cli/run/session.shared.test.ts @@ -303,6 +303,8 @@ describe("run session shared", () => { const out = await resolveCurrentSession(client, "ses_1") + expect(out.model).toEqual({ providerID: "openai", modelID: "gpt-5" }) + expect(out.variant).toBe("high") expect(out.turns[0]?.prompt).toEqual({ text: "Review @note.ts", parts: [