From 8bf0675997675b1d2af05b07614fc33e47517dd7 Mon Sep 17 00:00:00 2001 From: Dax Date: Wed, 10 Jun 2026 22:55:01 -0400 Subject: [PATCH] feat(server): add v2 session API endpoints (#31822) --- .../test/server/httpapi-exercise/index.ts | 25 +++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 123 ++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 160 +++++++++++++++++- packages/server/src/api.ts | 2 + packages/server/src/groups/location.ts | 19 ++- packages/server/src/groups/question.ts | 15 ++ packages/server/src/groups/session.ts | 36 ++++ packages/server/src/handlers.ts | 2 + packages/server/src/handlers/location.ts | 18 ++ packages/server/src/handlers/question.ts | 7 + packages/server/src/handlers/session.ts | 31 ++++ 11 files changed, 431 insertions(+), 7 deletions(-) create mode 100644 packages/server/src/handlers/location.ts diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index d4a8b5f4e..3b97c5083 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -646,6 +646,7 @@ const scenarios: Scenario[] = [ object(body) check(body.healthy === true, "v2 server should report healthy") }), + http.protected.get("/api/location", "v2.location.get").json(200, object), http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)), http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)), http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)), @@ -698,6 +699,14 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .json(200, data(array)), + http.protected + .get("/api/session/{sessionID}/question", "v2.session.question.list") + .seeded((ctx) => ctx.session({ title: "Question list owner" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/question", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .json(200, data(array)), http.protected .post("/api/session/{sessionID}/permission/{requestID}/reply", "v2.session.permission.reply") .seeded((ctx) => ctx.session({ title: "Permission owner" })) @@ -807,6 +816,22 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .status(400, undefined, "none"), + http.protected + .post("/api/session", "v2.session.create") + .at((ctx) => ({ + path: "/api/session", + headers: { ...ctx.headers(), "content-type": "application/json" }, + body: {}, + })) + .json(200, data(object)), + http.protected + .get("/api/session/{sessionID}", "v2.session.get") + .seeded((ctx) => ctx.session({ title: "Session get" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .json(200, data(object)), http.protected .get("/api/session/{sessionID}/context", "v2.session.context") .at((ctx) => ({ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index c16ab6dd1..9e70c4b44 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -92,6 +92,7 @@ import type { GlobalUpgradeResponses, InstanceDisposeErrors, InstanceDisposeResponses, + LocationRef, LspStatusErrors, LspStatusResponses, McpAddErrors, @@ -274,6 +275,8 @@ import type { V2FsReadResponses, V2HealthGetErrors, V2HealthGetResponses, + V2LocationGetErrors, + V2LocationGetResponses, V2ModelListErrors, V2ModelListResponses, V2PermissionRequestListErrors, @@ -294,6 +297,10 @@ import type { V2SessionCompactResponses, V2SessionContextErrors, V2SessionContextResponses, + V2SessionCreateErrors, + V2SessionCreateResponses, + V2SessionGetErrors, + V2SessionGetResponses, V2SessionListErrors, V2SessionListResponses, V2SessionMessagesErrors, @@ -304,6 +311,8 @@ import type { V2SessionPermissionReplyResponses, V2SessionPromptErrors, V2SessionPromptResponses, + V2SessionQuestionListErrors, + V2SessionQuestionListResponses, V2SessionQuestionRejectErrors, V2SessionQuestionRejectResponses, V2SessionQuestionReplyErrors, @@ -5013,6 +5022,30 @@ export class Health extends HeyApiClient { } } +export class Location extends HeyApiClient { + /** + * Get location + * + * Resolve the requested location or the server default location. + */ + public get( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/location", + ...options, + ...params, + }) + } +} + export class Agent extends HeyApiClient { /** * List agents @@ -5106,6 +5139,29 @@ export class Permission2 extends HeyApiClient { } export class Question2 extends HeyApiClient { + /** + * List session question requests + * + * Retrieve pending question requests owned by a session. + */ + public list( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).get< + V2SessionQuestionListResponses, + V2SessionQuestionListErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/question", + ...options, + ...params, + }) + } + /** * Reply to pending question request * @@ -5225,6 +5281,68 @@ export class Session3 extends HeyApiClient { }) } + /** + * Create session + * + * Create a session at the requested location. + */ + public create( + parameters?: { + id?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + location?: LocationRef + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "body", key: "id" }, + { in: "body", key: "agent" }, + { in: "body", key: "model" }, + { in: "body", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/session", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Get session + * + * Retrieve a session by ID. + */ + public get( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}", + ...options, + ...params, + }) + } + /** * Send message * @@ -5779,6 +5897,11 @@ export class V2 extends HeyApiClient { return (this._health ??= new Health({ client: this.client })) } + private _location?: Location + get location(): Location { + return (this._location ??= new Location({ client: this.client })) + } + private _agent?: Agent get agent(): Agent { return (this._agent ??= new Agent({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 7f5e765a4..101a837c6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2737,18 +2737,18 @@ export type InvalidCursorError = { message: string } -export type ConflictError = { - _tag: "ConflictError" - message: string - resource?: string -} - export type SessionNotFoundError = { _tag: "SessionNotFoundError" sessionID: string message: string } +export type ConflictError = { + _tag: "ConflictError" + message: string + resource?: string +} + export type ServiceUnavailableError = { _tag: "ServiceUnavailableError" message: string @@ -9453,6 +9453,40 @@ export type V2HealthGetResponses = { export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses] +export type V2LocationGetData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/location" +} + +export type V2LocationGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2LocationGetError = V2LocationGetErrors[keyof V2LocationGetErrors] + +export type V2LocationGetResponses = { + /** + * Location.Info + */ + 200: LocationInfo +} + +export type V2LocationGetResponse = V2LocationGetResponses[keyof V2LocationGetResponses] + export type V2AgentListData = { body?: never path?: never @@ -9531,6 +9565,83 @@ export type V2SessionListResponses = { export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses] +export type V2SessionCreateData = { + body: { + id?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + location?: LocationRef + } + path?: never + query?: never + url: "/api/session" +} + +export type V2SessionCreateErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2SessionCreateError = V2SessionCreateErrors[keyof V2SessionCreateErrors] + +export type V2SessionCreateResponses = { + /** + * Success + */ + 200: { + data: SessionV2Info + } +} + +export type V2SessionCreateResponse = V2SessionCreateResponses[keyof V2SessionCreateResponses] + +export type V2SessionGetData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}" +} + +export type V2SessionGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionGetError = V2SessionGetErrors[keyof V2SessionGetErrors] + +export type V2SessionGetResponses = { + /** + * Success + */ + 200: { + data: SessionV2Info + } +} + +export type V2SessionGetResponse = V2SessionGetResponses[keyof V2SessionGetResponses] + export type V2SessionPromptData = { body: { id?: string @@ -10310,6 +10421,43 @@ export type V2QuestionRequestListResponses = { export type V2QuestionRequestListResponse = V2QuestionRequestListResponses[keyof V2QuestionRequestListResponses] +export type V2SessionQuestionListData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/question" +} + +export type V2SessionQuestionListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionQuestionListError = V2SessionQuestionListErrors[keyof V2SessionQuestionListErrors] + +export type V2SessionQuestionListResponses = { + /** + * Success + */ + 200: { + data: Array + } +} + +export type V2SessionQuestionListResponse = V2SessionQuestionListResponses[keyof V2SessionQuestionListResponses] + export type V2SessionQuestionReplyData = { body: QuestionV2Reply path: { diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index 52b9daf7b..eab0dbfc4 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -14,9 +14,11 @@ import { HealthGroup } from "./groups/health" import { QuestionGroup } from "./groups/question" import { ReferenceGroup } from "./groups/reference" import { Authorization } from "./middleware/authorization" +import { LocationGroup } from "./groups/location" export const Api = HttpApi.make("server") .add(HealthGroup) + .add(LocationGroup) .add(AgentGroup) .add(SessionGroup) .add(MessageGroup) diff --git a/packages/server/src/groups/location.ts b/packages/server/src/groups/location.ts index 4ee082c71..e1249bb95 100644 --- a/packages/server/src/groups/location.ts +++ b/packages/server/src/groups/location.ts @@ -5,7 +5,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { Effect, Layer, Schema } from "effect" import { HttpServerRequest } from "effect/unstable/http" -import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" export const LocationQuery = Schema.Struct({ location: Schema.optional( @@ -54,6 +54,23 @@ export class LocationMiddleware extends HttpApiMiddleware.Service< } >()("@opencode/HttpApiLocation") {} +export const LocationGroup = HttpApiGroup.make("server.location") + .add( + HttpApiEndpoint.get("location.get", "/api/location", { + query: LocationQuery, + success: Location.Info, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.location.get", + summary: "Get location", + description: "Resolve the requested location or the server default location.", + }), + ), + ) + .middleware(LocationMiddleware) + function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref { const query = new URL(request.url, "http://localhost").searchParams const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"] diff --git a/packages/server/src/groups/question.ts b/packages/server/src/groups/question.ts index 94993884b..cb8932129 100644 --- a/packages/server/src/groups/question.ts +++ b/packages/server/src/groups/question.ts @@ -24,6 +24,21 @@ export const QuestionGroup = HttpApiGroup.make("server.question") ) .annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." })) .middleware(LocationMiddleware) + .add( + HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", { + params: { sessionID: SessionV2.ID }, + success: Schema.Struct({ data: Schema.Array(QuestionV2.Request) }), + error: SessionNotFoundError, + }) + .middleware(SessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.question.list", + summary: "List session question requests", + description: "Retrieve pending question requests owned by a session.", + }), + ), + ) .add( HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", { params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID }, diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts index 467e466e1..604fcf907 100644 --- a/packages/server/src/groups/session.ts +++ b/packages/server/src/groups/session.ts @@ -16,6 +16,9 @@ import { UnknownError, } from "../errors" import { SessionLocationMiddleware } from "../middleware/session-location" +import { AgentV2 } from "@opencode-ai/core/agent" +import { ModelV2 } from "@opencode-ai/core/model" +import { Location } from "@opencode-ai/core/location" const SessionsQueryFields = { workspace: WorkspaceV2.ID.pipe(Schema.optional), @@ -105,6 +108,39 @@ export const SessionGroup = HttpApiGroup.make("server.session") }), ), ) + .add( + HttpApiEndpoint.post("session.create", "/api/session", { + payload: Schema.Struct({ + id: SessionV2.ID.pipe(Schema.optional), + agent: AgentV2.ID.pipe(Schema.optional), + model: ModelV2.Ref.pipe(Schema.optional), + location: Location.Ref.pipe(Schema.optional), + }), + success: Schema.Struct({ data: SessionV2.Info }), + }) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.create", + summary: "Create session", + description: "Create a session at the requested location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.get", "/api/session/:sessionID", { + params: { sessionID: SessionV2.ID }, + success: Schema.Struct({ data: SessionV2.Info }), + error: SessionNotFoundError, + }) + .middleware(SessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.get", + summary: "Get session", + description: "Retrieve a session by ID.", + }), + ), + ) .add( HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { params: { sessionID: SessionV2.ID }, diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index c8ad548a0..6a29bed86 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -18,9 +18,11 @@ import { HealthHandler } from "./handlers/health" import { QuestionHandler } from "./handlers/question" import { ReferenceHandler } from "./handlers/reference" import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" +import { LocationHandler } from "./handlers/location" export const handlers = Layer.mergeAll( HealthHandler, + LocationHandler, AgentHandler, SessionHandler, MessageHandler, diff --git a/packages/server/src/handlers/location.ts b/packages/server/src/handlers/location.ts new file mode 100644 index 000000000..ded8c8c2e --- /dev/null +++ b/packages/server/src/handlers/location.ts @@ -0,0 +1,18 @@ +import { Location } from "@opencode-ai/core/location" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" + +export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) => + handlers.handle( + "location.get", + Effect.fn(function* () { + const location = yield* Location.Service + return new Location.Info({ + directory: location.directory, + workspaceID: location.workspaceID, + project: location.project, + }) + }), + ), +) diff --git a/packages/server/src/handlers/question.ts b/packages/server/src/handlers/question.ts index f40416609..151557c50 100644 --- a/packages/server/src/handlers/question.ts +++ b/packages/server/src/handlers/question.ts @@ -29,6 +29,13 @@ export const QuestionHandler = HttpApiBuilder.group(Api, "server.question", (han return yield* response((yield* QuestionV2.Service).list()) }), ) + .handle( + "session.question.list", + Effect.fn(function* (ctx) { + const requests = yield* (yield* QuestionV2.Service).list() + return { data: requests.filter((request) => request.sessionID === ctx.params.sessionID) } + }), + ) .handle( "session.question.reply", Effect.fn(function* (ctx) { diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 8726f92f8..66383cfbf 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -10,6 +10,7 @@ import { SessionNotFoundError, UnknownError, } from "../errors" +import { AbsolutePath } from "@opencode-ai/core/schema" const DefaultSessionsLimit = 50 @@ -61,6 +62,36 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.create", + Effect.fn(function* (ctx) { + return { + data: yield* session.create({ + id: ctx.payload.id, + agent: ctx.payload.agent, + model: ctx.payload.model, + location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) }, + }), + } + }), + ) + .handle( + "session.get", + Effect.fn(function* (ctx) { + return { + data: yield* session.get(ctx.params.sessionID).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + } + }), + ) .handle( "session.prompt", Effect.fn(function* (ctx) {