Compare commits

..

3 Commits

Author SHA1 Message Date
Simon Klee 9973c7e739 simplify 2026-04-28 10:16:37 +02:00
Simon Klee 7141d76e1c add test 2026-04-28 10:16:37 +02:00
Simon Klee 81e8cb81a5 fix(tui): startup rejection handling
Propagate renderer startup failures from the TUI promise instead of
leaving opencode hanging, and destroy any partially initialized
renderer before rejecting to restore terminal state.
2026-04-28 10:16:37 +02:00
33 changed files with 494 additions and 526 deletions
+1 -8
View File
@@ -391,14 +391,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree)
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
// icon from the database instead of using their individual overrides.
const base = { ...metadata, ...project }
if (childStore.icon) {
return { ...base, icon: { ...base.icon, override: childStore.icon } }
}
return base
return { ...metadata, ...project }
}
const roots = createMemo(() => {
+2 -2
View File
@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "150K",
full: "150,000",
compact: "140K",
full: "140,000",
},
},
-1
View File
@@ -7,4 +7,3 @@ src/provider/models-snapshot.js
src/provider/models-snapshot.d.ts
script/build-*.ts
temporary-*.md
.artifacts
+4 -15
View File
@@ -1,26 +1,15 @@
import { Server } from "../../server/server"
import { PublicApi } from "../../server/routes/instance/httpapi/public"
import type { CommandModule } from "yargs"
import { OpenApi } from "effect/unstable/httpapi"
type Args = {
httpapi: boolean
}
export const GenerateCommand = {
command: "generate",
builder: (yargs) =>
yargs.option("httpapi", {
type: "boolean",
default: false,
description: "Generate OpenAPI from the experimental Effect HttpApi contract",
}),
handler: async (args) => {
const specs = args.httpapi ? OpenApi.fromApi(PublicApi) : await Server.openapi()
handler: async () => {
const specs = await Server.openapi()
for (const item of Object.values(specs.paths)) {
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
const operation = item[method]
if (!operation?.operationId) continue
// @ts-expect-error
operation["x-codeSamples"] = [
{
lang: "js",
@@ -58,4 +47,4 @@ export const GenerateCommand = {
})
})
},
} satisfies CommandModule<object, Args>
} satisfies CommandModule
+71 -70
View File
@@ -117,11 +117,8 @@ export function tui(input: {
headers?: RequestInit["headers"]
events?: EventSource
}) {
// promise to prevent immediate exit
// oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve
return new Promise<void>(async (resolve) => {
return new Promise<void>((resolve, reject) => {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
const onExit = async () => {
unguard?.()
@@ -132,73 +129,77 @@ export function tui(input: {
await TuiPluginRuntime.dispose()
}
const renderer = await createCliRenderer(rendererConfig(input.config))
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
void (async () => {
win32DisableProcessedInput()
await render(() => {
return (
<ErrorBoundary
fallback={(error, reset) => (
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
)}
>
<ArgsProvider {...input.args}>
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<ProjectProvider>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</ErrorBoundary>
)
}, renderer)
const renderer = await createCliRenderer(rendererConfig(input.config))
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
await render(() => {
return (
<ErrorBoundary
fallback={(error, reset) => (
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
)}
>
<ArgsProvider {...input.args}>
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<ProjectProvider>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</ErrorBoundary>
)
}, renderer)
})().catch(reject)
})
}
+1 -1
View File
@@ -127,7 +127,7 @@ export const layer = Layer.effect(
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
}
: undefined,
fetch: async (...args) => Server.Default().app.fetch(...args),
fetch: async (...args) => (await Server.Default()).app.fetch(...args),
})
const cfg = yield* config.get()
const input: PluginInput = {
+8 -26
View File
@@ -1089,37 +1089,19 @@ export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JS
}
*/
// Moonshot models want their tools in MFJS format: https://github.com/MoonshotAI/walle/blob/main/docs/mfjs-spec.md
if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) {
const isRecord = (obj: unknown): obj is Record<string, unknown> =>
typeof obj === "object" && obj !== null && !Array.isArray(obj)
const sanitizeMoonshot = (obj: unknown): void => {
if (Array.isArray(obj)) return obj.forEach(sanitizeMoonshot)
if (!isRecord(obj)) return
const sanitizeMoonshot = (obj: unknown): unknown => {
if (obj === null || typeof obj !== "object") return obj
if (Array.isArray(obj)) return obj.map(sanitizeMoonshot)
// Moonshot expands $ref before validation and rejects sibling keywords like description on the same node.
if (typeof obj.$ref === "string") {
for (const key of Object.keys(obj)) {
if (key !== "$ref") delete obj[key]
}
return
}
for (const key of ["title", "$comment", "format"]) {
delete obj[key]
}
for (const key of ["exclusiveMinimum", "exclusiveMaximum", "minContains", "maxContains"]) {
delete obj[key]
}
// MFJS does not support tuple-style arrays (`prefixItems`) or open-ended tuple controls.
const prefixItems = Array.isArray(obj.prefixItems) ? obj.prefixItems : undefined
delete obj.unevaluatedItems
Object.values(obj).forEach(sanitizeMoonshot)
if ("$ref" in obj && typeof obj.$ref === "string") return { $ref: obj.$ref }
const result = Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, sanitizeMoonshot(value)]))
// MFJS does not support tuple-style `items` arrays; it requires one schema object for all array items.
if (Array.isArray(obj.items)) obj.items = obj.items[0] ?? {}
if (prefixItems && !isRecord(obj.items)) obj.items = prefixItems[0] ?? {}
delete obj.prefixItems
if (Array.isArray(result.items)) result.items = result.items[0] ?? {}
return result
}
sanitizeMoonshot(schema)
schema = sanitizeMoonshot(schema) as JSONSchema.BaseSchema | JSONSchema7
}
// Convert integer enums to string enums for Google/Gemini
+29 -33
View File
@@ -1,44 +1,40 @@
import type { Hono } from "hono"
import { createBunWebSocket } from "hono/bun"
import type { Adapter, FetchApp, Opts } from "./adapter"
function listen(app: FetchApp, opts: Opts, websocket?: ReturnType<typeof createBunWebSocket>["websocket"]) {
const start = (port: number) => {
try {
if (websocket) {
return Bun.serve({ fetch: app.fetch, hostname: opts.hostname, idleTimeout: 0, websocket, port })
}
return Bun.serve({ fetch: app.fetch, hostname: opts.hostname, idleTimeout: 0, port })
} catch {
return
}
}
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
if (!server) {
throw new Error(`Failed to start server on port ${opts.port}`)
}
if (!server.port) {
throw new Error(`Failed to resolve server address for port ${opts.port}`)
}
return {
port: server.port,
stop(close?: boolean) {
return Promise.resolve(server.stop(close))
},
}
}
import type { Adapter } from "./adapter"
export const adapter: Adapter = {
create(app: Hono) {
const ws = createBunWebSocket()
return {
upgradeWebSocket: ws.upgradeWebSocket,
listen: (opts) => Promise.resolve(listen(app, opts, ws.websocket)),
}
},
createFetch(app) {
return {
listen: (opts) => Promise.resolve(listen(app, opts)),
async listen(opts) {
const args = {
fetch: app.fetch,
hostname: opts.hostname,
idleTimeout: 0,
websocket: ws.websocket,
} as const
const start = (port: number) => {
try {
return Bun.serve({ ...args, port })
} catch {
return
}
}
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
if (!server) {
throw new Error(`Failed to start server on port ${opts.port}`)
}
if (!server.port) {
throw new Error(`Failed to resolve server address for port ${opts.port}`)
}
return {
port: server.port,
stop(close?: boolean) {
return Promise.resolve(server.stop(close))
},
}
},
}
},
}
+54 -61
View File
@@ -1,73 +1,66 @@
import { createAdaptorServer, type ServerType } from "@hono/node-server"
import { createNodeWebSocket } from "@hono/node-ws"
import type { Hono } from "hono"
import type { Adapter, FetchApp, Opts } from "./adapter"
async function listen(app: FetchApp, opts: Opts, inject?: (server: ServerType) => void) {
const start = (port: number) =>
new Promise<ServerType>((resolve, reject) => {
const server = createAdaptorServer({ fetch: app.fetch })
inject?.(server)
const fail = (err: Error) => {
cleanup()
reject(err)
}
const ready = () => {
cleanup()
resolve(server)
}
const cleanup = () => {
server.off("error", fail)
server.off("listening", ready)
}
server.once("error", fail)
server.once("listening", ready)
server.listen(port, opts.hostname)
})
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
const addr = server.address()
if (!addr || typeof addr === "string") {
throw new Error(`Failed to resolve server address for port ${opts.port}`)
}
let closing: Promise<void> | undefined
return {
port: addr.port,
stop(close?: boolean) {
closing ??= new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
if (close) {
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
server.closeAllConnections()
}
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
server.closeIdleConnections()
}
}
})
return closing
},
}
}
import type { Adapter } from "./adapter"
export const adapter: Adapter = {
create(app: Hono) {
const ws = createNodeWebSocket({ app })
return {
upgradeWebSocket: ws.upgradeWebSocket,
listen: (opts) => listen(app, opts, ws.injectWebSocket),
}
},
createFetch(app) {
return {
listen: (opts) => listen(app, opts),
async listen(opts) {
const start = (port: number) =>
new Promise<ServerType>((resolve, reject) => {
const server = createAdaptorServer({ fetch: app.fetch })
ws.injectWebSocket(server)
const fail = (err: Error) => {
cleanup()
reject(err)
}
const ready = () => {
cleanup()
resolve(server)
}
const cleanup = () => {
server.off("error", fail)
server.off("listening", ready)
}
server.once("error", fail)
server.once("listening", ready)
server.listen(port, opts.hostname)
})
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
const addr = server.address()
if (!addr || typeof addr === "string") {
throw new Error(`Failed to resolve server address for port ${opts.port}`)
}
let closing: Promise<void> | undefined
return {
port: addr.port,
stop(close?: boolean) {
closing ??= new Promise((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
if (close) {
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
server.closeAllConnections()
}
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
server.closeIdleConnections()
}
}
})
return closing
},
}
},
}
},
}
-5
View File
@@ -1,10 +1,6 @@
import type { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
export type FetchApp = {
fetch(request: Request): Response | Promise<Response>
}
export type Opts = {
port: number
hostname: string
@@ -22,5 +18,4 @@ export interface Runtime {
export interface Adapter {
create(app: Hono): Runtime
createFetch(app: FetchApp): Omit<Runtime, "upgradeWebSocket">
}
@@ -118,6 +118,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
.add(
HttpApiEndpoint.get("connect", PtyPaths.connect, {
params: Params,
query: CursorQuery,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
@@ -17,56 +17,6 @@ import { SyncApi } from "./sync"
import { TuiApi } from "./tui"
import { WorkspaceApi } from "./workspace"
type OpenApiParameter = {
name: string
in: string
required?: boolean
schema?: unknown
}
type OpenApiOperation = {
parameters?: OpenApiParameter[]
}
type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch", OpenApiOperation>>
type OpenApiSpec = {
paths?: Record<string, OpenApiPathItem>
}
const InstanceQueryParameters = [
{
name: "directory",
in: "query",
required: false,
schema: { type: "string" },
},
{
name: "workspace",
in: "query",
required: false,
schema: { type: "string" },
},
] satisfies OpenApiParameter[]
function documentInstanceQueryParameters(input: Record<string, unknown>) {
const spec = input as OpenApiSpec
for (const [path, item] of Object.entries(spec.paths ?? {})) {
if (path.startsWith("/global/") || path.startsWith("/auth/")) continue
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
const operation = item[method]
if (!operation) continue
operation.parameters = [
...InstanceQueryParameters,
...(operation.parameters ?? []).filter(
(param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"),
),
]
}
}
return input
}
export const PublicApi = HttpApi.make("opencode")
.addHttpApi(ControlApi)
.addHttpApi(GlobalApi)
@@ -91,6 +41,5 @@ export const PublicApi = HttpApi.make("opencode")
title: "opencode",
version: "1.0.0",
description: "opencode api",
transform: documentInstanceQueryParameters,
}),
)
@@ -1,4 +1,4 @@
import { Context, Effect, Layer, Schema } from "effect"
import { Effect, Layer, Schema } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import { Bus } from "@/bus"
@@ -41,8 +41,6 @@ const Headers = Schema.Struct({
"x-opencode-directory": Schema.optional(Schema.String),
})
export const context = Context.empty() as Context.Context<unknown>
function decode(input: string) {
try {
return decodeURIComponent(input)
@@ -9,16 +9,15 @@ import { not } from "drizzle-orm"
import { or } from "drizzle-orm"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
import { NonNegativeInt } from "@/util/schema"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
const root = "/sync"
const ReplayEvent = Schema.Struct({
id: Schema.String,
aggregateID: Schema.String,
seq: NonNegativeInt,
seq: Schema.Number,
type: Schema.String,
data: Schema.Record(Schema.String, Schema.Unknown),
}).annotate({ identifier: "SyncReplayEvent" })
@@ -29,7 +28,7 @@ const ReplayPayload = Schema.Struct({
const ReplayResponse = Schema.Struct({
sessionID: Schema.String,
}).annotate({ identifier: "SyncReplayResponse" })
const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
const HistoryPayload = Schema.Record(Schema.String, Schema.Number)
const HistoryEvent = Schema.Struct({
id: Schema.String,
aggregate_id: Schema.String,
@@ -60,7 +59,6 @@ export const SyncApi = HttpApi.make("sync")
HttpApiEndpoint.post("replay", SyncPaths.replay, {
payload: ReplayPayload,
success: ReplayResponse,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.replay",
@@ -71,7 +69,6 @@ export const SyncApi = HttpApi.make("sync")
HttpApiEndpoint.post("history", SyncPaths.history, {
payload: HistoryPayload,
success: Schema.Array(HistoryEvent),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.history.list",
@@ -61,7 +61,6 @@ export const TuiApi = HttpApi.make("tui")
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
payload: TuiEvent.PromptAppend.properties,
success: Schema.Boolean,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.appendPrompt",
@@ -114,7 +113,6 @@ export const TuiApi = HttpApi.make("tui")
HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, {
payload: CommandPayload,
success: Schema.Boolean,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.executeCommand",
@@ -135,7 +133,6 @@ export const TuiApi = HttpApi.make("tui")
HttpApiEndpoint.post("publish", TuiPaths.publish, {
payload: TuiPublishPayload,
success: Schema.Boolean,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.publish",
@@ -146,7 +143,7 @@ export const TuiApi = HttpApi.make("tui")
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
payload: TuiEvent.SessionSelect.properties,
success: Schema.Boolean,
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.selectSession",
@@ -1,7 +1,7 @@
import { describeRoute, resolver, validator } from "hono-openapi"
import { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect"
import { Context, Effect } from "effect"
import z from "zod"
import { Format } from "@/format"
import { TuiRoutes } from "./tui"
@@ -14,6 +14,18 @@ import { LSP } from "@/lsp/lsp"
import { Command } from "@/command"
import { QuestionRoutes } from "./question"
import { PermissionRoutes } from "./permission"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ExperimentalHttpApiServer } from "./httpapi/server"
import { PtyPaths } from "./httpapi/pty"
import { EventPaths } from "./httpapi/event"
import { ExperimentalPaths } from "./httpapi/experimental"
import { FilePaths } from "./httpapi/file"
import { InstancePaths } from "./httpapi/instance"
import { McpPaths } from "./httpapi/mcp"
import { SessionPaths } from "./httpapi/session"
import { SyncPaths } from "./httpapi/sync"
import { TuiPaths } from "./httpapi/tui"
import { WorkspacePaths } from "./httpapi/workspace"
import { ProjectRoutes } from "./project"
import { SessionRoutes } from "./session"
import { PtyRoutes } from "./pty"
@@ -30,6 +42,118 @@ import { jsonRequest } from "./trace"
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
const app = new Hono()
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
const handler = ExperimentalHttpApiServer.webHandler().handler
const context = Context.empty() as Context.Context<unknown>
app.get(EventPaths.event, (c) => handler(c.req.raw, context))
app.get("/question", (c) => handler(c.req.raw, context))
app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context))
app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context))
app.get("/permission", (c) => handler(c.req.raw, context))
app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context))
app.get("/config", (c) => handler(c.req.raw, context))
app.patch("/config", (c) => handler(c.req.raw, context))
app.get("/config/providers", (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context))
app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context))
app.get("/provider", (c) => handler(c.req.raw, context))
app.get("/provider/auth", (c) => handler(c.req.raw, context))
app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context))
app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context))
app.get("/project", (c) => handler(c.req.raw, context))
app.get("/project/current", (c) => handler(c.req.raw, context))
app.post("/project/git/init", (c) => handler(c.req.raw, context))
app.patch("/project/:projectID", (c) => handler(c.req.raw, context))
app.get(FilePaths.findText, (c) => handler(c.req.raw, context))
app.get(FilePaths.findFile, (c) => handler(c.req.raw, context))
app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context))
app.get(FilePaths.list, (c) => handler(c.req.raw, context))
app.get(FilePaths.content, (c) => handler(c.req.raw, context))
app.get(FilePaths.status, (c) => handler(c.req.raw, context))
app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context))
app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
app.get(InstancePaths.command, (c) => handler(c.req.raw, context))
app.get(InstancePaths.agent, (c) => handler(c.req.raw, context))
app.get(InstancePaths.skill, (c) => handler(c.req.raw, context))
app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context))
app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context))
app.get(McpPaths.status, (c) => handler(c.req.raw, context))
app.post(McpPaths.status, (c) => handler(c.req.raw, context))
app.post(McpPaths.auth, (c) => handler(c.req.raw, context))
app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context))
app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context))
app.delete(McpPaths.auth, (c) => handler(c.req.raw, context))
app.post(McpPaths.connect, (c) => handler(c.req.raw, context))
app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context))
app.post(SyncPaths.start, (c) => handler(c.req.raw, context))
app.post(SyncPaths.replay, (c) => handler(c.req.raw, context))
app.post(SyncPaths.history, (c) => handler(c.req.raw, context))
app.get(PtyPaths.shells, (c) => handler(c.req.raw, context))
app.get(PtyPaths.list, (c) => handler(c.req.raw, context))
app.post(PtyPaths.create, (c) => handler(c.req.raw, context))
app.get(PtyPaths.get, (c) => handler(c.req.raw, context))
app.put(PtyPaths.update, (c) => handler(c.req.raw, context))
app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context))
app.get(PtyPaths.connect, (c) => handler(c.req.raw, context))
app.get(SessionPaths.list, (c) => handler(c.req.raw, context))
app.get(SessionPaths.status, (c) => handler(c.req.raw, context))
app.get(SessionPaths.get, (c) => handler(c.req.raw, context))
app.get(SessionPaths.children, (c) => handler(c.req.raw, context))
app.get(SessionPaths.todo, (c) => handler(c.req.raw, context))
app.get(SessionPaths.diff, (c) => handler(c.req.raw, context))
app.get(SessionPaths.messages, (c) => handler(c.req.raw, context))
app.get(SessionPaths.message, (c) => handler(c.req.raw, context))
app.post(SessionPaths.create, (c) => handler(c.req.raw, context))
app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context))
app.patch(SessionPaths.update, (c) => handler(c.req.raw, context))
app.post(SessionPaths.init, (c) => handler(c.req.raw, context))
app.post(SessionPaths.fork, (c) => handler(c.req.raw, context))
app.post(SessionPaths.abort, (c) => handler(c.req.raw, context))
app.post(SessionPaths.share, (c) => handler(c.req.raw, context))
app.delete(SessionPaths.share, (c) => handler(c.req.raw, context))
app.post(SessionPaths.summarize, (c) => handler(c.req.raw, context))
app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context))
app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context))
app.post(SessionPaths.command, (c) => handler(c.req.raw, context))
app.post(SessionPaths.shell, (c) => handler(c.req.raw, context))
app.post(SessionPaths.revert, (c) => handler(c.req.raw, context))
app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context))
app.post(SessionPaths.permissions, (c) => handler(c.req.raw, context))
app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context))
app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context))
app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context))
app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context))
app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context))
app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context))
app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context))
app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context))
app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context))
app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context))
app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context))
app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context))
app.post(TuiPaths.publish, (c) => handler(c.req.raw, context))
app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context))
app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context))
app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context))
app.get(WorkspacePaths.adaptors, (c) => handler(c.req.raw, context))
app.post(WorkspacePaths.list, (c) => handler(c.req.raw, context))
app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context))
app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context))
}
return app
.route("/project", ProjectRoutes())
.route("/pty", PtyRoutes(upgrade))
+14 -28
View File
@@ -17,6 +17,8 @@ import { WorkspaceRouterMiddleware } from "./workspace"
import { InstanceMiddleware } from "./routes/instance/middleware"
import { WorkspaceRoutes } from "./routes/control/workspace"
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
import { WorkspacePaths } from "./routes/instance/httpapi/workspace"
import { Context } from "effect"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -32,35 +34,9 @@ export type Listener = {
stop: (close?: boolean) => Promise<void>
}
type ServerApp = {
fetch(request: Request): Response | Promise<Response>
request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
}
const DefaultHono = lazy(() => createHono({}))
const DefaultHttpApi = lazy(() => createHttpApi())
export const Default = () => (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI ? DefaultHttpApi() : DefaultHono())
export const Default = lazy(() => create({}))
function create(opts: { cors?: string[] }) {
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return createHttpApi()
return createHono(opts)
}
function createHttpApi() {
const handler = ExperimentalHttpApiServer.webHandler().handler
const app: ServerApp = {
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
request(input, init) {
return app.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
},
}
return {
app,
runtime: adapter.createFetch(app),
}
}
function createHono(opts: { cors?: string[] }) {
const app = new Hono()
.onError(ErrorMiddleware)
.use(AuthMiddleware)
@@ -86,6 +62,16 @@ function createHono(opts: { cors?: string[] }) {
.use(InstanceMiddleware())
.route("/experimental/workspace", WorkspaceRoutes())
.use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket))
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
const handler = ExperimentalHttpApiServer.webHandler().handler
const context = Context.empty() as Context.Context<unknown>
workspaceApp.get(WorkspacePaths.adaptors, (c) => handler(c.req.raw, context))
workspaceApp.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
workspaceApp.post(WorkspacePaths.list, (c) => handler(c.req.raw, context))
workspaceApp.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
workspaceApp.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context))
workspaceApp.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context))
}
workspaceApp.route("/", workspaceLegacyApp)
return {
@@ -103,7 +89,7 @@ export async function openapi() {
// hono-openapi can see describeRoute metadata (`.route()` wraps
// handlers when the sub-app has a custom errorHandler, which
// strips the metadata symbol).
const { app } = createHono({})
const { app } = create({})
const result = await generateSpecs(app, {
documentation: {
info: {
@@ -0,0 +1,29 @@
import { afterEach, expect, mock, spyOn, test } from "bun:test"
import * as Core from "@opentui/core"
afterEach(() => {
mock.restore()
})
test("tui rejects when renderer startup fails", async () => {
const err = new Error("setRawMode failed with errno: 9")
spyOn(Core, "createCliRenderer").mockRejectedValue(err)
const { tui } = await import("../../../src/cli/cmd/tui/app")
const result = await Promise.race([
tui({
url: "http://opencode.internal",
config: {},
args: {
continue: false,
fork: false,
},
}).then(
() => "resolved",
(error) => error,
),
Bun.sleep(100).then(() => "timeout"),
])
expect(result).toBe(err)
})
@@ -997,80 +997,6 @@ describe("ProviderTransform.schema - moonshot $ref siblings", () => {
type: "number",
})
})
test("converts prefixItems tuples to a single item schema", () => {
const result = ProviderTransform.schema(moonshotModel, {
type: "object",
properties: {
renderedSize: {
description: "Rendered size [width, height] in px",
type: "array",
prefixItems: [{ type: "number", title: "Width" }, { type: "number" }],
unevaluatedItems: false,
},
},
} as any) as any
expect(result.properties.renderedSize.prefixItems).toBeUndefined()
expect(result.properties.renderedSize.unevaluatedItems).toBeUndefined()
expect(result.properties.renderedSize.items).toEqual({
type: "number",
})
})
test("removes unsupported annotation fields", () => {
const result = ProviderTransform.schema(moonshotModel, {
title: "Tool input",
$comment: "Internal note",
type: "object",
properties: {
count: {
title: "Count",
$comment: "Generated from int32",
description: "How many items to include.",
default: 10,
format: "int32",
type: "integer",
},
},
} as any) as any
expect(result.title).toBeUndefined()
expect(result.$comment).toBeUndefined()
expect(result.properties.count.title).toBeUndefined()
expect(result.properties.count.$comment).toBeUndefined()
expect(result.properties.count.format).toBeUndefined()
expect(result.properties.count.description).toBe("How many items to include.")
expect(result.properties.count.default).toBe(10)
})
test("removes unsupported complex validation fields", () => {
const result = ProviderTransform.schema(moonshotModel, {
type: "object",
properties: {
count: {
type: "integer",
minimum: 1,
exclusiveMinimum: 0,
exclusiveMaximum: 10,
},
values: {
type: "array",
items: { type: "string" },
contains: { type: "string" },
minContains: 1,
maxContains: 3,
},
},
} as any) as any
expect(result.properties.count.exclusiveMinimum).toBeUndefined()
expect(result.properties.count.exclusiveMaximum).toBeUndefined()
expect(result.properties.count.minimum).toBe(1)
expect(result.properties.values.minContains).toBeUndefined()
expect(result.properties.values.maxContains).toBeUndefined()
expect(result.properties.values.contains).toEqual({ type: "string" })
})
})
describe("ProviderTransform.message - DeepSeek reasoning content", () => {
@@ -1,11 +1,28 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { WorkspaceRoutes } from "../../src/server/routes/control/workspace"
import { ConfigApi } from "../../src/server/routes/instance/httpapi/config"
import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
import { ExperimentalApi } from "../../src/server/routes/instance/httpapi/experimental"
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file"
import { InstanceApi } from "../../src/server/routes/instance/httpapi/instance"
import { McpApi } from "../../src/server/routes/instance/httpapi/mcp"
import { PermissionApi } from "../../src/server/routes/instance/httpapi/permission"
import { ProjectApi } from "../../src/server/routes/instance/httpapi/project"
import { ProviderApi } from "../../src/server/routes/instance/httpapi/provider"
import { PtyApi, PtyPaths } from "../../src/server/routes/instance/httpapi/pty"
import { QuestionApi } from "../../src/server/routes/instance/httpapi/question"
import { SessionApi } from "../../src/server/routes/instance/httpapi/session"
import { SyncApi } from "../../src/server/routes/instance/httpapi/sync"
import { TuiApi } from "../../src/server/routes/instance/httpapi/tui"
import { WorkspaceApi } from "../../src/server/routes/instance/httpapi/workspace"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
@@ -17,13 +34,48 @@ const original = {
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
}
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
const methods = ["get", "post", "put", "delete", "patch"] as const
function app(input?: { password?: string; username?: string }) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_SERVER_PASSWORD = input?.password
Flag.OPENCODE_SERVER_USERNAME = input?.username
return Server.Default().app
return InstanceRoutes(websocket)
}
function routeKey(route: ReturnType<typeof InstanceRoutes>["routes"][number]) {
return `${route.method} ${route.path}`
}
function reflectedHttpApiRoutes() {
const routes = [`GET ${EventPaths.event}`, `GET ${PtyPaths.connect}`]
function addRoutes<Id extends string, Groups extends HttpApiGroup.Any>(api: HttpApi.HttpApi<Id, Groups>) {
HttpApi.reflect(api, {
onGroup() {},
onEndpoint({ endpoint }) {
routes.push(`${endpoint.method} ${endpoint.path}`)
},
})
}
addRoutes(ConfigApi)
addRoutes(ExperimentalApi)
addRoutes(FileApi)
addRoutes(InstanceApi)
addRoutes(McpApi)
addRoutes(PermissionApi)
addRoutes(ProjectApi)
addRoutes(ProviderApi)
addRoutes(PtyApi)
addRoutes(QuestionApi)
addRoutes(SessionApi)
addRoutes(SyncApi)
addRoutes(TuiApi)
addRoutes(WorkspaceApi)
return [...new Set(routes)]
}
function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
@@ -34,32 +86,6 @@ function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof m
.sort()
}
function openApiParameters(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }) {
return Object.fromEntries(
Object.entries(spec.paths).flatMap(([path, item]) =>
methods
.filter((method) => item[method])
.map((method) => [
`${method.toUpperCase()} ${path}`,
(item[method]?.parameters ?? [])
.map(parameterKey)
.filter((param) => param !== undefined)
.sort(),
]),
),
)
}
type Operation = {
parameters?: unknown[]
}
function parameterKey(param: unknown) {
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return
if (typeof param.in !== "string" || typeof param.name !== "string") return
return `${param.in}:${param.name}:${"required" in param && param.required === true}`
}
function authorization(username: string, password: string) {
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
}
@@ -80,7 +106,40 @@ afterEach(async () => {
await resetDatabase()
})
describe("HttpApi server", () => {
describe("HttpApi Hono bridge", () => {
test("mounts experimental handlers for every legacy instance route", () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
const legacy = InstanceRoutes(websocket)
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
const experimental = InstanceRoutes(websocket)
const bridge = experimental.routes.slice(0, experimental.routes.length - legacy.routes.length)
const workspaceRoutes = WorkspaceRoutes().routes.map((route) => ({
...route,
path: `/experimental/workspace${route.path === "/" ? "" : route.path}`,
}))
const legacyRoutes = [...new Set([...legacy.routes, ...workspaceRoutes].map(routeKey))]
const bridgeRoutes = new Set(bridge.map(routeKey))
expect(legacyRoutes.filter((route) => !bridgeRoutes.has(route))).toEqual([])
expect([...bridgeRoutes].filter((route) => !legacyRoutes.includes(route)).sort()).toEqual([])
})
test("mounts every Effect HttpApi route through the Hono bridge", () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
const legacy = InstanceRoutes(websocket)
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
const experimental = InstanceRoutes(websocket)
const bridgeRoutes = new Set(
experimental.routes.slice(0, experimental.routes.length - legacy.routes.length).map(routeKey),
)
const httpApiRoutes = reflectedHttpApiRoutes()
expect(httpApiRoutes.filter((route) => !bridgeRoutes.has(route))).toEqual([])
expect([...bridgeRoutes].filter((route) => !httpApiRoutes.includes(route)).sort()).toEqual([])
})
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
const honoRoutes = openApiRouteKeys(await Server.openapi())
const effectRoutes = openApiRouteKeys(OpenApi.fromApi(PublicApi))
@@ -89,17 +148,6 @@ describe("HttpApi server", () => {
expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([])
})
test("matches generated OpenAPI route parameters", async () => {
const hono = openApiParameters(await Server.openapi())
const effect = openApiParameters(OpenApi.fromApi(PublicApi))
expect(
Object.keys(hono)
.filter((route) => JSON.stringify(hono[route]) !== JSON.stringify(effect[route]))
.map((route) => ({ route, hono: hono[route], effect: effect[route] })),
).toEqual([])
})
test("allows requests when auth is disabled", async () => {
await using tmp = await tmpdir({ git: true })
await Bun.write(`${tmp.path}/hello.txt`, "hello")
@@ -1,9 +1,10 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import path from "path"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { InstanceRoutes } from "../../src/server/routes/instance"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
@@ -11,10 +12,11 @@ import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
return InstanceRoutes(websocket)
}
async function waitDisposed(directory: string) {
@@ -1,7 +1,8 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
@@ -10,10 +11,11 @@ import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
return InstanceRoutes(websocket)
}
async function readFirstChunk(response: Response) {
@@ -1,9 +1,10 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
import { Session } from "@/session/session"
import { Database } from "@/storage/db"
@@ -15,11 +16,12 @@ import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
const testWorktreeMutations = process.platform === "win32" ? test.skip : test
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
return InstanceRoutes(websocket)
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
@@ -1,9 +1,10 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import path from "path"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/instance"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
@@ -12,10 +13,11 @@ import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
return InstanceRoutes(websocket)
}
async function waitDisposed(directory: string) {
@@ -1,9 +1,10 @@
import { afterEach, describe, expect } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/session"
import { MessageID, PartID } from "../../src/session/schema"
@@ -16,12 +17,12 @@ import { it } from "../lib/effect"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
function app(experimental: boolean) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return Server.Default().app
return InstanceRoutes(websocket)
}
type TestApp = ReturnType<typeof app>
function pathFor(path: string, params: Record<string, string>) {
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
@@ -59,9 +60,9 @@ function withTmp<A, E, R>(
).pipe(Effect.flatMap((tmp) => fn(tmp).pipe(provideInstance(tmp.path))))
}
function readJson(label: string, serverApp: TestApp, path: string, headers: HeadersInit) {
function readJson(label: string, app: ReturnType<typeof InstanceRoutes>, path: string, headers: HeadersInit) {
return Effect.promise(async () => {
const response = await serverApp.request(path, { headers })
const response = await app.request(path, { headers })
if (response.status !== 200) throw new Error(`${label} returned ${response.status}: ${await response.text()}`)
return await response.json()
})
@@ -69,8 +70,8 @@ function readJson(label: string, serverApp: TestApp, path: string, headers: Head
function expectJsonParity(input: {
label: string
legacy: TestApp
httpapi: TestApp
legacy: ReturnType<typeof InstanceRoutes>
httpapi: ReturnType<typeof InstanceRoutes>
path: string
headers: HeadersInit
}) {
@@ -1,11 +1,12 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { McpPaths } from "../../src/server/routes/instance/httpapi/mcp"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { InstanceRoutes } from "../../src/server/routes/instance"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { provideInstance, tmpdir } from "../fixture/fixture"
@@ -15,13 +16,13 @@ void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const context = Context.empty() as Context.Context<unknown>
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer))
function app(experimental: boolean) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return Server.Default().app
return InstanceRoutes(websocket)
}
type TestApp = ReturnType<typeof app>
function request(route: string, directory: string, init?: RequestInit) {
const headers = new Headers(init?.headers)
@@ -64,7 +65,11 @@ function withMcpProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E, R>)
})
}
const readResponse = Effect.fnUntraced(function* (input: { app: TestApp; path: string; headers: HeadersInit }) {
const readResponse = Effect.fnUntraced(function* (input: {
app: ReturnType<typeof InstanceRoutes>
path: string
headers: HeadersInit
}) {
const response = yield* Effect.promise(() =>
Promise.resolve(input.app.request(input.path, { method: "POST", headers: input.headers })),
)
@@ -1,9 +1,10 @@
import { afterEach, describe, expect } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import { Effect, FileSystem, Layer, Path } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { InstanceRoutes } from "../../src/server/routes/instance"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { provideInstance } from "../fixture/fixture"
@@ -12,6 +13,7 @@ import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer))
const providerID = "test-oauth-parity"
const oauthURL = "https://example.com/oauth"
@@ -19,11 +21,11 @@ const oauthInstructions = "Finish OAuth"
function app(experimental: boolean) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return Server.Default().app
return InstanceRoutes(websocket)
}
function requestAuthorize(input: {
app: ReturnType<typeof app>
app: ReturnType<typeof InstanceRoutes>
providerID: string
method: number
headers: HeadersInit
@@ -1,8 +1,9 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import { Flag } from "@opencode-ai/core/flag/flag"
import { PtyID } from "../../src/pty/schema"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/pty"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
@@ -11,11 +12,12 @@ import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
const testPty = process.platform === "win32" ? test.skip : test
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
return InstanceRoutes(websocket)
}
afterEach(async () => {
@@ -1,10 +1,11 @@
import { afterEach, describe, expect } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/session"
import { Session } from "@/session/session"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
@@ -17,10 +18,11 @@ import { it } from "../lib/effect"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
return InstanceRoutes(websocket)
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
@@ -1,8 +1,9 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { SyncPaths } from "../../src/server/routes/instance/httpapi/sync"
import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
@@ -13,10 +14,11 @@ void Log.init({ print: false })
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
function app(httpapi = true) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi
return Server.Default().app
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return InstanceRoutes(websocket)
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
@@ -79,48 +81,4 @@ describe("sync HttpApi", () => {
expect(replayed.status).toBe(200)
expect(await replayed.json()).toEqual({ sessionID: session.id })
})
test("matches legacy seq validation", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
const cases = [
{
path: SyncPaths.history,
body: { aggregate: -1 },
},
{
path: SyncPaths.history,
body: { aggregate: 1.5 },
},
{
path: SyncPaths.replay,
body: {
directory: tmp.path,
events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }],
},
},
{
path: SyncPaths.replay,
body: {
directory: tmp.path,
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
},
},
]
for (const item of cases) {
const legacy = await app(false).request(item.path, {
method: "POST",
headers,
body: JSON.stringify(item.body),
})
const httpapi = await app(true).request(item.path, {
method: "POST",
headers,
body: JSON.stringify(item.body),
})
expect(httpapi.status).toBe(legacy.status)
expect(httpapi.status).toBe(400)
}
})
})
@@ -1,23 +1,24 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { Context } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import { Flag } from "@opencode-ai/core/flag/flag"
import { SessionID } from "../../src/session/schema"
import { Instance } from "../../src/project/instance"
import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/tui"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { TuiPaths } from "../../src/server/routes/instance/httpapi/tui"
import { callTui } from "../../src/server/routes/instance/tui"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { OpenApi } from "effect/unstable/httpapi"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
return InstanceRoutes(websocket)
}
async function expectTrue(path: string, headers: Record<string, string>, body?: unknown) {
@@ -37,15 +38,6 @@ afterEach(async () => {
})
describe("tui HttpApi bridge", () => {
test("documents legacy bad request responses", async () => {
const legacy = await Server.openapi()
const effect = OpenApi.fromApi(TuiApi)
for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) {
expect(legacy.paths[path].post?.responses?.[400]).toBeDefined()
expect(effect.paths[path].post?.responses?.[400]).toBeDefined()
}
})
test("serves TUI command and event routes through experimental Effect routes", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Effect } from "effect"
import type { UpgradeWebSocket } from "hono/ws"
import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdaptor } from "../../src/control-plane/adaptors"
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
@@ -9,7 +10,7 @@ import { Workspace } from "../../src/control-plane/workspace"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace"
import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
@@ -18,12 +19,13 @@ void Log.init({ print: false })
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
function request(path: string, directory: string, init: RequestInit = {}) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
const headers = new Headers(init.headers)
headers.set("x-opencode-directory", directory)
return Server.Default().app.request(path, { ...init, headers })
return InstanceRoutes(websocket).request(path, { ...init, headers })
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
+1 -8
View File
@@ -9,14 +9,7 @@ import path from "path"
import { createClient } from "@hey-api/openapi-ts"
const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "httpapi" ? "httpapi" : "hono"
const opencode = path.resolve(dir, "../../opencode")
if (openapiSource === "httpapi") {
await $`bun dev generate --httpapi > ${dir}/openapi.json`.cwd(opencode)
} else {
await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
}
await $`bun dev generate > ${dir}/openapi.json`.cwd(path.resolve(dir, "../../opencode"))
await createClient({
input: "./openapi.json",