mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-25 05:44:37 +00:00
Prepare Effect HttpApi backend parity (#24853)
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
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/control"
|
||||
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file"
|
||||
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/global"
|
||||
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
|
||||
import { FileApi, 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 { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -57,16 +57,32 @@ function openApiParameters(spec: { paths: Record<string, Partial<Record<(typeof
|
||||
)
|
||||
}
|
||||
|
||||
function openApiRequestBodies(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }) {
|
||||
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(item[method]?.requestBody)]),
|
||||
.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
|
||||
@@ -74,7 +90,7 @@ type Operation = {
|
||||
}
|
||||
|
||||
type RequestBody = {
|
||||
content?: Record<string, { schema?: { $ref?: string; type?: string } }>
|
||||
content?: Record<string, { schema?: OpenApiSchema }>
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
@@ -97,17 +113,27 @@ function parameterSchema(input: {
|
||||
return param.schema
|
||||
}
|
||||
|
||||
function requestBodyKey(body: unknown) {
|
||||
function requestBodyKey(spec: OpenApiSpec, body: unknown) {
|
||||
if (!body || typeof body !== "object" || !("content" in body)) return ""
|
||||
const requestBody = body as RequestBody
|
||||
return JSON.stringify({
|
||||
required: requestBody.required === true,
|
||||
content: Object.entries(requestBody.content ?? {})
|
||||
.map(([type, value]) => [type, value.schema?.$ref ?? value.schema?.type ?? "inline"])
|
||||
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)])
|
||||
.sort(),
|
||||
})
|
||||
}
|
||||
|
||||
function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) {
|
||||
if (!schema) return ""
|
||||
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
|
||||
@@ -146,6 +172,14 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
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.openapi())
|
||||
const effectRoutes = openApiRouteKeys(effectOpenApi())
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { Session } from "@/session/session"
|
||||
import { Database } from "@/storage/db"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Context } from "effect"
|
||||
import path from "path"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { FilePaths } from "../../src/server/routes/instance/httpapi/file"
|
||||
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/instance"
|
||||
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 { tmpdir } from "../fixture/fixture"
|
||||
|
||||
@@ -4,8 +4,8 @@ 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/experimental"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/session"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
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"
|
||||
@@ -19,7 +19,7 @@ const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
|
||||
function app(experimental: boolean) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||
return Server.Default().app
|
||||
return experimental ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
type TestApp = ReturnType<typeof app>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { McpPaths } from "../../src/server/routes/instance/httpapi/mcp"
|
||||
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -19,7 +19,7 @@ const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer))
|
||||
|
||||
function app(experimental: boolean) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||
return Server.Default().app
|
||||
return experimental ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
type TestApp = ReturnType<typeof app>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const oauthInstructions = "Finish OAuth"
|
||||
|
||||
function app(experimental: boolean) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||
return Server.Default().app
|
||||
return experimental ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
|
||||
function requestAuthorize(input: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { PtyID } from "../../src/pty/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/pty"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
@@ -1,40 +1,209 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { TestLLMServer } from "../lib/llm-server"
|
||||
import path from "path"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
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 Sdk = ReturnType<typeof createOpencodeClient>
|
||||
type SdkResult = { response: Response; data?: unknown; error?: unknown }
|
||||
|
||||
function client(directory?: string) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
const handler = ExperimentalHttpApiServer.webHandler().handler
|
||||
function app(backend: Backend, input?: { password?: string; username?: string }) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi"
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
||||
return backend === "httpapi" ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
|
||||
function client(
|
||||
backend: Backend,
|
||||
directory?: string,
|
||||
input?: { password?: string; username?: string; headers?: Record<string, string> },
|
||||
) {
|
||||
const serverApp = app(backend, input)
|
||||
const fetch = Object.assign(
|
||||
(request: RequestInfo | URL, init?: RequestInit) =>
|
||||
handler(new Request(request, init), ExperimentalHttpApiServer.context),
|
||||
async (request: RequestInfo | URL, init?: RequestInit) =>
|
||||
await serverApp.fetch(request instanceof Request ? request : new Request(request, init)),
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
) satisfies typeof globalThis.fetch
|
||||
return createOpencodeClient({
|
||||
baseUrl: "http://localhost",
|
||||
directory,
|
||||
headers: input?.headers,
|
||||
fetch,
|
||||
})
|
||||
}
|
||||
|
||||
function authorization(username: string, password: string) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
}
|
||||
|
||||
function providerConfig(url: string) {
|
||||
return {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
provider: {
|
||||
test: {
|
||||
name: "Test",
|
||||
id: "test",
|
||||
env: [],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: {
|
||||
"test-model": {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: false,
|
||||
tool_call: true,
|
||||
release_date: "2025-01-01",
|
||||
limit: { context: 100000, output: 10000 },
|
||||
cost: { input: 0, output: 0 },
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
baseURL: url,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function expectStatus(result: Promise<{ response: Response }>, status: number) {
|
||||
expect((await result).response.status).toBe(status)
|
||||
}
|
||||
|
||||
async function capture(result: Promise<SdkResult>) {
|
||||
const response = await result
|
||||
return {
|
||||
status: response.response.status,
|
||||
data: response.data,
|
||||
error: response.error,
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
function array(value: unknown) {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function statuses(input: Record<string, Awaited<ReturnType<typeof capture>>>) {
|
||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, value.status]))
|
||||
}
|
||||
|
||||
function firstPartText(value: unknown) {
|
||||
return record(array(record(value).parts)[0]).text
|
||||
}
|
||||
|
||||
function sessionTitles(value: unknown) {
|
||||
return array(value)
|
||||
.map((item) => record(item).title)
|
||||
.filter((title): title is string => typeof title === "string")
|
||||
.sort()
|
||||
}
|
||||
|
||||
async function runSession<A, E>(directory: string, effect: Effect.Effect<A, E, SessionNs.Service>) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
fn: () => Effect.runPromise(effect.pipe(Effect.provide(SessionNs.defaultLayer))),
|
||||
})
|
||||
}
|
||||
|
||||
async function seedMessage(directory: string, sessionID: string) {
|
||||
const id = SessionID.make(sessionID)
|
||||
return runSession(
|
||||
directory,
|
||||
SessionNs.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const message = yield* svc.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
sessionID: id,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "test",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
tools: {},
|
||||
mode: "",
|
||||
} as unknown as MessageV2.Info)
|
||||
const part = yield* svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID: id,
|
||||
messageID: message.id,
|
||||
type: "text",
|
||||
text: "seeded message",
|
||||
})
|
||||
return { message, part }
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function compareBackends<T>(scenario: (backend: Backend) => Promise<T>) {
|
||||
const legacy = await scenario("legacy")
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
const httpapi = await scenario("httpapi")
|
||||
expect(httpapi).toEqual(legacy)
|
||||
}
|
||||
|
||||
async function withTmp<T>(backend: Backend, fn: (input: { sdk: Sdk; directory: string }) => Promise<T>) {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
config: { formatter: false, lsp: false },
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "hello.txt"), "hello")
|
||||
await Bun.write(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n")
|
||||
},
|
||||
})
|
||||
return fn({ sdk: client(backend, tmp.path), directory: tmp.path })
|
||||
}
|
||||
|
||||
async function withFakeLlm<T>(
|
||||
backend: Backend,
|
||||
fn: (input: { sdk: Sdk; directory: string; llm: TestLLMServer["Service"] }) => Promise<T>,
|
||||
) {
|
||||
return Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLMServer
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir({ git: true, config: providerConfig(llm.url) })),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
return yield* Effect.promise(() => fn({ sdk: client(backend, tmp.path), directory: tmp.path, llm }))
|
||||
}).pipe(Effect.scoped, Effect.provide(TestLLMServer.layer)),
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
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 Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("HttpApi SDK", () => {
|
||||
test("uses the generated SDK for global and control routes", async () => {
|
||||
const sdk = client()
|
||||
const sdk = client("httpapi")
|
||||
const health = await sdk.global.health()
|
||||
|
||||
expect(health.response.status).toBe(200)
|
||||
@@ -60,7 +229,7 @@ describe("HttpApi SDK", () => {
|
||||
config: { formatter: false, lsp: false },
|
||||
init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello"),
|
||||
})
|
||||
const sdk = client(tmp.path)
|
||||
const sdk = client("httpapi", tmp.path)
|
||||
|
||||
const file = await sdk.file.read({ path: "hello.txt" })
|
||||
expect(file.response.status).toBe(200)
|
||||
@@ -81,4 +250,381 @@ describe("HttpApi SDK", () => {
|
||||
expectStatus(sdk.find.files({ query: "hello", limit: 10 }), 200),
|
||||
])
|
||||
})
|
||||
|
||||
test("matches generated SDK global and control behavior across backends", async () => {
|
||||
await compareBackends(async (backend) => {
|
||||
const sdk = client(backend)
|
||||
const health = await capture(sdk.global.health())
|
||||
const log = await capture(sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" }))
|
||||
const invalidAuth = await capture(sdk.auth.set({ providerID: "test" }))
|
||||
|
||||
return {
|
||||
statuses: statuses({ health, log, invalidAuth }),
|
||||
health: record(health.data).healthy,
|
||||
log: log.data,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("matches generated SDK global event stream across backends", async () => {
|
||||
await compareBackends(async (backend) => {
|
||||
const events = await client(backend).global.event({ signal: AbortSignal.timeout(1_000) })
|
||||
try {
|
||||
const first = await events.stream.next()
|
||||
return {
|
||||
type: record(record(first.value).payload).type,
|
||||
}
|
||||
} finally {
|
||||
await events.stream.return(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("matches generated SDK instance event stream across backends", async () => {
|
||||
await compareBackends((backend) =>
|
||||
withTmp(backend, async ({ sdk }) => {
|
||||
const events = await sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) })
|
||||
try {
|
||||
const first = await events.stream.next()
|
||||
return {
|
||||
type: record(record(first.value).payload).type,
|
||||
}
|
||||
} finally {
|
||||
await events.stream.return(undefined)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("matches generated SDK basic auth behavior across backends", async () => {
|
||||
await compareBackends((backend) =>
|
||||
withTmp(backend, async ({ directory }) => {
|
||||
const missing = await capture(
|
||||
client(backend, directory, { password: "secret" }).file.read({ path: "hello.txt" }),
|
||||
)
|
||||
const bad = await capture(
|
||||
client(backend, directory, {
|
||||
password: "secret",
|
||||
headers: { authorization: authorization("opencode", "wrong") },
|
||||
}).file.read({ path: "hello.txt" }),
|
||||
)
|
||||
const good = await capture(
|
||||
client(backend, directory, {
|
||||
password: "secret",
|
||||
headers: { authorization: authorization("opencode", "secret") },
|
||||
}).file.read({ path: "hello.txt" }),
|
||||
)
|
||||
|
||||
return {
|
||||
statuses: statuses({ missing, bad, good }),
|
||||
content: record(good.data).content,
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("matches generated SDK instance read routes across backends", async () => {
|
||||
await compareBackends((backend) =>
|
||||
withTmp(backend, async ({ sdk, directory }) => {
|
||||
const project = await capture(sdk.project.current())
|
||||
const projects = await capture(sdk.project.list())
|
||||
const paths = await capture(sdk.path.get())
|
||||
const config = await capture(sdk.config.get())
|
||||
const providers = await capture(sdk.config.providers())
|
||||
const file = await capture(sdk.file.read({ path: "hello.txt" }))
|
||||
const files = await capture(sdk.file.list({ path: "." }))
|
||||
const fileStatus = await capture(sdk.file.status())
|
||||
const findFiles = await capture(sdk.find.files({ query: "hello", limit: 10 }))
|
||||
const findText = await capture(sdk.find.text({ pattern: "sdk-parity" }))
|
||||
const agents = await capture(sdk.app.agents())
|
||||
const skills = await capture(sdk.app.skills())
|
||||
const tools = await capture(sdk.tool.ids())
|
||||
const vcs = await capture(sdk.vcs.get())
|
||||
const formatter = await capture(sdk.formatter.status())
|
||||
const lsp = await capture(sdk.lsp.status())
|
||||
|
||||
return {
|
||||
statuses: statuses({
|
||||
project,
|
||||
projects,
|
||||
paths,
|
||||
config,
|
||||
providers,
|
||||
file,
|
||||
files,
|
||||
fileStatus,
|
||||
findFiles,
|
||||
findText,
|
||||
agents,
|
||||
skills,
|
||||
tools,
|
||||
vcs,
|
||||
formatter,
|
||||
lsp,
|
||||
}),
|
||||
project: {
|
||||
worktreeSelected: record(project.data).worktree === directory,
|
||||
},
|
||||
paths: {
|
||||
cwdSelected: record(paths.data).cwd === directory,
|
||||
},
|
||||
file: record(file.data).content,
|
||||
hasProject: array(projects.data).length > 0,
|
||||
foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),
|
||||
foundText: JSON.stringify(findText.data ?? null).includes("sdk-parity"),
|
||||
listedFile: JSON.stringify(files.data).includes("hello.txt"),
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("matches generated SDK session lifecycle routes across backends", async () => {
|
||||
await compareBackends((backend) =>
|
||||
withTmp(backend, async ({ sdk }) => {
|
||||
const parent = await capture(sdk.session.create({ title: "parent" }))
|
||||
const parentID = String(record(parent.data).id)
|
||||
const child = await capture(sdk.session.create({ title: "child", parentID }))
|
||||
const childID = String(record(child.data).id)
|
||||
const get = await capture(sdk.session.get({ sessionID: parentID }))
|
||||
const update = await capture(sdk.session.update({ sessionID: parentID, title: "renamed" }))
|
||||
const roots = await capture(sdk.session.list({ roots: true, limit: 10 }))
|
||||
const all = await capture(sdk.session.list({ roots: false, limit: 10 }))
|
||||
const children = await capture(sdk.session.children({ sessionID: parentID }))
|
||||
const todo = await capture(sdk.session.todo({ sessionID: parentID }))
|
||||
const status = await capture(sdk.session.status())
|
||||
const messages = await capture(sdk.session.messages({ sessionID: parentID }))
|
||||
const missingGet = await capture(sdk.session.get({ sessionID: "ses_missing" }))
|
||||
const missingMessages = await capture(sdk.session.messages({ sessionID: "ses_missing", limit: 2 }))
|
||||
const invalidCursor = await capture(sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" }))
|
||||
const deleted = await capture(sdk.session.delete({ sessionID: childID }))
|
||||
const getDeleted = await capture(sdk.session.get({ sessionID: childID }))
|
||||
|
||||
return {
|
||||
statuses: statuses({
|
||||
parent,
|
||||
child,
|
||||
get,
|
||||
update,
|
||||
roots,
|
||||
all,
|
||||
children,
|
||||
todo,
|
||||
status,
|
||||
messages,
|
||||
missingGet,
|
||||
missingMessages,
|
||||
invalidCursor,
|
||||
deleted,
|
||||
getDeleted,
|
||||
}),
|
||||
getTitle: record(get.data).title,
|
||||
updatedTitle: record(update.data).title,
|
||||
rootTitles: sessionTitles(roots.data),
|
||||
allTitles: sessionTitles(all.data),
|
||||
childCount: array(children.data).length,
|
||||
todoCount: array(todo.data).length,
|
||||
messageCount: array(messages.data).length,
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("matches generated SDK session message and part routes across backends", async () => {
|
||||
await compareBackends((backend) =>
|
||||
withTmp(backend, async ({ sdk, directory }) => {
|
||||
const session = await capture(sdk.session.create({ title: "messages" }))
|
||||
const sessionID = String(record(session.data).id)
|
||||
const seeded = await seedMessage(directory, sessionID)
|
||||
const list = await capture(sdk.session.messages({ sessionID }))
|
||||
const page = await capture(sdk.session.messages({ sessionID, limit: 1 }))
|
||||
const message = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id }))
|
||||
const partUpdate = await capture(
|
||||
sdk.part.update({
|
||||
sessionID,
|
||||
messageID: seeded.message.id,
|
||||
partID: seeded.part.id,
|
||||
part: {
|
||||
...seeded.part,
|
||||
text: "updated message",
|
||||
} as NonNullable<Parameters<Sdk["part"]["update"]>[0]["part"]>,
|
||||
}),
|
||||
)
|
||||
const updated = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id }))
|
||||
const partDelete = await capture(
|
||||
sdk.part.delete({ sessionID, messageID: seeded.message.id, partID: seeded.part.id }),
|
||||
)
|
||||
const withoutPart = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id }))
|
||||
const deleteMessage = await capture(sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id }))
|
||||
const missingMessage = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id }))
|
||||
|
||||
return {
|
||||
statuses: statuses({
|
||||
session,
|
||||
list,
|
||||
page,
|
||||
message,
|
||||
partUpdate,
|
||||
updated,
|
||||
partDelete,
|
||||
withoutPart,
|
||||
deleteMessage,
|
||||
missingMessage,
|
||||
}),
|
||||
listCount: array(list.data).length,
|
||||
pageCount: array(page.data).length,
|
||||
initialText: firstPartText(message.data),
|
||||
updatedText: firstPartText(updated.data),
|
||||
partCountAfterDelete: array(record(withoutPart.data).parts).length,
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("matches generated SDK prompt no-reply routes across backends", async () => {
|
||||
await compareBackends((backend) =>
|
||||
withTmp(backend, async ({ sdk }) => {
|
||||
const session = await capture(sdk.session.create({ title: "prompt" }))
|
||||
const sessionID = String(record(session.data).id)
|
||||
const prompt = await capture(
|
||||
sdk.session.prompt({
|
||||
sessionID,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
}),
|
||||
)
|
||||
const asyncPrompt = await capture(
|
||||
sdk.session.promptAsync({
|
||||
sessionID,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "async hello" }],
|
||||
}),
|
||||
)
|
||||
const messages = await capture(sdk.session.messages({ sessionID }))
|
||||
|
||||
return {
|
||||
statuses: statuses({ session, prompt, asyncPrompt, messages }),
|
||||
promptRole: record(record(prompt.data).info).role,
|
||||
messageCount: array(messages.data).length,
|
||||
messageTexts: array(messages.data)
|
||||
.flatMap((item) => array(record(item).parts))
|
||||
.map((part) => record(part).text)
|
||||
.filter((text): text is string => typeof text === "string")
|
||||
.sort(),
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("matches generated SDK prompt streaming through fake LLM across backends", async () => {
|
||||
await compareBackends((backend) =>
|
||||
withFakeLlm(backend, async ({ sdk, llm }) => {
|
||||
await Effect.runPromise(llm.text("fake world", { usage: { input: 11, output: 7 } }))
|
||||
const session = await capture(
|
||||
sdk.session.create({
|
||||
title: "llm prompt",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
}),
|
||||
)
|
||||
const sessionID = String(record(session.data).id)
|
||||
const prompt = await capture(
|
||||
sdk.session.prompt({
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: { providerID: "test", modelID: "test-model" },
|
||||
parts: [{ type: "text", text: "hello llm" }],
|
||||
}),
|
||||
)
|
||||
const messages = await capture(sdk.session.messages({ sessionID }))
|
||||
const inputs = await Effect.runPromise(llm.inputs)
|
||||
|
||||
return {
|
||||
statuses: statuses({ session, prompt, messages }),
|
||||
calls: inputs.length,
|
||||
requestedModel: inputs[0]?.model,
|
||||
responseText: JSON.stringify(prompt.data).includes("fake world"),
|
||||
persistedText: JSON.stringify(messages.data).includes("fake world"),
|
||||
userText: JSON.stringify(messages.data).includes("hello llm"),
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("matches generated SDK TUI validation and command routes across backends", async () => {
|
||||
await compareBackends((backend) =>
|
||||
withTmp(backend, async ({ sdk }) => {
|
||||
const session = await capture(sdk.session.create({ title: "tui" }))
|
||||
const sessionID = String(record(session.data).id)
|
||||
const appendPrompt = await capture(sdk.tui.appendPrompt({ text: "hello" }))
|
||||
const openHelp = await capture(sdk.tui.openHelp())
|
||||
const openSessions = await capture(sdk.tui.openSessions())
|
||||
const openThemes = await capture(sdk.tui.openThemes())
|
||||
const openModels = await capture(sdk.tui.openModels())
|
||||
const submitPrompt = await capture(sdk.tui.submitPrompt())
|
||||
const clearPrompt = await capture(sdk.tui.clearPrompt())
|
||||
const executeCommand = await capture(sdk.tui.executeCommand({ command: "session_new" }))
|
||||
const showToast = await capture(sdk.tui.showToast({ title: "SDK", message: "hello", variant: "info" }))
|
||||
const selectSession = await capture(sdk.tui.selectSession({ sessionID }))
|
||||
const missingSession = await capture(sdk.tui.selectSession({ sessionID: "ses_missing" }))
|
||||
const invalidSession = await capture(sdk.tui.selectSession({ sessionID: "invalid_session_id" }))
|
||||
|
||||
return {
|
||||
statuses: statuses({
|
||||
session,
|
||||
appendPrompt,
|
||||
openHelp,
|
||||
openSessions,
|
||||
openThemes,
|
||||
openModels,
|
||||
submitPrompt,
|
||||
clearPrompt,
|
||||
executeCommand,
|
||||
showToast,
|
||||
selectSession,
|
||||
missingSession,
|
||||
invalidSession,
|
||||
}),
|
||||
data: {
|
||||
appendPrompt: appendPrompt.data,
|
||||
openHelp: openHelp.data,
|
||||
openSessions: openSessions.data,
|
||||
openThemes: openThemes.data,
|
||||
openModels: openModels.data,
|
||||
submitPrompt: submitPrompt.data,
|
||||
clearPrompt: clearPrompt.data,
|
||||
executeCommand: executeCommand.data,
|
||||
showToast: showToast.data,
|
||||
selectSession: selectSession.data,
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("matches generated SDK project git initialization across backends", async () => {
|
||||
await compareBackends(async (backend) => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const sdk = client(backend, tmp.path)
|
||||
const before = await capture(sdk.project.current())
|
||||
const init = await capture(sdk.project.initGit())
|
||||
const after = await capture(sdk.project.current())
|
||||
|
||||
return {
|
||||
statuses: statuses({ before, init, after }),
|
||||
before: {
|
||||
vcs: record(before.data).vcs ?? null,
|
||||
worktree: record(before.data).worktree,
|
||||
},
|
||||
init: {
|
||||
vcs: record(init.data).vcs,
|
||||
worktreeSelected: record(init.data).worktree === tmp.path,
|
||||
},
|
||||
after: {
|
||||
vcs: record(after.data).vcs,
|
||||
worktreeSelected: record(after.data).worktree === tmp.path,
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PermissionID } from "../../src/permission/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/session"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SyncPaths } from "../../src/server/routes/instance/httpapi/sync"
|
||||
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
|
||||
import { Session } from "@/session/session"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
@@ -16,7 +16,7 @@ const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
function app(httpapi = true) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi
|
||||
return Server.Default().app
|
||||
return httpapi ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
|
||||
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Context } from "hono"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/tui"
|
||||
import { 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"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Effect } from "effect"
|
||||
@@ -6,13 +6,14 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { registerAdaptor } from "../../src/control-plane/adaptors"
|
||||
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import { Session } from "@/session/session"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -54,7 +55,41 @@ function localAdaptor(directory: string): WorkspaceAdaptor {
|
||||
}
|
||||
}
|
||||
|
||||
function remoteAdaptor(directory: string, url: string): WorkspaceAdaptor {
|
||||
return {
|
||||
name: "Remote Test",
|
||||
description: "Create a remote test workspace",
|
||||
configure(info) {
|
||||
return {
|
||||
...info,
|
||||
name: "remote-test",
|
||||
directory,
|
||||
}
|
||||
},
|
||||
async create() {
|
||||
await mkdir(directory, { recursive: true })
|
||||
},
|
||||
async remove() {},
|
||||
target() {
|
||||
return {
|
||||
type: "remote" as const,
|
||||
url,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function eventStreamResponse() {
|
||||
return new Response(new ReadableStream({ start() {} }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/event-stream",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
|
||||
await Instance.disposeAll()
|
||||
@@ -125,4 +160,81 @@ describe("workspace HttpApi", () => {
|
||||
expect(listed.status).toBe(200)
|
||||
expect(await listed.json()).toEqual([])
|
||||
})
|
||||
|
||||
test("routes local workspace requests through the workspace target directory", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const workspaceDir = path.join(tmp.path, ".workspace-local")
|
||||
const workspace = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
registerAdaptor(Instance.project.id, "local-target", localAdaptor(workspaceDir))
|
||||
return Workspace.create({
|
||||
type: "local-target",
|
||||
branch: null,
|
||||
extra: null,
|
||||
projectID: Instance.project.id,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const url = new URL(`http://localhost${InstancePaths.path}`)
|
||||
url.searchParams.set("workspace", workspace.id)
|
||||
|
||||
try {
|
||||
const response = await request(url.toString(), tmp.path)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ directory: workspaceDir })
|
||||
} finally {
|
||||
await Workspace.remove(workspace.id)
|
||||
}
|
||||
})
|
||||
|
||||
test("proxies remote workspace HTTP requests", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const proxied: string[] = []
|
||||
const rawFetch = globalThis.fetch
|
||||
spyOn(globalThis, "fetch").mockImplementation(
|
||||
Object.assign(
|
||||
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
|
||||
const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url)
|
||||
if (url.pathname === "/base/global/event") return eventStreamResponse()
|
||||
if (url.pathname === "/base/sync/history") return Response.json([])
|
||||
proxied.push(url.toString())
|
||||
return Response.json({ proxied: true, path: url.pathname, workspace: url.searchParams.get("workspace") })
|
||||
},
|
||||
{
|
||||
preconnect: rawFetch.preconnect?.bind(rawFetch),
|
||||
},
|
||||
) as typeof globalThis.fetch,
|
||||
)
|
||||
|
||||
const workspace = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
registerAdaptor(Instance.project.id, "remote-target", remoteAdaptor(path.join(tmp.path, ".remote"), "https://remote.test/base"))
|
||||
return Workspace.create({
|
||||
type: "remote-target",
|
||||
branch: null,
|
||||
extra: null,
|
||||
projectID: Instance.project.id,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const url = new URL(`http://localhost${InstancePaths.path}`)
|
||||
url.searchParams.set("workspace", workspace.id)
|
||||
|
||||
try {
|
||||
const response = await request(url.toString(), tmp.path)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ proxied: true, path: "/base/path", workspace: null })
|
||||
expect(proxied).toEqual(["https://remote.test/base/path"])
|
||||
} finally {
|
||||
await Workspace.remove(workspace.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user