Compare commits

...

9 Commits

Author SHA1 Message Date
Kit Langton
5a2737d233 refactor(question): build httpapi handlers once
Resolve the question service once when constructing the HttpApi group layer and close over the resulting handlers, instead of doing a tag lookup inside each handler invocation.
2026-04-14 13:52:42 -04:00
Kit Langton
dc62600abd docs(effect): track HttpApi route inventory (#22388) 2026-04-14 12:54:35 -04:00
Kit Langton
6f007368c2 docs(effect): describe HttpApi boundary composition (#22384) 2026-04-14 12:37:37 -04:00
Kit Langton
5f30b9c1f8 docs(effect): record HttpApi spike learnings
Capture the reusable pattern from the question HttpApi spike and turn the follow-up work into a checklist so the next route-group migrations can follow the same shape.
2026-04-13 22:21:51 -04:00
Kit Langton
2f60f9d45b refactor(question): use withStatics for answer schema
Replace the ad hoc Object.assign wrapper with the shared schema helper so the Answer schema follows the same static attachment pattern as the rest of the codebase.
2026-04-13 22:00:41 -04:00
Kit Langton
dd34bd8203 fix question request schema encoding
Decode pending question requests through the Schema.Class constructor path so the HttpApi list endpoint returns valid Question.Request instances while keeping QuestionID as a newtype.
2026-04-13 21:54:45 -04:00
Kit Langton
0f86a4ebfe use Schema.Class for question payloads
Align the experimental question API schemas with the repo's Effect modeling style and keep the TUI answer rendering compatible with readonly arrays.
2026-04-13 21:20:07 -04:00
Kit Langton
ff95ce7e62 Merge branch 'dev' into kit/question-httpapi-spike 2026-04-13 19:56:49 -04:00
Kit Langton
7536d26f36 add experimental question HttpApi slice 2026-04-13 19:23:58 -04:00
10 changed files with 545 additions and 81 deletions

View File

@@ -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
- [ ] 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
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:
- `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

View File

@@ -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(", ")
}

View File

@@ -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 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)
}
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>
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 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 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 Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
export type Answer = z.infer<typeof Answer>
export class Prompt extends Schema.Class<Prompt>("QuestionPrompt")(base) {
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 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) {

View File

@@ -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({

View File

@@ -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),
)

View File

@@ -0,0 +1,92 @@
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 = Layer.unwrap(
Effect.gen(function* () {
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 HttpApiBuilder.group(Api, "question", (handlers) => 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)

View File

@@ -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")

View File

@@ -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>(

View File

@@ -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)))

View File

@@ -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"]])
})
})