diff --git a/packages/cli/src/mini.ts b/packages/cli/src/mini.ts index ff578099d7..049f08a24e 100644 --- a/packages/cli/src/mini.ts +++ b/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> | 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 { diff --git a/packages/cli/src/run/noninteractive.ts b/packages/cli/src/run/noninteractive.ts index 102294f64b..5ea239ca1b 100644 --- a/packages/cli/src/run/noninteractive.ts +++ b/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 diff --git a/packages/cli/src/run/run.ts b/packages/cli/src/run/run.ts index 496bdfa86d..4342bb329f 100644 --- a/packages/cli/src/run/run.ts +++ b/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 { const file = path.resolve(directory, input) const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`)) diff --git a/packages/cli/src/services/catalog.ts b/packages/cli/src/services/catalog.ts deleted file mode 100644 index ddf8252c31..0000000000 --- a/packages/cli/src/services/catalog.ts +++ /dev/null @@ -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((resolve) => setTimeout(resolve, delay)) - if (signal.aborted) return Promise.resolve() - return new Promise((resolve) => { - const timer = setTimeout(done, delay) - signal.addEventListener("abort", done, { once: true }) - function done() { - clearTimeout(timer) - signal?.removeEventListener("abort", done) - resolve() - } - }) -} diff --git a/packages/cli/test/drive/mini-interactive.drive.mjs b/packages/cli/test/drive/mini-interactive.drive.mjs index e745c481b4..02d0772ef6 100644 --- a/packages/cli/test/drive/mini-interactive.drive.mjs +++ b/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" diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts index 3eb4185981..ba2c51ab04 100644 --- a/packages/cli/test/mini.test.ts +++ b/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) } diff --git a/packages/cli/test/run/noninteractive.test.ts b/packages/cli/test/run/noninteractive.test.ts index f27869a991..58dea74a93 100644 --- a/packages/cli/test/run/noninteractive.test.ts +++ b/packages/cli/test/run/noninteractive.test.ts @@ -169,6 +169,7 @@ async function run(input: { renderToolError?: (part: SessionMessageAssistantTool) => Promise messages?: (inputID: string) => SessionMessageInfo[] wait?: () => Promise + 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[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 () => { diff --git a/packages/protocol/src/groups/model.ts b/packages/protocol/src/groups/model.ts index a5970192bb..c3f6828770 100644 --- a/packages/protocol/src/groups/model.ts +++ b/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.", }), ), ) diff --git a/packages/tui/src/mini/catalog.shared.ts b/packages/tui/src/mini/catalog.shared.ts index b152a4926f..941e44d9c5 100644 --- a/packages/tui/src/mini/catalog.shared.ts +++ b/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(task: Promise, signal: AbortSignal): Promise { - 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((resolve) => setTimeout(resolve, delay)) - if (signal.aborted) return Promise.resolve() - return new Promise((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 { const result = await sdk.agent.list(location(ref), ...requestOptions(signal)) return result.data.map(runAgent) diff --git a/packages/tui/src/mini/footer.view.tsx b/packages/tui/src/mini/footer.view.tsx index c1cf4cc895..72e4cf68ca 100644 --- a/packages/tui/src/mini/footer.view.tsx +++ b/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, diff --git a/packages/tui/src/mini/runtime.lifecycle.ts b/packages/tui/src/mini/runtime.lifecycle.ts index 280cb28517..2baacfe46f 100644 --- a/packages/tui/src/mini/runtime.lifecycle.ts +++ b/packages/tui/src/mini/runtime.lifecycle.ts @@ -126,7 +126,7 @@ function footerLabels(input: Pick): 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", } } diff --git a/packages/tui/src/mini/runtime.ts b/packages/tui/src/mini/runtime.ts index fac1efda11..9f56d270b0 100644 --- a/packages/tui/src/mini/runtime.ts +++ b/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 diff --git a/packages/tui/test/mini/catalog.shared.test.ts b/packages/tui/test/mini/catalog.shared.test.ts index 334454c3aa..fbfdf40089 100644 --- a/packages/tui/test/mini/catalog.shared.test.ts +++ b/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( diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index a4a5936dba..aa71307625 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/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",