Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7e29ff4e8 | |||
| eef1e38bec |
+20
-22
@@ -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`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" }))
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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() })
|
||||
}),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user