import { afterEach, describe, expect } from "bun:test" import { Effect, Schema } from "effect" import { OpenApi } from "effect/unstable/httpapi" import { Flag } from "@opencode-ai/core/flag/flag" import { Server } from "../../src/server/server" import { SessionID } from "../../src/session/schema" import { PublicApi } from "../../src/server/routes/instance/httpapi/public" import { FilePaths, FileQuery, FindFileQuery, FindTextQuery, } from "../../src/server/routes/instance/httpapi/groups/file" import { ExperimentalPaths, SessionListQuery as ExperimentalSessionListQuery, ToolListQuery, } from "../../src/server/routes/instance/httpapi/groups/experimental" import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { ListQuery as SessionListQuery, MessagesQuery, SessionPaths, } from "../../src/server/routes/instance/httpapi/groups/session" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { SessionMessagesQuery } from "@opencode-ai/protocol/groups/message" import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES type Method = "get" | "post" | "put" | "delete" | "patch" type QuerySchema = { readonly fields: Record } type OpenApiSchema = { readonly anyOf?: readonly OpenApiSchema[] readonly enum?: readonly string[] readonly maximum?: number readonly minimum?: number readonly pattern?: string readonly type?: string } type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema } type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] } const openApiDriftRoutes = [ { method: "get", path: SessionPaths.list, query: SessionListQuery }, { method: "get", path: SessionPaths.messages, query: MessagesQuery }, { method: "get", path: FilePaths.findFile, query: FindFileQuery }, { method: "get", path: FilePaths.findText, query: FindTextQuery }, { method: "get", path: FilePaths.list, query: FileQuery }, { method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery }, { method: "get", path: ExperimentalPaths.tool, query: ToolListQuery }, { method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery }, { method: "get", path: "/api/session/:sessionID/message", query: SessionMessagesQuery }, ] satisfies Array<{ method: Method; path: string; query: QuerySchema }> const numericSdkQueryParams = [ { method: "get", path: ExperimentalPaths.session, name: "start", schema: { type: "number" } }, { method: "get", path: ExperimentalPaths.session, name: "cursor", schema: { type: "number" } }, { method: "get", path: ExperimentalPaths.session, name: "limit", schema: { type: "number" } }, { method: "get", path: FilePaths.findFile, name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } }, { method: "get", path: SessionPaths.list, name: "start", schema: { type: "number" } }, { method: "get", path: SessionPaths.list, name: "limit", schema: { type: "number" } }, { method: "get", path: SessionPaths.messages, name: "limit", schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, }, { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } }, ] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }> const booleanSdkQueryParams = [ { method: "get", path: ExperimentalPaths.session, name: "roots" }, { method: "get", path: ExperimentalPaths.session, name: "archived" }, { method: "get", path: SessionPaths.list, name: "roots" }, ] satisfies Array<{ method: Method; path: string; name: string }> const queryParamPatterns = [ { method: "get", path: SessionPaths.diff, name: "messageID", pattern: "^msg" }, ] satisfies Array<{ method: Method; path: string; name: string; pattern: string }> const pathParamPatterns = [ { method: "get", path: SessionPaths.get, name: "sessionID", pattern: "^ses" }, { method: "get", path: SessionPaths.message, name: "messageID", pattern: "^msg" }, { method: "patch", path: SessionPaths.updatePart, name: "partID", pattern: "^prt" }, { method: "post", path: SessionPaths.permissions, name: "permissionID", pattern: "^per" }, { method: "post", path: "/permission/:requestID/reply", name: "requestID", pattern: "^per" }, { method: "post", path: "/question/:requestID/reply", name: "requestID", pattern: "^que" }, { method: "put", path: PtyPaths.update, name: "ptyID", pattern: "^pty" }, { method: "delete", path: WorkspacePaths.remove, name: "id", pattern: "^wrk" }, ] satisfies Array<{ method: Method; path: string; name: string; pattern: string }> function app() { return Server.Default().app } function request(url: string, init?: RequestInit) { return Effect.promise(async () => app().request(url, init)) } function withTmp( options: Parameters[0], fn: (tmp: Awaited>) => Effect.Effect, ) { return Effect.acquireRelease( Effect.promise(() => tmpdir(options)), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ).pipe(Effect.flatMap(fn)) } function openApiPath(path: string) { return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}") } function queryParameters(operation: OpenApiOperation | undefined) { return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name) } function queryParameter(operation: OpenApiOperation | undefined, name: string) { return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name) } function pathParameter(operation: OpenApiOperation | undefined, name: string) { return (operation?.parameters ?? []).find((param) => param.in === "path" && param.name === name) } function assertAdvertisedQueryParamsAreRuntimeFields(input: { readonly method: Method readonly operation: OpenApiOperation | undefined readonly path: string readonly query: QuerySchema }) { const runtimeFields = new Set(Object.keys(input.query.fields)) const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name)) expect( advertisedOnly, `${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`, ).toEqual([]) } afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await disposeAllInstances() await resetDatabase() }) // Regression for the "OpenAPI advertises ?directory&workspace, runtime // rejects them" drift class. Each affected route must accept both params // without 400. describe("httpapi query schema drift", () => { const routingParams = (dir: string) => `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}` const expectNotSchemaRejection = (status: number, url: string) => { expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400) } it.effect( "boolean query schema accepts only true and false strings", Effect.sync(() => { const decode = Schema.decodeUnknownSync(QueryBoolean) const encode = Schema.encodeUnknownSync(QueryBoolean) expect(decode("true")).toBe(true) expect(decode("false")).toBe(false) expect(encode(true)).toBe("true") expect(encode(false)).toBe("false") for (const input of ["1", "yes", "True", "", true, false]) { expect(() => decode(input)).toThrow() } }), ) it.effect( "OpenAPI query params are declared by runtime query schemas", Effect.sync(() => { const spec = OpenApi.fromApi(PublicApi) for (const route of openApiDriftRoutes) { assertAdvertisedQueryParamsAreRuntimeFields({ ...route, operation: spec.paths[openApiPath(route.path)]?.[route.method], }) } }), ) it.effect( "OpenAPI query and path schemas preserve compatibility metadata", Effect.sync(() => { const spec = OpenApi.fromApi(PublicApi) for (const expected of numericSdkQueryParams) { expect( queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, ).toEqual(expected.schema) } for (const expected of booleanSdkQueryParams) { expect( queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, ).toEqual(QueryBooleanOpenApi) } for (const expected of queryParamPatterns) { expect( queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, ).toEqual({ type: "string", pattern: expected.pattern }) } for (const expected of pathParamPatterns) { expect( pathParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, ).toEqual({ type: "string", pattern: expected.pattern }) } }), ) it.effect( "drift assertion catches spec-only workspace query params", Effect.sync(() => { expect(() => assertAdvertisedQueryParamsAreRuntimeFields({ method: "get", operation: { parameters: [ { name: "directory", in: "query" }, { name: "workspace", in: "query" }, ], }, path: "/fixture", query: Schema.Struct({}), }), ).toThrow("advertises query params not accepted by runtime schema") }), ) it.live( "session list accepts directory and workspace", withTmp({ config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const url = `/session?${routingParams(tmp.path)}` const response = yield* request(url) expectNotSchemaRejection(response.status, url) }), ), ) it.live( "session messages accepts directory and workspace", withTmp({ config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}` const response = yield* request(url) expectNotSchemaRejection(response.status, url) }), ), ) it.live( "file find/file accepts directory and workspace", withTmp({ config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const url = `/find/file?query=foo&${routingParams(tmp.path)}` const response = yield* request(url) expectNotSchemaRejection(response.status, url) }), ), ) it.live( "file find/text accepts directory and workspace", withTmp({ config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const url = `/find?pattern=foo&${routingParams(tmp.path)}` const response = yield* request(url) expectNotSchemaRejection(response.status, url) }), ), ) it.live( "file read accepts directory and workspace", withTmp({ config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const url = `/file?path=foo&${routingParams(tmp.path)}` const response = yield* request(url) expectNotSchemaRejection(response.status, url) }), ), ) it.live( "experimental session list accepts directory and workspace", withTmp({ config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const url = `/experimental/session?${routingParams(tmp.path)}` const response = yield* request(url) expectNotSchemaRejection(response.status, url) }), ), ) it.live( "experimental tool list accepts directory and workspace", withTmp({ config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}` const response = yield* request(url) expectNotSchemaRejection(response.status, url) }), ), ) it.live( "vcs diff accepts directory and workspace", withTmp({ config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}` const response = yield* request(url) expectNotSchemaRejection(response.status, url) }), ), ) })