research: delete Hono backend (do not merge) (#25667)

This commit is contained in:
Kit Langton
2026-05-09 09:10:42 -04:00
committed by GitHub
parent 32684e70e6
commit 28b03595bf
81 changed files with 224 additions and 7566 deletions

View File

@@ -1,501 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { ConfigProvider, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { OpenApi } from "effect/unstable/httpapi"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
}
const methods = ["get", "post", "put", "delete", "patch"] as const
let effectSpec: ReturnType<typeof OpenApi.fromApi> | undefined
function effectOpenApi() {
return (effectSpec ??= OpenApi.fromApi(PublicApi))
}
function app(input?: { password?: string; username?: string }) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_SERVER_PASSWORD = input?.password
Flag.OPENCODE_SERVER_USERNAME = input?.username
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_SERVER_PASSWORD: input?.password,
OPENCODE_SERVER_USERNAME: input?.username,
}),
),
),
),
{ disableLogger: true },
).handler
return {
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
request(input: string | URL | Request, init?: RequestInit) {
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
},
}
}
function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
return Object.entries(spec.paths)
.flatMap(([path, item]) =>
methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`),
)
.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(),
]),
),
)
}
function openApiRequestBodies(spec: OpenApiSpec) {
return Object.fromEntries(
Object.entries(spec.paths).flatMap(([path, item]) =>
methods
.filter((method) => item[method])
.map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(spec, item[method]?.requestBody)]),
),
)
}
type OpenApiSpec = {
components?: {
schemas?: Record<string, unknown>
}
paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>>
}
type OpenApiSchema = {
$ref?: string
allOf?: unknown[]
anyOf?: unknown[]
oneOf?: unknown[]
properties?: Record<string, unknown>
type?: string | string[]
}
type Operation = {
parameters?: unknown[]
responses?: unknown
requestBody?: unknown
}
type RequestBody = {
content?: Record<string, { schema?: OpenApiSchema }>
required?: boolean
}
function parameterKey(param: unknown): string | undefined {
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined
if (typeof param.in !== "string" || typeof param.name !== "string") return undefined
return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema(
"schema" in param ? param.schema : undefined,
)}`
}
function stableSchema(input: unknown): string {
return JSON.stringify(sortSchema(input))
}
function sortSchema(input: unknown): unknown {
if (Array.isArray(input)) return input.map(sortSchema)
if (!input || typeof input !== "object") return input
return Object.fromEntries(
Object.entries(input)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => [key, sortSchema(value)]),
)
}
function parameterSchema(input: {
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
path: string
method: (typeof methods)[number]
name: string
}): unknown {
const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find(
(param) => !!param && typeof param === "object" && "name" in param && param.name === input.name,
)
if (!param || typeof param !== "object" || !("schema" in param)) return undefined
return param.schema
}
function requestBodyKey(spec: OpenApiSpec, body: unknown) {
if (!body || typeof body !== "object" || !("content" in body)) return ""
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset.
const requestBody = body as RequestBody
return JSON.stringify({
required: requestBody.required === true,
content: Object.entries(requestBody.content ?? {})
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)] as const)
.sort(([left], [right]) => left.localeCompare(right)),
})
}
function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) {
if (!schema) return ""
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper.
const resolved = (
schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema
) as OpenApiSchema | undefined
if (resolved?.properties) return "object"
if (resolved?.anyOf ?? resolved?.oneOf ?? resolved?.allOf) return "object"
return resolved?.type ?? schema.type ?? "inline"
}
function responseContentTypes(input: {
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
path: string
method: (typeof methods)[number]
status: string
}) {
const responses = input.spec.paths[input.path]?.[input.method]?.responses
if (!responses || typeof responses !== "object" || !(input.status in responses)) return []
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup.
const response = (responses as Record<string, unknown>)[input.status]
if (!response || typeof response !== "object" || !("content" in response)) return []
const content = (response as { content?: unknown }).content
if (!content || typeof content !== "object") {
return []
}
return Object.keys(content).sort()
}
function authorization(username: string, password: string) {
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
}
function fileUrl(input?: { directory?: string; token?: string }) {
const url = new URL(`http://localhost${FilePaths.content}`)
url.searchParams.set("path", "hello.txt")
if (input?.directory) url.searchParams.set("directory", input.directory)
if (input?.token) url.searchParams.set("auth_token", input.token)
return url
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
await disposeAllInstances()
await resetDatabase()
})
describe("HttpApi server", () => {
test("keeps Effect HttpApi behind the feature flag", () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
expect(Server.backend()).toEqual({ backend: "hono", reason: "stable" })
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
expect(Server.backend()).toEqual({ backend: "effect-httpapi", reason: "env" })
})
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
const honoRoutes = openApiRouteKeys(await Server.openapiHono())
const effectRoutes = openApiRouteKeys(effectOpenApi())
expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([
"GET /api/session",
"GET /api/session/{sessionID}/context",
"GET /api/session/{sessionID}/message",
"POST /api/session/{sessionID}/compact",
"POST /api/session/{sessionID}/prompt",
"POST /api/session/{sessionID}/wait",
])
})
test("matches generated OpenAPI route parameters", async () => {
const hono = openApiParameters(await Server.openapiHono())
const effect = openApiParameters(effectOpenApi())
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("matches generated OpenAPI request body shape", async () => {
const hono = openApiRequestBodies(await Server.openapiHono())
const effect = openApiRequestBodies(effectOpenApi())
expect(
Object.keys(hono)
.filter((route) => hono[route] !== effect[route])
.map((route) => ({ route, hono: hono[route], effect: effect[route] })),
).toEqual([])
})
test("matches SDK-affecting query parameter schemas", async () => {
const effect = effectOpenApi()
expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "roots" })).toEqual({
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
})
expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "start" })).toEqual({
type: "number",
})
expect(parameterSchema({ spec: effect, path: "/find/file", method: "get", name: "limit" })).toEqual({
type: "integer",
minimum: 1,
maximum: 200,
})
expect(
parameterSchema({ spec: effect, path: "/session/{sessionID}/message", method: "get", name: "limit" }),
).toEqual({
type: "integer",
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
})
})
test("matches SDK-affecting request schema details", () => {
const effect = effectOpenApi()
const sessionUpdate = effect.paths["/session/{sessionID}"]?.patch?.requestBody
const sessionUpdateSchema =
typeof sessionUpdate === "object" && sessionUpdate && "content" in sessionUpdate
? sessionUpdate.content?.["application/json"]?.schema
: undefined
const sessionUpdateProperties = sessionUpdateSchema?.properties as Record<string, OpenApiSchema> | undefined
const time = sessionUpdateProperties?.time
expect(time?.properties?.archived).toEqual({ type: "number" })
})
test("documents event routes as server-sent events", () => {
const effect = effectOpenApi()
expect(responseContentTypes({ spec: effect, path: "/event", method: "get", status: "200" })).toEqual([
"text/event-stream",
])
expect(responseContentTypes({ spec: effect, path: "/global/event", method: "get", status: "200" })).toEqual([
"text/event-stream",
])
})
test("allows requests when auth is disabled", async () => {
await using tmp = await tmpdir({ git: true })
await Bun.write(`${tmp.path}/hello.txt`, "hello")
const response = await app().request(fileUrl(), {
headers: {
"x-opencode-directory": tmp.path,
},
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ content: "hello" })
})
test("provides instance context to bridged handlers", async () => {
await using tmp = await tmpdir({ git: true })
const response = await app().request("/project/current", {
headers: {
"x-opencode-directory": tmp.path,
},
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ worktree: tmp.path })
})
test("requires credentials when auth is enabled", async () => {
await using tmp = await tmpdir({ git: true })
await Bun.write(`${tmp.path}/hello.txt`, "hello")
const [missing, bad, good] = await Promise.all([
app({ password: "secret" }).request(fileUrl(), {
headers: { "x-opencode-directory": tmp.path },
}),
app({ password: "secret" }).request(fileUrl(), {
headers: {
authorization: authorization("opencode", "wrong"),
"x-opencode-directory": tmp.path,
},
}),
app({ password: "secret" }).request(fileUrl(), {
headers: {
authorization: authorization("opencode", "secret"),
"x-opencode-directory": tmp.path,
},
}),
])
expect(missing.status).toBe(401)
expect(bad.status).toBe(401)
expect(good.status).toBe(200)
})
test("requires credentials for root routes when auth is enabled", async () => {
const server = app({ password: "secret" })
const auth = { authorization: authorization("opencode", "secret") }
const wrongAuth = { authorization: authorization("opencode", "wrong") }
const [missingHealth, goodHealth, missingConfig, wrongConfig, goodConfig] = await Promise.all([
server.request(GlobalPaths.health),
server.request(GlobalPaths.health, { headers: auth }),
server.request(GlobalPaths.config),
server.request(GlobalPaths.config, { headers: wrongAuth }),
server.request(GlobalPaths.config, { headers: auth }),
])
expect(missingHealth.status).toBe(401)
expect(goodHealth.status).toBe(200)
expect(missingConfig.status).toBe(401)
expect(wrongConfig.status).toBe(401)
expect(goodConfig.status).toBe(200)
const missingDispose = await server.request(GlobalPaths.dispose, { method: "POST" })
expect(missingDispose.status).toBe(401)
const missingUpgrade = await server.request(GlobalPaths.upgrade, {
method: "POST",
headers: { "content-type": "application/json" },
body: "not-json",
})
expect(missingUpgrade.status).toBe(401)
const invalidUpgrade = await server.request(GlobalPaths.upgrade, {
method: "POST",
headers: { ...auth, "content-type": "application/json" },
body: "not-json",
})
expect(invalidUpgrade.status).toBe(400)
const missingLog = await server.request(ControlPaths.log, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ service: "httpapi-auth-test", level: "info", message: "hello" }),
})
expect(missingLog.status).toBe(401)
const missingAuth = await server.request(ControlPaths.auth.replace(":providerID", "test"), {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "api", key: "secret" }),
})
expect(missingAuth.status).toBe(401)
const invalidAuth = await server.request(ControlPaths.auth.replace(":providerID", "test"), {
method: "PUT",
headers: { ...auth, "content-type": "application/json" },
body: JSON.stringify({ type: "api" }),
})
expect(invalidAuth.status).toBe(400)
})
test("accepts auth_token query credentials", async () => {
await using tmp = await tmpdir({ git: true })
await Bun.write(`${tmp.path}/hello.txt`, "hello")
const response = await app({ password: "secret" }).request(
fileUrl({ token: Buffer.from("opencode:secret").toString("base64") }),
{
headers: {
"x-opencode-directory": tmp.path,
},
},
)
expect(response.status).toBe(200)
})
test("selects instance from query before directory header", async () => {
await using header = await tmpdir({ git: true })
await using query = await tmpdir({ git: true })
await Bun.write(`${header.path}/hello.txt`, "header")
await Bun.write(`${query.path}/hello.txt`, "query")
const response = await app().request(fileUrl({ directory: query.path }), {
headers: {
"x-opencode-directory": header.path,
},
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ content: "query" })
})
test("serves global health from Effect HttpApi", async () => {
const response = await app().request(`${GlobalPaths.health}?directory=/does/not/exist/opencode-test`)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ healthy: true })
})
test("serves global event stream from Effect HttpApi", async () => {
const response = await app().request(GlobalPaths.event)
if (!response.body) throw new Error("missing event stream body")
const reader = response.body.getReader()
const chunk = await reader.read()
await reader.cancel()
expect(response.status).toBe(200)
expect(response.headers.get("content-type")).toContain("text/event-stream")
expect(new TextDecoder().decode(chunk.value)).toContain("server.connected")
})
test("serves control log from Effect HttpApi", async () => {
const response = await app().request(ControlPaths.log, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ service: "httpapi-test", level: "info", message: "hello" }),
})
expect(response.status).toBe(200)
expect(await response.json()).toBe(true)
})
test("validates control auth without falling through to 404", async () => {
const response = await app().request(ControlPaths.auth.replace(":providerID", "test"), {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "api" }),
})
expect(response.status).toBe(400)
})
test("validates global upgrade without invoking installers", async () => {
const response = await app().request(GlobalPaths.upgrade, {
method: "POST",
headers: { "content-type": "application/json" },
body: "not-json",
})
expect(response.status).toBe(400)
expect(await response.json()).toMatchObject({ success: false })
})
})

View File

@@ -1,6 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test"
import { gunzipSync, inflateSync } from "node:zlib"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server"
import { resetDatabase } from "../fixture/db"
@@ -8,16 +7,12 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
}

View File

@@ -1,6 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
@@ -9,10 +8,8 @@ import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
}
@@ -24,7 +21,6 @@ async function waitDisposed(directory: string) {
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})

View File

@@ -1,5 +1,4 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server"
import { resetDatabase } from "../fixture/db"
@@ -7,17 +6,13 @@ import { disposeAllInstances } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
function app(experimental: boolean) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
function app() {
return Server.Default().app
}
const PREFLIGHT_HEADERS = {
@@ -33,19 +28,8 @@ const PREFLIGHT_HEADERS = {
// cached for one origin against a different origin. corsVaryFixLayer
// restores the merged form.
describe("CORS preflight Vary header", () => {
test("Hono backend preflight Vary contains Origin", async () => {
const response = await app(false).request("/global/config", {
method: "OPTIONS",
headers: PREFLIGHT_HEADERS,
})
expect([200, 204]).toContain(response.status)
expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
expect((response.headers.get("vary") ?? "").toLowerCase()).toContain("origin")
})
test("HTTP API backend preflight Vary contains Origin", async () => {
const response = await app(true).request("/global/config", {
const response = await app().request("/global/config", {
method: "OPTIONS",
headers: PREFLIGHT_HEADERS,
})
@@ -56,7 +40,7 @@ describe("CORS preflight Vary header", () => {
})
test("HTTP API backend preflight Vary still preserves Access-Control-Request-Headers", async () => {
const response = await app(true).request("/global/config", {
const response = await app().request("/global/config", {
method: "OPTIONS",
headers: PREFLIGHT_HEADERS,
})
@@ -67,7 +51,7 @@ describe("CORS preflight Vary header", () => {
})
test("HTTP API backend does not duplicate Origin in Vary", async () => {
const response = await app(true).request("/global/config", {
const response = await app().request("/global/config", {
method: "OPTIONS",
headers: PREFLIGHT_HEADERS,
})
@@ -75,8 +59,8 @@ describe("CORS preflight Vary header", () => {
const vary = response.headers.get("vary") ?? ""
const originCount = vary
.split(",")
.map((s) => s.trim().toLowerCase())
.filter((s) => s === "origin").length
.map((s: string) => s.trim().toLowerCase())
.filter((s: string) => s === "origin").length
expect(originCount).toBe(1)
})
})

View File

@@ -1,7 +1,7 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { describe, expect } from "bun:test"
import { Config, Effect, Layer } from "effect"
import { Config, ConfigProvider, Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { Server } from "../../src/server/server"
@@ -13,15 +13,12 @@ import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
}
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_SERVER_PASSWORD = "secret"
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
await resetDatabase()
}),
@@ -63,12 +60,21 @@ describe("HttpApi CORS", () => {
}),
)
it.live("adds CORS headers to legacy unauthorized responses", () =>
it.live("adds CORS headers to unauthorized responses", () =>
Effect.gen(function* () {
const response = yield* Effect.promise(async () =>
Server.Legacy().app.request("/global/config", {
headers: { origin: "https://app.opencode.ai" },
}),
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.createRoutes().pipe(
Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: "secret" }))),
),
{ disableLogger: true },
).handler
const response = yield* Effect.promise(() =>
handler(
new Request(new URL("/global/config", "http://localhost"), {
headers: { origin: "https://app.opencode.ai" },
}),
ExperimentalHttpApiServer.context,
),
)
expect(response.status).toBe(401)

View File

@@ -1,5 +1,4 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
@@ -9,11 +8,8 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
function app(experimental = true) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
function app() {
return Server.Default().app
}
async function readFirstChunk(response: Response) {
@@ -36,13 +32,12 @@ async function readFirstEvent(response: Response) {
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
describe("event HttpApi bridge", () => {
test("serves event stream through experimental Effect route", async () => {
describe("event HttpApi", () => {
test("serves event stream", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
@@ -54,15 +49,11 @@ describe("event HttpApi bridge", () => {
expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} })
})
test("matches legacy first event frame", async () => {
test("serves the initial server connected event", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }
const legacy = await app(false).request(EventPaths.event, { headers })
const effect = await app(true).request(EventPaths.event, { headers })
const response = await app().request(EventPaths.event, { headers })
const legacyEvent = await readFirstEvent(legacy)
const effectEvent = await readFirstEvent(effect)
expect(effectEvent.type).toBe(legacyEvent.type)
expect(effectEvent.properties).toEqual(legacyEvent.properties)
expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} })
})
})

View File

@@ -1,4 +1,3 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import { ConfigProvider, Effect, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { parse } from "./assertions"
@@ -56,16 +55,7 @@ function app(modules: Runtime, backend: Backend, options: CallOptions) {
const username = options.auth?.username
const password = options.auth?.password
const cacheKey = `${backend}:${username ?? ""}:${password ?? ""}`
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect"
Flag.OPENCODE_SERVER_PASSWORD = password
Flag.OPENCODE_SERVER_USERNAME = username
if (appCache[cacheKey]) return appCache[cacheKey]
if (backend === "legacy") {
const legacy = modules.Server.Legacy().app
return (appCache[cacheKey] = {
request: (input, init) => legacy.request(input, init),
})
}
const handler = HttpRouter.toWebHandler(
modules.ExperimentalHttpApiServer.routes.pipe(

View File

@@ -22,7 +22,6 @@ process.env.OPENCODE_DB = exerciseDatabasePath
Flag.OPENCODE_DB = exerciseDatabasePath
export const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
}

View File

@@ -1,10 +1,10 @@
/**
* End-to-end exerciser for the legacy Hono instance routes and the Effect HttpApi routes.
* End-to-end exerciser for the Effect HttpApi routes.
*
* The goal is not to be a normal unit test file. This is a route-coverage and parity
* harness we can run while deleting Hono: every public route should eventually have a
* small scenario that proves the Effect route decodes requests, uses the right instance
* context, mutates storage when expected, and returns a compatible response shape.
* The goal is not to be a normal unit test file. This is a route-coverage harness:
* every public route should have a small scenario that proves the route decodes
* requests, uses the right instance context, mutates storage when expected, and
* returns the expected response shape.
*
* The script intentionally isolates `OPENCODE_DB` before importing modules that touch
* storage. Scenarios may create/delete sessions and reset the database after each run,
@@ -15,8 +15,7 @@
* - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`.
* - `.at(...)` builds the request from that typed state.
* - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects.
* - `.mutating()` tells parity mode to run Effect and Hono in separate isolated contexts
* so destructive routes compare equivalent fresh setups instead of sharing one DB.
* - `.mutating()` tells the runner to reset isolated state after destructive routes.
*/
import { Effect } from "effect"
import { OpenApi } from "effect/unstable/httpapi"
@@ -1263,7 +1262,6 @@ const main = Effect.gen(function* () {
const options = parseOptions(Bun.argv.slice(2))
const modules = yield* Effect.promise(() => runtime())
const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi))
const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono()))
const selected = selectedScenarios(options, scenarios)
const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario)))
const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario)))
@@ -1274,7 +1272,7 @@ const main = Effect.gen(function* () {
}
}
printHeader(options, effectRoutes, honoRoutes, selected, missing, extra, {
printHeader(options, effectRoutes, selected, missing, extra, {
database: exerciseDatabasePath,
global: exerciseGlobalRoot,
})

View File

@@ -14,7 +14,6 @@ export const color = {
export function printHeader(
options: Options,
effectRoutes: string[],
honoRoutes: string[],
selected: Scenario[],
missing: string[],
extra: Scenario[],
@@ -24,7 +23,7 @@ export function printHeader(
console.log(`${color.dim}db=${paths.database}${color.reset}`)
console.log(`${color.dim}global=${paths.global}${color.reset}`)
console.log(
`${color.dim}mode=${options.mode} selected=${selected.length} scenarioTimeout=${Duration.format(options.scenarioTimeout)} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`,
`${color.dim}mode=${options.mode} selected=${selected.length} scenarioTimeout=${Duration.format(options.scenarioTimeout)} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length}${color.reset}`,
)
console.log("")
}

View File

@@ -41,7 +41,7 @@ export function coverageResult(scenario: Scenario): Result {
export function parseOptions(args: string[]): Options {
const mode = option(args, "--mode") ?? "effect"
if (mode !== "effect" && mode !== "parity" && mode !== "coverage" && mode !== "auth")
if (mode !== "effect" && mode !== "coverage" && mode !== "auth")
throw new Error(`invalid --mode ${mode}`)
return {
mode,

View File

@@ -5,13 +5,11 @@ import type { Config } from "../../../src/config/config"
import { ModelID, ProviderID } from "../../../src/provider/schema"
import type { MessageV2 } from "../../../src/session/message-v2"
import { MessageID, PartID } from "../../../src/session/schema"
import { stable } from "./assertions"
import { call, callAuthProbe } from "./backend"
import { original } from "./environment"
import { runtime } from "./runtime"
import type {
ActiveScenario,
CallResult,
Options,
ProjectOptions,
Result,
@@ -38,16 +36,6 @@ export function runScenario(options: Options) {
function runActive(options: Options, scenario: ActiveScenario) {
if (options.mode === "auth") return runAuth(scenario)
if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") {
return Effect.gen(function* () {
const effect = yield* runBackend(options, "effect", scenario)
const legacy = yield* runBackend(options, "legacy", scenario)
yield* trace(options, scenario, "compare start")
yield* compare(scenario, effect, legacy)
yield* trace(options, scenario, "compare done")
})
}
return withContext(options, scenario, "shared", (ctx) =>
Effect.gen(function* () {
yield* trace(options, scenario, "effect request start")
@@ -56,17 +44,6 @@ function runActive(options: Options, scenario: ActiveScenario) {
yield* trace(options, scenario, "effect expect start")
yield* scenario.expect(ctx, ctx.state, effect)
yield* trace(options, scenario, "effect expect done")
if (options.mode === "parity" && scenario.compare !== "none") {
yield* trace(options, scenario, "legacy request start")
const legacy = yield* call("legacy", scenario, ctx)
yield* trace(options, scenario, `legacy response ${legacy.status}`)
yield* trace(options, scenario, "legacy expect start")
yield* scenario.expect(ctx, ctx.state, legacy)
yield* trace(options, scenario, "legacy expect done")
yield* trace(options, scenario, "compare start")
yield* compare(scenario, effect, legacy)
yield* trace(options, scenario, "compare done")
}
}),
)
}
@@ -74,38 +51,18 @@ function runActive(options: Options, scenario: ActiveScenario) {
function runAuth(scenario: ActiveScenario) {
return Effect.gen(function* () {
const effect = yield* callAuthProbe("effect", scenario, "missing")
const legacy = yield* callAuthProbe("legacy", scenario, "missing")
if (scenario.auth === "protected") {
if (effect.status !== 401) throw new Error(`effect auth expected 401, got ${effect.status}`)
if (legacy.status !== 401) throw new Error(`legacy auth expected 401, got ${legacy.status}`)
const effectAuthed = yield* callAuthProbe("effect", scenario, "valid")
const legacyAuthed = yield* callAuthProbe("legacy", scenario, "valid")
if (effectAuthed.status === 401) throw new Error("effect auth rejected valid credentials")
if (legacyAuthed.status === 401) throw new Error("legacy auth rejected valid credentials")
return
}
if (effect.status === 401) throw new Error("effect auth expected public access, got 401")
if (legacy.status === 401) throw new Error("legacy auth expected public access, got 401")
if (effect.timedOut) throw new Error("effect auth expected public access, probe timed out")
if (legacy.timedOut) throw new Error("legacy auth expected public access, probe timed out")
})
}
function runBackend(options: Options, backend: "effect" | "legacy", scenario: ActiveScenario) {
return withContext(options, scenario, backend, (ctx) =>
Effect.gen(function* () {
yield* trace(options, scenario, `${backend} request start`)
const result = yield* call(backend, scenario, ctx)
yield* trace(options, scenario, `${backend} response ${result.status}`)
yield* trace(options, scenario, `${backend} expect start`)
yield* scenario.expect(ctx, ctx.state, result)
yield* trace(options, scenario, `${backend} expect done`)
return result
}),
)
}
function withContext<A, E>(
options: Options,
scenario: ActiveScenario,
@@ -300,19 +257,8 @@ function fakeLlmConfig(url: string): Partial<Config.Info> {
}
}
function compare(scenario: ActiveScenario, effect: CallResult, legacy: CallResult) {
return Effect.sync(() => {
if (effect.status !== legacy.status)
throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`)
if (scenario.compare === "status") return
if (stable(effect.body) !== stable(legacy.body))
throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`)
})
}
const resetState = Effect.promise(async () => {
const modules = await runtime()
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
await modules.disposeAllInstances()

View File

@@ -1,7 +1,6 @@
export type Runtime = {
PublicApi: (typeof import("../../../src/server/routes/instance/httpapi/public"))["PublicApi"]
ExperimentalHttpApiServer: (typeof import("../../../src/server/routes/instance/httpapi/server"))["ExperimentalHttpApiServer"]
Server: (typeof import("../../../src/server/server"))["Server"]
AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"]
InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"]
Instance: (typeof import("../../../src/project/instance"))["Instance"]
@@ -22,7 +21,6 @@ export function runtime() {
return (runtimePromise ??= (async () => {
const publicApi = await import("../../../src/server/routes/instance/httpapi/public")
const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server")
const server = await import("../../../src/server/server")
const appRuntime = await import("../../../src/effect/app-runtime")
const instanceRef = await import("../../../src/effect/instance-ref")
const instance = await import("../../../src/project/instance")
@@ -37,7 +35,6 @@ export function runtime() {
return {
PublicApi: publicApi.PublicApi,
ExperimentalHttpApiServer: httpApiServer.ExperimentalHttpApiServer,
Server: server.Server,
AppLayer: appRuntime.AppLayer,
InstanceRef: instanceRef.InstanceRef,
Instance: instance.Instance,

View File

@@ -10,8 +10,8 @@ export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const
export type Method = (typeof Methods)[number]
export type OpenApiMethod = (typeof OpenApiMethods)[number]
export type Mode = "effect" | "parity" | "coverage" | "auth"
export type Backend = "effect" | "legacy"
export type Mode = "effect" | "coverage" | "auth"
export type Backend = "effect"
export type Comparison = "none" | "status" | "json"
export type CaptureMode = "full" | "stream"
export type AuthPolicy = "protected" | "public" | "public-bypass" | "ticket-bypass"

View File

@@ -1,6 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
@@ -15,11 +14,9 @@ import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const testWorktreeMutations = process.platform === "win32" ? test.skip : test
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
}
@@ -39,7 +36,6 @@ async function waitReady(directory: string) {
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})

View File

@@ -1,122 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Server } from "../../src/server/server"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
}
async function waitDisposed(directory: string) {
await waitGlobalBusEventPromise({
message: "timed out waiting for instance disposal",
predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory,
})
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
describe("instance HttpApi", () => {
test("serves catalog read endpoints through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const [commands, agents, skills, lsp, formatter] = await Promise.all([
app().request(InstancePaths.command, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.agent, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.skill, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.lsp, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.formatter, { headers: { "x-opencode-directory": tmp.path } }),
])
expect(commands.status).toBe(200)
expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" }))
expect(agents.status).toBe(200)
expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" }))
expect(skills.status).toBe(200)
expect(await skills.json()).toBeArray()
expect(lsp.status).toBe(200)
expect(await lsp.json()).toEqual([])
expect(formatter.status).toBe(200)
expect(await formatter.json()).toEqual([])
})
test("serves project git init through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const disposed = waitDisposed(tmp.path)
const response = await app().request("/project/git/init", {
method: "POST",
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
await disposed
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
expect(current.status).toBe(200)
expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
})
test("serves project update through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
expect(current.status).toBe(200)
const project = (await current.json()) as { id: string }
const response = await app().request(`/project/${project.id}`, {
method: "PATCH",
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }),
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
id: project.id,
name: "patched-project",
commands: { start: "bun dev" },
})
const list = await app().request("/project", { headers: { "x-opencode-directory": tmp.path } })
expect(list.status).toBe(200)
expect(await list.json()).toContainEqual(
expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }),
)
})
test("serves instance dispose through Hono bridge", async () => {
await using tmp = await tmpdir()
const disposed = waitGlobalBusEventPromise({
message: "timed out waiting for instance disposal",
predicate: (event) => event.payload.type === "server.instance.disposed",
})
const response = await app().request(InstancePaths.dispose, {
method: "POST",
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toBe(true)
expect((await disposed).directory).toBe(tmp.path)
})
})

View File

@@ -14,22 +14,18 @@ import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
// Flip the experimental HttpApi flag so backend selection telemetry on the
// production routes reports the right backend, and the experimental
// workspaces flag so SyncEvent.run actually writes to EventSequenceTable
// (the source of truth the fence middleware reads). Reset the database
// around the test so per-instance state does not leak between runs.
// resetDatabase() already calls disposeAllInstances(), so we don't repeat it.
// Flip the experimental workspaces flag so SyncEvent.run actually writes to
// EventSequenceTable (the source of truth the fence middleware reads). Reset
// the database around the test so per-instance state does not leak between
// runs. resetDatabase() already calls disposeAllInstances(), so we don't
// repeat it.
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await resetDatabase()
}),

View File

@@ -1,254 +0,0 @@
import { afterEach, describe, expect } from "bun:test"
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 { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { MessageID, PartID } from "../../src/session/schema"
import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
function app(experimental: boolean) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
}
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)
}
const seedSessions = Effect.gen(function* () {
const svc = yield* Session.Service
const parent = yield* svc.create({ title: "parent" })
yield* svc.create({ title: "child", parentID: parent.id })
const message = yield* svc.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: parent.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
yield* svc.updatePart({
id: PartID.ascending(),
sessionID: parent.id,
messageID: message.id,
type: "text",
text: "hello",
})
return { parent, message }
})
function withTmp<A, E, R>(
options: Parameters<typeof tmpdir>[0],
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir(options)),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => fn(tmp).pipe(provideInstance(tmp.path))))
}
function readJson(label: string, serverApp: TestApp, path: string, headers: HeadersInit) {
return Effect.promise(async () => {
const response = await serverApp.request(path, { headers })
if (response.status !== 200) throw new Error(`${label} returned ${response.status}: ${await response.text()}`)
return await response.json()
})
}
function expectJsonParity(input: {
label: string
legacy: TestApp
httpapi: TestApp
path: string
headers: HeadersInit
}) {
return Effect.gen(function* () {
const legacy = yield* readJson(input.label, input.legacy, input.path, input.headers)
const httpapi = yield* readJson(input.label, input.httpapi, input.path, input.headers)
expect({ label: input.label, body: httpapi }).toEqual({ label: input.label, body: legacy })
return httpapi
})
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
describe("HttpApi JSON parity", () => {
it.live(
"matches legacy JSON shape for safe GET endpoints",
withTmp(
{
git: true,
config: {
formatter: false,
lsp: false,
mcp: {
demo: {
type: "local",
command: ["echo", "demo"],
enabled: false,
},
},
},
},
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(`${tmp.path}/hello.txt`, "hello\n"))
const headers = { "x-opencode-directory": tmp.path }
const legacy = app(false)
const httpapi = app(true)
yield* Effect.forEach(
[
{ label: "global.health", path: GlobalPaths.health, headers: {} },
{ label: "global.config", path: GlobalPaths.config, headers: {} },
{ label: "instance.path", path: InstancePaths.path, headers },
{ label: "instance.vcs", path: InstancePaths.vcs, headers },
{ label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers },
{ label: "instance.command", path: InstancePaths.command, headers },
{ label: "instance.agent", path: InstancePaths.agent, headers },
{ label: "instance.skill", path: InstancePaths.skill, headers },
{ label: "instance.lsp", path: InstancePaths.lsp, headers },
{ label: "instance.formatter", path: InstancePaths.formatter, headers },
{ label: "config.get", path: "/config", headers },
{ label: "config.providers", path: "/config/providers", headers },
{ label: "project.list", path: "/project", headers },
{ label: "project.current", path: "/project/current", headers },
{ label: "provider.list", path: "/provider", headers },
{ label: "provider.auth", path: "/provider/auth", headers },
{ label: "permission.list", path: "/permission", headers },
{ label: "question.list", path: "/question", headers },
{ label: "mcp.status", path: McpPaths.status, headers },
{ label: "pty.shells", path: PtyPaths.shells, headers },
{ label: "pty.list", path: PtyPaths.list, headers },
{ label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers },
{
label: "file.content",
path: `${FilePaths.content}?${new URLSearchParams({ path: "hello.txt" })}`,
headers,
},
{ label: "file.status", path: FilePaths.status, headers },
{
label: "find.file",
path: `${FilePaths.findFile}?${new URLSearchParams({ query: "hello", dirs: "false" })}`,
headers,
},
{
label: "find.text",
path: `${FilePaths.findText}?${new URLSearchParams({ pattern: "hello" })}`,
headers,
},
{
label: "find.symbol",
path: `${FilePaths.findSymbol}?${new URLSearchParams({ query: "hello" })}`,
headers,
},
{ label: "experimental.console", path: ExperimentalPaths.console, headers },
{ label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers },
{ label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers },
{ label: "experimental.worktree", path: ExperimentalPaths.worktree, headers },
{ label: "experimental.resource", path: ExperimentalPaths.resource, headers },
],
(input) => expectJsonParity({ ...input, legacy, httpapi }),
{ concurrency: 1 },
)
}),
),
)
it.live(
"matches legacy JSON shape for session read endpoints",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": tmp.path }
const seeded = yield* seedSessions.pipe(Effect.provide(Session.defaultLayer))
const legacy = app(false)
const httpapi = app(true)
const rootsFalse = yield* expectJsonParity({
label: "session.list roots false",
legacy,
httpapi,
path: `${SessionPaths.list}?roots=false`,
headers,
})
expect((rootsFalse as Session.Info[]).map((session) => session.id)).toContain(seeded.parent.id)
expect((rootsFalse as Session.Info[]).length).toBe(2)
const experimentalRootsFalse = yield* expectJsonParity({
label: "experimental.session roots false",
legacy,
httpapi,
path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", roots: "false" })}`,
headers,
})
expect((experimentalRootsFalse as Session.GlobalInfo[]).length).toBe(2)
const experimentalArchivedFalse = yield* expectJsonParity({
label: "experimental.session archived false",
legacy,
httpapi,
path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", archived: "false" })}`,
headers,
})
expect((experimentalArchivedFalse as Session.GlobalInfo[]).length).toBe(2)
yield* Effect.forEach(
[
{ label: "session.list roots", path: `${SessionPaths.list}?roots=true`, headers },
{ label: "session.list all", path: SessionPaths.list, headers },
{ label: "session.get", path: pathFor(SessionPaths.get, { sessionID: seeded.parent.id }), headers },
{
label: "session.children",
path: pathFor(SessionPaths.children, { sessionID: seeded.parent.id }),
headers,
},
{
label: "session.messages",
path: pathFor(SessionPaths.messages, { sessionID: seeded.parent.id }),
headers,
},
{
label: "session.messages empty before",
path: `${pathFor(SessionPaths.messages, { sessionID: seeded.parent.id })}?before=`,
headers,
},
{
label: "session.message",
path: pathFor(SessionPaths.message, { sessionID: seeded.parent.id, messageID: seeded.message.id }),
headers,
},
{
label: "experimental.session",
path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10" })}`,
headers,
},
],
(input) => expectJsonParity({ ...input, legacy, httpapi }),
{ concurrency: 1 },
)
}),
),
)
})

View File

@@ -10,7 +10,6 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
envPassword: process.env.OPENCODE_SERVER_PASSWORD,
@@ -20,7 +19,6 @@ const auth = { username: "opencode", password: "listen-secret" }
const testPty = process.platform === "win32" ? test.skip : test
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
if (original.envPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD
@@ -31,8 +29,7 @@ afterEach(async () => {
await resetDatabase()
})
async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi"
async function startListener() {
Flag.OPENCODE_SERVER_PASSWORD = auth.password
Flag.OPENCODE_SERVER_USERNAME = auth.username
process.env.OPENCODE_SERVER_PASSWORD = auth.password
@@ -40,8 +37,7 @@ async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpap
return Server.listen({ hostname: "127.0.0.1", port: 0 })
}
async function startNoAuthListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi"
async function startNoAuthListener() {
Flag.OPENCODE_SERVER_PASSWORD = undefined
Flag.OPENCODE_SERVER_USERNAME = auth.username
delete process.env.OPENCODE_SERVER_PASSWORD
@@ -212,22 +208,6 @@ describe("HttpApi Server.listen", () => {
}
})
testPty("serves PTY websocket tickets through legacy Hono Server.listen", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener("hono")
try {
const info = await createCat(listener, tmp.path)
const ticket = await connectTicket(listener, info.id, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket))
const message = waitForMessage(ws, (message) => message.includes("ping-hono-ticket"))
ws.send("ping-hono-ticket\n")
expect(await message).toContain("ping-hono-ticket")
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up hono listener").catch(() => undefined)
}
})
testPty("rejects unsafe PTY ticket mint and connect requests", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
@@ -300,20 +280,18 @@ describe("HttpApi Server.listen", () => {
}
})
for (const backend of ["effect-httpapi", "hono"] as const) {
testPty(`keeps PTY websocket tickets optional when server auth is disabled (${backend})`, async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startNoAuthListener(backend)
try {
const info = await createCat(listener, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path))
const message = waitForMessage(ws, (message) => message.includes(`ping-no-auth-${backend}`))
ws.send(`ping-no-auth-${backend}\n`)
expect(await message).toContain(`ping-no-auth-${backend}`)
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined)
}
})
}
testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startNoAuthListener()
try {
const info = await createCat(listener, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path))
const message = waitForMessage(ws, (message) => message.includes("ping-no-auth"))
ws.send("ping-no-auth\n")
expect(await message).toContain("ping-no-auth")
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined)
}
})
})

View File

@@ -1,7 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test"
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/groups/mcp"
import { Instance } from "../../src/project/instance"
@@ -15,13 +14,11 @@ import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const context = Context.empty() as Context.Context<unknown>
const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer))
function app(experimental: boolean) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
function app() {
return Server.Default().app
}
type TestApp = ReturnType<typeof app>
@@ -79,7 +76,6 @@ const readResponse = Effect.fnUntraced(function* (input: { app: TestApp; path: s
})
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
@@ -165,23 +161,19 @@ describe("mcp HttpApi", () => {
})
it.live(
"matches legacy unsupported OAuth error responses",
"returns unsupported OAuth error responses",
withMcpProject((dir) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": dir }
const legacy = app(false)
const httpapi = app(true)
yield* Effect.forEach(["/mcp/demo/auth", "/mcp/demo/auth/authenticate"], (path) =>
Effect.gen(function* () {
const legacyResponse = yield* readResponse({ app: legacy, path, headers })
const httpapiResponse = yield* readResponse({ app: httpapi, path, headers })
const response = yield* readResponse({ app: app(), path, headers })
expect(legacyResponse).toEqual({
expect(response).toEqual({
status: 400,
body: JSON.stringify({ error: "MCP server demo does not support OAuth" }),
})
expect(httpapiResponse).toEqual(legacyResponse)
}),
)
}),

View File

@@ -1,127 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { Session } from "@/session/session"
import { MessageID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
function app(experimental: boolean) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
}
function createSessionWithMessages(directory: string, count: number) {
return WithInstance.provide({
directory,
fn: async () => {
const session = await runSession(Session.Service.use((svc) => svc.create({})))
for (let i = 0; i < count; i++) {
await runSession(
Effect.gen(function* () {
const svc = yield* Session.Service
yield* svc.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: session.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
}),
)
}
return session.id
},
})
}
// ──────────────────────────────────────────────────────────────────────────────
// Reproducer 1: Link header should reflect the request's actual Host header,
// not "localhost". HttpApi uses `new URL(request.url, "http://localhost")`
// which embeds localhost because request.url is path-only. Fix: use
// `HttpServerRequest.toURL(request)` which honors the Host header.
// ──────────────────────────────────────────────────────────────────────────────
describe("Link header host", () => {
test("HttpApi pagination Link header echoes request host", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const sessionID = await createSessionWithMessages(tmp.path, 3)
const response = await app(true).request(`/session/${sessionID}/message?limit=2`, {
headers: {
host: "opencode.test:4096",
"x-opencode-directory": tmp.path,
},
})
expect(response.status).toBe(200)
const link = response.headers.get("link")
expect(link).not.toBeNull()
// Link should contain the request's Host, not "localhost".
expect(link).toContain("opencode.test")
expect(link).not.toContain("localhost")
})
})
// ──────────────────────────────────────────────────────────────────────────────
// Reproducer 2: GET /session/{missing-id}/todo should return 404, not 500.
// The session.todo handler in HttpApi doesn't wrap with `mapNotFound`, so a
// `NotFoundError` from the service surfaces as a defect → 500. Hono's
// equivalent maps to 404 via `errors.notFound`.
//
// Affected endpoints (handlers without mapNotFound): todo, diff, summarize,
// fork, abort, init, deleteMessage, command, shell, revert, unrevert.
//
// FIXME: unskip when mapNotFound coverage is added (next PR).
// ──────────────────────────────────────────────────────────────────────────────
describe("404 mapping for missing session", () => {
test.todo("HttpApi /session/{missing}/todo returns 404 not 500", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const response = await app(true).request("/session/ses_does_not_exist/todo", {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(404)
})
})
// ──────────────────────────────────────────────────────────────────────────────
// Reproducer 3: 404 response body shape should match Hono's public NamedError
// envelope `{ name, data: { message } }`. SDK consumers read
// `error.data.message`, so returning an Effect built-in `{ _tag }` body is a
// compatibility break.
// ──────────────────────────────────────────────────────────────────────────────
describe("Error JSON shape parity", () => {
test("HttpApi 404 body matches Hono shape", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }
const hono = await app(false).request("/session/ses_does_not_exist", { headers })
const httpapi = await app(true).request("/session/ses_does_not_exist", { headers })
expect(httpapi.status).toBe(hono.status)
const body = (await httpapi.json()) as { name?: string; data?: { message?: string } }
expect(body).toEqual(await hono.json())
expect(body.name).toBe("NotFoundError")
expect(typeof body.data?.message).toBe("string")
})
})

View File

@@ -1,7 +1,6 @@
import { afterEach, describe, expect } from "bun:test"
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 { WithInstance } from "../../src/project/with-instance"
import { InstanceRuntime } from "../../src/project/instance-runtime"
@@ -13,15 +12,13 @@ import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer))
const providerID = "test-oauth-parity"
const oauthURL = "https://example.com/oauth"
const oauthInstructions = "Finish OAuth"
function app(experimental: boolean) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
function app() {
return Server.Default().app
}
function requestAuthorize(input: {
@@ -101,54 +98,37 @@ function withProviderProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E,
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
describe("provider HttpApi", () => {
it.live(
"matches legacy OAuth authorize response shapes",
"serves OAuth authorize response shapes",
withProviderProject((dir) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": dir, "content-type": "application/json" }
const legacy = app(false)
const httpapi = app(true)
const server = app()
const apiLegacy = yield* requestAuthorize({
app: legacy,
const api = yield* requestAuthorize({
app: server,
providerID,
method: 0,
headers,
})
const apiHttpApi = yield* requestAuthorize({
app: httpapi,
providerID,
method: 0,
headers,
})
expect(apiLegacy).toEqual({ status: 200, body: "" })
// #26474 changed the HTTP API authorize handler to serialize an
// undefined service result as JSON `null` instead of an empty body
// so clients can `.json()` parse the response uniformly. The legacy
// Hono path still emits an empty body (`c.json(undefined)`); the new
// backend's body diverges intentionally.
expect(apiHttpApi).toEqual({ status: 200, body: "null" })
// method 0 (api-key style) — authorize() resolves with no further
// redirect; #26474 changed the wire format to JSON `null` so clients
// can `.json()` parse uniformly instead of getting an empty body
// that throws.
expect(api).toEqual({ status: 200, body: "null" })
const oauthLegacy = yield* requestAuthorize({
app: legacy,
const oauth = yield* requestAuthorize({
app: server,
providerID,
method: 1,
headers,
})
const oauthHttpApi = yield* requestAuthorize({
app: httpapi,
providerID,
method: 1,
headers,
})
expect(oauthHttpApi).toEqual(oauthLegacy)
expect(JSON.parse(oauthHttpApi.body)).toEqual({
expect(JSON.parse(oauth.body)).toEqual({
url: oauthURL,
method: "code",
instructions: oauthInstructions,

View File

@@ -1,6 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test"
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
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"
@@ -17,16 +16,13 @@ import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const testPty = process.platform === "win32" ? test.skip : test
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await resetDatabase()
}),
)
@@ -50,9 +46,8 @@ const effectIt = testEffect(
),
)
function app(experimental = true) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
function app() {
return Server.Default().app
}
function serverUrl() {
@@ -62,7 +57,6 @@ function serverUrl() {
const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir)
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
@@ -121,18 +115,6 @@ describe("pty HttpApi bridge", () => {
expect(missing.status).toBe(404)
})
test("matches Hono missing PTY error body", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }
const path = PtyPaths.get.replace(":ptyID", PtyID.ascending())
const hono = await app(false).request(path, { headers })
const httpapi = await app().request(path, { headers })
expect(httpapi.status).toBe(hono.status)
expect(await httpapi.json()).toEqual(await hono.json())
})
test("returns 404 for missing PTY websocket before upgrade", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), {

View File

@@ -1,7 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test"
import { ConfigProvider, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
@@ -13,10 +12,8 @@ import * as Log from "@opencode-ai/core/util/log"
void Log.init({ print: false })
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
function app(input: { password?: string; username?: string }) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.routes.pipe(
Layer.provide(
@@ -48,7 +45,6 @@ async function cancelBody(response: Response) {
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
await disposeAllInstances()
await resetDatabase()
})

View File

@@ -22,23 +22,21 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
}
type Backend = "legacy" | "httpapi"
type ServerPath = "default" | "raw"
type Sdk = ReturnType<typeof createOpencodeClient>
type SdkResult = { response: Response; data?: unknown; error?: unknown }
type Captured = { status: number; data?: unknown; error?: unknown }
type ProjectFixture = { sdk: Sdk; directory: string }
type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] }
function app(backend: Backend, input?: { password?: string; username?: string }) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi"
function app(serverPath: ServerPath, input?: { password?: string; username?: string }) {
Flag.OPENCODE_SERVER_PASSWORD = input?.password
Flag.OPENCODE_SERVER_USERNAME = input?.username
if (backend === "legacy") return Server.Legacy().app
if (serverPath === "default") return Server.Default().app
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.routes.pipe(
@@ -62,7 +60,7 @@ function app(backend: Backend, input?: { password?: string; username?: string })
}
function client(
backend: Backend,
serverPath: ServerPath,
directory?: string,
input?: { password?: string; username?: string; headers?: Record<string, string> },
) {
@@ -70,12 +68,12 @@ function client(
baseUrl: "http://localhost",
directory,
headers: input?.headers,
fetch: serverFetch(backend, input),
fetch: serverFetch(serverPath, input),
})
}
function serverFetch(backend: Backend, input?: { password?: string; username?: string }) {
const serverApp = app(backend, input)
function serverFetch(serverPath: ServerPath, input?: { password?: string; username?: string }) {
const serverApp = app(serverPath, input)
return Object.assign(
async (request: RequestInfo | URL, init?: RequestInit) =>
await serverApp.fetch(request instanceof Request ? request : new Request(request, init)),
@@ -194,20 +192,20 @@ function httpapi<A, E>(name: string, effect: Effect.Effect<A, E, Scope.Scope>) {
it.live(name, effect)
}
function parity<A, E>(name: string, scenario: (backend: Backend) => Effect.Effect<A, E, Scope.Scope>) {
function serverPathParity<A, E>(name: string, scenario: (serverPath: ServerPath) => Effect.Effect<A, E, Scope.Scope>) {
it.live(
name,
Effect.gen(function* () {
const legacy = yield* scenario("legacy")
const standard = yield* scenario("default")
yield* resetState()
const httpapi = yield* scenario("httpapi")
expect(httpapi).toEqual(legacy)
const raw = yield* scenario("raw")
expect(raw).toEqual(standard)
}),
)
}
function withProject<A, E, R>(
backend: Backend,
serverPath: ServerPath,
options: { git?: boolean; config?: Partial<Config.Info>; setup?: (dir: string) => Effect.Effect<void> },
run: (input: ProjectFixture) => Effect.Effect<A, E, R>,
) {
@@ -216,30 +214,30 @@ function withProject<A, E, R>(
(tmp) => call(() => tmp[Symbol.asyncDispose]()).pipe(Effect.ignore),
).pipe(
Effect.tap((tmp) => options.setup?.(tmp.path) ?? Effect.void),
Effect.flatMap((tmp) => run({ sdk: client(backend, tmp.path), directory: tmp.path })),
Effect.flatMap((tmp) => run({ sdk: client(serverPath, tmp.path), directory: tmp.path })),
)
}
function withStandardProject<A, E, R>(backend: Backend, run: (input: ProjectFixture) => Effect.Effect<A, E, R>) {
return withProject(backend, { setup: writeStandardFiles }, run)
function withStandardProject<A, E, R>(serverPath: ServerPath, run: (input: ProjectFixture) => Effect.Effect<A, E, R>) {
return withProject(serverPath, { setup: writeStandardFiles }, run)
}
function withFakeLlm<A, E, R>(backend: Backend, run: (input: LlmProjectFixture) => Effect.Effect<A, E, R>) {
function withFakeLlm<A, E, R>(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const llm = yield* TestLLMServer
return yield* withProject(backend, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm }))
return yield* withProject(serverPath, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm }))
}).pipe(Effect.provide(TestLLMServer.layer))
}
function withFakeLlmProject<A, E, R>(
backend: Backend,
serverPath: ServerPath,
options: { setup?: (dir: string) => Effect.Effect<void> },
run: (input: LlmProjectFixture) => Effect.Effect<A, E, R>,
) {
return Effect.gen(function* () {
const llm = yield* TestLLMServer
return yield* withProject(
backend,
serverPath,
{
config: providerConfig(llm.url),
setup: options.setup,
@@ -306,7 +304,6 @@ function seedMessage(directory: string, sessionID: string) {
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
await disposeAllInstances()
@@ -317,7 +314,7 @@ describe("HttpApi SDK", () => {
httpapi(
"uses the generated SDK for global and control routes",
Effect.gen(function* () {
const sdk = client("httpapi")
const sdk = client("raw")
const health = yield* call(() => sdk.global.health())
const log = yield* call(() => sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" }))
@@ -334,7 +331,7 @@ describe("HttpApi SDK", () => {
httpapi(
"uses the generated SDK for safe instance routes",
withProject("httpapi", { git: false, setup: writeStandardFiles }, ({ sdk }) =>
withProject("raw", { git: false, setup: writeStandardFiles }, ({ sdk }) =>
Effect.gen(function* () {
const file = yield* call(() => sdk.file.read({ path: "hello.txt" }))
const session = yield* call(() => sdk.session.create({ title: "sdk" }))
@@ -357,9 +354,9 @@ describe("HttpApi SDK", () => {
),
)
parity("matches generated SDK global and control behavior across backends", (backend) =>
serverPathParity("matches generated SDK global and control behavior", (serverPath) =>
Effect.gen(function* () {
const sdk = client(backend)
const sdk = client(serverPath)
const health = yield* capture(() => sdk.global.health())
const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" }))
const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" }))
@@ -372,22 +369,22 @@ describe("HttpApi SDK", () => {
}),
)
parity("matches generated SDK global event stream across backends", (backend) =>
firstEvent(() => client(backend).global.event({ signal: AbortSignal.timeout(1_000) })).pipe(
serverPathParity("matches generated SDK global event stream", (serverPath) =>
firstEvent(() => client(serverPath).global.event({ signal: AbortSignal.timeout(1_000) })).pipe(
Effect.map((event) => ({ type: record(record(event).payload).type })),
),
)
parity("matches generated SDK instance event stream across backends", (backend) =>
withStandardProject(backend, ({ sdk }) =>
serverPathParity("matches generated SDK instance event stream", (serverPath) =>
withStandardProject(serverPath, ({ sdk }) =>
firstEvent(() => sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) })).pipe(
Effect.map((event) => ({ type: record(record(event).payload).type })),
),
),
)
parity("matches generated SDK missing session errors across backends", (backend) =>
withStandardProject(backend, ({ sdk }) =>
serverPathParity("matches generated SDK missing session errors", (serverPath) =>
withStandardProject(serverPath, ({ sdk }) =>
Effect.gen(function* () {
const sessionID = "ses_missing"
const expected = {
@@ -408,8 +405,8 @@ describe("HttpApi SDK", () => {
),
)
parity("formats missing session validation errors for -s", (backend) =>
withStandardProject(backend, ({ directory }) =>
serverPathParity("formats missing session validation errors for -s", (serverPath) =>
withStandardProject(serverPath, ({ directory }) =>
Effect.gen(function* () {
const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T"
const thrown = yield* captureThrown(() =>
@@ -417,7 +414,7 @@ describe("HttpApi SDK", () => {
url: "http://localhost",
directory,
sessionID,
fetch: serverFetch(backend),
fetch: serverFetch(serverPath),
}),
)
expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`)
@@ -426,20 +423,21 @@ describe("HttpApi SDK", () => {
),
)
parity("matches generated SDK basic auth behavior across backends", (backend) =>
withStandardProject(backend, ({ directory }) =>
httpapi(
"uses generated SDK basic auth behavior",
withStandardProject("raw", ({ directory }) =>
Effect.gen(function* () {
const missing = yield* capture(() =>
client(backend, directory, { password: "secret" }).file.read({ path: "hello.txt" }),
client("raw", directory, { password: "secret" }).file.read({ path: "hello.txt" }),
)
const bad = yield* capture(() =>
client(backend, directory, {
client("raw", directory, {
password: "secret",
headers: { authorization: authorization("opencode", "wrong") },
}).file.read({ path: "hello.txt" }),
)
const good = yield* capture(() =>
client(backend, directory, {
client("raw", directory, {
password: "secret",
headers: { authorization: authorization("opencode", "secret") },
}).file.read({ path: "hello.txt" }),
@@ -453,8 +451,8 @@ describe("HttpApi SDK", () => {
),
)
parity("matches generated SDK instance read routes across backends", (backend) =>
withStandardProject(backend, ({ sdk, directory }) =>
serverPathParity("matches generated SDK instance read routes", (serverPath) =>
withStandardProject(serverPath, ({ sdk, directory }) =>
Effect.gen(function* () {
const project = yield* capture(() => sdk.project.current())
const projects = yield* capture(() => sdk.project.list())
@@ -504,8 +502,8 @@ describe("HttpApi SDK", () => {
),
)
parity("matches generated SDK session lifecycle routes across backends", (backend) =>
withStandardProject(backend, ({ sdk }) =>
serverPathParity("matches generated SDK session lifecycle routes", (serverPath) =>
withStandardProject(serverPath, ({ sdk }) =>
Effect.gen(function* () {
const parent = yield* capture(() => sdk.session.create({ title: "parent" }))
const parentID = String(record(parent.data).id)
@@ -557,8 +555,8 @@ describe("HttpApi SDK", () => {
),
)
parity("matches generated SDK session message and part routes across backends", (backend) =>
withStandardProject(backend, ({ sdk, directory }) =>
serverPathParity("matches generated SDK session message and part routes", (serverPath) =>
withStandardProject(serverPath, ({ sdk, directory }) =>
Effect.gen(function* () {
const session = yield* capture(() => sdk.session.create({ title: "messages" }))
const sessionID = String(record(session.data).id)
@@ -609,8 +607,8 @@ describe("HttpApi SDK", () => {
),
)
parity("matches generated SDK prompt no-reply routes across backends", (backend) =>
withStandardProject(backend, ({ sdk }) =>
serverPathParity("matches generated SDK prompt no-reply routes", (serverPath) =>
withStandardProject(serverPath, ({ sdk }) =>
Effect.gen(function* () {
const session = yield* capture(() => sdk.session.create({ title: "prompt" }))
const sessionID = String(record(session.data).id)
@@ -646,8 +644,8 @@ describe("HttpApi SDK", () => {
),
)
parity("matches generated SDK prompt streaming through fake LLM across backends", (backend) =>
withFakeLlm(backend, ({ sdk, llm }) =>
serverPathParity("matches generated SDK prompt streaming through fake LLM", (serverPath) =>
withFakeLlm(serverPath, ({ sdk, llm }) =>
Effect.gen(function* () {
yield* llm.text("fake world", { usage: { input: 11, output: 7 } })
const session = yield* capture(() =>
@@ -682,7 +680,7 @@ describe("HttpApi SDK", () => {
httpapi(
"includes project skills in REST API async prompt context",
withFakeLlmProject("httpapi", { setup: writeProjectSkill }, ({ sdk, llm }) =>
withFakeLlmProject("default", { setup: writeProjectSkill }, ({ sdk, llm }) =>
Effect.gen(function* () {
yield* llm.text("skill context ok", { usage: { input: 11, output: 7 } })
const session = yield* capture(() =>
@@ -710,8 +708,8 @@ describe("HttpApi SDK", () => {
),
)
parity("matches generated SDK TUI validation and command routes across backends", (backend) =>
withStandardProject(backend, ({ sdk }) =>
serverPathParity("matches generated SDK TUI validation and command routes", (serverPath) =>
withStandardProject(serverPath, ({ sdk }) =>
Effect.gen(function* () {
const session = yield* capture(() => sdk.session.create({ title: "tui" }))
const sessionID = String(record(session.data).id)
@@ -761,8 +759,8 @@ describe("HttpApi SDK", () => {
),
)
parity("matches generated SDK project git initialization across backends", (backend) =>
withProject(backend, { git: false }, ({ sdk, directory }) =>
serverPathParity("matches generated SDK project git initialization", (serverPath) =>
withProject(serverPath, { git: false }, ({ sdk, directory }) =>
Effect.gen(function* () {
const before = yield* capture(() => sdk.project.current())
const init = yield* capture(() => sdk.project.initGit())

View File

@@ -30,16 +30,14 @@ import { it } from "../lib/effect"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
)
function app(experimental = true) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
function app() {
return Server.Default().app
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
@@ -119,10 +117,6 @@ function request(path: string, init?: RequestInit) {
return Effect.promise(async () => app().request(path, init))
}
function requestWithBackend(experimental: boolean, path: string, init?: RequestInit) {
return Effect.promise(async () => app(experimental).request(path, init))
}
function json<T>(response: Response) {
return Effect.promise(async () => {
if (response.status !== 200) throw new Error(await response.text())
@@ -149,7 +143,6 @@ function withTmp<A, E, R>(
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await disposeAllInstances()
await resetDatabase()
@@ -198,7 +191,7 @@ describe("session HttpApi", () => {
)
it.live(
"serves read routes through Hono bridge",
"serves read routes",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": tmp.path }
@@ -305,7 +298,7 @@ describe("session HttpApi", () => {
)
it.live(
"serves lifecycle mutation routes through Hono bridge",
"serves lifecycle mutation routes",
withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
@@ -389,39 +382,26 @@ describe("session HttpApi", () => {
)
it.live(
"matches legacy archived timestamp validation",
"validates archived timestamp values",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
const legacy = yield* createSession(tmp.path, { title: "legacy" })
const effect = yield* createSession(tmp.path, { title: "effect" })
const session = yield* createSession(tmp.path, { title: "archived" })
const body = JSON.stringify({ time: { archived: -1 } })
const legacyResponse = yield* requestWithBackend(
false,
pathFor(SessionPaths.update, { sessionID: legacy.id }),
{
method: "PATCH",
headers,
body,
},
)
expect(legacyResponse.status).toBe(200)
expect((yield* json<Session.Info>(legacyResponse)).time.archived).toBe(-1)
const effectResponse = yield* requestWithBackend(true, pathFor(SessionPaths.update, { sessionID: effect.id }), {
const response = yield* request(pathFor(SessionPaths.update, { sessionID: session.id }), {
method: "PATCH",
headers,
body,
})
expect(effectResponse.status).toBe(legacyResponse.status)
expect((yield* json<Session.Info>(effectResponse)).time.archived).toBe(-1)
expect(response.status).toBe(200)
expect((yield* json<Session.Info>(response)).time.archived).toBe(-1)
}),
),
)
it.live(
"matches legacy project-scoped path and directory precedence",
"uses project-scoped path and directory precedence",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const currentDir = path.join(tmp.path, "packages", "opencode", "src")
@@ -441,22 +421,18 @@ describe("session HttpApi", () => {
directory: currentDir,
})
const headers = { "x-opencode-directory": tmp.path }
const legacy = (yield* json<Session.Info[]>(
yield* requestWithBackend(false, `${SessionPaths.list}?${query}`, { headers }),
)).map((item) => item.id)
const effect = (yield* json<Session.Info[]>(
yield* requestWithBackend(true, `${SessionPaths.list}?${query}`, { headers }),
)).map((item) => item.id)
const sessions = (yield* json<Session.Info[]>(yield* request(`${SessionPaths.list}?${query}`, { headers }))).map(
(item) => item.id,
)
expect(legacy).toContain(pathSession.id)
expect(legacy).not.toContain(pathlessSession.id)
expect(effect).toEqual(legacy)
expect(sessions).toContain(pathSession.id)
expect(sessions).not.toContain(pathlessSession.id)
}),
),
)
it.live(
"matches legacy paginated message link headers",
"serves paginated message link headers",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": tmp.path }
@@ -465,20 +441,17 @@ describe("session HttpApi", () => {
yield* createTextMessage(tmp.path, session.id, "second")
const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1`
const legacy = yield* requestWithBackend(false, route, { headers })
const effect = yield* requestWithBackend(true, route, { headers })
const response = yield* request(route, { headers })
expect(effect.headers.get("x-next-cursor")).toBe(legacy.headers.get("x-next-cursor"))
expect(effect.headers.get("link")).toBe(legacy.headers.get("link"))
expect(effect.headers.get("access-control-expose-headers")).toBe(
legacy.headers.get("access-control-expose-headers"),
)
expect(response.headers.get("x-next-cursor")).toBeTruthy()
expect(response.headers.get("link")).toContain("limit=1")
expect(response.headers.get("access-control-expose-headers")?.toLowerCase()).toContain("x-next-cursor")
}),
),
)
it.live(
"serves message mutation routes through Hono bridge",
"serves message mutation routes",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
@@ -522,7 +495,7 @@ describe("session HttpApi", () => {
)
it.live(
"serves remaining non-LLM session mutation routes through Hono bridge",
"serves remaining non-LLM session mutation routes",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }

View File

@@ -13,13 +13,11 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const context = Context.empty() as Context.Context<unknown>
function app(httpapi = true) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi
return httpapi ? Server.Default().app : Server.Legacy().app
function app() {
return Server.Default().app
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
@@ -28,14 +26,13 @@ function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
afterEach(async () => {
mock.restore()
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await disposeAllInstances()
await resetDatabase()
})
describe("sync HttpApi", () => {
test("serves sync routes through Hono bridge", async () => {
test("serves sync routes", async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
@@ -87,7 +84,7 @@ describe("sync HttpApi", () => {
expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
})
test("matches legacy seq validation", async () => {
test("validates seq values", 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 = [
@@ -116,18 +113,12 @@ describe("sync HttpApi", () => {
]
for (const item of cases) {
const legacy = await app(false).request(item.path, {
const response = await app().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)
expect(response.status).toBe(400)
}
})

View File

@@ -1,129 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { Context } from "hono"
import { Flag } from "@opencode-ai/core/flag/flag"
import { TuiEvent } from "../../src/cli/cmd/tui/event"
import { SessionID } from "../../src/session/schema"
import { Instance } from "../../src/project/instance"
import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/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 { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
function app(experimental = true) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
}
function nextCommandExecute() {
return waitGlobalBusEventPromise({
predicate: (event) => event.payload.type === TuiEvent.CommandExecute.type,
}).then((event) => event.payload.properties?.command)
}
async function expectTrue(path: string, headers: Record<string, string>, body?: unknown) {
const response = await app().request(path, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify(body ?? {}),
})
expect(response.status).toBe(200)
expect(await response.json()).toBe(true)
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
describe("tui HttpApi bridge", () => {
test("documents legacy bad request responses", async () => {
const legacy = await Server.openapiHono()
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 }
await expectTrue(TuiPaths.appendPrompt, headers, { text: "hello" })
await expectTrue(TuiPaths.openHelp, headers)
await expectTrue(TuiPaths.openSessions, headers)
await expectTrue(TuiPaths.openThemes, headers)
await expectTrue(TuiPaths.openModels, headers)
await expectTrue(TuiPaths.submitPrompt, headers)
await expectTrue(TuiPaths.clearPrompt, headers)
await expectTrue(TuiPaths.executeCommand, headers, { command: "agent_cycle" })
await expectTrue(TuiPaths.showToast, headers, { message: "Saved", variant: "success" })
await expectTrue(TuiPaths.publish, headers, {
type: "tui.prompt.append",
properties: { text: "from publish" },
})
const missingSessionID = SessionID.descending()
const missing = await app().request(TuiPaths.selectSession, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ sessionID: missingSessionID }),
})
expect(missing.status).toBe(404)
})
test("matches Hono missing selected session error body", 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 body = JSON.stringify({ sessionID: SessionID.descending() })
const hono = await app(false).request(TuiPaths.selectSession, { method: "POST", headers, body })
const httpapi = await app().request(TuiPaths.selectSession, { method: "POST", headers, body })
expect(httpapi.status).toBe(hono.status)
expect(await httpapi.json()).toEqual(await hono.json())
})
test("matches legacy unknown execute command behavior", 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 body = JSON.stringify({ command: "unknown_command" })
const legacyCommand = nextCommandExecute()
const legacy = await app(false).request(TuiPaths.executeCommand, { method: "POST", headers, body })
expect(legacy.status).toBe(200)
expect(await legacy.json()).toBe(true)
const effectCommand = nextCommandExecute()
const effect = await app().request(TuiPaths.executeCommand, { method: "POST", headers, body })
expect(effect.status).toBe(200)
expect(await effect.json()).toBe(true)
const legacyPublished = await legacyCommand
const effectPublished = await effectCommand
expect(effectPublished).toBe(legacyPublished)
expect(legacyPublished).toBeUndefined()
})
test("serves TUI control queue through experimental Effect routes", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const pending = callTui({ req: { json: async () => ({ value: 1 }), path: "/demo" } } as unknown as Context)
const headers = { "x-opencode-directory": tmp.path }
const next = await app().request(TuiPaths.controlNext, { headers })
expect(next.status).toBe(200)
expect(await next.json()).toEqual({ path: "/demo", body: { value: 1 } })
await expectTrue(TuiPaths.controlResponse, headers, { ok: true })
expect(await pending).toEqual({ ok: true })
})
})

View File

@@ -22,7 +22,6 @@ import { Server } from "../../src/server/server"
void Log.init({ print: false })
const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
OPENCODE_DISABLE_EMBEDDED_WEB_UI: Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI,
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
@@ -31,7 +30,6 @@ const original = {
}
afterEach(() => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = original.OPENCODE_DISABLE_EMBEDDED_WEB_UI
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
@@ -117,7 +115,6 @@ function httpClient(response: Response, onRequest?: (request: HttpClientRequest.
describe("HttpApi UI fallback", () => {
test("serves the web UI through the experimental backend", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
let proxiedUrl: string | undefined
@@ -137,7 +134,6 @@ describe("HttpApi UI fallback", () => {
})
test("strips upstream transfer encoding headers from proxied assets", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
let proxiedUrl: string | undefined
@@ -189,7 +185,6 @@ describe("HttpApi UI fallback", () => {
// forwarded through the proxy while the proxy itself re-frames the body,
// causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`.
test("strips upstream transfer-encoding header from proxied assets", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
const response = await Effect.runPromise(
@@ -232,7 +227,6 @@ describe("HttpApi UI fallback", () => {
})
test("serves embedded UI assets when Bun can read them but access reports missing", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
let readPath: string | undefined
const response = await Effect.runPromise(
@@ -262,7 +256,6 @@ describe("HttpApi UI fallback", () => {
})
test("allows embedded UI terminal wasm and theme preload CSP", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
const script = 'document.documentElement.dataset.theme = "dark"'
const response = await Effect.runPromise(
@@ -294,7 +287,6 @@ describe("HttpApi UI fallback", () => {
})
test("keeps matched API routes ahead of the UI fallback", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
const response = await Server.Default().app.request("/session/nope")
@@ -302,7 +294,6 @@ describe("HttpApi UI fallback", () => {
})
test("requires server password for the web UI", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
const response = await uiApp({ password: "secret", username: "opencode" }).request("/")
@@ -312,7 +303,6 @@ describe("HttpApi UI fallback", () => {
})
test("accepts auth token for the web UI", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
const response = await uiApp({
@@ -326,7 +316,6 @@ describe("HttpApi UI fallback", () => {
})
test("accepts basic auth for the web UI", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
const response = await uiApp({ password: "secret", username: "opencode" }).request("/", {
@@ -342,7 +331,6 @@ describe("HttpApi UI fallback", () => {
// server returning 401 breaks PWA install. These specific public assets
// should bypass auth.
test("serves the PWA manifest without auth even when a server password is set", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) {
@@ -356,7 +344,6 @@ describe("HttpApi UI fallback", () => {
})
test("allows web UI preflight without auth", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
const response = await app({ password: "secret", username: "opencode" }).request("/", {
method: "OPTIONS",

View File

@@ -24,16 +24,14 @@ import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
)
const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer))
function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) {
function request(path: string, directory: string, init: RequestInit = {}) {
return Effect.promise(() => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpApi
const headers = new Headers(init.headers)
headers.set("x-opencode-directory", directory)
return Promise.resolve(Server.Default().app.request(path, { ...init, headers }))
@@ -161,7 +159,6 @@ function eventStreamResponse() {
afterEach(async () => {
mock.restore()
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
await disposeAllInstances()
await resetDatabase()
})
@@ -289,32 +286,6 @@ describe("workspace HttpApi", () => {
}),
)
it.live("documents legacy Hono accepting the TUI payload shape", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
const created = yield* request(
WorkspacePaths.list,
dir,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "local-test", branch: null }),
},
false,
)
expect(created.status).toBe(200)
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
type: "local-test",
name: "local-test",
})
}),
)
it.live("routes local workspace requests through the workspace target directory", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true

View File

@@ -1,76 +0,0 @@
import { describe, expect, test } from "bun:test"
import { paramToAttributeKey, requestAttributes } from "../../src/server/routes/instance/trace"
function fakeContext(method: string, url: string, params: Record<string, string>) {
return {
req: {
method,
url,
param: () => params,
},
}
}
describe("paramToAttributeKey", () => {
test("converts fooID to foo.id", () => {
expect(paramToAttributeKey("sessionID")).toBe("session.id")
expect(paramToAttributeKey("messageID")).toBe("message.id")
expect(paramToAttributeKey("partID")).toBe("part.id")
expect(paramToAttributeKey("projectID")).toBe("project.id")
expect(paramToAttributeKey("providerID")).toBe("provider.id")
expect(paramToAttributeKey("ptyID")).toBe("pty.id")
expect(paramToAttributeKey("permissionID")).toBe("permission.id")
expect(paramToAttributeKey("requestID")).toBe("request.id")
expect(paramToAttributeKey("workspaceID")).toBe("workspace.id")
})
test("namespaces non-ID params under opencode.", () => {
expect(paramToAttributeKey("name")).toBe("opencode.name")
expect(paramToAttributeKey("slug")).toBe("opencode.slug")
})
})
describe("requestAttributes", () => {
test("includes http method and path", () => {
const attrs = requestAttributes(fakeContext("GET", "http://localhost/session", {}))
expect(attrs["http.method"]).toBe("GET")
expect(attrs["http.path"]).toBe("/session")
})
test("strips query string from path", () => {
const attrs = requestAttributes(fakeContext("GET", "http://localhost/file/search?query=foo&limit=10", {}))
expect(attrs["http.path"]).toBe("/file/search")
})
test("emits OTel-style <domain>.id for ID-shaped route params", () => {
const attrs = requestAttributes(
fakeContext("GET", "http://localhost/session/ses_abc/message/msg_def/part/prt_ghi", {
sessionID: "ses_abc",
messageID: "msg_def",
partID: "prt_ghi",
}),
)
expect(attrs["session.id"]).toBe("ses_abc")
expect(attrs["message.id"]).toBe("msg_def")
expect(attrs["part.id"]).toBe("prt_ghi")
// No camelCase leftovers:
expect(attrs["opencode.sessionID"]).toBeUndefined()
expect(attrs["opencode.messageID"]).toBeUndefined()
expect(attrs["opencode.partID"]).toBeUndefined()
})
test("produces no param attributes when no params are matched", () => {
const attrs = requestAttributes(fakeContext("POST", "http://localhost/config", {}))
expect(Object.keys(attrs).filter((k) => k !== "http.method" && k !== "http.path")).toEqual([])
})
test("namespaces non-ID params under opencode. (e.g. mcp :name)", () => {
const attrs = requestAttributes(
fakeContext("POST", "http://localhost/mcp/exa/connect", {
name: "exa",
}),
)
expect(attrs["opencode.name"]).toBe("exa")
expect(attrs["name"]).toBeUndefined()
})
})

View File

@@ -13,16 +13,13 @@ import { testEffect } from "../lib/effect"
const stateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
}
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES
await resetDatabase()
}),