From 58d18be59084bf46bdebfbd29e14e845ca52d66a Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Wed, 22 Jul 2026 11:50:21 +0200 Subject: [PATCH] oc mini v2 (#38278) --- packages/tui/src/mini/runtime.ts | 72 ++++++++++++-- packages/tui/src/mini/stream-v2.transport.ts | 14 ++- packages/tui/test/mini/runtime.test.ts | 97 +++++++++++++++++++ .../tui/test/mini/stream-v2.transport.test.ts | 7 +- 4 files changed, 178 insertions(+), 12 deletions(-) diff --git a/packages/tui/src/mini/runtime.ts b/packages/tui/src/mini/runtime.ts index dc12edd3152..a8fbb8b5565 100644 --- a/packages/tui/src/mini/runtime.ts +++ b/packages/tui/src/mini/runtime.ts @@ -115,6 +115,7 @@ type RuntimeState = { shown: boolean aborting: boolean model: RunInput["model"] + defaultModel: RunInput["model"] providers: RunProvider[] variants: string[] activeVariant: string | undefined @@ -212,6 +213,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep shown: !session.first, aborting: false, model: ctx.model ?? session.model, + defaultModel: undefined, providers: [], variants: [], activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []), @@ -286,17 +288,19 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep await settleForm(next.sessionID, next.formID) }, onCycleVariant: () => { - if (!state.model || state.variants.length === 0) { + const model = state.model ?? state.defaultModel + if (!model || state.variants.length === 0) { return { status: "no variants available", } } + if (!state.model) state.model = model state.activeVariant = cycleVariant(state.activeVariant, state.variants) - void input.host.preferences.saveVariant(state.model, state.activeVariant) + void input.host.preferences.saveVariant(model, state.activeVariant) return { status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default", - modelLabel: formatModelLabel(state.model, state.activeVariant, state.providers), + modelLabel: formatModelLabel(model, state.activeVariant, state.providers), variant: state.activeVariant, } }, @@ -335,7 +339,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep } }, onVariantSelect: async (variant) => { - if (!state.model || state.variants.length === 0) { + const model = state.model ?? state.defaultModel + if (!model || state.variants.length === 0) { return { status: "no variants available", } @@ -347,11 +352,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep } } + if (!state.model) state.model = model state.activeVariant = variant - void input.host.preferences.saveVariant(state.model, state.activeVariant) + void input.host.preferences.saveVariant(model, state.activeVariant) return { status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default", - modelLabel: formatModelLabel(state.model, state.activeVariant, state.providers), + modelLabel: formatModelLabel(model, state.activeVariant, state.providers), variant: state.activeVariant, variants: state.variants, } @@ -602,7 +608,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep ) { if (!currentClient(attempt)) return state.providers = info.providers - state.variants = variantsFor(state.providers, state.model) + const model = state.model ?? state.defaultModel + state.variants = variantsFor(state.providers, model) state.activeVariant = boot ? resolveVariant(ctx.variant, current, saved, state.variants) : current && !state.variants.includes(current) @@ -611,11 +618,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep if (footer.isClosed) return footer.event({ type: "models", providers: info.providers }) footer.event({ type: "variants", variants: state.variants, current: state.activeVariant }) - if (state.model) + if (model) footer.event({ type: "model", - model: formatModelLabel(state.model, state.activeVariant, state.providers), - selection: state.model, + model: formatModelLabel(model, state.activeVariant, state.providers), + selection: model, }) } @@ -627,6 +634,50 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep task: Promise } | undefined + let defaultModelLoad: Promise | undefined + let defaultModelQueued = false + const loadDefaultModel = (attempt: ClientAttempt) => { + if (state.model || !currentClient(attempt)) return + if (defaultModelLoad) { + defaultModelQueued = true + return + } + defaultModelQueued = false + defaultModelLoad = attempt.sdk.model + .default( + { + location: { + directory: state.location.directory, + workspace: state.location.workspaceID, + }, + }, + { signal: attempt.signal }, + ) + .then(async (result) => { + if (!result.data || state.model || !currentClient(attempt)) return + const model = { providerID: result.data.providerID, modelID: result.data.id } + const changed = + state.defaultModel?.providerID !== model.providerID || state.defaultModel.modelID !== model.modelID + const saved = changed ? await input.host.preferences.resolveVariant(model) : undefined + if (state.model || !currentClient(attempt)) return + state.defaultModel = model + state.variants = variantsFor(state.providers, model) + if (changed) + state.activeVariant = resolveVariant(ctx.variant, state.activeVariant, saved, state.variants) + if (state.activeVariant) state.model = model + footer.event({ type: "variants", variants: state.variants, current: state.activeVariant }) + footer.event({ + type: "model", + model: formatModelLabel(model, state.activeVariant, state.providers), + selection: model, + }) + }) + .catch(() => {}) + .finally(() => { + defaultModelLoad = undefined + if (defaultModelQueued) loadDefaultModel(clientAttempt()) + }) + } const requestCatalogRefresh = (signal?: AbortSignal): Promise => { const attempt = clientAttempt(signal) if (!currentClient(attempt)) return Promise.resolve() @@ -658,6 +709,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep if (!currentClient(attempt)) return if (catalog) applyCatalog(catalog, attempt) if (info) applyModelInfo(info, state.activeVariant, attempt) + loadDefaultModel(attempt) } })() refresh.task = task diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index b8aab5ee249..7fcd3054f96 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -380,7 +380,19 @@ async function resolveSelectedModel( .then((response) => response.model) if (session) return { ...session, variant: next.variant } - const fallback = await sdk.model.default(undefined, { signal: next.signal }).then((response) => response.data) + const fallback = await sdk.model + .default( + input.location + ? { + location: { + directory: input.location.directory, + workspace: input.location.workspaceID, + }, + } + : undefined, + { signal: next.signal }, + ) + .then((response) => response.data) if (!fallback) return return { providerID: fallback.providerID, id: fallback.id, variant: next.variant } } diff --git a/packages/tui/test/mini/runtime.test.ts b/packages/tui/test/mini/runtime.test.ts index 6aff69274d0..1955b4a4700 100644 --- a/packages/tui/test/mini/runtime.test.ts +++ b/packages/tui/test/mini/runtime.test.ts @@ -48,6 +48,103 @@ afterEach(() => { }) describe("run interactive runtime", () => { + test("resolves the default model reactively without blocking catalog startup", async () => { + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) + const events: FooterEvent[] = [] + const ui = createFooterApiFixture({ events }) + const api = ui.api + const selected = defer>>() + const catalogLoaded = defer() + const model = catalogModel({ + id: "resolved", + providerID: "test", + name: "Resolved Model", + variants: ["low", "high"], + }) + let lifecycle!: LifecycleInput + let turnModel: { providerID: string; modelID: string } | undefined + let refreshCatalog: (() => Promise) | undefined + stubCatalogLists(sdk, { + providers: [catalogProvider("test", "Test Provider")], + models: [model], + }) + const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => selected.promise) + + const task = runInteractiveDeferredMode( + { + host: host(), + sdk, + directory: "/tmp", + target: async () => ({ + sessionID: "ses_root", + location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } }, + agent: "build", + model: undefined, + variant: undefined, + resume: false, + }), + agent: "build", + model: undefined, + variant: undefined, + files: [], + }, + { + createRuntimeLifecycle: async (input) => { + lifecycle = input + return { + footer: api, + onResize: () => () => {}, + refreshTheme: () => {}, + setTitle: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, + streamTransport: Promise.resolve({ + createSessionTransport: async (input) => { + refreshCatalog = () => Promise.resolve(input.onCatalogRefresh?.()) + await refreshCatalog() + catalogLoaded.resolve() + return { + runPromptTurn: async (input) => { + turnModel = input.model + api.close() + }, + queuePromptTurn: async () => {}, + waitForIdle: async () => {}, + interruptActiveTurn: async () => {}, + selectSubagent: () => {}, + replayOnResize: async () => false, + close: async () => {}, + } + }, + formatUnknownError: (error: unknown) => String(error), + }), + }, + ) + + await catalogLoaded.promise + expect(events.some((event) => event.type === "model")).toBe(false) + await refreshCatalog?.() + expect(defaultModel).toHaveBeenCalledTimes(1) + selected.resolve({ + location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } }, + data: model, + }) + while (defaultModel.mock.calls.length < 2) await Bun.sleep(0) + while (!events.some((event) => event.type === "model")) await Bun.sleep(0) + expect(events).toContainEqual({ + type: "model", + model: "Resolved Model ยท Test Provider", + selection: { providerID: "test", modelID: "resolved" }, + }) + expect(lifecycle.onCycleVariant?.()).toMatchObject({ status: "variant low", variant: "low" }) + ui.submit("hello") + while (!turnModel) await Bun.sleep(0) + expect(turnModel).toEqual({ providerID: "test", modelID: "resolved" }) + await task + }) + test("routes form responses to their owners with global location and local settlement", async () => { const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) const api = footer() diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index 18bbc0b08c7..98ff08aac76 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -2085,12 +2085,13 @@ describe("V2 mini transport", () => { const ui = footer() const transport = await createSessionTransport({ sdk: client, + location: { directory: "/project", workspaceID: "wrk_1" }, sessionID: "ses_1", thinking: false, footer: ui.api, }) spyOn(client.session, "get").mockImplementation(() => ok({ model: undefined }) as never) - spyOn(client.model, "default").mockImplementation( + const defaultModel = spyOn(client.model, "default").mockImplementation( () => ok({ location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, @@ -2138,6 +2139,10 @@ describe("V2 mini transport", () => { { sessionID: "ses_1", model: { providerID: "openai", id: "gpt-5", variant: "high" } }, { signal: undefined }, ) + expect(defaultModel).toHaveBeenCalledWith( + { location: { directory: "/project", workspace: "wrk_1" } }, + { signal: undefined }, + ) await transport.close() })