From fba752a5016a93ad7ea54890cf444de02a89a0f8 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 15 Apr 2026 04:45:25 +0530 Subject: [PATCH] feat(server): extract question httpapi contract --- packages/opencode/package.json | 1 + .../src/server/instance/httpapi/question.ts | 61 ++---------- packages/server/package.json | 4 + packages/server/src/definition/api.ts | 16 +++- packages/server/src/definition/index.ts | 1 + packages/server/src/definition/question.ts | 94 +++++++++++++++++++ packages/server/src/index.ts | 1 + packages/server/src/openapi.ts | 13 +-- packages/server/src/types.ts | 17 +--- 9 files changed, 127 insertions(+), 81 deletions(-) create mode 100644 packages/server/src/definition/question.ts diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 5e8e981c26..b957c695ba 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -111,6 +111,7 @@ "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/server": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", diff --git a/packages/opencode/src/server/instance/httpapi/question.ts b/packages/opencode/src/server/instance/httpapi/question.ts index 496476b68a..7aae89c19f 100644 --- a/packages/opencode/src/server/instance/httpapi/question.ts +++ b/packages/opencode/src/server/instance/httpapi/question.ts @@ -3,74 +3,31 @@ import { memoMap } from "@/effect/run-service" import { Question } from "@/question" import { QuestionID } from "@/question/schema" import { lazy } from "@/util/lazy" +import { QuestionReply, QuestionRequest, questionApi } from "@opencode-ai/server" import { Effect, Layer, Schema } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApiBuilder } from "effect/unstable/httpapi" import type { Handler } from "hono" const root = "/experimental/httpapi/question" -const Reply = Schema.Struct({ - answers: Schema.Array(Question.Answer).annotate({ - description: "User answers in order of questions (each answer is an array of selected labels)", - }), -}) - -const Api = HttpApi.make("question") - .add( - HttpApiGroup.make("question") - .add( - HttpApiEndpoint.get("list", root, { - success: Schema.Array(Question.Request), - }).annotateMerge( - OpenApi.annotations({ - identifier: "question.list", - summary: "List pending questions", - description: "Get all pending question requests across all sessions.", - }), - ), - HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { - params: { requestID: QuestionID }, - payload: Reply, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "question.reply", - summary: "Reply to question request", - description: "Provide answers to a question request from the AI assistant.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "question", - description: "Experimental HttpApi question routes.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) const QuestionLive = HttpApiBuilder.group( - Api, + questionApi, "question", Effect.fn("QuestionHttpApi.handlers")(function* (handlers) { const svc = yield* Question.Service + const decode = Schema.decodeUnknownSync(Schema.Array(QuestionRequest)) const list = Effect.fn("QuestionHttpApi.list")(function* () { - return yield* svc.list() + return decode(yield* svc.list()) }) const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: { - params: { requestID: QuestionID } - payload: Schema.Schema.Type + params: { requestID: string } + payload: Schema.Schema.Type }) { yield* svc.reply({ - requestID: ctx.params.requestID, + requestID: QuestionID.make(ctx.params.requestID), answers: ctx.payload.answers, }) return true @@ -84,7 +41,7 @@ const web = lazy(() => HttpRouter.toWebHandler( Layer.mergeAll( AppLayer, - HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe( + HttpApiBuilder.layer(questionApi, { openapiPath: `${root}/doc` }).pipe( Layer.provide(QuestionLive), Layer.provide(HttpServer.layerServices), ), diff --git a/packages/server/package.json b/packages/server/package.json index f0b0bae5e7..3fc0133100 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -9,6 +9,7 @@ "./openapi": "./src/openapi.ts", "./definition": "./src/definition/index.ts", "./definition/api": "./src/definition/api.ts", + "./definition/question": "./src/definition/question.ts", "./api": "./src/api/index.ts" }, "files": [ @@ -20,5 +21,8 @@ }, "devDependencies": { "typescript": "catalog:" + }, + "dependencies": { + "effect": "catalog:" } } diff --git a/packages/server/src/definition/api.ts b/packages/server/src/definition/api.ts index 6eda4090e0..e2f70196da 100644 --- a/packages/server/src/definition/api.ts +++ b/packages/server/src/definition/api.ts @@ -1,6 +1,12 @@ -import type { ServerApi } from "../types.js" +import { HttpApi, OpenApi } from "effect/unstable/httpapi" +import { questionApi } from "./question.js" -export const api: ServerApi = { - name: "opencode", - groups: [], -} +export const api = HttpApi.make("opencode") + .addHttpApi(questionApi) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) diff --git a/packages/server/src/definition/index.ts b/packages/server/src/definition/index.ts index 39cab2446c..e9a52dc930 100644 --- a/packages/server/src/definition/index.ts +++ b/packages/server/src/definition/index.ts @@ -1 +1,2 @@ export { api } from "./api.js" +export { questionApi, QuestionReply, QuestionRequest } from "./question.js" diff --git a/packages/server/src/definition/question.ts b/packages/server/src/definition/question.ts new file mode 100644 index 0000000000..0d161e013d --- /dev/null +++ b/packages/server/src/definition/question.ts @@ -0,0 +1,94 @@ +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const root = "/experimental/httpapi/question" + +// Temporary transport-local schemas until canonical question schemas move into packages/core. +export const QuestionID = Schema.String.annotate({ identifier: "QuestionID" }) +export const SessionID = Schema.String.annotate({ identifier: "SessionID" }) +export const MessageID = Schema.String.annotate({ identifier: "MessageID" }) + +export class QuestionOption extends Schema.Class("QuestionOption")({ + label: Schema.String.annotate({ + description: "Display text (1-5 words, concise)", + }), + description: Schema.String.annotate({ + description: "Explanation of choice", + }), +}) {} + +const base = { + question: Schema.String.annotate({ + description: "Complete question", + }), + header: Schema.String.annotate({ + description: "Very short label (max 30 chars)", + }), + options: Schema.Array(QuestionOption).annotate({ + description: "Available choices", + }), + multiple: Schema.optional(Schema.Boolean).annotate({ + description: "Allow selecting multiple choices", + }), +} + +export class QuestionInfo extends Schema.Class("QuestionInfo")({ + ...base, + custom: Schema.optional(Schema.Boolean).annotate({ + description: "Allow typing a custom answer (default: true)", + }), +}) {} + +export class QuestionTool extends Schema.Class("QuestionTool")({ + messageID: MessageID, + callID: Schema.String, +}) {} + +export class QuestionRequest extends Schema.Class("QuestionRequest")({ + id: QuestionID, + sessionID: SessionID, + questions: Schema.Array(QuestionInfo).annotate({ + description: "Questions to ask", + }), + tool: Schema.optional(QuestionTool), +}) {} + +export const QuestionAnswer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" }) + +export class QuestionReply extends Schema.Class("QuestionReply")({ + answers: Schema.Array(QuestionAnswer).annotate({ + description: "User answers in order of questions (each answer is an array of selected labels)", + }), +}) {} + +export const questionApi = HttpApi.make("question").add( + HttpApiGroup.make("question") + .add( + HttpApiEndpoint.get("list", root, { + success: Schema.Array(QuestionRequest), + }).annotateMerge( + OpenApi.annotations({ + identifier: "question.list", + summary: "List pending questions", + description: "Get all pending question requests across all sessions.", + }), + ), + HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { + params: { requestID: QuestionID }, + payload: QuestionReply, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "question.reply", + summary: "Reply to question request", + description: "Provide answers to a question request from the AI assistant.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "question", + description: "Experimental HttpApi question routes.", + }), + ), +) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 2fbe31a0da..d5bdb6c8db 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,3 +1,4 @@ export { openapi } from "./openapi.js" export { api } from "./definition/api.js" +export { questionApi, QuestionReply, QuestionRequest } from "./definition/question.js" export type { OpenApiSpec, ServerApi } from "./types.js" diff --git a/packages/server/src/openapi.ts b/packages/server/src/openapi.ts index c4ac953004..dda870d2b6 100644 --- a/packages/server/src/openapi.ts +++ b/packages/server/src/openapi.ts @@ -1,14 +1,5 @@ +import { OpenApi } from "effect/unstable/httpapi" import { api } from "./definition/api.js" import type { OpenApiSpec } from "./types.js" -export function openapi(): OpenApiSpec { - return { - openapi: "3.1.1", - info: { - title: api.name, - version: "0.0.0", - description: "Contract-first server package scaffold.", - }, - paths: {}, - } -} +export const openapi = (): OpenApiSpec => OpenApi.fromApi(api) diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 8d337be42e..9e89fe74c2 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -1,14 +1,5 @@ -export interface ServerApi { - readonly name: string - readonly groups: readonly string[] -} +import type { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -export interface OpenApiSpec { - readonly openapi: string - readonly info: { - readonly title: string - readonly version: string - readonly description: string - } - readonly paths: Record -} +export type ServerApi = HttpApi.HttpApi + +export type OpenApiSpec = OpenApi.OpenAPISpec