summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-14 15:43:49 -0400
committerGitHub <[email protected]>2026-04-14 19:43:49 +0000
commitf2525a63c92dd46beeefc8741afcb98153abc776 (patch)
treed0cd8c4357404e4fd8ba18d43db22d43172bd5d9 /packages
parent8c42d391f5a765212e5b221d8382b3c01835cbe4 (diff)
downloadopencode-f2525a63c92dd46beeefc8741afcb98153abc776.tar.gz
opencode-f2525a63c92dd46beeefc8741afcb98153abc776.zip
add experimental question HttpApi slice (#22357)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/specs/effect/http-api.md269
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx2
-rw-r--r--packages/opencode/src/question/index.ts168
-rw-r--r--packages/opencode/src/server/instance/experimental.ts2
-rw-r--r--packages/opencode/src/server/instance/httpapi/index.ts7
-rw-r--r--packages/opencode/src/server/instance/httpapi/question.ts94
-rw-r--r--packages/opencode/src/server/instance/question.ts4
-rw-r--r--packages/opencode/src/tool/question.ts4
-rw-r--r--packages/opencode/test/question/question.test.ts4
-rw-r--r--packages/opencode/test/server/question-httpapi.test.ts78
10 files changed, 549 insertions, 83 deletions
diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md
index a18d805a3..cce3f4081 100644
--- a/packages/opencode/specs/effect/http-api.md
+++ b/packages/opencode/specs/effect/http-api.md
@@ -104,6 +104,19 @@ Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
+Recommended first slice:
+
+- start with `question`
+- start with `GET /question`
+- start with `POST /question/:requestID/reply`
+
+Why `question` first:
+
+- already JSON-only
+- already delegates into an Effect service
+- proves list + mutation + params + payload + OpenAPI in one small slice
+- avoids the harder streaming and middleware cases
+
### 3. Reuse existing services
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
@@ -121,13 +134,257 @@ Prefer mounting an experimental `HttpApi` surface alongside the existing Hono ro
If the parallel slice works well, migrate additional JSON route groups one at a time. Leave streaming-style endpoints on Hono until there is a clear reason to move them.
-## Proposed first steps
+## Schema rule for HttpApi work
+
+Every `HttpApi` slice should follow `specs/effect/schema.md` and the Schema -> Zod interop rule in `specs/effect/migration.md`.
+
+Default rule:
+
+- Effect Schema owns the type
+- `.zod` exists only as a compatibility surface
+- do not introduce a new hand-written Zod schema for a type that is already migrating to Effect Schema
+
+Practical implication for `HttpApi` migration:
+
+- if a route boundary already depends on a shared DTO, ID, input, output, or tagged error, migrate that model to Effect Schema first or in the same change
+- if an existing Hono route or tool still needs Zod, derive it with `@/util/effect-zod`
+- avoid maintaining parallel Zod and Effect definitions for the same request or response type
+
+Ordering for a route-group migration:
+
+1. move implicated shared `schema.ts` leaf types to Effect Schema first
+2. move exported `Info` / `Input` / `Output` route DTOs to Effect Schema
+3. move tagged route-facing errors to `Schema.TaggedErrorClass` where needed
+4. switch existing Zod boundary validators to derived `.zod`
+5. define the `HttpApi` contract from the canonical Effect schemas
+
+Temporary exception:
+
+- it is acceptable to keep a route-local Zod schema for the first spike only when the type is boundary-local and migrating it would create unrelated churn
+- if that happens, leave a short note so the type does not become a permanent second source of truth
+
+## First vertical slice
+
+The first `HttpApi` spike should be intentionally small and repeatable.
+
+Chosen slice:
+
+- group: `question`
+- endpoints: `GET /question` and `POST /question/:requestID/reply`
+
+Non-goals:
+
+- no `session` routes
+- no SSE or websocket routes
+- no auth redesign
+- no broad service refactor
+
+Behavior rule:
+
+- preserve current runtime behavior first
+- treat semantic changes such as introducing new `404` behavior as a separate follow-up unless they are required to make the contract honest
+
+Add `POST /question/:requestID/reject` only after the first two endpoints work cleanly.
+
+## Repeatable slice template
+
+Use the same sequence for each route group.
+
+1. Pick one JSON-only route group that already mostly delegates into services.
+2. Identify the shared DTOs, IDs, and errors implicated by that slice.
+3. Apply the schema migration ordering above so those types are Effect Schema-first.
+4. Define the `HttpApi` contract separately from the handlers.
+5. Implement handlers by yielding the existing service from context.
+6. Mount the new surface in parallel under an experimental prefix.
+7. Add one end-to-end test and one OpenAPI-focused test.
+8. Compare ergonomics before migrating the next endpoint.
+
+Rule of thumb:
+
+- migrate one route group at a time
+- migrate one or two endpoints first, not the whole file
+- keep business logic in the existing service
+- keep the first spike easy to delete if the experiment is not worth continuing
+
+## Example structure
+
+Placement rule:
+
+- keep `HttpApi` code under `src/server`, not `src/effect`
+- `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing
+- place each `HttpApi` slice next to the HTTP boundary it serves
+- for instance-scoped routes, prefer `src/server/instance/httpapi/*`
+- if control-plane routes ever migrate, prefer `src/server/control/httpapi/*`
+
+Suggested file layout for a repeatable spike:
+
+- `src/server/instance/httpapi/question.ts`
+- `src/server/instance/httpapi/index.ts`
+- `test/server/question-httpapi.test.ts`
+- `test/server/question-httpapi-openapi.test.ts`
+
+Suggested responsibilities:
+
+- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers for the experimental slice
+- `index.ts` combines experimental `HttpApi` groups and exposes the mounted handler or layer
+- `question-httpapi.test.ts` proves the route works end-to-end against the real service
+- `question-httpapi-openapi.test.ts` proves the generated OpenAPI is acceptable for the migrated endpoints
+
+## Example migration shape
+
+Each route-group spike should follow the same shape.
+
+### 1. Contract
+
+- define an experimental `HttpApi`
+- define one `HttpApiGroup`
+- define endpoint params, payload, success, and error schemas from canonical Effect schemas
+- annotate summary, description, and operation ids explicitly so generated docs are stable
+
+### 2. Handler layer
+
+- implement with `HttpApiBuilder.group(api, groupName, ...)`
+- yield the existing Effect service from context
+- keep handler bodies thin
+- keep transport mapping at the HTTP boundary only
+
+### 3. Mounting
+
+- mount under an experimental prefix such as `/experimental/httpapi`
+- keep existing Hono routes unchanged
+- expose separate OpenAPI output for the experimental slice first
+
+### 4. Verification
+
+- seed real state through the existing service
+- call the experimental endpoints
+- assert that the service behavior is unchanged
+- assert that the generated OpenAPI contains the migrated paths and schemas
+
+## Boundary composition
+
+The first slices should keep the existing outer server composition and only replace the route contract and handler layer.
+
+### Auth
+
+- keep `AuthMiddleware` at the outer Hono app level
+- do not duplicate auth checks inside each `HttpApi` group for the first parallel slices
+- treat auth as an already-satisfied transport concern before the request reaches the `HttpApi` handler
+
+Practical rule:
+
+- if a route is currently protected by the shared server middleware stack, the experimental `HttpApi` route should stay mounted behind that same stack
+
+### Instance and workspace lookup
+
+- keep `WorkspaceRouterMiddleware` as the source of truth for resolving `directory`, `workspace`, and session-derived workspace context
+- let that middleware provide `Instance.current` and `WorkspaceContext` before the request reaches the `HttpApi` handler
+- keep the `HttpApi` handlers unaware of path-to-instance lookup details when the existing Hono middleware already handles them
+
+Practical rule:
+
+- `HttpApi` handlers should yield services from context and assume the correct instance has already been provided
+- only move instance lookup into the `HttpApi` layer if we later decide to migrate the outer middleware boundary itself
+
+### Error mapping
+
+- keep domain and service errors typed in the service layer
+- declare typed transport errors on the endpoint only when the route can actually return them intentionally
+- prefer explicit endpoint-level error schemas over relying on the outer Hono `ErrorMiddleware` for expected route behavior
+
+Practical rule:
+
+- request decoding failures should remain transport-level `400`s
+- storage or lookup failures that are part of the route contract should be declared as typed endpoint errors
+- unexpected defects can still fall through to the outer error middleware while the slice is experimental
+
+For the current parallel slices, this means:
+
+- auth still composes outside `HttpApi`
+- instance selection still composes outside `HttpApi`
+- success payloads should be schema-defined from canonical Effect schemas
+- known route errors should be modeled at the endpoint boundary incrementally instead of all at once
+
+## Exit criteria for the spike
+
+The first slice is successful if:
+
+- the endpoints run in parallel with the current Hono routes
+- the handlers reuse the existing Effect service
+- request decoding and response shapes are schema-defined from canonical Effect schemas
+- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
+- OpenAPI is generated from the `HttpApi` contract
+- the tests are straightforward enough that the next slice feels mechanical
+
+## Learnings from the question slice
+
+The first parallel `question` spike gave us a concrete pattern to reuse.
+
+- `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`.
+- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
+- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects.
+- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
+- the experimental slice should stay mounted in parallel and keep calling the existing service layer unchanged.
+- compare generated OpenAPI semantically at the route and schema level; in the current setup the exported OpenAPI paths do not include the outer Hono mount prefix.
+
+## Route inventory
+
+Status legend:
+
+- `done` - parallel `HttpApi` slice exists
+- `next` - good near-term candidate
+- `later` - possible, but not first wave
+- `defer` - not a good early `HttpApi` target
+
+Current instance route inventory:
-- [ ] add one small spike that defines an `HttpApi` group for a simple JSON route set
-- [ ] use Effect Schema request / response types for that slice
-- [ ] keep the underlying service calls identical to the current handlers
-- [ ] compare generated OpenAPI against the current Hono/OpenAPI setup
-- [ ] document how auth, instance lookup, and error mapping would compose in the new stack
+- `question` - `done`
+ endpoints in slice: `GET /question`, `POST /question/:requestID/reply`
+- `permission` - `done`
+ endpoints in slice: `GET /permission`, `POST /permission/:requestID/reply`
+- `provider` - `next`
+ best next endpoint: `GET /provider/auth`
+ later endpoint: `GET /provider`
+ defer first-wave OAuth mutations
+- `config` - `next`
+ best next endpoint: `GET /config/providers`
+ later endpoint: `GET /config`
+ defer `PATCH /config` for now
+- `project` - `later`
+ best small reads: `GET /project`, `GET /project/current`
+ defer git-init mutation first
+- `workspace` - `later`
+ best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
+ defer create/remove mutations first
+- `file` - `later`
+ good JSON-only candidate set, but larger than the current first-wave slices
+- `mcp` - `later`
+ has JSON-only endpoints, but interactive OAuth/auth flows make it a worse early fit
+- `session` - `defer`
+ large, stateful, mixes CRUD with prompt/shell/command/share/revert flows and a streaming route
+- `event` - `defer`
+ SSE only
+- `global` - `defer`
+ mixed bag with SSE and process-level side effects
+- `pty` - `defer`
+ websocket-heavy route surface
+- `tui` - `defer`
+ queue-style UI bridge, weak early `HttpApi` fit
+
+Recommended near-term sequence after the first spike:
+
+1. `provider` auth read endpoint
+2. `config` providers read endpoint
+3. `project` read endpoints
+4. `workspace` read endpoints
+
+## Checklist
+
+- [x] add one small spike that defines an `HttpApi` group for a simple JSON route set
+- [x] use Effect Schema request / response types for that slice
+- [x] keep the underlying service calls identical to the current handlers
+- [x] compare generated OpenAPI against the current Hono/OpenAPI setup
+- [x] document how auth, instance lookup, and error mapping would compose in the new stack
- [ ] decide after the spike whether `HttpApi` should stay parallel, replace only some groups, or become the long-term default
## Rule of thumb
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index c6bc231fc..c7790006f 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -2195,7 +2195,7 @@ function Question(props: ToolProps<typeof QuestionTool>) {
const { theme } = useTheme()
const count = createMemo(() => props.input.questions?.length ?? 0)
- function format(answer?: string[]) {
+ function format(answer?: ReadonlyArray<string>) {
if (!answer?.length) return "(no answer)"
return answer.join(", ")
}
diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts
index 178bc7943..ba76efa64 100644
--- a/packages/opencode/src/question/index.ts
+++ b/packages/opencode/src/question/index.ts
@@ -3,8 +3,9 @@ import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { SessionID, MessageID } from "@/session/schema"
+import { zod } from "@/util/effect-zod"
import { Log } from "@/util/log"
-import z from "zod"
+import { withStatics } from "@/util/schema"
import { QuestionID } from "./schema"
export namespace Question {
@@ -12,67 +13,91 @@ export namespace Question {
// Schemas
- export const Option = z
- .object({
- label: z.string().describe("Display text (1-5 words, concise)"),
- description: z.string().describe("Explanation of choice"),
- })
- .meta({ ref: "QuestionOption" })
- export type Option = z.infer<typeof Option>
-
- export const Info = z
- .object({
- question: z.string().describe("Complete question"),
- header: z.string().describe("Very short label (max 30 chars)"),
- options: z.array(Option).describe("Available choices"),
- multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
- custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
- })
- .meta({ ref: "QuestionInfo" })
- export type Info = z.infer<typeof Info>
-
- export const Request = z
- .object({
- id: QuestionID.zod,
- sessionID: SessionID.zod,
- questions: z.array(Info).describe("Questions to ask"),
- tool: z
- .object({
- messageID: MessageID.zod,
- callID: z.string(),
- })
- .optional(),
- })
- .meta({ ref: "QuestionRequest" })
- export type Request = z.infer<typeof Request>
+ export class Option extends Schema.Class<Option>("QuestionOption")({
+ label: Schema.String.annotate({
+ description: "Display text (1-5 words, concise)",
+ }),
+ description: Schema.String.annotate({
+ description: "Explanation of choice",
+ }),
+ }) {
+ static readonly zod = zod(this)
+ }
+
+ const base = {
+ question: Schema.String.annotate({
+ description: "Complete question",
+ }),
+ header: Schema.String.annotate({
+ description: "Very short label (max 30 chars)",
+ }),
+ options: Schema.Array(Option).annotate({
+ description: "Available choices",
+ }),
+ multiple: Schema.optional(Schema.Boolean).annotate({
+ description: "Allow selecting multiple choices",
+ }),
+ }
- export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
- export type Answer = z.infer<typeof Answer>
+ export class Info extends Schema.Class<Info>("QuestionInfo")({
+ ...base,
+ custom: Schema.optional(Schema.Boolean).annotate({
+ description: "Allow typing a custom answer (default: true)",
+ }),
+ }) {
+ static readonly zod = zod(this)
+ }
- export const Reply = z.object({
- answers: z
- .array(Answer)
- .describe("User answers in order of questions (each answer is an array of selected labels)"),
- })
- export type Reply = z.infer<typeof Reply>
+ export class Prompt extends Schema.Class<Prompt>("QuestionPrompt")(base) {
+ static readonly zod = zod(this)
+ }
+
+ export class Tool extends Schema.Class<Tool>("QuestionTool")({
+ messageID: MessageID,
+ callID: Schema.String,
+ }) {
+ static readonly zod = zod(this)
+ }
+
+ export class Request extends Schema.Class<Request>("QuestionRequest")({
+ id: QuestionID,
+ sessionID: SessionID,
+ questions: Schema.Array(Info).annotate({
+ description: "Questions to ask",
+ }),
+ tool: Schema.optional(Tool),
+ }) {
+ static readonly zod = zod(this)
+ }
+
+ export const Answer = Schema.Array(Schema.String)
+ .annotate({ identifier: "QuestionAnswer" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+ export type Answer = Schema.Schema.Type<typeof Answer>
+
+ export class Reply extends Schema.Class<Reply>("QuestionReply")({
+ answers: Schema.Array(Answer).annotate({
+ description: "User answers in order of questions (each answer is an array of selected labels)",
+ }),
+ }) {
+ static readonly zod = zod(this)
+ }
+
+ class Replied extends Schema.Class<Replied>("QuestionReplied")({
+ sessionID: SessionID,
+ requestID: QuestionID,
+ answers: Schema.Array(Answer),
+ }) {}
+
+ class Rejected extends Schema.Class<Rejected>("QuestionRejected")({
+ sessionID: SessionID,
+ requestID: QuestionID,
+ }) {}
export const Event = {
- Asked: BusEvent.define("question.asked", Request),
- Replied: BusEvent.define(
- "question.replied",
- z.object({
- sessionID: SessionID.zod,
- requestID: QuestionID.zod,
- answers: z.array(Answer),
- }),
- ),
- Rejected: BusEvent.define(
- "question.rejected",
- z.object({
- sessionID: SessionID.zod,
- requestID: QuestionID.zod,
- }),
- ),
+ Asked: BusEvent.define("question.asked", Request.zod),
+ Replied: BusEvent.define("question.replied", zod(Replied)),
+ Rejected: BusEvent.define("question.rejected", zod(Rejected)),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
@@ -83,7 +108,7 @@ export namespace Question {
interface PendingEntry {
info: Request
- deferred: Deferred.Deferred<Answer[], RejectedError>
+ deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
}
interface State {
@@ -95,12 +120,12 @@ export namespace Question {
export interface Interface {
readonly ask: (input: {
sessionID: SessionID
- questions: Info[]
- tool?: { messageID: MessageID; callID: string }
- }) => Effect.Effect<Answer[], RejectedError>
- readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
+ questions: ReadonlyArray<Info>
+ tool?: Tool
+ }) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
+ readonly reply: (input: { requestID: QuestionID; answers: ReadonlyArray<Answer> }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
- readonly list: () => Effect.Effect<Request[]>
+ readonly list: () => Effect.Effect<ReadonlyArray<Request>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Question") {}
@@ -130,20 +155,20 @@ export namespace Question {
const ask = Effect.fn("Question.ask")(function* (input: {
sessionID: SessionID
- questions: Info[]
- tool?: { messageID: MessageID; callID: string }
+ questions: ReadonlyArray<Info>
+ tool?: Tool
}) {
const pending = (yield* InstanceState.get(state)).pending
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
- const deferred = yield* Deferred.make<Answer[], RejectedError>()
- const info: Request = {
+ const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
+ const info = Schema.decodeUnknownSync(Request)({
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
- }
+ })
pending.set(id, { info, deferred })
yield* bus.publish(Event.Asked, info)
@@ -155,7 +180,10 @@ export namespace Question {
)
})
- const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
+ const reply = Effect.fn("Question.reply")(function* (input: {
+ requestID: QuestionID
+ answers: ReadonlyArray<Answer>
+ }) {
const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(input.requestID)
if (!existing) {
diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts
index e8e46b2e3..6309a21bb 100644
--- a/packages/opencode/src/server/instance/experimental.ts
+++ b/packages/opencode/src/server/instance/experimental.ts
@@ -18,6 +18,7 @@ import { lazy } from "../../util/lazy"
import { Effect, Option } from "effect"
import { WorkspaceRoutes } from "./workspace"
import { Agent } from "@/agent/agent"
+import { HttpApiRoutes } from "./httpapi"
const ConsoleOrgOption = z.object({
accountID: z.string(),
@@ -39,6 +40,7 @@ const ConsoleSwitchBody = z.object({
export const ExperimentalRoutes = lazy(() =>
new Hono()
+ .route("/httpapi", HttpApiRoutes())
.get(
"/console",
describeRoute({
diff --git a/packages/opencode/src/server/instance/httpapi/index.ts b/packages/opencode/src/server/instance/httpapi/index.ts
new file mode 100644
index 000000000..523041de8
--- /dev/null
+++ b/packages/opencode/src/server/instance/httpapi/index.ts
@@ -0,0 +1,7 @@
+import { lazy } from "@/util/lazy"
+import { Hono } from "hono"
+import { QuestionHttpApiHandler } from "./question"
+
+export const HttpApiRoutes = lazy(() =>
+ new Hono().all("/question", QuestionHttpApiHandler).all("/question/*", QuestionHttpApiHandler),
+)
diff --git a/packages/opencode/src/server/instance/httpapi/question.ts b/packages/opencode/src/server/instance/httpapi/question.ts
new file mode 100644
index 000000000..c694d321d
--- /dev/null
+++ b/packages/opencode/src/server/instance/httpapi/question.ts
@@ -0,0 +1,94 @@
+import { AppLayer } from "@/effect/app-runtime"
+import { memoMap } from "@/effect/run-service"
+import { Question } from "@/question"
+import { QuestionID } from "@/question/schema"
+import { lazy } from "@/util/lazy"
+import { Effect, Layer, Schema } from "effect"
+import { HttpRouter, HttpServer } from "effect/unstable/http"
+import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import type { Handler } from "hono"
+
+const root = "/experimental/httpapi/question"
+
+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: Question.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,
+ "question",
+ Effect.fn("QuestionHttpApi.handlers")(function* (handlers) {
+ const svc = yield* Question.Service
+
+ const list = Effect.fn("QuestionHttpApi.list")(function* () {
+ return yield* svc.list()
+ })
+
+ const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
+ params: { requestID: QuestionID }
+ payload: Question.Reply
+ }) {
+ yield* svc.reply({
+ requestID: ctx.params.requestID,
+ answers: ctx.payload.answers,
+ })
+ return true
+ })
+
+ return handlers.handle("list", list).handle("reply", reply)
+ }),
+).pipe(Layer.provide(Question.defaultLayer))
+
+const web = lazy(() =>
+ HttpRouter.toWebHandler(
+ Layer.mergeAll(
+ AppLayer,
+ HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe(
+ Layer.provide(QuestionLive),
+ Layer.provide(HttpServer.layerServices),
+ ),
+ ),
+ {
+ disableLogger: true,
+ memoMap,
+ },
+ ),
+)
+
+export const QuestionHttpApiHandler: Handler = (c, _next) => web().handler(c.req.raw)
diff --git a/packages/opencode/src/server/instance/question.ts b/packages/opencode/src/server/instance/question.ts
index 501ae2181..1375f12e7 100644
--- a/packages/opencode/src/server/instance/question.ts
+++ b/packages/opencode/src/server/instance/question.ts
@@ -21,7 +21,7 @@ export const QuestionRoutes = lazy(() =>
description: "List of pending questions",
content: {
"application/json": {
- schema: resolver(Question.Request.array()),
+ schema: resolver(Question.Request.zod.array()),
},
},
},
@@ -56,7 +56,7 @@ export const QuestionRoutes = lazy(() =>
requestID: QuestionID.zod,
}),
),
- validator("json", Question.Reply),
+ validator("json", Question.Reply.zod),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts
index 8cfa700a5..50e4b1c51 100644
--- a/packages/opencode/src/tool/question.ts
+++ b/packages/opencode/src/tool/question.ts
@@ -5,11 +5,11 @@ import { Question } from "../question"
import DESCRIPTION from "./question.txt"
const parameters = z.object({
- questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"),
+ questions: z.array(Question.Prompt.zod).describe("Questions to ask"),
})
type Metadata = {
- answers: Question.Answer[]
+ answers: ReadonlyArray<Question.Answer>
}
export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Service>(
diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts
index 7c101ce28..d44f41f1a 100644
--- a/packages/opencode/test/question/question.test.ts
+++ b/packages/opencode/test/question/question.test.ts
@@ -6,12 +6,12 @@ import { tmpdir } from "../fixture/fixture"
import { SessionID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
-const ask = (input: { sessionID: SessionID; questions: Question.Info[]; tool?: { messageID: any; callID: string } }) =>
+const ask = (input: { sessionID: SessionID; questions: ReadonlyArray<Question.Info>; tool?: Question.Tool }) =>
AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input)))
const list = () => AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
-const reply = (input: { requestID: QuestionID; answers: Question.Answer[] }) =>
+const reply = (input: { requestID: QuestionID; answers: ReadonlyArray<Question.Answer> }) =>
AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input)))
const reject = (id: QuestionID) => AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id)))
diff --git a/packages/opencode/test/server/question-httpapi.test.ts b/packages/opencode/test/server/question-httpapi.test.ts
new file mode 100644
index 000000000..00cc32f59
--- /dev/null
+++ b/packages/opencode/test/server/question-httpapi.test.ts
@@ -0,0 +1,78 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { AppRuntime } from "../../src/effect/app-runtime"
+import { Instance } from "../../src/project/instance"
+import { Question } from "../../src/question"
+import { Server } from "../../src/server/server"
+import { SessionID } from "../../src/session/schema"
+import { Log } from "../../src/util/log"
+import { tmpdir } from "../fixture/fixture"
+
+Log.init({ print: false })
+
+const ask = (input: { sessionID: SessionID; questions: ReadonlyArray<Question.Info> }) =>
+ AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input)))
+
+afterEach(async () => {
+ await Instance.disposeAll()
+})
+
+describe("experimental question httpapi", () => {
+ test("lists pending questions, replies, and serves docs", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const app = Server.Default().app
+ const headers = {
+ "content-type": "application/json",
+ "x-opencode-directory": tmp.path,
+ }
+ const questions: ReadonlyArray<Question.Info> = [
+ {
+ question: "What would you like to do?",
+ header: "Action",
+ options: [
+ { label: "Option 1", description: "First option" },
+ { label: "Option 2", description: "Second option" },
+ ],
+ },
+ ]
+
+ let pending!: ReturnType<typeof ask>
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ pending = ask({
+ sessionID: SessionID.make("ses_test"),
+ questions,
+ })
+ },
+ })
+
+ const list = await app.request("/experimental/httpapi/question", {
+ headers,
+ })
+
+ expect(list.status).toBe(200)
+ const items = await list.json()
+ expect(items).toHaveLength(1)
+ expect(items[0]).toMatchObject({ questions })
+
+ const doc = await app.request("/experimental/httpapi/question/doc", {
+ headers,
+ })
+
+ expect(doc.status).toBe(200)
+ const spec = await doc.json()
+ expect(spec.paths["/experimental/httpapi/question"]?.get?.operationId).toBe("question.list")
+ expect(spec.paths["/experimental/httpapi/question/{requestID}/reply"]?.post?.operationId).toBe("question.reply")
+
+ const reply = await app.request(`/experimental/httpapi/question/${items[0].id}/reply`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ answers: [["Option 1"]] }),
+ })
+
+ expect(reply.status).toBe(200)
+ expect(await reply.json()).toBe(true)
+ expect(await pending).toEqual([["Option 1"]])
+ })
+})