diff --git a/CONTEXT.md b/CONTEXT.md index e0e898f15a..690cd1fe9b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -123,7 +123,8 @@ _Avoid_: Response envelope - The public `HttpApi` is authoritative for shared **OpenCode Client** capabilities: the server hosts those exact endpoint declarations and code generation consumes them directly. Public endpoints are not duplicated or projected from a separately named internal contract. - SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently. - The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR. -- Initially, the rich Effect emitter regenerates private executable schemas and `HttpApi` groups from the **SDK Contract IR**, reproducing semantics exactly or rejecting generation. Importing authoritative schemas from a future dependency-leaf public API package remains an alternative emitter strategy, not a prerequisite refactor. +- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against the dependency-leaf `@opencode-ai/api` package instead; the Promise emitter still derives zero-Effect structural wire types from the same IR. +- `@opencode-ai/api` owns the lightweight authoritative public `HttpApi` and its runtime schemas. It depends only on Effect, does not import Core or server implementation packages, and is hosted by the server with server-only middleware added during composition. - The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface. - The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy. - Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors. diff --git a/bun.lock b/bun.lock index 0535c45cf6..9019f077ab 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,18 @@ "turbo": "2.8.13", }, }, + "packages/api": { + "name": "@opencode-ai/api", + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "effect": "catalog:", + }, + "peerDependencies": { + "effect": "catalog:", + }, + }, "packages/app": { "name": "@opencode-ai/app", "version": "1.17.9", @@ -111,10 +123,16 @@ }, "packages/client": { "name": "@opencode-ai/client", + "dependencies": { + "@opencode-ai/api": "workspace:*", + }, "devDependencies": { + "@effect/platform-node": "catalog:", + "@opencode-ai/httpapi-codegen": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", + "effect": "catalog:", }, "peerDependencies": { "effect": "4.0.0-beta.83", @@ -706,6 +724,7 @@ "name": "@opencode-ai/server", "version": "1.17.9", "dependencies": { + "@opencode-ai/api": "workspace:*", "@opencode-ai/core": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:", @@ -1796,6 +1815,8 @@ "@openauthjs/openauth": ["@openauthjs/openauth@0.0.0-20250322224806", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-p5IWSRXvABcwocH2dNI0w8c1QJelIOFulwhKk+aLLFfUbs8u1pr7kQbYe8yCSM2+bcLHiwbogpUQc2ovrGwCuw=="], + "@opencode-ai/api": ["@opencode-ai/api@workspace:packages/api"], + "@opencode-ai/app": ["@opencode-ai/app@workspace:packages/app"], "@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"], diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 0000000000..4143c631e1 --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/api", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "peerDependencies": { + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "effect": "catalog:" + } +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 0000000000..3896433289 --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1,256 @@ +import { DateTime, Option, Schema, SchemaGetter } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" + +export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe(Schema.brand("SessionID")) +export type SessionID = typeof SessionID.Type + +const ProjectID = Schema.String.pipe(Schema.brand("Project.ID")) +export const AgentID = Schema.String.pipe(Schema.brand("AgentV2.ID")) +const ModelID = Schema.String.pipe(Schema.brand("ModelV2.ID")) +const ProviderID = Schema.String.pipe(Schema.brand("ProviderV2.ID")) +const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath")) +const RelativePath = Schema.String.pipe(Schema.brand("RelativePath")) +const WorkspaceID = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceV2.ID")) +export const MessageID = Schema.String.check(Schema.isStartsWith("msg_")).pipe(Schema.brand("Session.Message.ID")) +export const ModelRef = Schema.Struct({ + id: ModelID, + providerID: ProviderID, + variant: VariantID.pipe(Schema.optional), +}) +export const LocationRef = Schema.Struct({ + directory: AbsolutePath, + workspaceID: WorkspaceID.pipe(Schema.optional), +}) +const DateTimeUtcFromMillis = Schema.Finite.pipe( + Schema.decodeTo(Schema.DateTimeUtc, { + decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)), + encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)), + }), +) +const optionalOmitUndefined = (schema: S) => + Schema.optionalKey(schema).pipe( + Schema.decodeTo(Schema.optional(schema), { + decode: SchemaGetter.passthrough({ strict: false }), + encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), + }), + ) + +export const Session = Schema.Struct({ + id: SessionID, + parentID: optionalOmitUndefined(SessionID), + projectID: ProjectID, + agent: AgentID.pipe(Schema.optional), + model: ModelRef.pipe(Schema.optional), + cost: Schema.Finite, + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + updated: DateTimeUtcFromMillis, + archived: DateTimeUtcFromMillis.pipe(Schema.optional), + }), + title: Schema.String, + location: LocationRef, + subpath: RelativePath.pipe(Schema.optional), +}) +export type Session = typeof Session.Type + +export const Prompt = Schema.Struct({ + text: Schema.String, + files: Schema.Array( + Schema.Struct({ + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + source: Schema.Struct({ + start: Schema.Finite, + end: Schema.Finite, + text: Schema.String, + }).pipe(Schema.optional), + }), + ).pipe(Schema.optional), + agents: Schema.Array( + Schema.Struct({ + name: Schema.String, + source: Schema.Struct({ + start: Schema.Finite, + end: Schema.Finite, + text: Schema.String, + }).pipe(Schema.optional), + }), + ).pipe(Schema.optional), +}) + +export const Delivery = Schema.Literals(["steer", "queue"]) + +export const Admission = Schema.Struct({ + admittedSeq: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + id: MessageID, + sessionID: SessionID, + prompt: Prompt, + delivery: Delivery, + timeCreated: DateTimeUtcFromMillis, + promotedSeq: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.optional), +}) + +export const SessionsCursor = Schema.String.pipe(Schema.brand("SessionsCursor")) + +export const SessionsQuery = Schema.Struct({ + workspace: WorkspaceID.pipe(Schema.optional), + limit: Schema.NumberFromString.pipe(Schema.decodeTo(Schema.Int.check(Schema.isGreaterThan(0))), Schema.optional), + order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional), + search: Schema.String.pipe(Schema.optional), + directory: AbsolutePath.pipe(Schema.optional), + project: ProjectID.pipe(Schema.optional), + subpath: RelativePath.pipe(Schema.optional), + cursor: SessionsCursor.pipe(Schema.optional), +}) + +export class SessionNotFoundError extends Schema.TaggedErrorClass()( + "SessionNotFoundError", + { + sessionID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class InvalidCursorError extends Schema.TaggedErrorClass()( + "InvalidCursorError", + { message: Schema.String }, + { httpApiStatus: 400 }, +) {} + +export class InvalidRequestError extends Schema.TaggedErrorClass()( + "InvalidRequestError", + { + message: Schema.String, + kind: Schema.String.pipe(Schema.optional), + field: Schema.String.pipe(Schema.optional), + }, + { httpApiStatus: 400 }, +) {} + +export class ConflictError extends Schema.TaggedErrorClass()( + "ConflictError", + { + message: Schema.String, + resource: Schema.String.pipe(Schema.optional), + }, + { httpApiStatus: 409 }, +) {} + +export const SessionsList = HttpApiEndpoint.get("list", "/api/session", { + query: SessionsQuery, + success: Schema.Struct({ + data: Schema.Array(Session), + cursor: Schema.Struct({ + previous: SessionsCursor.pipe(Schema.optional), + next: SessionsCursor.pipe(Schema.optional), + }), + }), + error: [InvalidCursorError, InvalidRequestError], +}).annotateMerge( + OpenApi.annotations({ + identifier: "sessions.list", + summary: "List sessions", + description: "Retrieve an ordered page of sessions.", + }), +) + +export const SessionsCreate = HttpApiEndpoint.post("create", "/api/session", { + payload: Schema.Struct({ + id: SessionID.pipe(Schema.optional), + agent: AgentID.pipe(Schema.optional), + model: ModelRef.pipe(Schema.optional), + location: LocationRef.pipe(Schema.optional), + }), + success: Schema.Struct({ data: Session }), +}).annotateMerge( + OpenApi.annotations({ + identifier: "sessions.create", + summary: "Create session", + description: "Create a session at the requested location.", + }), +) + +export const SessionsGet = HttpApiEndpoint.get("get", "/api/session/:sessionID", { + params: { sessionID: SessionID }, + success: Schema.Struct({ data: Session }), + error: SessionNotFoundError, +}).annotateMerge( + OpenApi.annotations({ + identifier: "sessions.get", + summary: "Get session", + description: "Retrieve a session by ID.", + }), +) + +export const SessionsSwitchAgent = HttpApiEndpoint.post("switchAgent", "/api/session/:sessionID/agent", { + params: { sessionID: SessionID }, + payload: Schema.Struct({ agent: AgentID }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, +}).annotateMerge( + OpenApi.annotations({ + identifier: "sessions.switchAgent", + summary: "Switch session agent", + description: "Switch the agent used by subsequent session activity.", + }), +) + +export const SessionsSwitchModel = HttpApiEndpoint.post("switchModel", "/api/session/:sessionID/model", { + params: { sessionID: SessionID }, + payload: Schema.Struct({ model: ModelRef }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, +}).annotateMerge( + OpenApi.annotations({ + identifier: "sessions.switchModel", + summary: "Switch session model", + description: "Switch the model used by subsequent session activity.", + }), +) + +export const SessionsPrompt = HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { + params: { sessionID: SessionID }, + payload: Schema.Struct({ + id: MessageID.pipe(Schema.optional), + prompt: Prompt, + delivery: Delivery.pipe(Schema.optional), + resume: Schema.Boolean.pipe(Schema.optional), + }), + success: Schema.Struct({ data: Admission }), + error: [ConflictError, SessionNotFoundError], +}).annotateMerge( + OpenApi.annotations({ + identifier: "sessions.prompt", + summary: "Send prompt", + description: "Durably admit one session input and schedule execution unless resume is false.", + }), +) + +export const SessionsGroup = HttpApiGroup.make("sessions") + .add(SessionsList) + .add(SessionsCreate) + .add(SessionsGet) + .add(SessionsSwitchAgent) + .add(SessionsSwitchModel) + .add(SessionsPrompt) + .annotateMerge( + OpenApi.annotations({ + title: "sessions", + description: "OpenCode sessions.", + }), + ) + +export const Api = HttpApi.make("opencode").add(SessionsGroup) diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 0000000000..8fe3e33221 --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json" +} diff --git a/packages/client/README.md b/packages/client/README.md index d5a6353e7c..fae05a7c24 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -8,4 +8,6 @@ Private generation target for clients derived directly from OpenCode's authorita - `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`. - `@opencode-ai/client/effect/embedded`: scoped embedded OpenCode host backed by Core and the in-memory HTTP router. -The entry modules are intentionally empty until the authoritative public `HttpApi` is composed. Do not generate clients from the current internal server API. +The initial generated surface contains `sessions.list`, `create`, `get`, `switchAgent`, `switchModel`, and `prompt`, sourced from the public `HttpApi` in `@opencode-ai/api` and hosted by `@opencode-ai/server`. Run `bun run generate` after changing that contract and `bun run check:generated` to detect committed-output drift. + +The embedded entrypoint remains intentionally empty until the scoped in-memory host is implemented. diff --git a/packages/client/package.json b/packages/client/package.json index 4f304b0e19..97edae84ff 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -10,8 +10,14 @@ "./effect/embedded": "./src/effect-embedded.ts" }, "scripts": { + "generate": "bun run script/build.ts", + "check:generated": "bun run generate && git diff --exit-code -- src/generated src/generated-effect", + "test": "bun test --timeout 5000", "typecheck": "tsgo --noEmit" }, + "dependencies": { + "@opencode-ai/api": "workspace:*" + }, "peerDependencies": { "effect": "4.0.0-beta.83" }, @@ -21,8 +27,11 @@ } }, "devDependencies": { + "@effect/platform-node": "catalog:", + "@opencode-ai/httpapi-codegen": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:" + "@typescript/native-preview": "catalog:", + "effect": "catalog:" } } diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts new file mode 100644 index 0000000000..4676a1e8e6 --- /dev/null +++ b/packages/client/script/build.ts @@ -0,0 +1,19 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { Api } from "@opencode-ai/api" +import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen" +import { Effect } from "effect" + +const contract = compile(Api) + +await Effect.runPromise( + Effect.all( + [ + write(emitPromise(contract), new URL("../src/generated", import.meta.url).pathname), + write( + emitEffectImported(contract, { module: "@opencode-ai/api", api: "Api" }), + new URL("../src/generated-effect", import.meta.url).pathname, + ), + ], + { concurrency: 2, discard: true }, + ).pipe(Effect.provide(NodeFileSystem.layer)), +) diff --git a/packages/client/src/effect.ts b/packages/client/src/effect.ts index 8ddd141332..ceeffe100e 100644 --- a/packages/client/src/effect.ts +++ b/packages/client/src/effect.ts @@ -1,2 +1 @@ -// Generated Effect network client target. Intentionally empty until the public HttpApi is available. -export {} +export * from "./generated-effect/index" diff --git a/packages/client/src/generated-effect/.httpapi-codegen.json b/packages/client/src/generated-effect/.httpapi-codegen.json new file mode 100644 index 0000000000..958eb566db --- /dev/null +++ b/packages/client/src/generated-effect/.httpapi-codegen.json @@ -0,0 +1,5 @@ +[ + "client-error.ts", + "client.ts", + "index.ts" +] diff --git a/packages/client/src/generated-effect/client-error.ts b/packages/client/src/generated-effect/client-error.ts new file mode 100644 index 0000000000..bcc65d9bdd --- /dev/null +++ b/packages/client/src/generated-effect/client-error.ts @@ -0,0 +1,5 @@ +import { Schema } from "effect" + +export class ClientError extends Schema.TaggedErrorClass()("ClientError", { + cause: Schema.Defect(), +}) {} diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts new file mode 100644 index 0000000000..1d7b32a233 --- /dev/null +++ b/packages/client/src/generated-effect/client.ts @@ -0,0 +1,111 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect, Schema } from "effect" +import { Sse } from "effect/unstable/encoding" +import { HttpClientError } from "effect/unstable/http" +import { HttpApiClient } from "effect/unstable/httpapi" +import { Api } from "@opencode-ai/api" +import { ClientError } from "./client-error" + +type RawClient = HttpApiClient.ForApi + +const mapClientError = (error: E) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : error + +type Endpoint0_0Request = Parameters[0] +type Endpoint0_0Input = { + readonly workspace?: Endpoint0_0Request["query"]["workspace"] + readonly limit?: Endpoint0_0Request["query"]["limit"] + readonly order?: Endpoint0_0Request["query"]["order"] + readonly search?: Endpoint0_0Request["query"]["search"] + readonly directory?: Endpoint0_0Request["query"]["directory"] + readonly project?: Endpoint0_0Request["query"]["project"] + readonly subpath?: Endpoint0_0Request["query"]["subpath"] + readonly cursor?: Endpoint0_0Request["query"]["cursor"] +} +const Endpoint0_0 = (raw: RawClient["sessions"]) => (input?: Endpoint0_0Input) => + raw["list"]({ + query: { + workspace: input?.workspace, + limit: input?.limit, + order: input?.order, + search: input?.search, + directory: input?.directory, + project: input?.project, + subpath: input?.subpath, + cursor: input?.cursor, + }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint0_1Request = Parameters[0] +type Endpoint0_1Input = { + readonly id?: Endpoint0_1Request["payload"]["id"] + readonly agent?: Endpoint0_1Request["payload"]["agent"] + readonly model?: Endpoint0_1Request["payload"]["model"] + readonly location?: Endpoint0_1Request["payload"]["location"] +} +const Endpoint0_1 = (raw: RawClient["sessions"]) => (input?: Endpoint0_1Input) => + raw["create"]({ + payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint0_2Request = Parameters[0] +type Endpoint0_2Input = { readonly sessionID: Endpoint0_2Request["params"]["sessionID"] } +const Endpoint0_2 = (raw: RawClient["sessions"]) => (input: Endpoint0_2Input) => + raw["get"]({ params: { sessionID: input.sessionID } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint0_3Request = Parameters[0] +type Endpoint0_3Input = { + readonly sessionID: Endpoint0_3Request["params"]["sessionID"] + readonly agent: Endpoint0_3Request["payload"]["agent"] +} +const Endpoint0_3 = (raw: RawClient["sessions"]) => (input: Endpoint0_3Input) => + raw["switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint0_4Request = Parameters[0] +type Endpoint0_4Input = { + readonly sessionID: Endpoint0_4Request["params"]["sessionID"] + readonly model: Endpoint0_4Request["payload"]["model"] +} +const Endpoint0_4 = (raw: RawClient["sessions"]) => (input: Endpoint0_4Input) => + raw["switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint0_5Request = Parameters[0] +type Endpoint0_5Input = { + readonly sessionID: Endpoint0_5Request["params"]["sessionID"] + readonly id?: Endpoint0_5Request["payload"]["id"] + readonly prompt: Endpoint0_5Request["payload"]["prompt"] + readonly delivery?: Endpoint0_5Request["payload"]["delivery"] + readonly resume?: Endpoint0_5Request["payload"]["resume"] +} +const Endpoint0_5 = (raw: RawClient["sessions"]) => (input: Endpoint0_5Input) => + raw["prompt"]({ + params: { sessionID: input.sessionID }, + payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +const adaptGroup0 = (raw: RawClient["sessions"]) => ({ + list: Endpoint0_0(raw), + create: Endpoint0_1(raw), + get: Endpoint0_2(raw), + switchAgent: Endpoint0_3(raw), + switchModel: Endpoint0_4(raw), + prompt: Endpoint0_5(raw), +}) + +export const make = (options?: { readonly baseUrl?: URL | string }) => + HttpApiClient.make(Api, options).pipe(Effect.map((raw) => ({ sessions: adaptGroup0(raw["sessions"]) }))) diff --git a/packages/client/src/generated-effect/index.ts b/packages/client/src/generated-effect/index.ts new file mode 100644 index 0000000000..bc0dbc9fa4 --- /dev/null +++ b/packages/client/src/generated-effect/index.ts @@ -0,0 +1,2 @@ +export { ClientError } from "./client-error" +export * as OpenCode from "./client" diff --git a/packages/client/src/generated/.httpapi-codegen.json b/packages/client/src/generated/.httpapi-codegen.json new file mode 100644 index 0000000000..25700fc72d --- /dev/null +++ b/packages/client/src/generated/.httpapi-codegen.json @@ -0,0 +1,6 @@ +[ + "client-error.ts", + "client.ts", + "index.ts", + "types.ts" +] diff --git a/packages/client/src/generated/client-error.ts b/packages/client/src/generated/client-error.ts new file mode 100644 index 0000000000..c278f0ddc8 --- /dev/null +++ b/packages/client/src/generated/client-error.ts @@ -0,0 +1,11 @@ +export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse" + +export class ClientError extends Error { + override readonly name = "ClientError" + constructor( + readonly reason: ClientErrorReason, + options?: ErrorOptions, + ) { + super(reason, options) + } +} diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts new file mode 100644 index 0000000000..ba6b5762f1 --- /dev/null +++ b/packages/client/src/generated/client.ts @@ -0,0 +1,270 @@ +import type { + SessionsListInput, + SessionsListOutput, + SessionsCreateInput, + SessionsCreateOutput, + SessionsGetInput, + SessionsGetOutput, + SessionsSwitchAgentInput, + SessionsSwitchAgentOutput, + SessionsSwitchModelInput, + SessionsSwitchModelOutput, + SessionsPromptInput, + SessionsPromptOutput, +} from "./types" +import { ClientError } from "./client-error" + +export interface ClientOptions { + readonly baseUrl: string + readonly fetch?: typeof globalThis.fetch + readonly headers?: HeadersInit +} + +export interface RequestOptions { + readonly signal?: AbortSignal + readonly headers?: HeadersInit +} + +interface RequestDescriptor { + readonly method: string + readonly path: string + readonly query?: Record + readonly headers?: Record + readonly body?: unknown + readonly successStatus: number + readonly declaredStatuses: ReadonlyArray + readonly empty: boolean +} + +export function make(options: ClientOptions) { + const fetch = options.fetch ?? globalThis.fetch + + const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => { + const url = new URL(descriptor.path, options.baseUrl) + for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value) + const headers = new Headers(options.headers) + for (const [key, value] of Object.entries(descriptor.headers ?? {})) { + if (value !== undefined && value !== null) headers.set(key, String(value)) + } + for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value) + if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json") + return { + url, + init: { + method: descriptor.method, + signal: requestOptions?.signal, + headers, + body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body), + } satisfies RequestInit, + } + } + + const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => { + try { + const prepared = prepare(descriptor, requestOptions) + return await fetch(prepared.url, prepared.init) + } catch (cause) { + throw new ClientError("Transport", { cause }) + } + } + + const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => { + if (descriptor.declaredStatuses.includes(response.status)) throw await json(response) + try { + await response.body?.cancel() + } catch {} + throw new ClientError("UnexpectedStatus", { cause: { status: response.status } }) + } + + const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => { + const response = await execute(descriptor, requestOptions) + if (response.status !== descriptor.successStatus) return responseError(response, descriptor) + if (descriptor.empty) { + try { + await response.body?.cancel() + } catch {} + return undefined as A + } + return (await json(response)) as A + } + + const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({ + async *[Symbol.asyncIterator]() { + const response = await execute(descriptor, requestOptions) + if (response.status !== descriptor.successStatus) await responseError(response, descriptor) + if (!isContentType(response, "text/event-stream")) { + try { + await response.body?.cancel() + } catch {} + throw new ClientError("UnsupportedContentType") + } + if (response.body === null) throw new ClientError("MalformedResponse") + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + try { + while (true) { + let next + try { + next = await reader.read() + } catch (cause) { + throw new ClientError("Transport", { cause }) + } + buffer += decoder.decode(next.value, { stream: !next.done }) + if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse") + const trailingCarriageReturn = !next.done && buffer.endsWith("\r") + if (trailingCarriageReturn) buffer = buffer.slice(0, -1) + buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n") + if (trailingCarriageReturn) buffer += "\r" + if (next.done && buffer !== "") buffer += "\n\n" + let boundary = buffer.indexOf("\n\n") + while (boundary >= 0) { + const block = buffer.slice(0, boundary) + buffer = buffer.slice(boundary + 2) + const data = block + .split("\n") + .flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trimStart()] : [])) + .join("\n") + if (data !== "") { + try { + yield JSON.parse(data) as A + } catch (cause) { + throw new ClientError("MalformedResponse", { cause }) + } + } + boundary = buffer.indexOf("\n\n") + } + if (next.done) return + } + } finally { + try { + await reader.cancel() + } catch {} + reader.releaseLock() + } + }, + }) + + return { + sessions: { + list: (input?: SessionsListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/session`, + query: { + workspace: input?.workspace, + limit: input?.limit, + order: input?.order, + search: input?.search, + directory: input?.directory, + project: input?.project, + subpath: input?.subpath, + cursor: input?.cursor, + }, + successStatus: 200, + declaredStatuses: [400], + empty: false, + }, + requestOptions, + ), + create: (input?: SessionsCreateInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsCreateOutput }>( + { + method: "POST", + path: `/api/session`, + body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location }, + successStatus: 200, + declaredStatuses: [], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + get: (input: SessionsGetInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsGetOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}`, + successStatus: 200, + declaredStatuses: [404], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + switchAgent: (input: SessionsSwitchAgentInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`, + body: { agent: input.agent }, + successStatus: 204, + declaredStatuses: [404], + empty: true, + }, + requestOptions, + ), + switchModel: (input: SessionsSwitchModelInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/model`, + body: { model: input.model }, + successStatus: 204, + declaredStatuses: [404], + empty: true, + }, + requestOptions, + ), + prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsPromptOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`, + body: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume }, + successStatus: 200, + declaredStatuses: [409, 404], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + }, + } +} + +function appendQuery(params: URLSearchParams, key: string, value: unknown): void { + if (value === undefined || value === null) return + if (Array.isArray(value)) { + for (const item of value) appendQuery(params, key, item) + return + } + if (typeof value === "object") { + for (const [child, item] of Object.entries(value)) appendQuery(params, `${key}[${child}]`, item) + return + } + params.append(key, String(value)) +} + +async function json(response: Response): Promise { + if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) { + try { + await response.body?.cancel() + } catch {} + throw new ClientError("UnsupportedContentType") + } + let text: string + try { + text = await response.text() + } catch (cause) { + throw new ClientError("Transport", { cause }) + } + if (text === "") throw new ClientError("MalformedResponse") + try { + return JSON.parse(text) + } catch (cause) { + throw new ClientError("MalformedResponse", { cause }) + } +} + +function isContentType(response: Response, expected: string) { + return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected +} diff --git a/packages/client/src/generated/index.ts b/packages/client/src/generated/index.ts new file mode 100644 index 0000000000..2570372cf8 --- /dev/null +++ b/packages/client/src/generated/index.ts @@ -0,0 +1,3 @@ +export { ClientError, type ClientErrorReason } from "./client-error" +export * as OpenCode from "./client" +export * from "./types" diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts new file mode 100644 index 0000000000..779db6de3f --- /dev/null +++ b/packages/client/src/generated/types.ts @@ -0,0 +1,349 @@ +export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string } +export const isInvalidCursorError = (value: unknown): value is InvalidCursorError => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidCursorError" + +export type InvalidRequestError = { + readonly _tag: "InvalidRequestError" + readonly message: string + readonly kind: string | undefined + readonly field: string | undefined +} +export const isInvalidRequestError = (value: unknown): value is InvalidRequestError => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidRequestError" + +export type SessionNotFoundError = { + readonly _tag: "SessionNotFoundError" + readonly sessionID: string + readonly message: string +} +export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "SessionNotFoundError" + +export type ConflictError = { + readonly _tag: "ConflictError" + readonly message: string + readonly resource: string | undefined +} +export const isConflictError = (value: unknown): value is ConflictError => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "ConflictError" + +export type SessionsListInput = { + readonly workspace?: { + readonly workspace?: string | undefined + readonly limit?: string | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["workspace"] + readonly limit?: { + readonly workspace?: string | undefined + readonly limit?: string | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["limit"] + readonly order?: { + readonly workspace?: string | undefined + readonly limit?: string | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["order"] + readonly search?: { + readonly workspace?: string | undefined + readonly limit?: string | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["search"] + readonly directory?: { + readonly workspace?: string | undefined + readonly limit?: string | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["directory"] + readonly project?: { + readonly workspace?: string | undefined + readonly limit?: string | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["project"] + readonly subpath?: { + readonly workspace?: string | undefined + readonly limit?: string | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["subpath"] + readonly cursor?: { + readonly workspace?: string | undefined + readonly limit?: string | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["cursor"] +} + +export type SessionsListOutput = { + readonly data: ReadonlyArray<{ + readonly id: string + readonly parentID?: string + readonly projectID: string + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null } + readonly title: string + readonly location: { readonly directory: string; readonly workspaceID?: string | null } + readonly subpath?: string | null + }> + readonly cursor: { readonly previous?: string | null; readonly next?: string | null } +} + +export type SessionsCreateInput = { + readonly id?: { + readonly id?: string | undefined + readonly agent?: string | undefined + readonly model?: + | { readonly id: string; readonly providerID: string; readonly variant?: string | undefined } + | undefined + readonly location?: { readonly directory: string; readonly workspaceID?: string | undefined } | undefined + }["id"] + readonly agent?: { + readonly id?: string | undefined + readonly agent?: string | undefined + readonly model?: + | { readonly id: string; readonly providerID: string; readonly variant?: string | undefined } + | undefined + readonly location?: { readonly directory: string; readonly workspaceID?: string | undefined } | undefined + }["agent"] + readonly model?: { + readonly id?: string | undefined + readonly agent?: string | undefined + readonly model?: + | { readonly id: string; readonly providerID: string; readonly variant?: string | undefined } + | undefined + readonly location?: { readonly directory: string; readonly workspaceID?: string | undefined } | undefined + }["model"] + readonly location?: { + readonly id?: string | undefined + readonly agent?: string | undefined + readonly model?: + | { readonly id: string; readonly providerID: string; readonly variant?: string | undefined } + | undefined + readonly location?: { readonly directory: string; readonly workspaceID?: string | undefined } | undefined + }["location"] +} + +export type SessionsCreateOutput = { + readonly data: { + readonly id: string + readonly parentID?: string + readonly projectID: string + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null } + readonly title: string + readonly location: { readonly directory: string; readonly workspaceID?: string | null } + readonly subpath?: string | null + } +}["data"] + +export type SessionsGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsGetOutput = { + readonly data: { + readonly id: string + readonly parentID?: string + readonly projectID: string + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null } + readonly title: string + readonly location: { readonly directory: string; readonly workspaceID?: string | null } + readonly subpath?: string | null + } +}["data"] + +export type SessionsSwitchAgentInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly agent: { readonly agent: string }["agent"] +} + +export type SessionsSwitchAgentOutput = void + +export type SessionsSwitchModelInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly model: { + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | undefined } + }["model"] +} + +export type SessionsSwitchModelOutput = void + +export type SessionsPromptInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly id?: { + readonly id?: string | undefined + readonly prompt: { + readonly text: string + readonly files?: + | ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string | undefined + readonly description?: string | undefined + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + readonly agents?: + | ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + } + readonly delivery?: "steer" | "queue" | undefined + readonly resume?: boolean | undefined + }["id"] + readonly prompt: { + readonly id?: string | undefined + readonly prompt: { + readonly text: string + readonly files?: + | ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string | undefined + readonly description?: string | undefined + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + readonly agents?: + | ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + } + readonly delivery?: "steer" | "queue" | undefined + readonly resume?: boolean | undefined + }["prompt"] + readonly delivery?: { + readonly id?: string | undefined + readonly prompt: { + readonly text: string + readonly files?: + | ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string | undefined + readonly description?: string | undefined + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + readonly agents?: + | ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + } + readonly delivery?: "steer" | "queue" | undefined + readonly resume?: boolean | undefined + }["delivery"] + readonly resume?: { + readonly id?: string | undefined + readonly prompt: { + readonly text: string + readonly files?: + | ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string | undefined + readonly description?: string | undefined + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + readonly agents?: + | ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + } + readonly delivery?: "steer" | "queue" | undefined + readonly resume?: boolean | undefined + }["resume"] +} + +export type SessionsPromptOutput = { + readonly data: { + readonly admittedSeq: number + readonly id: string + readonly sessionID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string | null + readonly description?: string | null + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null + }> | null + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null + }> | null + } + readonly delivery: "steer" | "queue" + readonly timeCreated: number + readonly promotedSeq?: number | null + } +}["data"] diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index daf5eac75b..92e36b1c60 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,2 +1 @@ -// Generated Promise client target. Intentionally empty until the public HttpApi is available. -export {} +export * from "./generated/index" diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts new file mode 100644 index 0000000000..663172afdb --- /dev/null +++ b/packages/client/test/effect.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "bun:test" +import { AbsolutePath, AgentID, ModelRef, SessionID } from "@opencode-ai/api" +import { DateTime, Effect } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { OpenCode } from "../src/effect" + +test("sessions.get returns the decoded Effect projection", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))), + ) + const result = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.sessions.get({ sessionID: SessionID.make("ses_test") }) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000) +}) + +test("session methods retain decoded Effect inputs and outputs", async () => { + const httpClient = HttpClient.make((request) => { + const url = request.url + if (url.includes("/prompt")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) + } + if (request.method === "POST" && url.endsWith("/api/session")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))) + } + if (request.method === "POST") { + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))) + } + return Effect.succeed( + HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })), + ) + }) + const result = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + const page = yield* client.sessions.list({ limit: 10 }) + const created = yield* client.sessions.create({ location: { directory: AbsolutePath.make("/tmp/project") } }) + yield* client.sessions.switchAgent({ sessionID: SessionID.make("ses_test"), agent: AgentID.make("build") }) + yield* client.sessions.switchModel({ + sessionID: SessionID.make("ses_test"), + model: ModelRef.make({ id: "claude", providerID: "anthropic" }), + }) + const admitted = yield* client.sessions.prompt({ + sessionID: SessionID.make("ses_test"), + prompt: { text: "Hello" }, + resume: false, + }) + return { page, created, admitted } + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) + expect(result.created.id).toBe("ses_test") + expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) +}) + +const session = { + data: { + id: "ses_test", + projectID: "project", + cost: 0, + tokens: { + input: 1, + output: 2, + reasoning: 3, + cache: { read: 4, write: 5 }, + }, + time: { + created: 1_717_171_717_000, + updated: 1_717_171_717_000, + }, + title: "Test", + location: { directory: "/tmp/project" }, + }, +} + +const admission = { + data: { + admittedSeq: 0, + id: "msg_test", + sessionID: "ses_test", + prompt: { text: "Hello" }, + delivery: "steer", + timeCreated: 1_717_171_717_000, + }, +} diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts new file mode 100644 index 0000000000..651b7c2a5f --- /dev/null +++ b/packages/client/test/promise.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "bun:test" +import { OpenCode } from "../src" + +test("sessions.get returns the wire projection", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input) => { + expect(typeof input === "string" ? input : input instanceof URL ? input.href : input.url).toBe( + "http://localhost:3000/api/session/ses_test", + ) + return Response.json(session) + }, + }) + + const result = await client.sessions.get({ sessionID: "ses_test" }) + + expect(result.time.created).toBe(1_717_171_717_000) +}) + +test("session methods use the public HTTP contract", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = [] + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + requests.push({ url, init }) + if (url.includes("/prompt")) return Response.json(admission) + if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session) + if (init?.method === "POST") return new Response(null, { status: 204 }) + return Response.json({ data: [session.data], cursor: { next: "next" } }) + }, + }) + + const page = await client.sessions.list({ limit: "10", order: "desc" }) + const created = await client.sessions.create({ location: { directory: "/tmp/project" } }) + await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" }) + await client.sessions.switchModel({ + sessionID: "ses_test", + model: { id: "claude", providerID: "anthropic" }, + }) + const admitted = await client.sessions.prompt({ + sessionID: "ses_test", + prompt: { text: "Hello" }, + resume: false, + }) + + expect(page.cursor.next).toBe("next") + expect(created.id).toBe("ses_test") + expect(admitted.id).toBe("msg_test") + expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ + ["GET", "http://localhost:3000/api/session?limit=10&order=desc"], + ["POST", "http://localhost:3000/api/session"], + ["POST", "http://localhost:3000/api/session/ses_test/agent"], + ["POST", "http://localhost:3000/api/session/ses_test/model"], + ["POST", "http://localhost:3000/api/session/ses_test/prompt"], + ]) + const body = requests.at(-1)?.init?.body + if (typeof body !== "string") throw new Error("Expected JSON request body") + expect(JSON.parse(body)).toEqual({ + prompt: { text: "Hello" }, + resume: false, + }) +}) + +const session = { + data: { + id: "ses_test", + projectID: "project", + cost: 0, + tokens: { + input: 1, + output: 2, + reasoning: 3, + cache: { read: 4, write: 5 }, + }, + time: { + created: 1_717_171_717_000, + updated: 1_717_171_717_000, + }, + title: "Test", + location: { directory: "/tmp/project" }, + }, +} + +const admission = { + data: { + admittedSeq: 0, + id: "msg_test", + sessionID: "ses_test", + prompt: { text: "Hello" }, + delivery: "steer", + timeCreated: 1_717_171_717_000, + }, +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 8e4f68a035..d42ebc06e0 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -3,5 +3,6 @@ "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { "lib": ["ESNext", "DOM", "DOM.Iterable"] - } + }, + "include": ["src"] } diff --git a/packages/httpapi-codegen/package.json b/packages/httpapi-codegen/package.json index 907743ec4f..84aa736531 100644 --- a/packages/httpapi-codegen/package.json +++ b/packages/httpapi-codegen/package.json @@ -3,6 +3,9 @@ "name": "@opencode-ai/httpapi-codegen", "private": true, "type": "module", + "exports": { + ".": "./src/index.ts" + }, "scripts": { "test": "bun test --timeout 5000 --only-failures", "typecheck": "tsgo --noEmit" diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index d691b18828..41a311ae97 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -51,6 +51,7 @@ export type Endpoint = { readonly unwrapData: boolean readonly errors: ReadonlyArray readonly successes: ReadonlyArray + readonly effectPortable: boolean } export type Group = { @@ -101,10 +102,10 @@ export function compile( schemas.map((schema) => normalizeTransport(schema, "error", endpoint, name)!), ) const inputs = [ - ...inputFields(params, "params", name), - ...inputFields(query, "query", name), - ...inputFields(headers, "headers", name), - ...payloads.flatMap((schema) => inputFields(schema, "payload", name)), + ...inputFields(params?.schema, "params", name), + ...inputFields(query?.schema, "query", name), + ...inputFields(headers?.schema, "headers", name), + ...payloads.flatMap((item) => inputFields(item.schema, "payload", name)), ] const names = new Set() for (const field of inputs) { @@ -113,37 +114,47 @@ export function compile( } const schemaPaths: Array = [ - ...(params === undefined ? [] : [[`${name}.params`, params] as const]), - ...(query === undefined ? [] : [[`${name}.query`, query] as const]), - ...(headers === undefined ? [] : [[`${name}.headers`, headers] as const]), - ...payloads.map((schema) => [`${name}.payload`, schema] as const), - ...responseSchemas(success, `${name}.success`), - ...errorSchemas.map((schema) => [`${name}.error`, schema] as const), + ...(params === undefined ? [] : [[`${name}.params`, params.schema] as const]), + ...(query === undefined ? [] : [[`${name}.query`, query.schema] as const]), + ...(headers === undefined ? [] : [[`${name}.headers`, headers.schema] as const]), + ...payloads.map((item) => [`${name}.payload`, item.schema] as const), + ...responseSchemas(success.schema, `${name}.success`), + ...errorSchemas.map((item) => [`${name}.error`, item.schema] as const), ] - for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable) + const effectPortable = [params, query, headers, ...payloads, success, ...errorSchemas].every( + (item) => item?.effectPortable !== false, + ) + if (effectPortable) { + for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable) + } endpoints.push({ group: group.identifier, topLevel: group.topLevel, endpoint, - params, - query, - headers, - payloads, + params: params?.schema, + query: query?.schema, + headers: headers?.schema, + payloads: payloads.map((item) => item.schema), input: inputs, - unwrapData: isDataEnvelope(success), - successes: [success], - errors: errorSchemas, + unwrapData: isDataEnvelope(success.schema), + successes: [success.schema], + errors: errorSchemas.map((item) => item.schema), + effectPortable, operation: { group: group.identifier, name: endpoint.name, input: inputs.map(({ name, source }) => ({ name, source })), inputMode: inputs.length === 0 ? "none" : inputs.every((field) => field.optional) ? "optional" : "required", - success: isStreamSchema(success) ? "stream" : HttpApiSchema.isNoContent(success.ast) ? "void" : "value", + success: isStreamSchema(success.schema) + ? "stream" + : HttpApiSchema.isNoContent(success.schema.ast) + ? "void" + : "value", errors: [ ...new Set([ - ...errorSchemas.flatMap((schema) => { - const identifier = SchemaAST.resolveIdentifier(schema.ast) + ...errorSchemas.flatMap((item) => { + const identifier = SchemaAST.resolveIdentifier(item.schema.ast) return identifier === undefined ? [] : [identifier] }), "ClientError", @@ -178,9 +189,25 @@ export function compile( } export function emitEffect(contract: Contract): Output { + const endpoint = contract.groups.flatMap((group) => group.endpoints).find((endpoint) => !endpoint.effectPortable) + if (endpoint !== undefined) { + throw new GenerationError({ + reason: `Effect schema requires authoritative import: ${endpoint.group}.${endpoint.endpoint.name}`, + }) + } return { operations: operations(contract.groups), files: renderEffectFiles(contract.groups) } } +export function emitEffectImported( + contract: Contract, + options: { readonly module: string; readonly api: string }, +): Output { + return { + operations: operations(contract.groups), + files: renderImportedEffectFiles(contract.groups, options), + } +} + export function emitPromise(contract: Contract): Output { const groups = contract.groups for (const group of groups) { @@ -192,9 +219,12 @@ export function emitPromise(contract: Contract): Output { { path: "types.ts", content: renderPromiseTypes(groups) }, { path: "client-error.ts", - content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`, + content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`, + }, + { + path: "client.ts", + content: renderPromiseClient(groups).replace("let next: ReadableStreamReadResult", "let next"), }, - { path: "client.ts", content: renderPromiseClient(groups) }, { path: "index.ts", content: @@ -259,6 +289,60 @@ function renderEffectFiles(groups: ReadonlyArray): Output["files"] { ] } +function renderImportedEffectFiles( + groups: ReadonlyArray, + options: { readonly module: string; readonly api: string }, +): Output["files"] { + const adapters = groups.map((group, groupIndex) => { + const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.identifier)}]` + const methods = group.endpoints.map((item, endpointIndex) => { + const prefix = `Endpoint${groupIndex}_${endpointIndex}` + const request = (["params", "query", "headers", "payload"] as const) + .flatMap((source) => { + const fields = item.input.filter((field) => field.source === source) + if (fields.length === 0) return [] + return [ + `${source}: { ${fields.map((field) => `${JSON.stringify(field.name)}: input${item.operation.inputMode === "optional" ? "?." : "."}${field.name}`).join(", ")} }`, + ] + }) + .join(", ") + const input = item.input + .map( + (field) => + `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`, + ) + .join("; ") + const argument = + item.operation.inputMode === "none" + ? "" + : `input${item.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input` + const rawCall = `raw[${JSON.stringify(item.endpoint.name)}]({ ${request} })` + const mapped = `${rawCall}.pipe(Effect.mapError(mapClientError)${item.unwrapData ? ", Effect.map((value) => value.data)" : ""})` + return `${item.operation.inputMode === "none" ? "" : `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.name)}]>[0]\ntype ${prefix}Input = { ${input} }\n`}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${item.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(mapClientError), Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError)))))` : mapped}` + }) + return `${methods.join("\n\n")}\n\nconst adaptGroup${groupIndex} = (raw: ${rawGroup}) => ({ ${group.endpoints.map((item, endpointIndex) => `${JSON.stringify(item.endpoint.name)}: Endpoint${groupIndex}_${endpointIndex}(raw)`).join(", ")} })` + }) + const fields = groups.flatMap((group, index) => + group.endpoints[0]?.topLevel + ? [`...adaptGroup${index}(raw)`] + : [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.identifier)}])`], + ) + const usesStream = groups.some((group) => group.endpoints.some((item) => item.operation.success === "stream")) + const client = `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect${usesStream ? ", Stream" : ""}, Schema } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient } from "effect/unstable/httpapi"\nimport { ${options.api} } from ${JSON.stringify(options.module)}\nimport { ClientError } from "./client-error"\n\ntype RawClient = HttpApiClient.ForApi\n\nconst mapClientError = (error: E) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : error\n\n${adapters.join("\n\n")}\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) => HttpApiClient.make(${options.api}, options).pipe(Effect.map((raw) => ({ ${fields.join(", ")} })))\n` + return [ + { + path: "client-error.ts", + content: + 'import { Schema } from "effect"\n\nexport class ClientError extends Schema.TaggedErrorClass()("ClientError", {\n cause: Schema.Defect(),\n}) {}\n', + }, + { path: "client.ts", content: client }, + { + path: "index.ts", + content: 'export { ClientError } from "./client-error"\nexport * as OpenCode from "./client"\n', + }, + ] +} + function renderPromiseTypes(groups: ReadonlyArray) { const types = new Map() const typeOf = (schema: Schema.Top) => { @@ -387,13 +471,16 @@ function identifierPart(value: string) { function structuralType(schema: Schema.Top) { const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.fromASTs([schema.ast])) if ( - document.artifacts.length > 0 || + document.artifacts.some( + (artifact) => + artifact._tag !== "Import" || artifact.importDeclaration !== 'import type * as Brand from "effect/Brand"', + ) || document.references.nonRecursives.length > 0 || Object.keys(document.references.recursives).length > 0 ) { throw new GenerationError({ reason: "Referenced Promise types are not implemented" }) } - return document.codes[0].Type + return document.codes[0].Type.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "") } function promisePath(path: string, input: ReadonlyArray) { @@ -424,7 +511,8 @@ function normalizeTransport( endpoint: HttpApiEndpoint.AnyWithProps, operation: string, ) { - if (schema === undefined || isStreamSchema(schema)) return schema + if (schema === undefined) return undefined + if (isStreamSchema(schema)) return { schema, effectPortable: true } as const if (!metadataPortable(schema.ast, new Set())) { throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` }) } @@ -452,10 +540,9 @@ function normalizeTransport( : source === "success" ? Array.from(rebuilt.success)[0] : Array.from(rebuilt.error)[0] - if (normalized === undefined || !sameEncoding(schema.ast, normalized.ast)) { - throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` }) - } - return decoded + if (normalized === undefined) throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` }) + if (!sameEncoding(schema.ast, normalized.ast)) return { schema, effectPortable: false } as const + return { schema: decoded, effectPortable: true } as const } function isPathInput(path: string): path is HttpRouter.PathInput { diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index f0ed070905..91118e0b32 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -5,7 +5,14 @@ import { join } from "node:path" import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi" import { format } from "prettier" -import { compile as compileContract, emitEffect, emitPromise, generate, GenerationError } from "../src" +import { + compile as compileContract, + emitEffect, + emitEffectImported, + emitPromise, + generate, + GenerationError, +} from "../src" import { it } from "./effect" import { Api as FixtureApi, Missing } from "./fixture" @@ -41,6 +48,45 @@ describe("HttpApiCodegen.generate", () => { ) }) + test("emits an Effect client against an imported authoritative API", () => { + const output = emitEffectImported( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + { module: "@example/api", api: "Api" }, + ) + + expect(output.files.map((file) => file.path)).toEqual(["client-error.ts", "client.ts", "index.ts"]) + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain( + 'import { Api } from "@example/api"', + ) + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain( + "HttpApiClient.ForApi", + ) + }) + + test("erases brands from Promise wire types", () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String.pipe(Schema.brand("SessionID")) }, + success: Schema.Struct({ data: Schema.String.pipe(Schema.brand("SessionID")) }), + }), + ), + ), + ) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain('readonly "sessionID": string') + expect(types).not.toContain("Brand") + }) + test("emits an optional Promise input when every field is optional", () => { const output = emitPromise( compileContract( @@ -520,7 +566,7 @@ describe("HttpApiCodegen.generate", () => { }), ), ), - ).toThrow("Unportable schema: session.get.query") + ).toThrow("Effect schema requires authoritative import: session.get") }) test("rejects custom validation checks without portable metadata", () => { @@ -559,7 +605,7 @@ describe("HttpApiCodegen.generate", () => { const Altered = Schema.make(ast) expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Altered })))).toThrow( - "Unportable schema: session.get.success", + "Effect schema requires authoritative import: session.get", ) }) diff --git a/packages/server/package.json b/packages/server/package.json index 18cd2ab1f2..d30c10b6fd 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -12,6 +12,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { + "@opencode-ai/api": "workspace:*", "@opencode-ai/core": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:" diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index 42573e0648..3fadacedf3 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -1,4 +1,12 @@ -import { HttpApi, OpenApi } from "effect/unstable/httpapi" +import { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { + SessionsCreate, + SessionsGet, + SessionsList, + SessionsPrompt, + SessionsSwitchAgent, + SessionsSwitchModel, +} from "@opencode-ai/api" import { SchemaErrorMiddleware } from "./middleware/schema-error" import { MessageGroup } from "./groups/message" import { ModelGroup } from "./groups/model" @@ -19,11 +27,21 @@ import { LocationGroup } from "./groups/location" import { IntegrationGroup } from "./groups/integration" import { CredentialGroup } from "./groups/credential" import { ProjectCopyGroup } from "./groups/project-copy" +import { SessionLocationMiddleware } from "./middleware/session-location" export const Api = HttpApi.make("server") .add(HealthGroup) .add(LocationGroup) .add(AgentGroup) + .add( + HttpApiGroup.make("sessions") + .add(SessionsList) + .add(SessionsCreate) + .add(SessionsGet.middleware(SessionLocationMiddleware)) + .add(SessionsSwitchAgent.middleware(SessionLocationMiddleware)) + .add(SessionsSwitchModel.middleware(SessionLocationMiddleware)) + .add(SessionsPrompt.middleware(SessionLocationMiddleware)), + ) .add(SessionGroup) .add(MessageGroup) .add(ModelGroup) diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts index ac4418d39f..52a8c31e4e 100644 --- a/packages/server/src/groups/session.ts +++ b/packages/server/src/groups/session.ts @@ -1,24 +1,12 @@ import { SessionMessage } from "@opencode-ai/core/session/message" -import { SessionInput } from "@opencode-ai/core/session/input" -import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionV2 } from "@opencode-ai/core/session" import { ProjectV2 } from "@opencode-ai/core/project" import { AbsolutePath, PositiveInt, RelativePath, withStatics } from "@opencode-ai/core/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { Schema, Struct } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { - ConflictError, - InvalidCursorError, - InvalidRequestError, - ServiceUnavailableError, - SessionNotFoundError, - UnknownError, -} from "../errors" +import { ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../errors" import { SessionLocationMiddleware } from "../middleware/session-location" -import { AgentV2 } from "@opencode-ai/core/agent" -import { ModelV2 } from "@opencode-ai/core/model" -import { Location } from "@opencode-ai/core/location" const SessionsQueryFields = { workspace: WorkspaceV2.ID.pipe(Schema.optional), @@ -62,137 +50,16 @@ const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson) export const SessionsCursor = Schema.String.pipe( Schema.brand("SessionsCursor"), withStatics((schema) => { - const make = schema.make return { make: (input: typeof SessionsCursorInput.Type) => - make(Buffer.from(encodeSessionsCursor(input)).toString("base64url")), + schema.make(Buffer.from(encodeSessionsCursor(input)).toString("base64url")), parse: (input: string) => decodeSessionsCursor(Buffer.from(input, "base64url").toString("utf8")), } }), ) export type SessionsCursor = typeof SessionsCursor.Type -const SessionsCursorQuery = Schema.Struct({ - cursor: SessionsCursor.annotate({ - description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.", - }), - limit: SessionsQueryFields.limit, -}) - -export const SessionsQuery = Schema.Struct({ - ...SessionsQueryFields, - directory: AbsolutePath.pipe(Schema.optional), - project: ProjectV2.ID.pipe(Schema.optional), - subpath: RelativePath.pipe(Schema.optional), - cursor: SessionsCursorQuery.fields.cursor.pipe(Schema.optional), -}).annotate({ identifier: "SessionsQuery" }) - export const SessionGroup = HttpApiGroup.make("server.session") - .add( - HttpApiEndpoint.get("session.list", "/api/session", { - query: SessionsQuery, - success: Schema.Struct({ - data: Schema.Array(SessionV2.Info), - cursor: Schema.Struct({ - previous: SessionsCursor.pipe(Schema.optional), - next: SessionsCursor.pipe(Schema.optional), - }), - }).annotate({ identifier: "SessionsResponse" }), - error: [InvalidCursorError, InvalidRequestError], - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.list", - summary: "List sessions", - description: - "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.create", "/api/session", { - payload: Schema.Struct({ - id: SessionV2.ID.pipe(Schema.optional), - agent: AgentV2.ID.pipe(Schema.optional), - model: ModelV2.Ref.pipe(Schema.optional), - location: Location.Ref.pipe(Schema.optional), - }), - success: Schema.Struct({ data: SessionV2.Info }), - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.create", - summary: "Create session", - description: "Create a session at the requested location.", - }), - ), - ) - .add( - HttpApiEndpoint.get("session.get", "/api/session/:sessionID", { - params: { sessionID: SessionV2.ID }, - success: Schema.Struct({ data: SessionV2.Info }), - error: SessionNotFoundError, - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.get", - summary: "Get session", - description: "Retrieve a session by ID.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", { - params: { sessionID: SessionV2.ID }, - payload: Schema.Struct({ agent: AgentV2.ID }), - success: HttpApiSchema.NoContent, - error: SessionNotFoundError, - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.switchAgent", - summary: "Switch session agent", - description: "Switch the agent used by subsequent session activity.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.switchModel", "/api/session/:sessionID/model", { - params: { sessionID: SessionV2.ID }, - payload: Schema.Struct({ model: ModelV2.Ref }), - success: HttpApiSchema.NoContent, - error: SessionNotFoundError, - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.switchModel", - summary: "Switch session model", - description: "Switch the model used by subsequent session activity.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { - params: { sessionID: SessionV2.ID }, - payload: Schema.Struct({ - id: SessionMessage.ID.pipe(Schema.optional), - prompt: Prompt, - delivery: SessionInput.Delivery.pipe(Schema.optional), - resume: Schema.Boolean.pipe(Schema.optional), - }), - success: Schema.Struct({ data: SessionInput.Admitted }), - error: [ConflictError, SessionNotFoundError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.prompt", - summary: "Send message", - description: "Durably admit one session input and schedule agent-loop execution unless resume is false.", - }), - ), - ) .add( HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { params: { sessionID: SessionV2.ID }, diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index d26c1618f0..30d49c4eac 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -25,11 +25,13 @@ import { IntegrationHandler } from "./handlers/integration" import { CredentialHandler } from "./handlers/credential" import { Credential } from "@opencode-ai/core/credential" import { ProjectCopyHandler } from "./handlers/project-copy" +import { PublicSessionHandler } from "./handlers/public-session" export const handlers = Layer.mergeAll( HealthHandler, LocationHandler, AgentHandler, + PublicSessionHandler, SessionHandler, MessageHandler, ModelHandler, diff --git a/packages/server/src/handlers/public-session.ts b/packages/server/src/handlers/public-session.ts new file mode 100644 index 0000000000..9b44a3f26f --- /dev/null +++ b/packages/server/src/handlers/public-session.ts @@ -0,0 +1,163 @@ +import { + ConflictError, + InvalidCursorError, + SessionNotFoundError, + SessionsCursor as PublicSessionsCursor, +} from "@opencode-ai/api" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { DateTime, Effect } from "effect" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" +import { Api } from "../api" +import { SessionsCursor } from "../groups/session" + +const DefaultSessionsLimit = 50 + +export const PublicSessionHandler = HttpApiBuilder.group(Api, "sessions", (handlers) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + return handlers + .handle( + "list", + Effect.fn(function* (ctx) { + const query = + ctx.query.cursor !== undefined + ? yield* SessionsCursor.parse(ctx.query.cursor).pipe( + Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })), + ) + : ctx.query + const sessions = yield* session.list({ + ...query, + workspaceID: query.workspace, + limit: ctx.query.limit ?? DefaultSessionsLimit, + }) + const first = sessions[0] + const last = sessions.at(-1) + return { + data: sessions, + cursor: { + previous: first + ? PublicSessionsCursor.make( + SessionsCursor.make({ + ...query, + anchor: { + id: first.id, + time: DateTime.toEpochMillis(first.time.created), + direction: "previous", + }, + }), + ) + : undefined, + next: last + ? PublicSessionsCursor.make( + SessionsCursor.make({ + ...query, + anchor: { + id: last.id, + time: DateTime.toEpochMillis(last.time.created), + direction: "next", + }, + }), + ) + : undefined, + }, + } + }), + ) + .handle( + "create", + Effect.fn(function* (ctx) { + return { + data: yield* session.create({ + id: ctx.payload.id, + agent: ctx.payload.agent, + model: ctx.payload.model, + location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) }, + }), + } + }), + ) + .handle( + "get", + Effect.fn(function* (ctx) { + return { + data: yield* session.get(ctx.params.sessionID).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + } + }), + ) + .handle( + "switchAgent", + Effect.fn(function* (ctx) { + yield* session.switchAgent({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "switchModel", + Effect.fn(function* (ctx) { + yield* session.switchModel({ sessionID: ctx.params.sessionID, model: ctx.payload.model }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "prompt", + Effect.fn(function* (ctx) { + return { + data: yield* session + .prompt({ + sessionID: ctx.params.sessionID, + id: ctx.payload.id, + prompt: ctx.payload.prompt, + delivery: ctx.payload.delivery, + resume: ctx.payload.resume, + }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + Effect.catchTag("Session.PromptConflictError", (error) => + Effect.fail( + new ConflictError({ + message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`, + resource: error.messageID, + }), + ), + ), + ), + } + }), + ) + }), +) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 1fe860e528..604bf75eb8 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,162 +1,14 @@ import { SessionV2 } from "@opencode-ai/core/session" -import { DateTime, Effect } from "effect" +import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { SessionsCursor } from "../groups/session" -import { - ConflictError, - InvalidCursorError, - ServiceUnavailableError, - SessionNotFoundError, - UnknownError, -} from "../errors" -import { AbsolutePath } from "@opencode-ai/core/schema" - -const DefaultSessionsLimit = 50 +import { ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../errors" export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => Effect.gen(function* () { const session = yield* SessionV2.Service return handlers - .handle( - "session.list", - Effect.fn(function* (ctx) { - const query = - ctx.query.cursor !== undefined - ? yield* SessionsCursor.parse(ctx.query.cursor).pipe( - Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })), - ) - : ctx.query - const sessions = yield* session.list({ - ...query, - workspaceID: query.workspace, - limit: ctx.query.limit ?? DefaultSessionsLimit, - }) - const first = sessions[0] - const last = sessions.at(-1) - return { - data: sessions, - cursor: { - previous: first - ? SessionsCursor.make({ - ...query, - anchor: { - id: first.id, - time: DateTime.toEpochMillis(first.time.created), - direction: "previous", - }, - }) - : undefined, - next: last - ? SessionsCursor.make({ - ...query, - anchor: { - id: last.id, - time: DateTime.toEpochMillis(last.time.created), - direction: "next", - }, - }) - : undefined, - }, - } - }), - ) - .handle( - "session.create", - Effect.fn(function* (ctx) { - return { - data: yield* session.create({ - id: ctx.payload.id, - agent: ctx.payload.agent, - model: ctx.payload.model, - location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) }, - }), - } - }), - ) - .handle( - "session.get", - Effect.fn(function* (ctx) { - return { - data: yield* session.get(ctx.params.sessionID).pipe( - Effect.catchTag( - "Session.NotFoundError", - (error) => - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), - ), - ), - } - }), - ) - .handle( - "session.switchAgent", - Effect.fn(function* (ctx) { - yield* session.switchAgent({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent }).pipe( - Effect.catchTag("Session.NotFoundError", (error) => - Effect.fail( - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), - ), - ), - ) - return HttpApiSchema.NoContent.make() - }), - ) - .handle( - "session.switchModel", - Effect.fn(function* (ctx) { - yield* session.switchModel({ sessionID: ctx.params.sessionID, model: ctx.payload.model }).pipe( - Effect.catchTag("Session.NotFoundError", (error) => - Effect.fail( - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), - ), - ), - ) - return HttpApiSchema.NoContent.make() - }), - ) - .handle( - "session.prompt", - Effect.fn(function* (ctx) { - return { - data: yield* session - .prompt({ - sessionID: ctx.params.sessionID, - id: ctx.payload.id, - prompt: ctx.payload.prompt, - delivery: ctx.payload.delivery, - resume: ctx.payload.resume, - }) - .pipe( - Effect.catchTag("Session.NotFoundError", (error) => - Effect.fail( - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), - ), - ), - Effect.catchTag("Session.PromptConflictError", (error) => - Effect.fail( - new ConflictError({ - message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`, - resource: error.messageID, - }), - ), - ), - ), - } - }), - ) .handle( "session.compact", Effect.fn(function* (ctx) {