Compare commits

...

2 Commits

Author SHA1 Message Date
Simon Klee b7e29ff4e8 fix(tui): hide project commands from mini palette 2026-07-22 07:44:42 +02:00
Simon Klee eef1e38bec location: add catalog readiness wait API
Plugin activation is async, so catalog snapshots can lag startup.
Move initial settlement behind POST /api/location/wait and drop
client-side polling so timeout and settled absence stay distinct.
2026-07-22 07:44:42 +02:00
21 changed files with 355 additions and 246 deletions
+20 -22
View File
@@ -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"
@@ -215,14 +214,17 @@ 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,
})
if (input.model || requestedAgent)
await input.client.location
.wait(
{
location: { directory: input.location.directory, workspace: input.location.workspaceID },
},
{ signal: input.signal },
)
.catch((error) => {
if (input.signal && error instanceof ClientError && error.reason === "Transport") throw error
})
return {
model: input.model,
agent: requestedAgent
@@ -246,26 +248,22 @@ async function validateAgent(
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) => {
const 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
}
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
warning(`agent "${name}" not found. Falling back to default agent`)
}
+6 -15
View File
@@ -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"
@@ -93,10 +92,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
: undefined,
agent: input.agent,
prepare: async (next) => {
const location = { location: { directory: next.location.directory, workspace: next.location.workspaceID } }
if (next.model) await client.location.wait(location)
const selected =
next.model ??
(await client.model
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.default(location)
.then((result) => result.data))
const model = selected
? {
@@ -105,21 +106,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
variant: options.variant ?? ("variant" in selected ? selected.variant : undefined),
}
: undefined
const models = await client.model.list(location).then((result) => result.data)
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)
}
if (model && !models.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
-43
View File
@@ -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()
}
})
}
@@ -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),
]),
),
)
@@ -153,6 +157,63 @@ 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, "Press ctrl+c again to exit"))
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 () => {
const child = Bun.spawn(
[
process.execPath,
path.join(root, "packages/cli/src/index.ts"),
"run",
"--server",
registration.url,
"--model",
"simulation/definitely-missing",
"catalog readiness 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, "08-unavailable-model.txt"), stdout + stderr)
if (exitCode !== 1) throw new Error(`Unavailable model run exited with status ${exitCode}`)
if (!stderr.includes("Model unavailable: simulation/definitely-missing"))
throw new Error(`Unavailable model was not diagnosed after catalog settlement: ${stderr}`)
})
})
yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true))))
+42 -2
View File
@@ -138,8 +138,9 @@ describe("mini command", () => {
expect(result.stderr).not.toContain("You must provide a message")
})
test("preserves a run failure exit code", async () => {
test("validates an explicit model after one readiness wait", async () => {
let modelRequests = 0
let waitRequests = 0
const server = Bun.serve({
port: 0,
fetch(request) {
@@ -148,11 +149,15 @@ describe("mini command", () => {
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/location/wait") {
waitRequests++
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" }] : [],
data: [],
})
}
return new Response(undefined, { status: 404 })
@@ -164,6 +169,41 @@ describe("mini command", () => {
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain("Model unavailable: definitely/missing")
expect(waitRequests).toBe(1)
expect(modelRequests).toBe(1)
} finally {
server.stop(true)
}
})
test("distinguishes catalog initialization timeout from settled model absence", async () => {
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
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/location/wait")
return Response.json(
{
_tag: "ServiceUnavailableError",
message: "Location catalog initialization timed out",
service: "location.catalog",
},
{ status: 503 },
)
return new Response(undefined, { status: 404 })
},
})
try {
const result = await cli(["run", "--server", server.url.toString(), "--model", "definitely/missing", "hi"])
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain("Location catalog initialization timed out")
expect(result.stderr).not.toContain("Model unavailable")
} finally {
server.stop(true)
}
+6
View File
@@ -32,8 +32,14 @@ export type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]
export type Endpoint2_0Output = EffectValue<ReturnType<RawClient["server.location"]["location.get"]>>
export type LocationGetOperation<E = never> = (input?: Endpoint2_0Input) => Effect.Effect<Endpoint2_0Output, E>
type Endpoint2_1Request = Parameters<RawClient["server.location"]["location.wait"]>[0]
export type Endpoint2_1Input = { readonly location?: Endpoint2_1Request["query"]["location"] }
export type Endpoint2_1Output = EffectValue<ReturnType<RawClient["server.location"]["location.wait"]>>
export type LocationWaitOperation<E = never> = (input?: Endpoint2_1Input) => Effect.Effect<Endpoint2_1Output, E>
export interface LocationApi<E = never> {
readonly get: LocationGetOperation<E>
readonly wait: LocationWaitOperation<E>
}
type Endpoint3_0Request = Parameters<RawClient["server.agent"]["agent.list"]>[0]
@@ -33,7 +33,12 @@ type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["locat
const Endpoint2_0 = (raw: RawClient["server.location"]) => (input?: Endpoint2_0Input) =>
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup2 = (raw: RawClient["server.location"]) => ({ get: Endpoint2_0(raw) })
type Endpoint2_1Request = Parameters<RawClient["server.location"]["location.wait"]>[0]
type Endpoint2_1Input = { readonly location?: Endpoint2_1Request["query"]["location"] }
const Endpoint2_1 = (raw: RawClient["server.location"]) => (input?: Endpoint2_1Input) =>
raw["location.wait"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup2 = (raw: RawClient["server.location"]) => ({ get: Endpoint2_0(raw), wait: Endpoint2_1(raw) })
type Endpoint3_0Request = Parameters<RawClient["server.agent"]["agent.list"]>[0]
type Endpoint3_0Input = { readonly location?: Endpoint3_0Request["query"]["location"] }
@@ -5,6 +5,8 @@ import type {
ServerGetOutput,
LocationGetInput,
LocationGetOutput,
LocationWaitInput,
LocationWaitOutput,
AgentListInput,
AgentListOutput,
PluginListInput,
@@ -385,6 +387,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
wait: (input?: LocationWaitInput, requestOptions?: RequestOptions) =>
request<LocationWaitOutput>(
{
method: "POST",
path: `/api/location/wait`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
},
agent: {
list: (input?: AgentListInput, requestOptions?: RequestOptions) =>
+16 -8
View File
@@ -2390,6 +2390,14 @@ export type InvalidRequestError = {
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2443,14 +2451,6 @@ export type SkillNotFoundError = {
export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
@@ -2562,6 +2562,14 @@ export type LocationGetInput = {
export type LocationGetOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
export type LocationWaitInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type LocationWaitOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
export type AgentListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+23
View File
@@ -48,6 +48,29 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
})
test("location.wait uses the explicit initial-settlement barrier", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({
directory: "/tmp/project",
workspaceID: "wrk_test",
project: { id: "proj_test", directory: "/tmp/project" },
})
},
})
const result = await client.location.wait({ location: { directory: "/tmp/project", workspace: "wrk_test" } })
expect(result.workspaceID).toBe("wrk_test")
expect(request?.method).toBe("POST")
expect(request?.url).toBe(
"http://localhost:3000/api/location/wait?location%5Bdirectory%5D=%2Ftmp%2Fproject&location%5Bworkspace%5D=wrk_test",
)
})
test("server.get uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
+16
View File
@@ -1,6 +1,7 @@
import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ServiceUnavailableError } from "../errors.js"
export const LocationQuery = Schema.Struct({
location: Schema.optional(
@@ -41,4 +42,19 @@ export const LocationGroup = HttpApiGroup.make("server.location")
}),
),
)
.add(
HttpApiEndpoint.post("location.wait", "/api/location/wait", {
query: LocationQuery,
success: Location.Info,
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.location.wait",
summary: "Wait for location",
description: "Wait for the location's initial plugin generation to settle.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "location" }))
+2 -1
View File
@@ -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.",
}),
),
)
+36 -11
View File
@@ -1,18 +1,43 @@
import { Location } from "@opencode-ai/core/location"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { ServiceUnavailableError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) =>
handlers.handle(
"location.get",
Effect.fn(function* () {
const location = yield* Location.Service
return new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
})
}),
),
handlers
.handle(
"location.get",
Effect.fn(function* () {
return info(yield* Location.Service)
}),
)
.handle(
"location.wait",
Effect.fn(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush.pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () =>
Effect.fail(
new ServiceUnavailableError({
message: "Location catalog initialization timed out",
service: "location.catalog",
}),
),
}),
)
return info(yield* Location.Service)
}),
),
)
function info(location: Location.Interface) {
return new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
})
}
@@ -0,0 +1,6 @@
export default {
id: "slow-location-wait-test",
async setup() {
await Bun.sleep(750)
},
}
+44
View File
@@ -0,0 +1,44 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import path from "node:path"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
it.live("keeps catalog snapshots non-blocking and waits for initial plugin settlement explicitly", () =>
Effect.gen(function* () {
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
models: { fetch: false },
config: {
content: JSON.stringify({ plugins: [path.join(import.meta.dir, "fixture/slow-plugin.ts")] }),
},
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
const request = (pathname: string, method = "GET") =>
fetch(new URL(pathname, base), {
method,
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
})
const snapshot = yield* Effect.promise(() => request("/api/model"))
expect(snapshot.status).toBe(200)
let settled = false
const waiting = request("/api/location/wait", "POST").then((response) => {
settled = true
return response
})
yield* Effect.sleep("50 millis")
expect(settled).toBe(false)
const response = yield* Effect.promise(() => waiting)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: process.cwd() })
}),
)
-61
View File
@@ -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)
-32
View File
@@ -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<CommandEntry[]>(() => {
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",
+10 -7
View File
@@ -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,
@@ -505,12 +505,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return
}
const model = await waitForDefaultModel({
sdk,
location: state.location,
active: () => currentModelLoad(generation, sdk),
signal,
})
const model = await sdk.model
.default(
{ location: { directory: state.location.directory, workspace: state.location.workspaceID } },
{ signal },
)
.then((result) =>
result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined,
)
.catch(() => undefined)
if (!currentModelLoad(generation, sdk)) return
const [fallbackSavedVariant, info] = await Promise.all([
input.host.preferences.resolveVariant(model),
+1 -21
View File
@@ -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(
+11 -1
View File
@@ -355,7 +355,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<RunCommand[] | undefined>([
command({ name: "review", description: "Review code" }),
command({ name: "deploy", description: "Deploy prompt", source: "mcp" }),
@@ -423,6 +423,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")
@@ -2,6 +2,20 @@
Status: **Historical decision record.** Option B, replayable Location-scoped Catalog transforms, was selected and implemented. All interfaces and flows below are historical option sketches, not current API reference; current behavior is owned by Core.
## Implemented Readiness Contract
Catalog list endpoints return Location-scoped snapshots and do not wait for
plugin activation. Consumers that need an authoritative startup validation call
`POST /api/location/wait`, which waits up to five seconds for the initial plugin
generation to settle and returns the resolved Location. A timeout returns 503;
clients may call the endpoint again. After the barrier settles, absence from a
catalog or agent snapshot is authoritative until a later configuration or
integration update.
Ongoing catalog changes remain event-driven: `catalog.updated` tells frontends
to fetch a new snapshot. The wait endpoint is only an initial-settlement barrier
and does not turn snapshot reads into blocking operations.
The decision compared where provider/model inputs live and how visible catalog state changes after boot. The designs below preserve that comparison.
## Scenarios