From c167bde6a040a027c761dd845e93030ebe7fa96c Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:55:15 -0500 Subject: [PATCH] feat(core): global forms support (#35106) --- packages/client/test/promise.test.ts | 1 + packages/core/test/form.test.ts | 19 +++++ .../src/server/routes/instance/httpapi/api.ts | 2 + .../server/routes/instance/httpapi/server.ts | 2 + packages/protocol/src/api.ts | 78 +++++++++++++++++-- packages/protocol/src/client.ts | 5 ++ packages/protocol/src/groups/form.ts | 35 +++++---- packages/schema/src/form.ts | 11 ++- packages/server/src/api.ts | 4 + packages/server/src/location.ts | 4 +- .../server/src/middleware/form-location.ts | 76 ++++++++++++++++++ packages/server/src/routes.ts | 2 + 12 files changed, 209 insertions(+), 30 deletions(-) create mode 100644 packages/server/src/middleware/form-location.ts diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 6509c8f7138..03a81a1dc9b 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -18,6 +18,7 @@ test("exposes every standard HTTP API group", () => { "server.mcp", "credential", "project", + "form", "permission", "file", "command", diff --git a/packages/core/test/form.test.ts b/packages/core/test/form.test.ts index d23721d10a5..19f6022bf8b 100644 --- a/packages/core/test/form.test.ts +++ b/packages/core/test/form.test.ts @@ -19,6 +19,25 @@ const input = { } satisfies Form.CreateInput describe("Form", () => { + it.effect("supports the temporary global mcp elicitation owner", () => + Effect.gen(function* () { + const service = yield* Form.Service + const created = yield* service.create({ + sessionID: "global", + mode: "form", + fields: [{ key: "name", type: "string", required: true }], + }) + expect(created.sessionID).toBe("global") + + const owned = yield* service.list({ sessionID: "global" }) + expect(owned.map((form) => form.id)).toEqual([created.id]) + expect(yield* service.list({ sessionID: "other" })).toEqual([]) + + yield* service.reply({ id: created.id, answer: { name: "Ava" } }) + expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { name: "Ava" } }) + }), + ) + it.effect("cleans up created forms when event publication fails", () => Effect.gen(function* () { const service = yield* Form.Service diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index f5076ad0808..37f734b1a06 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -27,6 +27,7 @@ import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" import { makeApi } from "@opencode-ai/protocol/api" import { LocationMiddleware } from "@opencode-ai/server/location" +import { FormLocationMiddleware } from "@opencode-ai/server/middleware/form-location" import { SessionLocationMiddleware } from "@opencode-ai/server/middleware/session-location" import { GlobalApi } from "./groups/global" import { Authorization } from "./middleware/authorization" @@ -48,6 +49,7 @@ const EventSchema = Schema.Union([ export const ServerApi = makeApi({ definitions: EventManifest.Latest.values().toArray(), locationMiddleware: LocationMiddleware, + formLocationMiddleware: FormLocationMiddleware, sessionLocationMiddleware: SessionLocationMiddleware, }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 1d4f31ae931..7edefdacd48 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -103,6 +103,7 @@ import { tuiHandlers } from "./handlers/tui" import { handlers } from "@opencode-ai/server/handlers" import { buildLocationServiceMap, LocationServiceMap } from "@opencode-ai/core/location-services" import { layer as locationLayer } from "@opencode-ai/server/location" +import { formLocationLayer } from "@opencode-ai/server/middleware/form-location" import { sessionLocationLayer } from "@opencode-ai/server/middleware/session-location" import { PtyEnvironment } from "@opencode-ai/server/pty-environment" import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error" @@ -295,6 +296,7 @@ export function createRoutes( Layer.provide(Layer.succeed(CorsConfig)(corsOptions)), Layer.provideMerge(Observability.layer), + Layer.provide(formLocationLayer), Layer.provide(sessionLocationLayer), Layer.provide(locationLayer), Layer.provide(PtyEnvironment.layer), diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 8cf6b21835c..e1d29d8a05d 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -51,6 +51,13 @@ type SessionGroups> | HttpApiGroup.AddMiddleware +type FormGroups< + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + FormLocationId extends HttpApiMiddleware.AnyId, + FormLocationService, +> = ReturnType> + type MixedMiddlewareGroups< LocationId extends HttpApiMiddleware.AnyId, LocationService, @@ -60,18 +67,20 @@ type MixedMiddlewareGroups< | ReturnType< typeof makePermissionGroup > - | ReturnType> | ReturnType> type ApiGroups< LocationId extends HttpApiMiddleware.AnyId, LocationService, + FormLocationId extends HttpApiMiddleware.AnyId, + FormLocationService, SessionLocationId extends HttpApiMiddleware.AnyId, SessionLocationService, Event extends HttpApiGroup.Any, > = | typeof HealthGroup | LocationGroups + | FormGroups | SessionGroups | MixedMiddlewareGroups | Event @@ -81,6 +90,8 @@ type EventGroupFor> = ReturnType, + ApiGroups< + LocationId, + LocationService, + FormLocationId, + FormLocationService, + SessionLocationId, + SessionLocationService, + Event + >, Authorization >, SchemaErrorMiddleware @@ -100,13 +119,24 @@ const makeApiFromGroup = < const Group extends HttpApiGroup.Any, LocationId extends HttpApiMiddleware.AnyId, LocationService, + FormLocationId extends HttpApiMiddleware.AnyId, + FormLocationService, SessionLocationId extends HttpApiMiddleware.AnyId, SessionLocationService, >( eventGroup: Group, locationMiddleware: Context.Key, + formLocationMiddleware: Context.Key, sessionLocationMiddleware: Context.Key, -): Api => +): Api< + LocationId, + LocationService, + FormLocationId, + FormLocationService, + SessionLocationId, + SessionLocationService, + Group +> => HttpApi.make("server") .add(HealthGroup) .add(LocationGroup.middleware(locationMiddleware)) @@ -121,7 +151,7 @@ const makeApiFromGroup = < .add(McpGroup.middleware(locationMiddleware)) .add(CredentialGroup.middleware(locationMiddleware)) .add(ProjectGroup.middleware(locationMiddleware)) - .add(makeFormGroup(locationMiddleware, sessionLocationMiddleware)) + .add(makeFormGroup(locationMiddleware, formLocationMiddleware)) .add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware)) .add(FileSystemGroup.middleware(locationMiddleware)) .add(CommandGroup.middleware(locationMiddleware)) @@ -146,22 +176,54 @@ export const makeApi = < const Definitions extends ReadonlyArray, LocationId extends HttpApiMiddleware.AnyId, LocationService, + FormLocationId extends HttpApiMiddleware.AnyId, + FormLocationService, SessionLocationId extends HttpApiMiddleware.AnyId, SessionLocationService, >(options: { readonly definitions: Definitions readonly locationMiddleware: Context.Key + readonly formLocationMiddleware: Context.Key readonly sessionLocationMiddleware: Context.Key -}): Api> => - makeApiFromGroup(makeEventGroup(options.definitions), options.locationMiddleware, options.sessionLocationMiddleware) +}): Api< + LocationId, + LocationService, + FormLocationId, + FormLocationService, + SessionLocationId, + SessionLocationService, + EventGroupFor +> => + makeApiFromGroup( + makeEventGroup(options.definitions), + options.locationMiddleware, + options.formLocationMiddleware, + options.sessionLocationMiddleware, + ) export const makeDefaultApi = < LocationId extends HttpApiMiddleware.AnyId, LocationService, + FormLocationId extends HttpApiMiddleware.AnyId, + FormLocationService, SessionLocationId extends HttpApiMiddleware.AnyId, SessionLocationService, >(options: { readonly locationMiddleware: Context.Key + readonly formLocationMiddleware: Context.Key readonly sessionLocationMiddleware: Context.Key -}): Api => - makeApiFromGroup(EventGroup, options.locationMiddleware, options.sessionLocationMiddleware) +}): Api< + LocationId, + LocationService, + FormLocationId, + FormLocationService, + SessionLocationId, + SessionLocationService, + typeof EventGroup +> => + makeApiFromGroup( + EventGroup, + options.locationMiddleware, + options.formLocationMiddleware, + options.sessionLocationMiddleware, + ) diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 65d539038f7..138fd68acfe 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -19,11 +19,16 @@ type ClientApiShape = Api< Context.Service.Shape, Context.Service.Identifier, Context.Service.Shape, + Context.Service.Identifier, + Context.Service.Shape, typeof EventGroup > export const ClientApi: ClientApiShape = makeDefaultApi({ locationMiddleware: LocationMiddleware, + // The real server uses a form-specific middleware with an undocumented `global` sentinel branch. + // The generated client only needs a middleware identity for API typing. + formLocationMiddleware: SessionLocationMiddleware, sessionLocationMiddleware: SessionLocationMiddleware, }) diff --git a/packages/protocol/src/groups/form.ts b/packages/protocol/src/groups/form.ts index 75e14d31535..145f1da0b82 100644 --- a/packages/protocol/src/groups/form.ts +++ b/packages/protocol/src/groups/form.ts @@ -1,6 +1,5 @@ import { Form } from "@opencode-ai/schema/form" import { Location } from "@opencode-ai/schema/location" -import { Session } from "@opencode-ai/schema/session" import { Context, Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { @@ -24,14 +23,18 @@ const CreatePayload = Schema.Struct({ export type CreatePayload = typeof CreatePayload.Type +// Form routes intentionally look session-scoped, but use a form-specific middleware instead of +// SessionLocationMiddleware. The middleware treats real session IDs normally and has an +// undocumented `global` sentinel branch for MCP elicitation forms that are still Location-scoped +// but not session-owned. This is temporary and should disappear once elicitations are attributable. export const makeFormGroup = < LocationId extends HttpApiMiddleware.AnyId, LocationService, - SessionLocationId extends HttpApiMiddleware.AnyId, - SessionLocationService, + FormLocationId extends HttpApiMiddleware.AnyId, + FormLocationService, >( locationMiddleware: Context.Key, - sessionLocationMiddleware: Context.Key, + formLocationMiddleware: Context.Key, ) => HttpApiGroup.make("server.form") .add( @@ -51,11 +54,11 @@ export const makeFormGroup = < .middleware(locationMiddleware) .add( HttpApiEndpoint.get("session.form.list", "/api/session/:sessionID/form", { - params: { sessionID: Session.ID }, + params: { sessionID: Schema.String }, success: Schema.Struct({ data: Schema.Array(Form.Info) }), error: SessionNotFoundError, }) - .middleware(sessionLocationMiddleware) + .middleware(formLocationMiddleware) .annotateMerge( OpenApi.annotations({ identifier: "v2.session.form.list", @@ -66,12 +69,12 @@ export const makeFormGroup = < ) .add( HttpApiEndpoint.post("session.form.create", "/api/session/:sessionID/form", { - params: { sessionID: Session.ID }, + params: { sessionID: Schema.String }, payload: CreatePayload, success: Schema.Struct({ data: Form.Info }), error: [SessionNotFoundError, ConflictError, InvalidRequestError], }) - .middleware(sessionLocationMiddleware) + .middleware(formLocationMiddleware) .annotateMerge( OpenApi.annotations({ identifier: "v2.session.form.create", @@ -82,11 +85,11 @@ export const makeFormGroup = < ) .add( HttpApiEndpoint.get("session.form.get", "/api/session/:sessionID/form/:formID", { - params: { sessionID: Session.ID, formID: Form.ID }, + params: { sessionID: Schema.String, formID: Form.ID }, success: Schema.Struct({ data: Form.Info }), error: [SessionNotFoundError, FormNotFoundError], }) - .middleware(sessionLocationMiddleware) + .middleware(formLocationMiddleware) .annotateMerge( OpenApi.annotations({ identifier: "v2.session.form.get", @@ -97,11 +100,11 @@ export const makeFormGroup = < ) .add( HttpApiEndpoint.get("session.form.state", "/api/session/:sessionID/form/:formID/state", { - params: { sessionID: Session.ID, formID: Form.ID }, + params: { sessionID: Schema.String, formID: Form.ID }, success: Schema.Struct({ data: Form.State }), error: [SessionNotFoundError, FormNotFoundError], }) - .middleware(sessionLocationMiddleware) + .middleware(formLocationMiddleware) .annotateMerge( OpenApi.annotations({ identifier: "v2.session.form.state", @@ -112,12 +115,12 @@ export const makeFormGroup = < ) .add( HttpApiEndpoint.post("session.form.reply", "/api/session/:sessionID/form/:formID/reply", { - params: { sessionID: Session.ID, formID: Form.ID }, + params: { sessionID: Schema.String, formID: Form.ID }, payload: Form.Reply, success: HttpApiSchema.NoContent, error: [SessionNotFoundError, FormAlreadySettledError, FormInvalidAnswerError, FormNotFoundError], }) - .middleware(sessionLocationMiddleware) + .middleware(formLocationMiddleware) .annotateMerge( OpenApi.annotations({ identifier: "v2.session.form.reply", @@ -128,11 +131,11 @@ export const makeFormGroup = < ) .add( HttpApiEndpoint.post("session.form.cancel", "/api/session/:sessionID/form/:formID/cancel", { - params: { sessionID: Session.ID, formID: Form.ID }, + params: { sessionID: Schema.String, formID: Form.ID }, success: HttpApiSchema.NoContent, error: [SessionNotFoundError, FormAlreadySettledError, FormNotFoundError], }) - .middleware(sessionLocationMiddleware) + .middleware(formLocationMiddleware) .annotateMerge( OpenApi.annotations({ identifier: "v2.session.form.cancel", diff --git a/packages/schema/src/form.ts b/packages/schema/src/form.ts index bad113e09bd..23f8a015b6d 100644 --- a/packages/schema/src/form.ts +++ b/packages/schema/src/form.ts @@ -4,7 +4,6 @@ import { Schema } from "effect" import { define, inventory } from "./event.js" import { ascending } from "./identifier.js" import { NonNegativeInt, optional, statics } from "./schema.js" -import { SessionID } from "./session-id.js" const IDSchema = Schema.String.check(Schema.isStartsWith("frm_")).pipe(Schema.brand("Form.ID")) @@ -95,7 +94,11 @@ export type Field = StringField | NumberField | IntegerField | BooleanField | Mu const InfoBase = { id: ID, - sessionID: SessionID, + // This should be typed as SessionID. It is a plain string only because MCP elicitation + // temporarily needs the `"global"` sentinel owner, which is not a real session. Once + // elicitations can be attributed to real sessions, revert this to SessionID. Do not rely + // on non-session owners anywhere else. + sessionID: Schema.String, title: Schema.String.pipe(optional), metadata: Metadata.pipe(optional), } @@ -140,7 +143,7 @@ export const Reply = Schema.Struct({ export interface Reply extends Schema.Schema.Type {} const Created = define({ type: "form.created", schema: { form: Info } }) -const Replied = define({ type: "form.replied", schema: { id: ID, sessionID: SessionID, answer: Answer } }) -const Cancelled = define({ type: "form.cancelled", schema: { id: ID, sessionID: SessionID } }) +const Replied = define({ type: "form.replied", schema: { id: ID, sessionID: Schema.String, answer: Answer } }) +const Cancelled = define({ type: "form.cancelled", schema: { id: ID, sessionID: Schema.String } }) export const Event = { Created, Replied, Cancelled, Definitions: inventory(Created, Replied, Cancelled) } diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index 981ad28db93..60831abc206 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -1,8 +1,12 @@ import { makeDefaultApi } from "@opencode-ai/protocol/api" import { LocationMiddleware } from "./location" +import { FormLocationMiddleware } from "./middleware/form-location" import { SessionLocationMiddleware } from "./middleware/session-location" export const Api = makeDefaultApi({ locationMiddleware: LocationMiddleware, + // FormLocationMiddleware contains the temporary `sessionID === "global"` MCP elicitation hack. + // Do not use that sentinel with general session APIs. + formLocationMiddleware: FormLocationMiddleware, sessionLocationMiddleware: SessionLocationMiddleware, }) diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts index 8ae5aa6e6d0..6cc90f3262e 100644 --- a/packages/server/src/location.ts +++ b/packages/server/src/location.ts @@ -26,7 +26,7 @@ export function response(data: Effect.Effect) { }) } -function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref { +export function requestRef(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"] const directory = @@ -53,7 +53,7 @@ export const layer = Layer.effect( return LocationMiddleware.of((effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest - return yield* effect.pipe(Effect.provide(locations.get(ref(request)))) + return yield* effect.pipe(Effect.provide(locations.get(requestRef(request)))) }), ) }), diff --git a/packages/server/src/middleware/form-location.ts b/packages/server/src/middleware/form-location.ts new file mode 100644 index 00000000000..f6b93b118a5 --- /dev/null +++ b/packages/server/src/middleware/form-location.ts @@ -0,0 +1,76 @@ +import { Database } from "@opencode-ai/core/database/database" +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap } from "@opencode-ai/core/location-services" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors" +import { eq } from "drizzle-orm" +import { Effect, Layer, Schema } from "effect" +import { HttpRouter, HttpServerRequest } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { requestRef, type LocationServices } from "../location" + +export class FormLocationMiddleware extends HttpApiMiddleware.Service< + FormLocationMiddleware, + { provides: LocationServices } +>()("@opencode/HttpApiFormLocation", { + error: [InvalidRequestError, SessionNotFoundError], +}) {} + +const decodeSessionID = Schema.decodeUnknownEffect(SessionV2.ID) + +export const formLocationLayer = Layer.effect( + FormLocationMiddleware, + Effect.gen(function* () { + const { db } = yield* Database.Service + const locations = yield* LocationServiceMap.Service + + return FormLocationMiddleware.of((effect) => + Effect.gen(function* () { + const route = yield* HttpRouter.RouteContext + if (route.params.sessionID === "global") { + // Temporary MCP elicitation escape hatch. This is still Location-scoped; it only bypasses + // the session row lookup because some MCP elicitations cannot currently be attributed to + // a real session. Keep this undocumented and remove once elicitations carry session ownership. + const request = yield* HttpServerRequest.HttpServerRequest + return yield* effect.pipe(Effect.provide(locations.get(requestRef(request)))) + } + + const sessionID = yield* decodeSessionID(route.params.sessionID).pipe( + Effect.mapError( + () => + new InvalidRequestError({ + message: "Invalid session ID", + field: "sessionID", + }), + ), + ) + const row = yield* db + .select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + .pipe(Effect.orDie) + if (!row) { + return yield* new SessionNotFoundError({ + sessionID, + message: `Session not found: ${sessionID}`, + }) + } + + return yield* effect.pipe( + Effect.provide( + locations.get( + Location.Ref.make({ + directory: AbsolutePath.make(row.directory), + workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined, + }), + ), + ), + ) + }), + ) + }), +) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 0cb76573deb..30c3d961b7a 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -24,6 +24,7 @@ import { authorizationLayer } from "./middleware/authorization" import { schemaErrorLayer } from "./middleware/schema-error" import { PtyEnvironment } from "./pty-environment" import { layer as locationLayer } from "./location" +import { formLocationLayer } from "./middleware/form-location" import { sessionLocationLayer } from "./middleware/session-location" const applicationServices = LayerNode.group([ @@ -79,6 +80,7 @@ function makeRoutes( return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( Layer.provide(handlers), + Layer.provide(formLocationLayer), Layer.provide(sessionLocationLayer), Layer.provide(locationLayer), Layer.provide(authorizationLayer),