diff options
| author | Kit Langton <[email protected]> | 2026-04-14 15:43:49 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-14 19:43:49 +0000 |
| commit | f2525a63c92dd46beeefc8741afcb98153abc776 (patch) | |
| tree | d0cd8c4357404e4fd8ba18d43db22d43172bd5d9 /packages | |
| parent | 8c42d391f5a765212e5b221d8382b3c01835cbe4 (diff) | |
| download | opencode-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.md | 269 | ||||
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 2 | ||||
| -rw-r--r-- | packages/opencode/src/question/index.ts | 168 | ||||
| -rw-r--r-- | packages/opencode/src/server/instance/experimental.ts | 2 | ||||
| -rw-r--r-- | packages/opencode/src/server/instance/httpapi/index.ts | 7 | ||||
| -rw-r--r-- | packages/opencode/src/server/instance/httpapi/question.ts | 94 | ||||
| -rw-r--r-- | packages/opencode/src/server/instance/question.ts | 4 | ||||
| -rw-r--r-- | packages/opencode/src/tool/question.ts | 4 | ||||
| -rw-r--r-- | packages/opencode/test/question/question.test.ts | 4 | ||||
| -rw-r--r-- | packages/opencode/test/server/question-httpapi.test.ts | 78 |
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"]]) + }) +}) |
