Simon Klee преди 3 седмици
родител
ревизия
58d18be590

+ 62 - 10
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<void>
       }
     | undefined
+  let defaultModelLoad: Promise<void> | 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<void> => {
     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

+ 13 - 1
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 }
 }

+ 97 - 0
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<Awaited<ReturnType<typeof sdk.model.default>>>()
+    const catalogLoaded = defer<void>()
+    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<unknown>) | 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()

+ 6 - 1
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()
   })