Skip to content

Commit 678d8f6

Browse files
authored
feat(server): extract question httpapi contract into packages/server (#22502)
1 parent 50c1d0a commit 678d8f6

10 files changed

Lines changed: 131 additions & 81 deletions

File tree

bun.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/opencode/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@
111111
"@octokit/rest": "catalog:",
112112
"@openauthjs/openauth": "catalog:",
113113
"@opencode-ai/plugin": "workspace:*",
114+
"@opencode-ai/server": "workspace:*",
114115
"@opencode-ai/script": "workspace:*",
115116
"@opencode-ai/sdk": "workspace:*",
116117
"@opencode-ai/util": "workspace:*",

packages/opencode/src/server/instance/httpapi/question.ts

Lines changed: 9 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,74 +3,31 @@ import { memoMap } from "@/effect/run-service"
33
import { Question } from "@/question"
44
import { QuestionID } from "@/question/schema"
55
import { lazy } from "@/util/lazy"
6+
import { QuestionReply, QuestionRequest, questionApi } from "@opencode-ai/server"
67
import { Effect, Layer, Schema } from "effect"
78
import { HttpRouter, HttpServer } from "effect/unstable/http"
8-
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
9+
import { HttpApiBuilder } from "effect/unstable/httpapi"
910
import type { Handler } from "hono"
1011

1112
const root = "/experimental/httpapi/question"
12-
const Reply = Schema.Struct({
13-
answers: Schema.Array(Question.Answer).annotate({
14-
description: "User answers in order of questions (each answer is an array of selected labels)",
15-
}),
16-
})
17-
18-
const Api = HttpApi.make("question")
19-
.add(
20-
HttpApiGroup.make("question")
21-
.add(
22-
HttpApiEndpoint.get("list", root, {
23-
success: Schema.Array(Question.Request),
24-
}).annotateMerge(
25-
OpenApi.annotations({
26-
identifier: "question.list",
27-
summary: "List pending questions",
28-
description: "Get all pending question requests across all sessions.",
29-
}),
30-
),
31-
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
32-
params: { requestID: QuestionID },
33-
payload: Reply,
34-
success: Schema.Boolean,
35-
}).annotateMerge(
36-
OpenApi.annotations({
37-
identifier: "question.reply",
38-
summary: "Reply to question request",
39-
description: "Provide answers to a question request from the AI assistant.",
40-
}),
41-
),
42-
)
43-
.annotateMerge(
44-
OpenApi.annotations({
45-
title: "question",
46-
description: "Experimental HttpApi question routes.",
47-
}),
48-
),
49-
)
50-
.annotateMerge(
51-
OpenApi.annotations({
52-
title: "opencode experimental HttpApi",
53-
version: "0.0.1",
54-
description: "Experimental HttpApi surface for selected instance routes.",
55-
}),
56-
)
5713

5814
const QuestionLive = HttpApiBuilder.group(
59-
Api,
15+
questionApi,
6016
"question",
6117
Effect.fn("QuestionHttpApi.handlers")(function* (handlers) {
6218
const svc = yield* Question.Service
19+
const decode = Schema.decodeUnknownSync(Schema.Array(QuestionRequest))
6320

6421
const list = Effect.fn("QuestionHttpApi.list")(function* () {
65-
return yield* svc.list()
22+
return decode(yield* svc.list())
6623
})
6724

6825
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
69-
params: { requestID: QuestionID }
70-
payload: Schema.Schema.Type<typeof Reply>
26+
params: { requestID: string }
27+
payload: Schema.Schema.Type<typeof QuestionReply>
7128
}) {
7229
yield* svc.reply({
73-
requestID: ctx.params.requestID,
30+
requestID: QuestionID.make(ctx.params.requestID),
7431
answers: ctx.payload.answers,
7532
})
7633
return true
@@ -84,7 +41,7 @@ const web = lazy(() =>
8441
HttpRouter.toWebHandler(
8542
Layer.mergeAll(
8643
AppLayer,
87-
HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe(
44+
HttpApiBuilder.layer(questionApi, { openapiPath: `${root}/doc` }).pipe(
8845
Layer.provide(QuestionLive),
8946
Layer.provide(HttpServer.layerServices),
9047
),

packages/server/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"./openapi": "./src/openapi.ts",
1010
"./definition": "./src/definition/index.ts",
1111
"./definition/api": "./src/definition/api.ts",
12+
"./definition/question": "./src/definition/question.ts",
1213
"./api": "./src/api/index.ts"
1314
},
1415
"files": [
@@ -20,5 +21,8 @@
2021
},
2122
"devDependencies": {
2223
"typescript": "catalog:"
24+
},
25+
"dependencies": {
26+
"effect": "catalog:"
2327
}
2428
}
Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import type { ServerApi } from "../types.js"
1+
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
2+
import { questionApi } from "./question.js"
23

3-
export const api: ServerApi = {
4-
name: "opencode",
5-
groups: [],
6-
}
4+
export const api = HttpApi.make("opencode")
5+
.addHttpApi(questionApi)
6+
.annotateMerge(
7+
OpenApi.annotations({
8+
title: "opencode experimental HttpApi",
9+
version: "0.0.1",
10+
description: "Experimental HttpApi surface for selected instance routes.",
11+
}),
12+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { api } from "./api.js"
2+
export { questionApi, QuestionReply, QuestionRequest } from "./question.js"
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { Schema } from "effect"
2+
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
3+
4+
const root = "/experimental/httpapi/question"
5+
6+
// Temporary transport-local schemas until canonical question schemas move into packages/core.
7+
export const QuestionID = Schema.String.annotate({ identifier: "QuestionID" })
8+
export const SessionID = Schema.String.annotate({ identifier: "SessionID" })
9+
export const MessageID = Schema.String.annotate({ identifier: "MessageID" })
10+
11+
export class QuestionOption extends Schema.Class<QuestionOption>("QuestionOption")({
12+
label: Schema.String.annotate({
13+
description: "Display text (1-5 words, concise)",
14+
}),
15+
description: Schema.String.annotate({
16+
description: "Explanation of choice",
17+
}),
18+
}) {}
19+
20+
const base = {
21+
question: Schema.String.annotate({
22+
description: "Complete question",
23+
}),
24+
header: Schema.String.annotate({
25+
description: "Very short label (max 30 chars)",
26+
}),
27+
options: Schema.Array(QuestionOption).annotate({
28+
description: "Available choices",
29+
}),
30+
multiple: Schema.optional(Schema.Boolean).annotate({
31+
description: "Allow selecting multiple choices",
32+
}),
33+
}
34+
35+
export class QuestionInfo extends Schema.Class<QuestionInfo>("QuestionInfo")({
36+
...base,
37+
custom: Schema.optional(Schema.Boolean).annotate({
38+
description: "Allow typing a custom answer (default: true)",
39+
}),
40+
}) {}
41+
42+
export class QuestionTool extends Schema.Class<QuestionTool>("QuestionTool")({
43+
messageID: MessageID,
44+
callID: Schema.String,
45+
}) {}
46+
47+
export class QuestionRequest extends Schema.Class<QuestionRequest>("QuestionRequest")({
48+
id: QuestionID,
49+
sessionID: SessionID,
50+
questions: Schema.Array(QuestionInfo).annotate({
51+
description: "Questions to ask",
52+
}),
53+
tool: Schema.optional(QuestionTool),
54+
}) {}
55+
56+
export const QuestionAnswer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" })
57+
58+
export class QuestionReply extends Schema.Class<QuestionReply>("QuestionReply")({
59+
answers: Schema.Array(QuestionAnswer).annotate({
60+
description: "User answers in order of questions (each answer is an array of selected labels)",
61+
}),
62+
}) {}
63+
64+
export const questionApi = HttpApi.make("question").add(
65+
HttpApiGroup.make("question")
66+
.add(
67+
HttpApiEndpoint.get("list", root, {
68+
success: Schema.Array(QuestionRequest),
69+
}).annotateMerge(
70+
OpenApi.annotations({
71+
identifier: "question.list",
72+
summary: "List pending questions",
73+
description: "Get all pending question requests across all sessions.",
74+
}),
75+
),
76+
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
77+
params: { requestID: QuestionID },
78+
payload: QuestionReply,
79+
success: Schema.Boolean,
80+
}).annotateMerge(
81+
OpenApi.annotations({
82+
identifier: "question.reply",
83+
summary: "Reply to question request",
84+
description: "Provide answers to a question request from the AI assistant.",
85+
}),
86+
),
87+
)
88+
.annotateMerge(
89+
OpenApi.annotations({
90+
title: "question",
91+
description: "Experimental HttpApi question routes.",
92+
}),
93+
),
94+
)

packages/server/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { openapi } from "./openapi.js"
22
export { api } from "./definition/api.js"
3+
export { questionApi, QuestionReply, QuestionRequest } from "./definition/question.js"
34
export type { OpenApiSpec, ServerApi } from "./types.js"

packages/server/src/openapi.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,5 @@
1+
import { OpenApi } from "effect/unstable/httpapi"
12
import { api } from "./definition/api.js"
23
import type { OpenApiSpec } from "./types.js"
34

4-
export function openapi(): OpenApiSpec {
5-
return {
6-
openapi: "3.1.1",
7-
info: {
8-
title: api.name,
9-
version: "0.0.0",
10-
description: "Contract-first server package scaffold.",
11-
},
12-
paths: {},
13-
}
14-
}
5+
export const openapi = (): OpenApiSpec => OpenApi.fromApi(api)

packages/server/src/types.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,5 @@
1-
export interface ServerApi {
2-
readonly name: string
3-
readonly groups: readonly string[]
4-
}
1+
import type { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
52

6-
export interface OpenApiSpec {
7-
readonly openapi: string
8-
readonly info: {
9-
readonly title: string
10-
readonly version: string
11-
readonly description: string
12-
}
13-
readonly paths: Record<string, never>
14-
}
3+
export type ServerApi = HttpApi.HttpApi<string, HttpApiGroup.Any>
4+
5+
export type OpenApiSpec = OpenApi.OpenAPISpec

0 commit comments

Comments
 (0)