Selaa lähdekoodia

fix: defer catalog validation to session execution (#38258)

Simon Klee 3 viikkoa sitten
vanhempi
sitoutus
8bb1cfaa3b

+ 1 - 58
packages/cli/src/mini.ts

@@ -2,7 +2,6 @@ import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
 import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
 import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
 import { setTimeout } from "node:timers/promises"
-import { waitForCatalogReady } from "./services/catalog"
 import { readStdin } from "./util/io"
 import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
 import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target"
@@ -214,63 +213,7 @@ function parseModel(value?: string) {
 }
 
 function prepareTarget(requestedAgent?: string): SessionTargetPreparation {
-  return async (input) => {
-    if (input.model)
-      await waitForCatalogReady({
-        sdk: input.client,
-        directory: input.location.directory,
-        workspace: input.location.workspaceID,
-        model: { providerID: input.model.providerID, modelID: input.model.id },
-        signal: input.signal,
-      })
-    return {
-      model: input.model,
-      agent: requestedAgent
-        ? await validateAgent(
-            input.client,
-            input.location.directory,
-            input.location.workspaceID,
-            requestedAgent,
-            input.signal,
-          )
-        : input.agent,
-    }
-  }
-}
-
-async function validateAgent(
-  sdk: OpenCodeClient,
-  directory: string,
-  workspace: string | undefined,
-  name?: string,
-  signal?: AbortSignal,
-) {
-  if (!name) return
-  const deadline = Date.now() + 5_000
-  let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
-  while (Date.now() < deadline && !signal?.aborted) {
-    agents = await sdk.agent.list({ location: { directory, workspace } }, { signal }).catch((error) => {
-      if (signal && error instanceof ClientError && error.reason === "Transport") throw error
-      return undefined
-    })
-    const agent = agents?.data.find((item) => item.id === name)
-    if (agent?.mode === "subagent") {
-      warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
-      return
-    }
-    if (agent) return name
-    await setTimeout(25, undefined, { signal }).catch(() => {})
-  }
-  if (signal?.aborted) return
-  if (!agents) {
-    warning("failed to list agents. Falling back to default agent")
-    return
-  }
-  warning(`agent "${name}" not found. Falling back to default agent`)
-}
-
-function warning(message: string) {
-  process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
+  return async (input) => ({ model: input.model, agent: requestedAgent ?? input.agent })
 }
 
 function fail(message: string): never {

+ 24 - 5
packages/cli/src/run/noninteractive.ts

@@ -211,10 +211,17 @@ export async function runNonInteractivePrompt(input: Input) {
       }
       if (!promoted && event.type === "session.execution.failed") {
         prePromotionError = event.data.error
+        if (finalizing) return
         continue
       }
+      if (
+        !promoted &&
+        finalizing &&
+        (event.type === "session.execution.succeeded" || event.type === "session.execution.interrupted")
+      )
+        return
       if (!promoted) continue
-      if (finalizing) continue
+      if (finalizing && !event.type.startsWith("session.execution.")) continue
 
       if (event.type === "session.step.started") {
         const part = {
@@ -618,7 +625,10 @@ export async function runNonInteractivePrompt(input: Input) {
         if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
       }
     }
-    return projected.found
+    return {
+      found: projected.found,
+      responded: projected.messages.some((message) => message.type === "assistant"),
+    }
   }
 
   const interrupt = () => {
@@ -708,9 +718,18 @@ export async function runNonInteractivePrompt(input: Input) {
     const waiting = input.client.session.wait({ sessionID: input.sessionID })
     await Promise.race([waiting, completed.then(() => waiting)])
     finalizing = true
-    controller.abort()
-    const found = await reconcile()
-    if (!found && !interrupted && !permissionRejected && !formCancelled && !emittedError) {
+    const projected = await reconcile()
+    if (
+      !projected.responded &&
+      !interrupted &&
+      !permissionRejected &&
+      !formCancelled &&
+      !emittedError &&
+      !prePromotionError
+    ) {
+      await completed
+    }
+    if (!projected.found && !interrupted && !permissionRejected && !formCancelled && !emittedError) {
       const error = prePromotionError ?? { type: "unknown", message: "Prompt was not promoted" }
       emittedError = true
       process.exitCode = 1

+ 6 - 45
packages/cli/src/run/run.ts

@@ -5,7 +5,6 @@ import { open } from "node:fs/promises"
 import path from "node:path"
 import { readStdin } from "../util/io"
 import { ServerConnection } from "../services/server-connection"
-import { waitForCatalogReady } from "../services/catalog"
 import { parseSessionTargetModel, resolveSessionTarget } from "../session-target"
 import { toolInlineInfo } from "@opencode-ai/tui/mini/tool"
 import { runNonInteractivePrompt } from "./noninteractive"
@@ -95,9 +94,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
     prepare: async (next) => {
       const selected =
         next.model ??
-        (await client.model
-          .default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
-          .then((result) => result.data))
+        (options.variant
+          ? await client.model
+              .default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
+              .then((result) => result.data)
+          : undefined)
       const model = selected
         ? {
             providerID: selected.providerID,
@@ -107,25 +108,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
         : undefined
       if ((options.variant ?? explicit?.variant) && !model)
         throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
-      if (model) {
-        await waitForCatalogReady({
-          sdk: client,
-          directory: next.location.directory,
-          workspace: next.location.workspaceID,
-          model: { providerID: model.providerID, modelID: model.id },
-        })
-        const available = await client.model.list({
-          location: { directory: next.location.directory, workspace: next.location.workspaceID },
-        })
-        if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.id))
-          throw new RunTargetError(`Model unavailable: ${model.providerID}/${model.id}`, next.session?.id)
-      }
-      return {
-        model,
-        agent: input.agent
-          ? await validateAgent(client, next.location.directory, next.location.workspaceID, input.agent)
-          : next.agent,
-      }
+      return { model, agent: next.agent }
     },
   }).catch((error) => {
     if (!(error instanceof RunTargetError)) throw error
@@ -190,28 +173,6 @@ export function parseRunModel(value?: string) {
   }
 }
 
-async function validateAgent(client: OpenCodeClient, directory: string, workspace: string | undefined, name?: string) {
-  if (!name) return
-  const agents = await client.agent
-    .list({ location: { directory, workspace } })
-    .then((result) => result.data)
-    .catch(() => undefined)
-  if (!agents) {
-    warning("failed to list agents. Falling back to default agent")
-    return
-  }
-  const agent = agents.find((item) => item.id === name)
-  if (!agent) {
-    warning(`agent "${name}" not found. Falling back to default agent`)
-    return
-  }
-  if (agent.mode === "subagent") {
-    warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
-    return
-  }
-  return name
-}
-
 async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise<FilePart> {
   const file = path.resolve(directory, input)
   const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))

+ 0 - 43
packages/cli/src/services/catalog.ts

@@ -1,43 +0,0 @@
-import { ClientError, type OpenCodeClient } from "@opencode-ai/client/promise"
-
-// Location plugins initialize asynchronously, so explicit model selection must
-// wait for that exact model before prompt admission. The execution path owns
-// the authoritative error if readiness times out.
-export async function waitForCatalogReady(input: {
-  sdk: OpenCodeClient
-  directory: string
-  workspace?: string
-  model: { providerID: string; modelID: string }
-  timeoutMs?: number
-  signal?: AbortSignal
-}) {
-  const deadline = Date.now() + (input.timeoutMs ?? 5_000)
-  while (Date.now() < deadline && !input.signal?.aborted) {
-    const models = await input.sdk.model
-      .list(
-        { location: { directory: input.directory, workspace: input.workspace } },
-        { signal: input.signal },
-      )
-      .then((result) => result.data)
-      .catch((error) => {
-        if (input.signal && error instanceof ClientError && error.reason === "Transport") throw error
-        return undefined
-      })
-    if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
-    await wait(25, input.signal)
-  }
-}
-
-function wait(delay: number, signal?: AbortSignal) {
-  if (!signal) return new Promise<void>((resolve) => setTimeout(resolve, delay))
-  if (signal.aborted) return Promise.resolve()
-  return new Promise<void>((resolve) => {
-    const timer = setTimeout(done, delay)
-    signal.addEventListener("abort", done, { once: true })
-    function done() {
-      clearTimeout(timer)
-      signal?.removeEventListener("abort", done)
-      resolve()
-    }
-  })
-}

+ 119 - 24
packages/cli/test/drive/mini-interactive.drive.mjs

@@ -16,7 +16,30 @@ export default defineScript({
       const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
       const session = `mini-stage2-${process.pid}`
       const snapshots = path.join(artifacts, "mini-stage2")
-      yield* Effect.promise(() => mkdir(snapshots, { recursive: true }))
+      const explicitDirectory = path.join(artifacts, "explicit-model")
+      yield* Effect.promise(() => Promise.all([snapshots, explicitDirectory].map((dir) => mkdir(dir, { recursive: true }))))
+      /** @param {string} directory @param {string | undefined} model */
+      const mini = (directory, model) => [
+        "env",
+        `PWD=${directory}`,
+        `OPENCODE_PASSWORD=${registration.password}`,
+        `OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
+        `OPENCODE_TEST_HOME=${artifacts}`,
+        `XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
+        `XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
+        `XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
+        `XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
+        "OPENCODE_DISABLE_AUTOUPDATE=1",
+        "OPENCODE_DIRECT_TRACE=1",
+        process.execPath,
+        "--conditions=browser",
+        `--preload=${preload}`,
+        path.join(root, "packages/cli/src/index.ts"),
+        "mini",
+        "--server",
+        registration.url,
+        ...(model ? ["--model", model] : []),
+      ]
 
       yield* llm.queue(
         Llm.toolCall({
@@ -42,26 +65,7 @@ export default defineScript({
               "-y",
               "30",
               "--",
-              "env",
-              `PWD=${path.join(artifacts, "files")}`,
-              `OPENCODE_PASSWORD=${registration.password}`,
-              `OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
-              `OPENCODE_TEST_HOME=${artifacts}`,
-              `XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
-              `XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
-              `XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
-              `XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
-              "OPENCODE_DISABLE_AUTOUPDATE=1",
-              "OPENCODE_DIRECT_TRACE=1",
-              process.execPath,
-              "--conditions=browser",
-              `--preload=${preload}`,
-              path.join(root, "packages/cli/src/index.ts"),
-              "mini",
-              "--server",
-              registration.url,
-              "--model",
-              "simulation/gpt-sim-model",
+              ...mini(path.join(artifacts, "files"), undefined),
             ]),
           ),
         )
@@ -72,7 +76,16 @@ export default defineScript({
         if (first.includes("drive mini response complete"))
           throw new Error("response rendered before prompt submission")
 
-        yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
+        yield* Effect.promise(() => waitForPane(session, "Default model", 15_000))
+        yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-p"]))
+        yield* Effect.promise(() => waitForVisiblePane(session, "Commands"))
+        yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "model"]))
+        yield* Effect.promise(() => waitForVisiblePane(session, "Switch model"))
+        yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
+        yield* Effect.promise(() => waitForVisiblePane(session, "Select model"))
+        yield* Effect.promise(() => waitForVisiblePane(session, "Simulated Model", 15_000))
+        yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
+        yield* Effect.promise(() => waitForVisiblePane(session, "Ask anything..."))
         yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"]))
         yield* Effect.sleep(100)
         yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
@@ -134,7 +147,7 @@ export default defineScript({
         yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
         yield* Effect.promise(() => waitForPane(session, "$ sleep 10"))
         yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
-        const armed = yield* Effect.promise(() => waitForPane(session, "again to interrupt"))
+        const armed = yield* Effect.promise(() => waitForPane(session, "esc again"))
         yield* Effect.promise(() => Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed))
         yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
         const interrupted = yield* Effect.promise(() => waitForPane(session, "Step interrupted", 10_000))
@@ -144,7 +157,7 @@ export default defineScript({
           if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
         })
         yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
-        yield* Effect.promise(() => waitForPane(session, "Press ctrl+c again to exit"))
+        yield* Effect.promise(() => waitForPane(session, "EXIT  Press ctrl+"))
         yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
         yield* Effect.promise(() => waitForDeadPane(session))
         const status = yield* Effect.promise(() => paneDeadStatus(session))
@@ -153,6 +166,75 @@ export default defineScript({
         if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
           throw new Error("Mini exit splash was not rendered before teardown")
         yield* Effect.promise(() => Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited))
+
+        yield* Effect.promise(() => tmux(["clear-history", "-t", session]))
+        yield* Effect.promise(() =>
+          tmux([
+            "respawn-pane",
+            "-k",
+            "-t",
+            session,
+            "--",
+            ...mini(explicitDirectory, "simulation/gpt-sim-model"),
+          ]),
+        )
+        const explicitModel = yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
+        yield* Effect.promise(() => Bun.write(path.join(snapshots, "07-explicit-model.txt"), explicitModel))
+        yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
+        yield* Effect.promise(() => waitForPane(session, "EXIT  Press ctrl+"))
+        yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
+        yield* Effect.promise(() => waitForDeadPane(session))
+        if ((yield* Effect.promise(() => paneDeadStatus(session))) !== 0)
+          throw new Error("Explicit-model Mini did not exit cleanly")
+
+        yield* Effect.promise(async () => {
+          for (const failure of [
+            {
+              args: ["--model", "simulation/definitely-missing"],
+              capture: "08-unavailable-model.txt",
+              expected: "Model unavailable: simulation/definitely-missing",
+            },
+            {
+              args: ["--agent", "definitely-missing"],
+              capture: "09-unavailable-agent.txt",
+              expected: 'Agent not found: "definitely-missing"',
+            },
+          ]) {
+            const child = Bun.spawn(
+              [
+                process.execPath,
+                path.join(root, "packages/cli/src/index.ts"),
+                "run",
+                "--server",
+                registration.url,
+                ...failure.args,
+                "optimistic selection check",
+              ],
+              {
+                cwd: path.join(root, "packages/cli"),
+                env: {
+                  ...process.env,
+                  PWD: path.join(artifacts, "files"),
+                  OPENCODE_PASSWORD: registration.password,
+                  OPENCODE_CONFIG_DIR: path.join(artifacts, "files/.opencode"),
+                  OPENCODE_DISABLE_AUTOUPDATE: "1",
+                },
+                stdin: "ignore",
+                stdout: "pipe",
+                stderr: "pipe",
+              },
+            )
+            const [exitCode, stdout, stderr] = await Promise.all([
+              child.exited,
+              new Response(child.stdout).text(),
+              new Response(child.stderr).text(),
+            ])
+            await Bun.write(path.join(snapshots, failure.capture), stdout + stderr)
+            if (exitCode !== 1) throw new Error(`${failure.expected} run exited with status ${exitCode}`)
+            if (!stderr.includes(failure.expected))
+              throw new Error(`Selection failure was not diagnosed by execution: ${stderr}`)
+          }
+        })
       })
 
       yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true))))
@@ -188,6 +270,19 @@ function captureVisiblePane(session) {
   return tmux(["capture-pane", "-p", "-t", session])
 }
 
+/** @param {string} session @param {string} text @param {number} [timeout] */
+async function waitForVisiblePane(session, text, timeout = 5_000) {
+  const deadline = Date.now() + timeout
+  let last = ""
+  while (Date.now() < deadline) {
+    last = await captureVisiblePane(session)
+    if (last.includes(text)) return last
+    if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`)
+    await Bun.sleep(50)
+  }
+  throw new Error(`Timed out waiting for visible ${JSON.stringify(text)}:\n${last}`)
+}
+
 /** @param {string} session */
 async function paneAlive(session) {
   return (await tmux(["display-message", "-p", "-t", session, "#{pane_dead}"], true)).trim() === "0"

+ 26 - 11
packages/cli/test/mini.test.ts

@@ -138,32 +138,47 @@ describe("mini command", () => {
     expect(result.stderr).not.toContain("You must provide a message")
   })
 
-  test("preserves a run failure exit code", async () => {
-    let modelRequests = 0
+  test("passes explicit selections to session creation without catalog preflight", async () => {
+    const requests: string[] = []
+    let session: unknown
     const server = Bun.serve({
       port: 0,
-      fetch(request) {
+      async fetch(request) {
         const url = new URL(request.url)
+        requests.push(url.pathname)
         if (url.pathname === "/api/health")
           return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
         if (url.pathname === "/api/location")
           return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
-        if (url.pathname === "/api/model") {
-          modelRequests++
-          return Response.json({
-            location: { directory: process.cwd(), project: { id: "global", directory: process.cwd() } },
-            data: modelRequests === 1 ? [{ id: "missing", providerID: "definitely" }] : [],
-          })
+        if (url.pathname === "/api/session") {
+          session = await request.json()
+          return new Response("boom", { status: 500 })
         }
         return new Response(undefined, { status: 404 })
       },
     })
 
     try {
-      const result = await cli(["run", "--server", server.url.toString(), "--model", "definitely/missing", "hi"])
+      const result = await cli([
+        "run",
+        "--server",
+        server.url.toString(),
+        "--model",
+        "definitely/missing",
+        "--agent",
+        "definitely-missing",
+        "hi",
+      ])
 
       expect(result.exitCode).toBe(1)
-      expect(result.stderr).toContain("Model unavailable: definitely/missing")
+      expect(result.stderr).toContain("UnexpectedStatus")
+      expect(session).toMatchObject({
+        agent: "definitely-missing",
+        model: { providerID: "definitely", id: "missing" },
+      })
+      expect(requests).not.toContain("/api/model")
+      expect(requests).not.toContain("/api/agent")
+      expect(requests).not.toContain("/api/location/wait")
     } finally {
       server.stop(true)
     }

+ 27 - 3
packages/cli/test/run/noninteractive.test.ts

@@ -169,6 +169,7 @@ async function run(input: {
   renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
   messages?: (inputID: string) => SessionMessageInfo[]
   wait?: () => Promise<void>
+  terminalDelay?: number
 }) {
   const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
   const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
@@ -183,7 +184,10 @@ async function run(input: {
         })
         continue
       }
-      if (value.type.startsWith("session.execution.")) setTimeout(wait.resolve, 0)
+      if (value.type.startsWith("session.execution.")) {
+        if (input.terminalDelay) await Bun.sleep(input.terminalDelay)
+        setTimeout(wait.resolve, 0)
+      }
       yield value
     }
   })()
@@ -251,7 +255,7 @@ async function capture(input: Parameters<typeof run>[0]) {
   })
   try {
     await run(input)
-    return { stdout: stdout.join(""), stderr: stderr.join("") }
+    return { stdout: stdout.join(""), stderr: stderr.join(""), exitCode: process.exitCode }
   } finally {
     process.exitCode = exitCode ?? 0
     stdoutWrite.mockRestore()
@@ -319,6 +323,26 @@ describe("runNonInteractivePrompt", () => {
         error: { type: "provider.transport", message: "instructions unavailable" },
       }),
     ])
+    expect(output.exitCode).toBe(1)
+  })
+
+  test("waits for a terminal failure when idle wins before projection", async () => {
+    for (const promotedBeforeFailure of [true, false]) {
+      const output = await capture({
+        format: "json",
+        turn: (messageID) => [
+          ...(promotedBeforeFailure ? [prompted(messageID)] : []),
+          executionFailed("selection unavailable"),
+        ],
+        messages: (messageID) =>
+          promotedBeforeFailure ? [{ id: messageID, type: "user", text: "hello", time: { created: 1 } }] : [],
+        wait: () => Promise.resolve(),
+        terminalDelay: 10,
+      })
+
+      expect(output.exitCode).toBe(1)
+      expect(output.stdout).toContain("selection unavailable")
+    }
   })
 
   test("cancels session and global form blockers and exits on pre-promotion interrupt", async () => {
@@ -413,7 +437,7 @@ describe("runNonInteractivePrompt", () => {
       ],
     })
 
-    expect(output).toEqual({ stdout: "", stderr: "" })
+    expect(output).toEqual({ stdout: "", stderr: "", exitCode: 0 })
   })
 
   test("renders native failed tool output before the terminal error", async () => {

+ 2 - 1
packages/protocol/src/groups/model.ts

@@ -17,7 +17,8 @@ export const ModelGroup = HttpApiGroup.make("server.model")
         OpenApi.annotations({
           identifier: "v2.model.list",
           summary: "List models",
-          description: "Retrieve available models ordered by release date.",
+          description:
+            "Retrieve the current snapshot of available models ordered by release date. The snapshot may precede initial plugin settlement.",
         }),
       ),
   )

+ 0 - 61
packages/tui/src/mini/catalog.shared.ts

@@ -85,67 +85,6 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[
   return [...grouped.values()]
 }
 
-export async function waitForDefaultModel(input: {
-  sdk: OpenCodeClient
-  location: LocationRef
-  timeoutMs?: number
-  requestTimeoutMs?: number
-  active?: () => boolean
-  signal?: AbortSignal
-}): Promise<{ providerID: string; modelID: string } | undefined> {
-  const deadline = Date.now() + (input.timeoutMs ?? 5_000)
-  while (Date.now() < deadline && !input.signal?.aborted && (input.active?.() ?? true)) {
-    const controller = new AbortController()
-    const timeout = setTimeout(
-      () => controller.abort(),
-      Math.min(input.requestTimeoutMs ?? 1_000, Math.max(1, deadline - Date.now())),
-    )
-    const abort = () => controller.abort()
-    input.signal?.addEventListener("abort", abort, { once: true })
-    const model = await abortable(
-      input.sdk.model
-        .default(location(input.location), { signal: controller.signal })
-        .then((result) => result.data)
-        .catch(() => undefined),
-      controller.signal,
-    ).finally(() => {
-      clearTimeout(timeout)
-      input.signal?.removeEventListener("abort", abort)
-    })
-    if (model) return { providerID: model.providerID, modelID: model.id }
-    await wait(25, input.signal)
-  }
-}
-
-function abortable<A>(task: Promise<A>, signal: AbortSignal): Promise<A | undefined> {
-  if (signal.aborted) return Promise.resolve(undefined)
-  return new Promise((resolve) => {
-    const abort = () => {
-      signal.removeEventListener("abort", abort)
-      resolve(undefined)
-    }
-    signal.addEventListener("abort", abort, { once: true })
-    void task.then((value) => {
-      signal.removeEventListener("abort", abort)
-      resolve(value)
-    })
-  })
-}
-
-function wait(delay: number, signal?: AbortSignal) {
-  if (!signal) return new Promise<void>((resolve) => setTimeout(resolve, delay))
-  if (signal.aborted) return Promise.resolve()
-  return new Promise<void>((resolve) => {
-    const timer = setTimeout(done, delay)
-    signal.addEventListener("abort", done, { once: true })
-    function done() {
-      clearTimeout(timer)
-      signal?.removeEventListener("abort", done)
-      resolve()
-    }
-  })
-}
-
 export async function loadRunAgents(sdk: OpenCodeClient, ref: LocationRef, signal?: AbortSignal): Promise<RunAgent[]> {
   const result = await sdk.agent.list(location(ref), ...requestOptions(signal))
   return result.data.map(runAgent)

+ 1 - 1
packages/tui/src/mini/footer.view.tsx

@@ -425,7 +425,7 @@ export function RunFooterView(props: RunFooterViewProps) {
     return props.mono ? usage().replaceAll(" · ", " - ") : usage()
   })
   const modelStatus = createMemo(() => {
-    const current = model()
+    const current = model() ?? props.state().model.trim()
     if (!footerDetails() || !prompt() || !responsive().statusline.showModel || !current) return
     return {
       model: current,

+ 1 - 1
packages/tui/src/mini/runtime.lifecycle.ts

@@ -126,7 +126,7 @@ function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): Foo
   const agentLabel = Locale.titlecase(input.agent ?? "build")
   return {
     agentLabel,
-    modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "",
+    modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "Default model",
   }
 }
 

+ 11 - 30
packages/tui/src/mini/runtime.ts

@@ -11,7 +11,7 @@
 import { SessionMessage } from "@opencode-ai/schema/session-message"
 import type { LocationRef } from "@opencode-ai/client/promise"
 import type { Config } from "../config"
-import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared"
+import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared"
 import {
   resolveMiniSettings,
   resolveModelInfo,
@@ -492,39 +492,20 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
     const signal = AbortSignal.any([runtimeController.signal, controller.signal])
     modelAttempt = controller
     try {
-      if (selected) {
-        const info = await abortable(resolveModelInfo(sdk, state.location, signal), signal)
-        if (
-          !info ||
-          !currentModelLoad(generation, sdk) ||
-          state.model?.providerID !== selected.providerID ||
-          state.model.modelID !== selected.modelID
-        )
-          return
-        applyModelInfo(info, session.variant, { sdk, generation, signal }, true, savedVariant)
+      const info = await abortable(resolveModelInfo(sdk, state.location, signal), signal)
+      if (
+        !info ||
+        !currentModelLoad(generation, sdk) ||
+        (selected &&
+          (state.model?.providerID !== selected.providerID || state.model.modelID !== selected.modelID))
+      )
         return
-      }
-
-      const model = await waitForDefaultModel({
-        sdk,
-        location: state.location,
-        active: () => currentModelLoad(generation, sdk),
-        signal,
-      })
-      if (!currentModelLoad(generation, sdk)) return
-      const [fallbackSavedVariant, info] = await Promise.all([
-        input.host.preferences.resolveVariant(model),
-        abortable(resolveModelInfo(sdk, state.location, signal), signal),
-      ])
-      if (!info || !currentModelLoad(generation, sdk)) return
-      if (model && !state.model) state.model = model
-      const boot = !!model && state.model?.providerID === model.providerID && state.model.modelID === model.modelID
       applyModelInfo(
         info,
-        boot ? session.variant : state.activeVariant,
+        selected ? session.variant : state.activeVariant,
         { sdk, generation, signal },
-        boot,
-        fallbackSavedVariant,
+        !!selected,
+        savedVariant,
       )
     } finally {
       if (modelAttempt === controller) modelAttempt = undefined

+ 1 - 21
packages/tui/test/mini/catalog.shared.test.ts

@@ -1,6 +1,6 @@
 import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
 import { OpenCode } from "@opencode-ai/client/promise"
-import { loadRunReferences, runProviders, waitForDefaultModel } from "../../src/mini/catalog.shared"
+import { loadRunReferences, runProviders } from "../../src/mini/catalog.shared"
 import { catalogModel, catalogProvider } from "./fixture/catalog"
 
 afterEach(() => {
@@ -8,26 +8,6 @@ afterEach(() => {
 })
 
 describe("run catalog shared", () => {
-  test("resolves the catalog-selected model for the footer", async () => {
-    const client = OpenCode.make({ baseUrl: "https://opencode.test" })
-    const selected = spyOn(client.model, "default").mockImplementation(
-      () =>
-        Promise.resolve({
-          location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
-          data: { id: "gpt-5", providerID: "openai" },
-        }) as never,
-    )
-
-    await expect(waitForDefaultModel({ sdk: client, location: { directory: "/tmp" } })).resolves.toEqual({
-      providerID: "openai",
-      modelID: "gpt-5",
-    })
-    expect(selected).toHaveBeenCalledWith(
-      { location: { directory: "/tmp", workspace: undefined } },
-      { signal: expect.any(AbortSignal) },
-    )
-  })
-
   test("loads visible project references from the current reference catalog", async () => {
     const client = OpenCode.make({ baseUrl: "https://opencode.test" })
     const list = spyOn(client.reference, "list").mockImplementation(

+ 10 - 0
packages/tui/test/mini/footer.view.test.tsx

@@ -195,6 +195,16 @@ async function renderFooter(
   }
 }
 
+test("direct footer shows the generic default model before resolution", async () => {
+  const app = await renderFooter({ state: { model: "Default model" } })
+  try {
+    await app.renderOnce()
+    expect(app.captureCharFrame()).toContain("Default model")
+  } finally {
+    app.cleanup()
+  }
+})
+
 test("direct footer preserves a partial multi-field form draft across permission preemption", async () => {
   const request: FormInfo = {
     id: "frm_preempted",