Selaa lähdekoodia

feat(server): extract question httpapi contract into packages/server (#22502)

Shoubhit Dash 4 päivää sitten
vanhempi
sitoutus
678d8f6ab2

+ 4 - 0
bun.lock

@@ -356,6 +356,7 @@
         "@opencode-ai/plugin": "workspace:*",
         "@opencode-ai/script": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
+        "@opencode-ai/server": "workspace:*",
         "@opencode-ai/util": "workspace:*",
         "@openrouter/ai-sdk-provider": "2.5.1",
         "@opentui/core": "0.1.99",
@@ -501,6 +502,9 @@
     "packages/server": {
       "name": "@opencode-ai/server",
       "version": "1.4.3",
+      "dependencies": {
+        "effect": "catalog:",
+      },
       "devDependencies": {
         "typescript": "catalog:",
       },

+ 1 - 0
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:*",

+ 9 - 52
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<typeof Reply>
+      params: { requestID: string }
+      payload: Schema.Schema.Type<typeof QuestionReply>
     }) {
       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),
       ),

+ 4 - 0
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:"
   }
 }

+ 11 - 5
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.",
+    }),
+  )

+ 1 - 0
packages/server/src/definition/index.ts

@@ -1 +1,2 @@
 export { api } from "./api.js"
+export { questionApi, QuestionReply, QuestionRequest } from "./question.js"

+ 94 - 0
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>("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>("QuestionInfo")({
+  ...base,
+  custom: Schema.optional(Schema.Boolean).annotate({
+    description: "Allow typing a custom answer (default: true)",
+  }),
+}) {}
+
+export class QuestionTool extends Schema.Class<QuestionTool>("QuestionTool")({
+  messageID: MessageID,
+  callID: Schema.String,
+}) {}
+
+export class QuestionRequest extends Schema.Class<QuestionRequest>("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>("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.",
+      }),
+    ),
+)

+ 1 - 0
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"

+ 2 - 11
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)

+ 4 - 13
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<string, never>
-}
+export type ServerApi = HttpApi.HttpApi<string, HttpApiGroup.Any>
+
+export type OpenApiSpec = OpenApi.OpenAPISpec