mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-14 18:04:48 +00:00
Compare commits
9 Commits
dev
...
kit/questi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a2737d233 | ||
|
|
dc62600abd | ||
|
|
6f007368c2 | ||
|
|
5f30b9c1f8 | ||
|
|
2f60f9d45b | ||
|
|
dd34bd8203 | ||
|
|
0f86a4ebfe | ||
|
|
ff95ce7e62 | ||
|
|
7536d26f36 |
@@ -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
|
||||
|
||||
@@ -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(", ")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
7
packages/opencode/src/server/instance/httpapi/index.ts
Normal file
7
packages/opencode/src/server/instance/httpapi/index.ts
Normal 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),
|
||||
)
|
||||
92
packages/opencode/src/server/instance/httpapi/question.ts
Normal file
92
packages/opencode/src/server/instance/httpapi/question.ts
Normal 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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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)))
|
||||
|
||||
78
packages/opencode/test/server/question-httpapi.test.ts
Normal file
78
packages/opencode/test/server/question-httpapi.test.ts
Normal 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"]])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user