research: delete Hono backend (do not merge) (#25667)
This commit is contained in:
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {} })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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("")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}),
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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()), {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user