diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 13a76c0a1f..c69de1d93b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,6 +69,11 @@ jobs: env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} + - name: Check generated client + if: runner.os == 'Linux' + working-directory: packages/client + run: bun run check:generated + - name: Run HttpApi exerciser gates if: runner.os == 'Linux' working-directory: packages/opencode diff --git a/CONTEXT.md b/CONTEXT.md index 0efabe0102..b507d76443 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -136,12 +136,12 @@ _Avoid_: Response envelope - The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately. - 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 **OpenCode Client** uses plural consumer-facing capability groups such as `sessions`; internal server identifiers such as `server.session` and `session.get` do not define its public names. -- 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. +- 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. - 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 real server and client generation consume that same API value; generator selection controls emitted capabilities without redefining their contracts. +- `@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. - 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. @@ -161,7 +161,7 @@ _Avoid_: Response envelope - The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API. - `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed. - `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question. -- `sessions.list(...)` returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. +- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented. - Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields. - A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor. - `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid. @@ -190,6 +190,24 @@ _Avoid_: Response envelope - **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority. - Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads. +## 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. + +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. +- 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`. +- 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. + ## Example dialogue > **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?" diff --git a/packages/client/README.md b/packages/client/README.md index 3b3b7e5a71..fdb5187076 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -8,7 +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`, sourced directly from the V2 `HttpApi` hosted by `@opencode-ai/server`. Run `bun run generate` after changing that contract and `bun run check:generated` to detect committed-output drift. +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. + +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." The embedded entrypoint exposes a scoped host backed by the same server router, middleware, handlers, and HTTP codecs as the network client: @@ -20,3 +22,5 @@ const session = yield * opencode.sessions.get({ sessionID }) ``` It also exposes embedded-only `tools.register(...)`. Closing the owning Effect Scope releases the router resources, location services, fibers, and scoped tool registrations. + +The beta embedded host currently assumes one active host per database. Multiple hosts sharing durable Session storage require shared process-local execution coordination and remain deferred together with embedded streaming support. diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts index 3fe98faaae..ac4403f5b4 100644 --- a/packages/client/script/build.ts +++ b/packages/client/script/build.ts @@ -30,12 +30,12 @@ await Effect.runPromise( emitEffectImported(contract, { module: "@opencode-ai/server/groups/session-endpoints", endpoints: { - "sessions.list": "SessionsList", - "sessions.create": "SessionsCreate", - "sessions.get": "SessionsGet", - "sessions.switchAgent": "SessionsSwitchAgent", - "sessions.switchModel": "SessionsSwitchModel", - "sessions.prompt": "SessionsPrompt", + "sessions.session.list": "SessionsList", + "sessions.session.create": "SessionsCreate", + "sessions.session.get": "SessionsGet", + "sessions.session.switchAgent": "SessionsSwitchAgent", + "sessions.session.switchModel": "SessionsSwitchModel", + "sessions.session.prompt": "SessionsPrompt", }, }), new URL("../src/generated-effect", import.meta.url).pathname, diff --git a/packages/client/src/effect-embedded.ts b/packages/client/src/effect-embedded.ts index c1e2b8768a..1418fcf0d7 100644 --- a/packages/client/src/effect-embedded.ts +++ b/packages/client/src/effect-embedded.ts @@ -2,8 +2,15 @@ export * as OpenCode from "./effect-embedded" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" -import { Context, Effect, Layer } from "effect" -import { HttpClient, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { Cause, Context, Effect, Layer } from "effect" +import { + HttpClient, + HttpRouter, + HttpServer, + HttpServerError, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" import { OpenCode as Generated } from "./generated-effect/index" export const create = Effect.fn("OpenCode.create")(function* () { @@ -18,9 +25,13 @@ export const create = Effect.fn("OpenCode.create")(function* () { Effect.fnUntraced(function* (request) { const response = yield* handler.pipe( Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromClientRequest(request)), - Effect.orDie, + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : HttpServerError.causeResponse(cause).pipe(Effect.map(([response]) => response)), + ), ) - return HttpServerResponse.toClientResponse(response) + return HttpServerResponse.toClientResponse(response, { request }) }, Effect.scoped), ) const client = yield* Generated.make({ baseUrl: "http://opencode.local" }).pipe( diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index b781067c3e..8ea7f95339 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -30,7 +30,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"] @@ -42,7 +42,7 @@ type Endpoint0_0Input = { readonly cursor?: Endpoint0_0Request["query"]["cursor"] } const Endpoint0_0 = (raw: RawClient["sessions"]) => (input?: Endpoint0_0Input) => - raw["list"]({ + raw["session.list"]({ query: { workspace: input?.workspace, limit: input?.limit, @@ -55,7 +55,7 @@ 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"] @@ -63,42 +63,42 @@ type Endpoint0_1Input = { readonly location?: Endpoint0_1Request["payload"]["location"] } const Endpoint0_1 = (raw: RawClient["sessions"]) => (input?: Endpoint0_1Input) => - raw["create"]({ + raw["session.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_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( + 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) => - raw["switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe( + 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) => - raw["switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe( + 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"] @@ -107,7 +107,7 @@ type Endpoint0_5Input = { readonly resume?: Endpoint0_5Request["payload"]["resume"] } const Endpoint0_5 = (raw: RawClient["sessions"]) => (input: Endpoint0_5Input) => - raw["prompt"]({ + raw["session.prompt"]({ params: { sessionID: input.sessionID }, payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume }, }).pipe( diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index ba6b5762f1..ea77161aea 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -163,7 +163,7 @@ export function make(options: ClientOptions) { cursor: input?.cursor, }, successStatus: 200, - declaredStatuses: [400], + declaredStatuses: [400, 401], empty: false, }, requestOptions, @@ -175,7 +175,7 @@ export function make(options: ClientOptions) { path: `/api/session`, body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location }, successStatus: 200, - declaredStatuses: [], + declaredStatuses: [400, 401], empty: false, }, requestOptions, @@ -186,7 +186,7 @@ export function make(options: ClientOptions) { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}`, successStatus: 200, - declaredStatuses: [404], + declaredStatuses: [400, 404, 401], empty: false, }, requestOptions, @@ -198,7 +198,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`, body: { agent: input.agent }, successStatus: 204, - declaredStatuses: [404], + declaredStatuses: [400, 404, 401], empty: true, }, requestOptions, @@ -210,7 +210,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/model`, body: { model: input.model }, successStatus: 204, - declaredStatuses: [404], + declaredStatuses: [400, 404, 401], empty: true, }, requestOptions, @@ -222,7 +222,7 @@ export function make(options: ClientOptions) { 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], + declaredStatuses: [409, 400, 404, 401], empty: false, }, requestOptions, diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 1f7f56b0e8..a41db3d70c 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -5,12 +5,16 @@ export const isInvalidCursorError = (value: unknown): value is InvalidCursorErro export type InvalidRequestError = { readonly _tag: "InvalidRequestError" readonly message: string - readonly kind: string | undefined - readonly field: string | undefined + 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 UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string } +export const isUnauthorizedError = (value: unknown): value is UnauthorizedError => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnauthorizedError" + export type SessionNotFoundError = { readonly _tag: "SessionNotFoundError" readonly sessionID: string @@ -22,7 +26,7 @@ export const isSessionNotFoundError = (value: unknown): value is SessionNotFound export type ConflictError = { readonly _tag: "ConflictError" readonly message: string - readonly resource: string | undefined + readonly resource?: string | undefined } export const isConflictError = (value: unknown): value is ConflictError => typeof value === "object" && value !== null && "_tag" in value && value._tag === "ConflictError" @@ -124,11 +128,7 @@ export type SessionsListOutput = { readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } } - readonly time: { - readonly created: number | "Infinity" | "-Infinity" | "NaN" - readonly updated: number | "Infinity" | "-Infinity" | "NaN" - readonly archived?: number | "Infinity" | "-Infinity" | "NaN" | null - } + 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 @@ -185,11 +185,7 @@ export type SessionsCreateOutput = { readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } } - readonly time: { - readonly created: number | "Infinity" | "-Infinity" | "NaN" - readonly updated: number | "Infinity" | "-Infinity" | "NaN" - readonly archived?: number | "Infinity" | "-Infinity" | "NaN" | null - } + 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 @@ -212,11 +208,7 @@ export type SessionsGetOutput = { readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } } - readonly time: { - readonly created: number | "Infinity" | "-Infinity" | "NaN" - readonly updated: number | "Infinity" | "-Infinity" | "NaN" - readonly archived?: number | "Infinity" | "-Infinity" | "NaN" | null - } + 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 @@ -355,7 +347,7 @@ export type SessionsPromptOutput = { }> | null } readonly delivery: "steer" | "queue" - readonly timeCreated: number | "Infinity" | "-Infinity" | "NaN" + readonly timeCreated: number readonly promotedSeq?: number | null } }["data"] diff --git a/packages/client/test/embedded.test.ts b/packages/client/test/embedded.test.ts index 01ee150a0a..ca9ee5049f 100644 --- a/packages/client/test/embedded.test.ts +++ b/packages/client/test/embedded.test.ts @@ -40,12 +40,16 @@ test("embedded client uses the real router and handlers", async () => { prompt: { text: "Do not run" }, resume: false, }) + const missing = yield* Effect.flip( + opencode.sessions.get({ sessionID: SessionID.make(`ses_missing_${crypto.randomUUID()}`) }), + ) expect(created.id).toBe(sessionID) expect(selected.model?.id).toBe(model.id) expect(selected.model?.providerID).toBe(model.providerID) expect(page.data.some((session) => session.id === sessionID)).toBe(true) expect(admitted.sessionID).toBe(sessionID) + expect(missing._tag).toBe("SessionNotFoundError") }) await Effect.runPromise(Effect.scoped(program)) } finally { diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 651b7c2a5f..0e0a31c3a3 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { OpenCode } from "../src" +import { isUnauthorizedError, OpenCode } from "../src" test("sessions.get returns the wire projection", async () => { const client = OpenCode.make({ @@ -62,6 +62,21 @@ test("session methods use the public HTTP contract", async () => { }) }) +test("middleware errors remain declared client errors", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + Response.json({ _tag: "UnauthorizedError", message: "Authentication required" }, { status: 401 }), + }) + + try { + await client.sessions.create({}) + throw new Error("Expected request to fail") + } catch (error) { + expect(isUnauthorizedError(error)).toBe(true) + } +}) + const session = { data: { id: "ses_test", diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 98807dc721..bd1f143cee 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -143,7 +143,7 @@ export function compile( effectPortable, operation: { group: group.identifier, - name: endpoint.name, + name: clientEndpointName(endpoint.name), input: inputs.map(({ name, source }) => ({ name, source })), inputMode: inputs.length === 0 ? "none" : inputs.every((field) => field.optional) ? "optional" : "required", success: isStreamSchema(success.schema) @@ -177,7 +177,16 @@ export function compile( ) const publicNames = new Set() for (const group of groups) { - const names = group.endpoints[0]?.topLevel ? group.endpoints.map((item) => item.endpoint.name) : [group.identifier] + const endpointNames = new Set() + for (const endpoint of group.endpoints) { + if (endpointNames.has(endpoint.operation.name)) { + throw new GenerationError({ + reason: `Client endpoint name collision: ${group.identifier}.${endpoint.operation.name}`, + }) + } + endpointNames.add(endpoint.operation.name) + } + const names = group.endpoints[0]?.topLevel ? group.endpoints.map((item) => item.operation.name) : [group.identifier] for (const name of names) { if (publicNames.has(name)) throw new GenerationError({ reason: `Client name collision: ${name}` }) publicNames.add(name) @@ -324,7 +333,7 @@ function renderImportedEffectFiles( 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(", ")} })` + return `${methods.join("\n\n")}\n\nconst adaptGroup${groupIndex} = (raw: ${rawGroup}) => ({ ${group.endpoints.map((item, endpointIndex) => `${JSON.stringify(item.operation.name)}: Endpoint${groupIndex}_${endpointIndex}(raw)`).join(", ")} })` }) const fields = groups.flatMap((group, index) => group.endpoints[0]?.topLevel @@ -398,14 +407,14 @@ function renderPromiseTypes(groups: ReadonlyArray) { ) const errorTypes = Array.from(errors.values()).map((error) => { const fields = error.fields - .map(([name, schema]) => `readonly ${JSON.stringify(name)}: ${typeOf(schema)}`) + .map(([name, schema, optional]) => `readonly ${JSON.stringify(name)}${optional ? "?" : ""}: ${typeOf(schema)}`) .join("; ") return `export type ${error.identifier} = { readonly _tag: ${JSON.stringify(error.tag)}; ${fields} }\nexport const is${error.identifier} = (value: unknown): value is ${error.identifier} => typeof value === "object" && value !== null && "_tag" in value && value._tag === ${JSON.stringify(error.tag)}` }) const operations = groups .flatMap((group) => group.endpoints.flatMap((endpoint) => { - const prefix = promiseTypePrefix(group.identifier, endpoint.endpoint.name) + const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) const schemas = { params: endpoint.params, query: endpoint.query, @@ -441,13 +450,13 @@ function renderPromiseTypes(groups: ReadonlyArray) { function renderPromiseClient(groups: ReadonlyArray) { const imports = groups.flatMap((group) => group.endpoints.flatMap((endpoint) => { - const prefix = promiseTypePrefix(group.identifier, endpoint.endpoint.name) + const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) return [...(endpoint.operation.inputMode === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`] }), ) const fields = groups.map((group) => { const methods = group.endpoints.map((endpoint) => { - const prefix = promiseTypePrefix(group.identifier, endpoint.endpoint.name) + const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) const argument = endpoint.operation.inputMode === "none" ? "requestOptions?: RequestOptions" @@ -478,10 +487,10 @@ function renderPromiseClient(groups: ReadonlyArray) { reason: `Promise stream emission is not implemented: ${group.identifier}.${endpoint.endpoint.name}`, }) } - return `${JSON.stringify(endpoint.endpoint.name)}: (${argument}): AsyncIterable<${prefix}Output> => sse<${prefix}Output>(${descriptor}, requestOptions)` + return `${JSON.stringify(endpoint.operation.name)}: (${argument}): AsyncIterable<${prefix}Output> => sse<${prefix}Output>(${descriptor}, requestOptions)` } const unwrap = endpoint.unwrapData ? ".then((value) => value.data)" : "" - return `${JSON.stringify(endpoint.endpoint.name)}: (${argument}) => request<${endpoint.unwrapData ? `{ readonly data: ${prefix}Output }` : `${prefix}Output`}>(${descriptor}, requestOptions)${unwrap}` + return `${JSON.stringify(endpoint.operation.name)}: (${argument}) => request<${endpoint.unwrapData ? `{ readonly data: ${prefix}Output }` : `${prefix}Output`}>(${descriptor}, requestOptions)${unwrap}` }) if (group.endpoints[0]?.topLevel) return methods.join(", ") return `${JSON.stringify(group.identifier)}: { ${methods.join(", ")} }` @@ -493,6 +502,10 @@ function promiseTypePrefix(group: string, endpoint: string) { return `${identifierPart(group)}${identifierPart(endpoint)}` } +function clientEndpointName(name: string) { + return name.slice(name.lastIndexOf(".") + 1) +} + function identifierPart(value: string) { return value .split(/[^A-Za-z0-9]+/) @@ -508,12 +521,22 @@ function structuralType(schema: Schema.Top) { (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.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "") + const references = new Map( + document.references.nonRecursives.map((reference) => [reference.$ref, reference.code.Type]), + ) + const expand = (type: string, seen = new Set()): string => { + for (const [reference, value] of references) { + if (!type.includes(reference)) continue + if (seen.has(reference)) throw new GenerationError({ reason: "Recursive Promise types are not implemented" }) + type = type.replaceAll(reference, `(${expand(value, new Set([...seen, reference]))})`) + } + return type + } + return expand(document.codes[0].Type).replaceAll(/ & Brand\.Brand<"[^"]+">/g, "") } function promisePath(path: string, input: ReadonlyArray) { @@ -890,7 +913,9 @@ function taggedErrorFields(schema: Schema.Top) { tag: tag.literal, identifier: SchemaAST.resolveIdentifier(schema.ast) ?? tag.literal, fields: fields.propertySignatures.flatMap((field) => - field.name === "_tag" || typeof field.name !== "string" ? [] : [[field.name, Schema.make(field.type)] as const], + field.name === "_tag" || typeof field.name !== "string" + ? [] + : [[field.name, Schema.make(field.type), SchemaAST.isOptional(field.type)] as const], ), } } @@ -1029,7 +1054,7 @@ function renderGroup(group: Group, groupIndex: number) { const groupSource = `HttpApiGroup.make(${JSON.stringify(group.identifier)}, { topLevel: ${group.endpoints[0]?.topLevel ?? false} })${endpointSources.map((endpoint) => `.add(${endpoint})`).join("")}` const usesHttpApiSchema = endpointSources.some((source) => source.includes("HttpApiSchema.")) const methods = group.endpoints - .map((item, index) => `${JSON.stringify(item.endpoint.name)}: Endpoint${index}(raw)`) + .map((item, index) => `${JSON.stringify(item.operation.name)}: Endpoint${index}(raw)`) .join(", ") const rawGroup = group.endpoints[0]?.topLevel ? `HttpApiClient.Client` diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 3859115f79..a1d4aac490 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -88,6 +88,42 @@ describe("HttpApiCodegen.generate", () => { expect(client).toContain('const Api = HttpApi.make("generated").add(HttpApiGroup.make("session").add(SessionGet))') }) + test("uses the unqualified endpoint name for the public client", () => { + const contract = compileContract( + api( + HttpApiEndpoint.get("session.get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.String, + }), + ), + ) + const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content + const effect = emitEffectImported(contract, { + module: "@example/api", + endpoints: { "session.session.get": "SessionGet" }, + }).files.find((file) => file.path === "client.ts")?.content + + expect(contract.groups[0]?.endpoints[0]?.operation.name).toBe("get") + expect(promise).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)') + expect(effect).toContain('const adaptGroup0 = (raw: RawClient["session"]) => ({ "get": Endpoint0_0(raw) })') + expect(effect).toContain('raw["session.get"]') + }) + + test("preserves optional keys in Promise error types", () => { + class OptionalError extends Schema.TaggedErrorClass()( + "OptionalError", + { message: Schema.String, detail: Schema.String.pipe(Schema.optional) }, + { httpApiStatus: 400 }, + ) {} + const output = emitPromise( + compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: OptionalError }))), + ) + + expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( + 'readonly "message": string; readonly "detail"?: string | undefined', + ) + }) + test("erases brands from Promise wire types", () => { const output = emitPromise( compileContract( @@ -105,6 +141,23 @@ describe("HttpApiCodegen.generate", () => { expect(types).not.toContain("Brand") }) + test("inlines non-recursive references in Promise wire types", () => { + const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" }) + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.Struct({ data: Referenced }), + }), + ), + ), + ) + + expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( + 'export type SessionGetOutput = ({ readonly "data": ({ readonly "value": string }) })["data"]', + ) + }) + test("emits an optional Promise input when every field is optional", () => { const output = emitPromise( compileContract( diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index 7a687ad387..460253c412 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -76,6 +76,9 @@ describe("PublicApi OpenAPI v2 errors", () => { expect(spec.paths["/session/{sessionID}"]?.get?.operationId).toBe("session.get") expect(spec.paths["/api/session"]?.get?.operationId).toBe("v2.session.list") expect(spec.paths["/api/session/{sessionID}"]?.get?.operationId).toBe("v2.session.get") + expect(responseRef(spec.paths["/api/session"]?.get?.responses?.["200"])).toBe( + "#/components/schemas/SessionsResponse", + ) }) test("documents nested legacy global sync events", () => { diff --git a/packages/server/src/groups/session-endpoints.ts b/packages/server/src/groups/session-endpoints.ts index b1cafa3389..8b9a67b206 100644 --- a/packages/server/src/groups/session-endpoints.ts +++ b/packages/server/src/groups/session-endpoints.ts @@ -1,6 +1,19 @@ -import { Schema } from "effect" +import { DateTime, Schema, SchemaGetter } from "effect" import { HttpApiEndpoint, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { ConflictError, InvalidCursorError, InvalidRequestError, SessionNotFoundError } from "../errors" +import { + ConflictError, + InvalidCursorError, + InvalidRequestError, + SessionNotFoundError, + UnauthorizedError, +} from "../errors" + +const DateTimeUtcFromMillis = Schema.Finite.pipe( + Schema.decodeTo(Schema.DateTimeUtc, { + decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)), + encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)), + }), +) export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe(Schema.brand("SessionID")) export const AgentID = Schema.String.pipe(Schema.brand("AgentV2.ID")) @@ -12,7 +25,7 @@ export const ModelRef = Schema.Struct({ export const LocationRef = Schema.Struct({ directory: Schema.String.pipe(Schema.brand("AbsolutePath")), workspaceID: Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceV2.ID"), Schema.optional), -}) +}).annotate({ identifier: "Location.Ref" }) export const Session = Schema.Struct({ id: SessionID, parentID: SessionID.pipe(Schema.optional), @@ -30,14 +43,14 @@ export const Session = Schema.Struct({ }), }), time: Schema.Struct({ - created: Schema.DateTimeUtcFromMillis, - updated: Schema.DateTimeUtcFromMillis, - archived: Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + created: DateTimeUtcFromMillis, + updated: DateTimeUtcFromMillis, + archived: DateTimeUtcFromMillis.pipe(Schema.optional), }), title: Schema.String, location: LocationRef, subpath: Schema.String.pipe(Schema.brand("RelativePath"), Schema.optional), -}) +}).annotate({ identifier: "SessionV2.Info" }) export const Prompt = Schema.Struct({ text: Schema.String, files: Schema.Array( @@ -63,7 +76,7 @@ export const Prompt = Schema.Struct({ }).pipe(Schema.optional), }), ).pipe(Schema.optional), -}) +}).annotate({ identifier: "Prompt" }) export const MessageID = Schema.String.check(Schema.isStartsWith("msg_")).pipe(Schema.brand("Session.Message.ID")) export const Delivery = Schema.Literals(["steer", "queue"]) export const Admission = Schema.Struct({ @@ -72,9 +85,9 @@ export const Admission = Schema.Struct({ sessionID: SessionID, prompt: Prompt, delivery: Delivery, - timeCreated: Schema.DateTimeUtcFromMillis, + timeCreated: DateTimeUtcFromMillis, promotedSeq: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.optional), -}) +}).annotate({ identifier: "SessionInput.Admitted" }) export const SessionsCursor = Schema.String.pipe(Schema.brand("SessionsCursor")) export const SessionsQuery = Schema.Struct({ @@ -91,9 +104,9 @@ export const SessionsQuery = Schema.Struct({ project: Schema.String.pipe(Schema.brand("Project.ID"), Schema.optional), subpath: Schema.String.pipe(Schema.brand("RelativePath"), Schema.optional), cursor: SessionsCursor.pipe(Schema.optional), -}) +}).annotate({ identifier: "SessionsQuery" }) -export const SessionsList = HttpApiEndpoint.get("list", "/api/session", { +export const SessionsList = HttpApiEndpoint.get("session.list", "/api/session", { query: SessionsQuery, success: Schema.Struct({ data: Schema.Array(Session), @@ -101,8 +114,8 @@ export const SessionsList = HttpApiEndpoint.get("list", "/api/session", { previous: SessionsCursor.pipe(Schema.optional), next: SessionsCursor.pipe(Schema.optional), }), - }), - error: [InvalidCursorError, InvalidRequestError], + }).annotate({ identifier: "SessionsResponse" }), + error: [InvalidCursorError, InvalidRequestError, UnauthorizedError], }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.list", @@ -112,7 +125,7 @@ export const SessionsList = HttpApiEndpoint.get("list", "/api/session", { }), ) -export const SessionsCreate = HttpApiEndpoint.post("create", "/api/session", { +export const SessionsCreate = HttpApiEndpoint.post("session.create", "/api/session", { payload: Schema.Struct({ id: SessionID.pipe(Schema.optional), agent: AgentID.pipe(Schema.optional), @@ -120,6 +133,7 @@ export const SessionsCreate = HttpApiEndpoint.post("create", "/api/session", { location: LocationRef.pipe(Schema.optional), }), success: Schema.Struct({ data: Session }), + error: [InvalidRequestError, UnauthorizedError], }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.create", @@ -128,10 +142,10 @@ export const SessionsCreate = HttpApiEndpoint.post("create", "/api/session", { }), ) -export const SessionsGet = HttpApiEndpoint.get("get", "/api/session/:sessionID", { +export const SessionsGet = HttpApiEndpoint.get("session.get", "/api/session/:sessionID", { params: { sessionID: SessionID }, success: Schema.Struct({ data: Session }), - error: SessionNotFoundError, + error: [InvalidRequestError, SessionNotFoundError, UnauthorizedError], }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.get", @@ -140,11 +154,11 @@ export const SessionsGet = HttpApiEndpoint.get("get", "/api/session/:sessionID", }), ) -export const SessionsSwitchAgent = HttpApiEndpoint.post("switchAgent", "/api/session/:sessionID/agent", { +export const SessionsSwitchAgent = HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", { params: { sessionID: SessionID }, payload: Schema.Struct({ agent: AgentID }), success: HttpApiSchema.NoContent, - error: SessionNotFoundError, + error: [InvalidRequestError, SessionNotFoundError, UnauthorizedError], }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.switchAgent", @@ -153,11 +167,11 @@ export const SessionsSwitchAgent = HttpApiEndpoint.post("switchAgent", "/api/ses }), ) -export const SessionsSwitchModel = HttpApiEndpoint.post("switchModel", "/api/session/:sessionID/model", { +export const SessionsSwitchModel = HttpApiEndpoint.post("session.switchModel", "/api/session/:sessionID/model", { params: { sessionID: SessionID }, payload: Schema.Struct({ model: ModelRef }), success: HttpApiSchema.NoContent, - error: SessionNotFoundError, + error: [InvalidRequestError, SessionNotFoundError, UnauthorizedError], }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.switchModel", @@ -166,7 +180,7 @@ export const SessionsSwitchModel = HttpApiEndpoint.post("switchModel", "/api/ses }), ) -export const SessionsPrompt = HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { +export const SessionsPrompt = HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { params: { sessionID: SessionID }, payload: Schema.Struct({ id: MessageID.pipe(Schema.optional), @@ -175,7 +189,7 @@ export const SessionsPrompt = HttpApiEndpoint.post("prompt", "/api/session/:sess resume: Schema.Boolean.pipe(Schema.optional), }), success: Schema.Struct({ data: Admission }), - error: [ConflictError, SessionNotFoundError], + error: [ConflictError, InvalidRequestError, SessionNotFoundError, UnauthorizedError], }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.prompt", diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts index 3bc9fc2898..f473f8f416 100644 --- a/packages/server/src/groups/session.ts +++ b/packages/server/src/groups/session.ts @@ -13,7 +13,10 @@ import { SessionsSwitchModel, } from "./session-endpoints" -export const SessionGroup = HttpApiGroup.make("sessions") +export { SessionsQuery } from "./session-endpoints" +export { SessionsCursor } from "../session-cursor" + +export const SessionGroup = HttpApiGroup.make("server.session") .add(SessionsList) .add(SessionsCreate) .add(SessionsGet.middleware(SessionLocationMiddleware)) @@ -21,7 +24,7 @@ export const SessionGroup = HttpApiGroup.make("sessions") .add(SessionsSwitchModel.middleware(SessionLocationMiddleware)) .add(SessionsPrompt.middleware(SessionLocationMiddleware)) .add( - HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { + HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { params: { sessionID: SessionV2.ID }, success: HttpApiSchema.NoContent, error: [SessionNotFoundError, ServiceUnavailableError], @@ -36,7 +39,7 @@ export const SessionGroup = HttpApiGroup.make("sessions") ), ) .add( - HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", { + HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", { params: { sessionID: SessionV2.ID }, success: HttpApiSchema.NoContent, error: [SessionNotFoundError, ServiceUnavailableError], @@ -51,7 +54,7 @@ export const SessionGroup = HttpApiGroup.make("sessions") ), ) .add( - HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { + HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", { params: { sessionID: SessionV2.ID }, success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }), error: [SessionNotFoundError, UnknownError], diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 7cbc9cbe7e..d26c1618f0 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -8,7 +8,7 @@ import { sessionLocationLayer } from "./middleware/session-location" import { MessageHandler } from "./handlers/message" import { ModelHandler } from "./handlers/model" import { ProviderHandler } from "./handlers/provider" -import { SessionsHandler } from "./handlers/session" +import { SessionHandler } from "./handlers/session" import { PermissionHandler } from "./handlers/permission" import { FileSystemHandler } from "./handlers/fs" import { CommandHandler } from "./handlers/command" @@ -30,7 +30,7 @@ export const handlers = Layer.mergeAll( HealthHandler, LocationHandler, AgentHandler, - SessionsHandler, + SessionHandler, MessageHandler, ModelHandler, ProviderHandler, diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index bab841ba00..e1a48a29ec 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -16,13 +16,13 @@ import { SessionsCursor } from "../session-cursor" const DefaultSessionsLimit = 50 const decodePrompt = Schema.decodeUnknownSync(Prompt) -export const SessionsHandler = HttpApiBuilder.group(Api, "sessions", (handlers) => +export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => Effect.gen(function* () { const session = yield* SessionV2.Service return handlers .handle( - "list", + "session.list", Effect.fn(function* (ctx) { const query = ctx.query.cursor !== undefined @@ -65,7 +65,7 @@ export const SessionsHandler = HttpApiBuilder.group(Api, "sessions", (handlers) }), ) .handle( - "create", + "session.create", Effect.fn(function* (ctx) { return { data: yield* session.create({ @@ -78,7 +78,7 @@ export const SessionsHandler = HttpApiBuilder.group(Api, "sessions", (handlers) }), ) .handle( - "get", + "session.get", Effect.fn(function* (ctx) { return { data: yield* session.get(ctx.params.sessionID).pipe( @@ -95,7 +95,7 @@ export const SessionsHandler = HttpApiBuilder.group(Api, "sessions", (handlers) }), ) .handle( - "switchAgent", + "session.switchAgent", Effect.fn(function* (ctx) { yield* session.switchAgent({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent }).pipe( Effect.catchTag("Session.NotFoundError", (error) => @@ -111,7 +111,7 @@ export const SessionsHandler = HttpApiBuilder.group(Api, "sessions", (handlers) }), ) .handle( - "switchModel", + "session.switchModel", Effect.fn(function* (ctx) { yield* session.switchModel({ sessionID: ctx.params.sessionID, model: ctx.payload.model }).pipe( Effect.catchTag("Session.NotFoundError", (error) => @@ -127,7 +127,7 @@ export const SessionsHandler = HttpApiBuilder.group(Api, "sessions", (handlers) }), ) .handle( - "prompt", + "session.prompt", Effect.fn(function* (ctx) { return { data: yield* session @@ -160,7 +160,7 @@ export const SessionsHandler = HttpApiBuilder.group(Api, "sessions", (handlers) }), ) .handle( - "compact", + "session.compact", Effect.fn(function* (ctx) { yield* session.compact({ sessionID: ctx.params.sessionID }).pipe( Effect.catchTag("Session.NotFoundError", (error) => @@ -184,7 +184,7 @@ export const SessionsHandler = HttpApiBuilder.group(Api, "sessions", (handlers) }), ) .handle( - "wait", + "session.wait", Effect.fn(function* (ctx) { yield* session.wait(ctx.params.sessionID).pipe( Effect.catchTag("Session.NotFoundError", (error) => @@ -208,7 +208,7 @@ export const SessionsHandler = HttpApiBuilder.group(Api, "sessions", (handlers) }), ) .handle( - "context", + "session.context", Effect.fn(function* (ctx) { return { data: yield* session.context(ctx.params.sessionID).pipe(