diff --git a/CONTEXT.md b/CONTEXT.md index b507d76443..ec01dd7e46 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -137,11 +137,11 @@ _Avoid_: Response envelope - Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers. - **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property. - The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names. -- The public `HttpApi` is authoritative for shared **OpenCode Client** capabilities: the server and code generation reuse the same endpoint declaration objects. As a temporary beta compromise, generation composes a lightweight projection group from selected endpoints because importing the hosted Session group currently reaches heavy Core and server runtime modules. +- The public `HttpApi` is authoritative for shared **OpenCode Client** capabilities: the server and code generation consume the same hosted `SessionGroup`. Codegen may assign a separate consumer-facing group name without reconstructing group membership or endpoint contracts. - 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. - 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 canonical V2 server `HttpApi`; the Promise emitter still derives zero-Effect structural wire types from the same IR. -- `@opencode-ai/server` owns the authoritative V2 `HttpApi`. The hosted server group remains authoritative; the temporary generated projection must reuse its endpoint declarations exactly and must not independently redefine their HTTP contracts. +- `@opencode-ai/server` owns the authoritative V2 `HttpApi`. The server and client generator consume the exact hosted `SessionGroup`, including `compact`, `wait`, and `context`. - 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. @@ -153,7 +153,7 @@ _Avoid_: Response envelope - Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides. - Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation. - Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently. -- Promise and Effect client outputs ship from `@opencode-ai/client` behind isolated root, `/effect`, and `/effect/embedded` exports. The root export has no runtime path to Effect; `/effect` contains only the rich network client and may use Effect as an optional peer dependency; `/effect/embedded` owns the heavy private Core/server dependency closure for the scoped embedded host. The package remains private until the authoritative public `HttpApi` is composed and packaging of embedded dependencies is settled. +- Promise and Effect client outputs ship from `@opencode-ai/client` behind isolated root, `/effect`, and `/effect/embedded` exports. The root export has no runtime path to Effect. During alpha, `/effect` imports the authoritative hosted group and therefore accepts its heavy private Core/server dependency closure; `/effect/embedded` additionally owns the scoped in-process host. The package remains private until the contract import graph and embedded packaging are settled. - A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately. - `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes. - `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state. @@ -192,19 +192,17 @@ _Avoid_: Response envelope ## Deferred client contract cleanup -The beta client currently reconstructs a small `HttpApi`/group projection from authoritative Session endpoint objects. This avoids pulling the full Core/server runtime graph into `@opencode-ai/client/effect`, but group membership, group annotations, and the client namespace are consequently maintained outside the hosted `SessionGroup`. This is an accepted beta compromise, not the intended stable boundary. +The alpha client imports the exact hosted `SessionGroup`. This keeps server hosting and client generation structurally aligned, but currently pulls the full Core/server runtime graph into `@opencode-ai/client/effect`. That cost is accepted during alpha; the intended stable boundary keeps the same authoritative group while isolating its contract imports. Before stabilizing the client API: - Isolate runtime HTTP contract values into lightweight leaf modules. Importing Session, prompt, admission, message, model, location, and related schemas must not transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM. - Separate the `SessionLocationMiddleware` service tag and error contract from its database-backed layer implementation. -- Export one lightweight authoritative `SessionGroup` and have both the server API and client codegen import that exact group value. -- Remove the endpoint-map projection and generated shadow `HttpApiGroup` once the authoritative group is browser-safe to import. -- Generate all appropriate shared Session operations from that group, including `compact`, `wait`, and `context`, rather than maintaining a selected six-endpoint list. +- Make the existing authoritative `SessionGroup` browser-safe to import without changing server or generated client behavior. - Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract. - Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier. - Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change. -- Verify the isolated `@opencode-ai/client/effect` browser bundle does not include heavy embedded dependencies; those remain owned by `@opencode-ai/client/effect/embedded`. +- Restore a browser-safe `@opencode-ai/client/effect` bundle after contract import isolation; until then only the zero-Effect Promise root has a lightweight portability guarantee. - Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly. - Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client. diff --git a/packages/client/README.md b/packages/client/README.md index fdb5187076..3af443025c 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -8,9 +8,9 @@ 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 initial generated surface contains `sessions.list`, `create`, `get`, `switchAgent`, `switchModel`, and `prompt`. The server and generator reuse the same endpoint declarations, while generation temporarily composes a lightweight projection group to keep the network Effect entrypoint isolated from the heavy Core/server runtime graph. Run `bun run generate` after changing that contract and `bun run check:generated` to detect committed-output drift. +The generated surface contains `sessions.list`, `create`, `get`, `switchAgent`, `switchModel`, `prompt`, `compact`, `wait`, and `context`. The server and generator consume the exact same hosted `SessionGroup`. Run `bun run generate` after changing that contract and `bun run check:generated` to detect committed-output drift. -This projection is a beta implementation compromise. The intended stable design is one browser-safe authoritative `SessionGroup`, imported directly by both the server and codegen after its schema and middleware-tag dependencies have been isolated into lightweight modules. The migration checklist is recorded in `CONTEXT.md` under "Deferred client contract cleanup." +During alpha, the Effect network entrypoint accepts the authoritative group's heavy Core/server import graph. The intended stable design keeps the same group while isolating its schema and middleware-tag dependencies into lightweight modules. The migration checklist is recorded in `CONTEXT.md` under "Deferred client contract cleanup." The embedded entrypoint exposes a scoped host backed by the same server router, middleware, handlers, and HTTP codecs as the network client: diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts index ac4403f5b4..a69a54a6fa 100644 --- a/packages/client/script/build.ts +++ b/packages/client/script/build.ts @@ -1,26 +1,11 @@ import { NodeFileSystem } from "@effect/platform-node" -import { - SessionsCreate, - SessionsGet, - SessionsList, - SessionsPrompt, - SessionsSwitchAgent, - SessionsSwitchModel, -} from "@opencode-ai/server/groups/session-endpoints" +import { SessionGroup } from "@opencode-ai/server/groups/session" import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen" import { Effect } from "effect" -import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi" +import { HttpApi } from "effect/unstable/httpapi" -const Api = HttpApi.make("opencode-client").add( - HttpApiGroup.make("sessions") - .add(SessionsList) - .add(SessionsCreate) - .add(SessionsGet) - .add(SessionsSwitchAgent) - .add(SessionsSwitchModel) - .add(SessionsPrompt), -) -const contract = compile(Api) +const Api = HttpApi.make("opencode-client").add(SessionGroup) +const contract = compile(Api, { groupNames: { "server.session": "sessions" } }) await Effect.runPromise( Effect.all( @@ -28,15 +13,8 @@ await Effect.runPromise( write(emitPromise(contract), new URL("../src/generated", import.meta.url).pathname), write( emitEffectImported(contract, { - module: "@opencode-ai/server/groups/session-endpoints", - endpoints: { - "sessions.session.list": "SessionsList", - "sessions.session.create": "SessionsCreate", - "sessions.session.get": "SessionsGet", - "sessions.session.switchAgent": "SessionsSwitchAgent", - "sessions.session.switchModel": "SessionsSwitchModel", - "sessions.session.prompt": "SessionsPrompt", - }, + module: "@opencode-ai/server/groups/session", + group: "SessionGroup", }), new URL("../src/generated-effect", import.meta.url).pathname, ), diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 8ea7f95339..fb2ba270d2 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -2,26 +2,11 @@ import { Effect, Schema } from "effect" import { Sse } from "effect/unstable/encoding" import { HttpClientError } from "effect/unstable/http" -import { HttpApi, HttpApiClient, HttpApiGroup } from "effect/unstable/httpapi" -import { - SessionsList, - SessionsCreate, - SessionsGet, - SessionsSwitchAgent, - SessionsSwitchModel, - SessionsPrompt, -} from "@opencode-ai/server/groups/session-endpoints" +import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" +import { SessionGroup } from "@opencode-ai/server/groups/session" import { ClientError } from "./client-error" -const Api = HttpApi.make("generated").add( - HttpApiGroup.make("sessions") - .add(SessionsList) - .add(SessionsCreate) - .add(SessionsGet) - .add(SessionsSwitchAgent) - .add(SessionsSwitchModel) - .add(SessionsPrompt), -) +const Api = HttpApi.make("generated").add(SessionGroup) type RawClient = HttpApiClient.ForApi @@ -30,7 +15,7 @@ const mapClientError = (error: E) => ? new ClientError({ cause: error }) : error -type Endpoint0_0Request = Parameters[0] +type Endpoint0_0Request = Parameters[0] type Endpoint0_0Input = { readonly workspace?: Endpoint0_0Request["query"]["workspace"] readonly limit?: Endpoint0_0Request["query"]["limit"] @@ -41,7 +26,7 @@ type Endpoint0_0Input = { readonly subpath?: Endpoint0_0Request["query"]["subpath"] readonly cursor?: Endpoint0_0Request["query"]["cursor"] } -const Endpoint0_0 = (raw: RawClient["sessions"]) => (input?: Endpoint0_0Input) => +const Endpoint0_0 = (raw: RawClient["server.session"]) => (input?: Endpoint0_0Input) => raw["session.list"]({ query: { workspace: input?.workspace, @@ -55,14 +40,14 @@ const Endpoint0_0 = (raw: RawClient["sessions"]) => (input?: Endpoint0_0Input) = }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint0_1Request = Parameters[0] +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) => +const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1Input) => raw["session.create"]({ payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location }, }).pipe( @@ -70,35 +55,35 @@ const Endpoint0_1 = (raw: RawClient["sessions"]) => (input?: Endpoint0_1Input) = Effect.map((value) => value.data), ) -type Endpoint0_2Request = Parameters[0] +type Endpoint0_2Request = Parameters[0] type Endpoint0_2Input = { readonly sessionID: Endpoint0_2Request["params"]["sessionID"] } -const Endpoint0_2 = (raw: RawClient["sessions"]) => (input: Endpoint0_2Input) => +const Endpoint0_2 = (raw: RawClient["server.session"]) => (input: Endpoint0_2Input) => raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint0_3Request = Parameters[0] +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) => +const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) => raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint0_4Request = Parameters[0] +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) => +const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) => raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint0_5Request = Parameters[0] +type Endpoint0_5Request = Parameters[0] type Endpoint0_5Input = { readonly sessionID: Endpoint0_5Request["params"]["sessionID"] readonly id?: Endpoint0_5Request["payload"]["id"] @@ -106,7 +91,7 @@ type Endpoint0_5Input = { readonly delivery?: Endpoint0_5Request["payload"]["delivery"] readonly resume?: Endpoint0_5Request["payload"]["resume"] } -const Endpoint0_5 = (raw: RawClient["sessions"]) => (input: Endpoint0_5Input) => +const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) => raw["session.prompt"]({ params: { sessionID: input.sessionID }, payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume }, @@ -115,14 +100,35 @@ const Endpoint0_5 = (raw: RawClient["sessions"]) => (input: Endpoint0_5Input) => Effect.map((value) => value.data), ) -const adaptGroup0 = (raw: RawClient["sessions"]) => ({ +type Endpoint0_6Request = Parameters[0] +type Endpoint0_6Input = { readonly sessionID: Endpoint0_6Request["params"]["sessionID"] } +const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) => + raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint0_7Request = Parameters[0] +type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] } +const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) => + raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint0_8Request = Parameters[0] +type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] } +const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) => + raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +const adaptGroup0 = (raw: RawClient["server.session"]) => ({ list: Endpoint0_0(raw), create: Endpoint0_1(raw), get: Endpoint0_2(raw), switchAgent: Endpoint0_3(raw), switchModel: Endpoint0_4(raw), prompt: Endpoint0_5(raw), + compact: Endpoint0_6(raw), + wait: Endpoint0_7(raw), + context: Endpoint0_8(raw), }) export const make = (options?: { readonly baseUrl?: URL | string }) => - HttpApiClient.make(Api, options).pipe(Effect.map((raw) => ({ sessions: adaptGroup0(raw["sessions"]) }))) + HttpApiClient.make(Api, options).pipe(Effect.map((raw) => ({ sessions: adaptGroup0(raw["server.session"]) }))) diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index ea77161aea..3c188de379 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -11,6 +11,12 @@ import type { SessionsSwitchModelOutput, SessionsPromptInput, SessionsPromptOutput, + SessionsCompactInput, + SessionsCompactOutput, + SessionsWaitInput, + SessionsWaitOutput, + SessionsContextInput, + SessionsContextOutput, } from "./types" import { ClientError } from "./client-error" @@ -227,6 +233,39 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + compact: (input: SessionsCompactInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`, + successStatus: 204, + declaredStatuses: [404, 503, 401, 400], + empty: true, + }, + requestOptions, + ), + wait: (input: SessionsWaitInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/wait`, + successStatus: 204, + declaredStatuses: [404, 503, 401, 400], + empty: true, + }, + requestOptions, + ), + context: (input: SessionsContextInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsContextOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/context`, + successStatus: 200, + declaredStatuses: [404, 401, 500, 400], + empty: false, + }, + requestOptions, + ).then((value) => value.data), }, } } diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index a41db3d70c..e17fd01019 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -1,3 +1,11 @@ +export type JsonValue = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: JsonValue } + 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" @@ -31,6 +39,22 @@ export type ConflictError = { export const isConflictError = (value: unknown): value is ConflictError => typeof value === "object" && value !== null && "_tag" in value && value._tag === "ConflictError" +export type ServiceUnavailableError = { + readonly _tag: "ServiceUnavailableError" + readonly message: string + readonly service?: string | undefined +} +export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "ServiceUnavailableError" + +export type UnknownError = { + readonly _tag: "UnknownError" + readonly message: string + readonly ref?: string | undefined +} +export const isUnknownError = (value: unknown): value is UnknownError => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnknownError" + export type SessionsListInput = { readonly workspace?: { readonly workspace?: string | undefined @@ -351,3 +375,181 @@ export type SessionsPromptOutput = { readonly promotedSeq?: number | null } }["data"] + +export type SessionsCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsCompactOutput = void + +export type SessionsWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsWaitOutput = void + +export type SessionsContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsContextOutput = { + readonly data: ReadonlyArray< + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + readonly type: "agent-switched" + readonly agent: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + readonly type: "model-switched" + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + 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 type: "user" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + readonly sessionID: string + readonly text: string + readonly type: "synthetic" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + readonly type: "system" + readonly text: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number; readonly completed?: number | null } + readonly type: "shell" + readonly callID: string + readonly command: string + readonly output: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number; readonly completed?: number | null } + readonly type: "assistant" + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly id: string; readonly text: string } + | { + readonly type: "reasoning" + readonly id: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null + } + | { + readonly type: "tool" + readonly id: string + readonly name: string + readonly provider?: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null + readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null + } | null + readonly state: + | { readonly status: "pending"; readonly input: string } + | { + readonly status: "running" + readonly input: { readonly [x: string]: JsonValue } + readonly structured: { readonly [x: string]: any } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { + readonly type: "file" + readonly uri: string + readonly mime: string + readonly name?: string | null + } + > + } + | { + readonly status: "completed" + readonly input: { readonly [x: string]: JsonValue } + readonly attachments?: 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 content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { + readonly type: "file" + readonly uri: string + readonly mime: string + readonly name?: string | null + } + > + readonly outputPaths?: ReadonlyArray | null + readonly structured: { readonly [x: string]: any } + readonly result?: JsonValue | null + } + | { + readonly status: "error" + readonly input: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { + readonly type: "file" + readonly uri: string + readonly mime: string + readonly name?: string | null + } + > + readonly structured: { readonly [x: string]: any } + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: JsonValue | null + } + readonly time: { + readonly created: number + readonly ran?: number | null + readonly completed?: number | null + readonly pruned?: number | null + } + } + > + readonly snapshot?: { readonly start?: string | null; readonly end?: string | null } | null + readonly finish?: string | null + readonly cost?: number | null + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } | null + readonly error?: { readonly type: "unknown"; readonly message: string } | null + } + | { + readonly type: "compaction" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + } + > +}["data"] diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index db11e1e052..7bd388f434 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -23,6 +23,9 @@ test("session methods retain decoded Effect inputs and outputs", async () => { if (url.includes("/prompt")) { return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) } + if (url.includes("/context")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] }))) + } if (request.method === "POST" && url.endsWith("/api/session")) { return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))) } @@ -47,12 +50,16 @@ test("session methods retain decoded Effect inputs and outputs", async () => { prompt: { text: "Hello" }, resume: false, }) - return { page, created, admitted } + yield* client.sessions.compact({ sessionID: SessionID.make("ses_test") }) + yield* client.sessions.wait({ sessionID: SessionID.make("ses_test") }) + const context = yield* client.sessions.context({ sessionID: SessionID.make("ses_test") }) + return { page, created, admitted, context } }).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) + expect(result.context).toEqual([]) }) const session = { diff --git a/packages/client/test/embedded.test.ts b/packages/client/test/embedded.test.ts index ca9ee5049f..f9cdf8f2cc 100644 --- a/packages/client/test/embedded.test.ts +++ b/packages/client/test/embedded.test.ts @@ -40,6 +40,7 @@ test("embedded client uses the real router and handlers", async () => { prompt: { text: "Do not run" }, resume: false, }) + const context = yield* opencode.sessions.context({ sessionID }) const missing = yield* Effect.flip( opencode.sessions.get({ sessionID: SessionID.make(`ses_missing_${crypto.randomUUID()}`) }), ) @@ -49,6 +50,7 @@ test("embedded client uses the real router and handlers", async () => { expect(selected.model?.providerID).toBe(model.providerID) expect(page.data.some((session) => session.id === sessionID)).toBe(true) expect(admitted.sessionID).toBe(sessionID) + expect(context.some((message) => message.type === "model-switched")).toBe(true) expect(missing._tag).toBe("SessionNotFoundError") }) await Effect.runPromise(Effect.scoped(program)) diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 0e0a31c3a3..a7fac9780f 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -25,6 +25,7 @@ test("session methods use the public HTTP contract", async () => { 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 (url.includes("/context")) return Response.json({ data: [] }) 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" } }) @@ -43,18 +44,25 @@ test("session methods use the public HTTP contract", async () => { prompt: { text: "Hello" }, resume: false, }) + await client.sessions.compact({ sessionID: "ses_test" }) + await client.sessions.wait({ sessionID: "ses_test" }) + const context = await client.sessions.context({ sessionID: "ses_test" }) expect(page.cursor.next).toBe("next") expect(created.id).toBe("ses_test") expect(admitted.id).toBe("msg_test") + expect(context).toEqual([]) 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"], + ["POST", "http://localhost:3000/api/session/ses_test/compact"], + ["POST", "http://localhost:3000/api/session/ses_test/wait"], + ["GET", "http://localhost:3000/api/session/ses_test/context"], ]) - const body = requests.at(-1)?.init?.body + const body = requests[4]?.init?.body if (typeof body !== "string") throw new Error("Expected JSON request body") expect(JSON.parse(body)).toEqual({ prompt: { text: "Hello" }, diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index bd1f143cee..dacf279da0 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -40,6 +40,7 @@ export class GenerationError extends Schema.TaggedErrorClass()( export type Endpoint = { readonly group: string + readonly sourceGroup: string readonly topLevel: boolean readonly endpoint: HttpApiEndpoint.AnyWithProps readonly params: Schema.Top | undefined @@ -56,6 +57,7 @@ export type Endpoint = { export type Group = { readonly identifier: string + readonly sourceIdentifier: string readonly module: string readonly endpoints: ReadonlyArray } @@ -72,6 +74,7 @@ const manifestName = ".httpapi-codegen.json" export function compile( api: HttpApi.HttpApi, + options?: { readonly groupNames?: Readonly> }, ): Contract { const endpoints: Array = [] const portable = new Map() @@ -79,7 +82,8 @@ export function compile( HttpApi.reflect(api, { onGroup() {}, onEndpoint({ endpoint, errors, group, middleware }) { - const name = `${group.identifier}.${endpoint.name}` + const groupName = options?.groupNames?.[group.identifier] ?? group.identifier + const name = `${groupName}.${endpoint.name}` const required = Array.from(middleware).find((item) => item.requiredForClient) if (required !== undefined) { throw new GenerationError({ reason: `Client middleware requires adapter: ${required.key}` }) @@ -129,7 +133,8 @@ export function compile( } endpoints.push({ - group: group.identifier, + group: groupName, + sourceGroup: group.identifier, topLevel: group.topLevel, endpoint, params: params?.schema, @@ -142,7 +147,7 @@ export function compile( errors: errorSchemas.map((item) => item.schema), effectPortable, operation: { - group: group.identifier, + group: groupName, name: clientEndpointName(endpoint.name), input: inputs.map(({ name, source }) => ({ name, source })), inputMode: inputs.length === 0 ? "none" : inputs.every((field) => field.optional) ? "optional" : "required", @@ -169,10 +174,13 @@ export function compile( const groups = Array.from( Map.groupBy(endpoints, (endpoint) => endpoint.group), ([identifier, endpoints], index) => { + if (new Set(endpoints.map((endpoint) => endpoint.sourceGroup)).size > 1) { + throw new GenerationError({ reason: `Client group name collision: ${identifier}` }) + } const base = /^[A-Za-z0-9_-]+$/.test(identifier) ? identifier : `group-${index}` const module = uniqueModule(base, index, modules) modules.add(module.toLowerCase()) - return { identifier, module, endpoints } + return { identifier, sourceIdentifier: endpoints[0].sourceGroup, module, endpoints } }, ) const publicNames = new Set() @@ -211,6 +219,7 @@ export function emitEffectImported( contract: Contract, options: | { readonly module: string; readonly api: string } + | { readonly module: string; readonly group: string } | { readonly module: string; readonly endpoints: Readonly> }, ): Output { return { @@ -304,10 +313,11 @@ function renderImportedEffectFiles( groups: ReadonlyArray, options: | { readonly module: string; readonly api: string } + | { readonly module: string; readonly group: string } | { readonly module: string; readonly endpoints: Readonly> }, ): Output["files"] { const adapters = groups.map((group, groupIndex) => { - const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.identifier)}]` + const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]` const methods = group.endpoints.map((item, endpointIndex) => { const prefix = `Endpoint${groupIndex}_${endpointIndex}` const request = (["params", "query", "headers", "payload"] as const) @@ -338,16 +348,20 @@ function renderImportedEffectFiles( const fields = groups.flatMap((group, index) => group.endpoints[0]?.topLevel ? [`...adaptGroup${index}(raw)`] - : [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.identifier)}])`], + : [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.sourceIdentifier)}])`], ) const usesStream = groups.some((group) => group.endpoints.some((item) => item.operation.success === "stream")) const imported = "api" in options - const projection = imported ? undefined : renderImportedProjection(groups, options.endpoints) + const projection = imported + ? undefined + : "group" in options + ? renderImportedGroup(options.group) + : renderImportedProjection(groups, options.endpoints) const api = imported ? options.api : "Api" const imports = projection === undefined ? `import { ${api} } from ${JSON.stringify(options.module)}` - : `import { HttpApi, HttpApiClient, HttpApiGroup } from "effect/unstable/httpapi"\nimport { ${projection.imports.join(", ")} } from ${JSON.stringify(options.module)}` + : `import { HttpApi, HttpApiClient${"endpoints" in options ? ", HttpApiGroup" : ""} } from "effect/unstable/httpapi"\nimport { ${projection.imports.join(", ")} } from ${JSON.stringify(options.module)}` const httpApiImport = projection === undefined ? 'import { HttpApiClient } from "effect/unstable/httpapi"\n' : "" 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"\n${httpApiImport}${imports}\nimport { ClientError } from "./client-error"\n\n${projection?.source ?? ""}type 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(${api}, options).pipe(Effect.map((raw) => ({ ${fields.join(", ")} })))\n` return [ @@ -364,6 +378,13 @@ function renderImportedEffectFiles( ] } +function renderImportedGroup(group: string) { + return { + imports: [group], + source: `const Api = HttpApi.make("generated").add(${group})\n\n`, + } +} + function renderImportedProjection(groups: ReadonlyArray, endpoints: Readonly>) { const imports = groups.flatMap((group) => group.endpoints.map((endpoint) => { @@ -444,7 +465,10 @@ function renderPromiseTypes(groups: ReadonlyArray) { }), ) .join("\n\n") - return [...errorTypes, operations].filter(Boolean).join("\n\n") + const json = operations.includes("JsonValue") + ? "export type JsonValue = null | boolean | number | string | ReadonlyArray | { readonly [key: string]: JsonValue }" + : "" + return [json, ...errorTypes, operations].filter(Boolean).join("\n\n") } function renderPromiseClient(groups: ReadonlyArray) { @@ -536,7 +560,9 @@ function structuralType(schema: Schema.Top) { } return type } - return expand(document.codes[0].Type).replaceAll(/ & Brand\.Brand<"[^"]+">/g, "") + return expand(document.codes[0].Type) + .replaceAll(/ & Brand\.Brand<"[^"]+">/g, "") + .replaceAll("Schema.Json", "JsonValue") } function promisePath(path: string, input: ReadonlyArray) { diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index a1d4aac490..8254652ec5 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -88,6 +88,48 @@ describe("HttpApiCodegen.generate", () => { expect(client).toContain('const Api = HttpApi.make("generated").add(HttpApiGroup.make("session").add(SessionGet))') }) + test("imports an authoritative group without reconstructing it", () => { + const output = emitEffectImported( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.String, + }), + ), + ), + { module: "@example/api", group: "SessionGroup" }, + ) + const client = output.files.find((file) => file.path === "client.ts")?.content + + expect(client).toContain('import { SessionGroup } from "@example/api"') + expect(client).toContain('const Api = HttpApi.make("generated").add(SessionGroup)') + expect(client).not.toContain("HttpApiGroup") + }) + + test("separates hosted and consumer group names", () => { + const source = HttpApi.make("test").add( + HttpApiGroup.make("server.session").add( + HttpApiEndpoint.get("session.get", "/session", { success: Schema.String }), + ), + ) + const contract = compileContract(source, { groupNames: { "server.session": "sessions" } }) + + expect(contract.groups[0]?.identifier).toBe("sessions") + expect(contract.groups[0]?.sourceIdentifier).toBe("server.session") + expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" }) + }) + + test("rejects consumer group name collisions", () => { + const source = HttpApi.make("test") + .add(HttpApiGroup.make("first").add(HttpApiEndpoint.get("one", "/one", { success: Schema.String }))) + .add(HttpApiGroup.make("second").add(HttpApiEndpoint.get("two", "/two", { success: Schema.String }))) + + expect(() => compileContract(source, { groupNames: { first: "same", second: "same" } })).toThrow( + "Client group name collision: same", + ) + }) + test("uses the unqualified endpoint name for the public client", () => { const contract = compileContract( api( @@ -158,6 +200,23 @@ describe("HttpApiCodegen.generate", () => { ) }) + test("emits Effect Json schemas as standalone Promise types", () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.Json, + }), + ), + ), + ) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain("export type JsonValue =") + expect(types).toContain("{ readonly [key: string]: JsonValue }") + expect(types).not.toContain("Schema.Json") + }) + test("emits an optional Promise input when every field is optional", () => { const output = emitPromise( compileContract( diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts index f473f8f416..6dfb88bad3 100644 --- a/packages/server/src/groups/session.ts +++ b/packages/server/src/groups/session.ts @@ -2,7 +2,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../errors" +import { ServiceUnavailableError, SessionNotFoundError, UnauthorizedError, UnknownError } from "../errors" import { SessionLocationMiddleware } from "../middleware/session-location" import { SessionsCreate, @@ -27,7 +27,7 @@ export const SessionGroup = HttpApiGroup.make("server.session") HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { params: { sessionID: SessionV2.ID }, success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, ServiceUnavailableError], + error: [SessionNotFoundError, ServiceUnavailableError, UnauthorizedError], }) .middleware(SessionLocationMiddleware) .annotateMerge( @@ -42,7 +42,7 @@ export const SessionGroup = HttpApiGroup.make("server.session") HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", { params: { sessionID: SessionV2.ID }, success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, ServiceUnavailableError], + error: [SessionNotFoundError, ServiceUnavailableError, UnauthorizedError], }) .middleware(SessionLocationMiddleware) .annotateMerge( @@ -57,7 +57,7 @@ export const SessionGroup = HttpApiGroup.make("server.session") HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", { params: { sessionID: SessionV2.ID }, success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }), - error: [SessionNotFoundError, UnknownError], + error: [SessionNotFoundError, UnauthorizedError, UnknownError], }) .middleware(SessionLocationMiddleware) .annotateMerge(