From 296408826495150e4aeebee0dff90550d0f069c0 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Wed, 22 Jul 2026 07:31:41 +0200 Subject: [PATCH 1/3] fix: defer catalog validation to session execution Session execution owns catalog readiness and authoritative model and agent validation. Remove client-side startup preflight so invalid selections fail through the durable execution path. --- packages/cli/src/mini.ts | 59 +------- packages/cli/src/run/noninteractive.ts | 29 +++- packages/cli/src/run/run.ts | 51 +------ packages/cli/src/services/catalog.ts | 43 ------ .../cli/test/drive/mini-interactive.drive.mjs | 143 +++++++++++++++--- packages/cli/test/mini.test.ts | 37 +++-- packages/cli/test/run/noninteractive.test.ts | 30 +++- packages/protocol/src/groups/model.ts | 3 +- packages/tui/src/mini/catalog.shared.ts | 61 -------- packages/tui/src/mini/footer.view.tsx | 2 +- packages/tui/src/mini/runtime.lifecycle.ts | 2 +- packages/tui/src/mini/runtime.ts | 41 ++--- packages/tui/test/mini/catalog.shared.test.ts | 22 +-- packages/tui/test/mini/footer.view.test.tsx | 10 ++ 14 files changed, 229 insertions(+), 304 deletions(-) delete mode 100644 packages/cli/src/services/catalog.ts diff --git a/packages/cli/src/mini.ts b/packages/cli/src/mini.ts index ff578099d754..049f08a24ea7 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 102294f64b0b..5ea239ca1b6c 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 496bdfa86d0d..4342bb329f09 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 ddf8252c31df..000000000000 --- 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 e745c481b417..02d0772ef664 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 3eb418598117..ba2c51ab0450 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 f27869a991d5..58dea74a93aa 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 a5970192bb46..c3f6828770e7 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 b152a4926f39..941e44d9c529 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 c1cf4cc89533..72e4cf68ca06 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 280cb2851785..2baacfe46f10 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 fac1efda117a..9f56d270b004 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 334454c3aa4d..fbfdf40089fc 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 a4a5936dba46..aa71307625f8 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", From a6d4167daff731e34d8855fd67429e054a2d9edb Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Wed, 22 Jul 2026 07:36:49 +0200 Subject: [PATCH 2/3] fix(tui): hide project commands from mini palette --- packages/tui/src/mini/footer.command.tsx | 32 --------------------- packages/tui/test/mini/footer.view.test.tsx | 12 +++++++- 2 files changed, 11 insertions(+), 33 deletions(-) diff --git a/packages/tui/src/mini/footer.command.tsx b/packages/tui/src/mini/footer.command.tsx index 4c7e5aef13cc..f2edfcbbf0c1 100644 --- a/packages/tui/src/mini/footer.command.tsx +++ b/packages/tui/src/mini/footer.command.tsx @@ -92,18 +92,6 @@ function countLabel(count: number, total: number, query: string) { return `${count}/${total}` } -function categoryRank(category: string) { - if (category === "Project Commands") { - return 0 - } - - if (category === "MCP Commands") { - return 1 - } - - return 2 -} - function subagentStatusLabel(status: FooterSubagentTab["status"]) { if (status === "completed") { return "done" @@ -374,7 +362,6 @@ export function RunCommandMenuBody(props: { const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill")) const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length) const entries = createMemo(() => { - const builtins = ["compact", "editor", "new", "settings"] const session: CommandEntry[] = [ { action: "editor", @@ -473,29 +460,10 @@ export function RunCommandMenuBody(props: { ] : []), ] - const commands = (props.commands() ?? []) - .filter((item) => item.source !== "skill" && !builtins.includes(item.name)) - .map( - (item) => - ({ - action: "slash", - category: item.source === "mcp" ? "MCP Commands" : "Project Commands", - name: item.name, - display: item.name, - footer: `/${item.name}`, - keywords: - item.source === "mcp" - ? `/${item.name} ${item.name} mcp ${item.description ?? ""}` - : `/${item.name} ${item.name} ${item.description ?? ""}`, - }) satisfies CommandEntry, - ) - .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)) - return [ ...session, ...prompt, ...agent, - ...commands, { action: "settings", category: "System", diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index aa71307625f8..93f4c1b03185 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -365,7 +365,7 @@ test("run entry content updates when live commit text changes", async () => { } }) -test("direct command panel renders grouped command palette", async () => { +test("direct command panel renders grouped actions without catalog commands", async () => { const [commands] = createSignal([ command({ name: "review", description: "Review code" }), command({ name: "deploy", description: "Deploy prompt", source: "mcp" }), @@ -433,6 +433,16 @@ test("direct command panel renders grouped command palette", async () => { expect(frame).not.toContain("Review code") expect(frame).not.toContain("Commands 8") + await app.mockInput.typeText("review") + await app.renderOnce() + expect(app.captureCharFrame()).toContain("No results found") + + app.mockInput.pressKey("u", { ctrl: true }) + await app.mockInput.typeText("deploy") + await app.renderOnce() + expect(app.captureCharFrame()).toContain("No results found") + + app.mockInput.pressKey("u", { ctrl: true }) await app.mockInput.typeText("status") await app.renderOnce() expect(app.captureCharFrame()).toContain("Show status") From 2f3476179017a1075c4fbcbe77016175887939ce Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Wed, 22 Jul 2026 07:43:39 +0200 Subject: [PATCH 3/3] fix(tui): submit prompt when resuming session --- packages/cli/src/commands/commands.ts | 1 + packages/cli/src/commands/handlers/default.ts | 6 +- packages/tui/src/app.tsx | 8 +- packages/tui/src/routes/session/index.tsx | 21 ++++- packages/tui/test/app-lifecycle.test.tsx | 83 +++++++++++++++++++ 5 files changed, 112 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index a287c3c9ecb8..c0b914fc62b8 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -32,6 +32,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Flag.withDescription("Session ID to continue"), Flag.optional, ), + prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional), }, commands: [ Spec.make("acp", { description: "Start an Agent Client Protocol server" }), diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 66f7469aa57e..f83a0ef7cb8f 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -55,7 +55,11 @@ export default Runtime.handler(Commands, (input) => } : undefined, }, - args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, + args: { + continue: input.continue, + sessionID: Option.getOrUndefined(input.session), + prompt: Option.getOrUndefined(input.prompt), + }, config: { path: config.path, get: () => runPromise(config.get()), diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index b4e6fb815ec8..704332977955 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -525,6 +525,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { }) const args = useArgs() + const startupPrompt = args.prompt ? { text: args.prompt, files: [], agents: [], pasted: [] } : undefined onMount(() => { batch(() => { if (args.agent) local.agent.set(args.agent) @@ -542,6 +543,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { route.navigate({ type: "session", sessionID: args.sessionID, + prompt: startupPrompt, }) } }) @@ -564,12 +566,12 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { const match = response.data[0]?.id if (!match) return if (!args.fork) { - route.navigate({ type: "session", sessionID: match }) + route.navigate({ type: "session", sessionID: match, prompt: startupPrompt }) return } void client.api.session .fork({ sessionID: match }) - .then((result) => route.navigate({ type: "session", sessionID: result.id })) + .then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt })) .catch(toast.error) }) .catch(toast.error) @@ -582,7 +584,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { forked = true void client.api.session .fork({ sessionID: args.sessionID }) - .then((result) => route.navigate({ type: "session", sessionID: result.id })) + .then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt })) .catch(toast.error) }) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index acf38c353c3a..5a4fd04e828c 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -84,6 +84,7 @@ import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type import { switchLabel } from "../../util/model" import { findMessageBoundary, messageNavigationSlack } from "./message-navigation" import { stringWidth } from "../../util/string-width" +import { useArgs } from "../../context/args" addDefaultParsers(parsers.parsers) @@ -120,6 +121,7 @@ export function Session() { const { navigate } = useRoute() const data = useData() const local = useLocal() + const args = useArgs() const paths = useTuiPaths() const configState = useConfig() const config = configState.data @@ -216,6 +218,7 @@ export function Session() { const boundaries = createMemo(() => messageBoundaryIDs(rows, messages())) const [navigationMessage, setNavigationMessage] = createSignal() const [navigationSlack, setNavigationSlack] = createSignal(0) + const [synced, setSynced] = createSignal(false) const clearMessageNavigation = () => { setNavigationSlack(0) @@ -242,6 +245,7 @@ export function Session() { createEffect(() => { if (client.connection.status() !== "connected") return + setSynced(false) const sessionID = route.sessionID void (async () => { await Promise.all([ @@ -261,6 +265,7 @@ export function Session() { } editor.reconnect(info.location.directory) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) + setSynced(true) })().catch((error) => { if (route.sessionID !== sessionID) return toast.show({ @@ -273,15 +278,25 @@ export function Session() { }) let seeded = false + let sent = false let scroll: ScrollBoxRenderable - let prompt: PromptRef | undefined + const [prompt, setPrompt] = createSignal() const bind = (r: PromptRef | undefined) => { - prompt = r + setPrompt(r) promptRef.set(r) if (seeded || !route.prompt || !r) return seeded = true r.set(route.prompt) } + + createEffect(() => { + const current = prompt() + if (sent || !current || !synced() || !local.model.ready) return + if (!local.agent.current() || !local.model.current()) return + if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return + sent = true + current.submit() + }) const dialog = useDialog() const renderer = useRenderer() const unavailable = (feature: string) => { @@ -526,7 +541,7 @@ export function Session() { void client.api.session.revert .stage({ sessionID: route.sessionID, messageID: message.id }) .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) - prompt?.set({ + prompt()?.set({ ...projectedPromptInput(message), pasted: [], }) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 81a61684baf9..a07e3e6b12b2 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -69,6 +69,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after setTitle(title) } const events = createEventStream() + let promptRequests = 0 const calls = createFetch((url) => { const session = { id: "dummy", @@ -88,6 +89,10 @@ test("session lifecycle updates the terminal title and prints the epilogue after if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} }) if (url.pathname === "/api/session/dummy/pending") return json({ data: [] }) if (url.pathname === "/api/session/dummy/permission") return json({ data: [] }) + if (url.pathname === "/api/session/dummy/prompt") { + promptRequests++ + return json({ data: {} }) + } }, events) const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) const originalWrite = process.stdout.write.bind(process.stdout) @@ -124,6 +129,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after expect(stdout).toContain("Renamed session") expect(stdout).toContain("opencode2 -s dummy") + expect(promptRequests).toBe(0) } finally { process.stdout.write = originalWrite if (!setup.renderer.isDestroyed) setup.renderer.destroy() @@ -131,3 +137,80 @@ test("session lifecycle updates the terminal title and prints the epilogue after mock.restore() } }) + +test("session startup prompt is submitted exactly once", async () => { + const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const events = createEventStream() + const cwd = process.cwd() + const location = { directory: cwd, project: { id: "project", directory: cwd } } + const session = { + id: "dummy", + title: "Demo session", + projectID: "project", + location: { directory: cwd }, + agent: "build", + model: { providerID: "provider", id: "model" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + const bodies: unknown[] = [] + const promptSubmitted = Promise.withResolvers() + const calls = createFetch(async (url, request) => { + if (url.pathname === "/api/location") return json(location) + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === "/api/session/dummy") return json({ data: session }) + if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} }) + if (url.pathname === "/api/session/dummy/pending") return json({ data: [] }) + if (url.pathname === "/api/session/dummy/permission") return json({ data: [] }) + if (url.pathname === "/api/agent") + return json({ + location, + data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }], + }) + if (url.pathname === "/api/model") + return json({ + location, + data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }], + }) + if (url.pathname === "/api/session/dummy/prompt") { + bodies.push(await request.json()) + promptSubmitted.resolve() + return json({ data: {} }) + } + }, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({}), update: async () => ({}) }, + packages: { resolve: async () => undefined }, + args: { sessionID: "dummy", prompt: "RESUME_READY" }, + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + + await Promise.race([ + promptSubmitted.promise, + Bun.sleep(2000).then(() => { + throw new Error("startup prompt was not submitted") + }), + ]) + await Bun.sleep(20) + setup.renderer.destroy() + await task + + expect(bodies).toHaveLength(1) + expect(bodies[0]).toMatchObject({ text: "RESUME_READY" }) + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + mock.restore() + } +})