import { Deferred, Effect, Layer, Schema, Context } from "effect" import { InstanceState } from "@/effect/instance-state" import { SessionID, MessageID } from "@/session/schema" import * as Log from "@opencode-ai/core/util/log" import { QuestionID } from "./schema" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" const log = Log.create({ service: "question" }) // Schemas — these are pure data; nothing checks class identity (see PR // description) so they're plain `Schema.Struct` + type alias. That lets // `Question.ask` and other internal sites trust the type contract without a // re-decode to coerce nested class instances. export const Option = Schema.Struct({ label: Schema.String.annotate({ description: "Display text (1-5 words, concise)", }), description: Schema.String.annotate({ description: "Explanation of choice", }), }).annotate({ identifier: "QuestionOption" }) export type Option = Schema.Schema.Type 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 Info = Schema.Struct({ ...base, custom: Schema.optional(Schema.Boolean).annotate({ description: "Allow typing a custom answer (default: true)", }), }).annotate({ identifier: "QuestionInfo" }) export type Info = Schema.Schema.Type export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionPrompt" }) export type Prompt = Schema.Schema.Type export const Tool = Schema.Struct({ messageID: MessageID, callID: Schema.String, }).annotate({ identifier: "QuestionTool" }) export type Tool = Schema.Schema.Type export const Request = Schema.Struct({ id: QuestionID, sessionID: SessionID, questions: Schema.Array(Info).annotate({ description: "Questions to ask", }), tool: Schema.optional(Tool), }).annotate({ identifier: "QuestionRequest" }) export type Request = Schema.Schema.Type export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" }) export type Answer = Schema.Schema.Type export const Reply = Schema.Struct({ answers: Schema.Array(Answer).annotate({ description: "User answers in order of questions (each answer is an array of selected labels)", }), }).annotate({ identifier: "QuestionReply" }) export type Reply = Schema.Schema.Type export const Replied = Schema.Struct({ sessionID: SessionID, requestID: QuestionID, answers: Schema.Array(Answer), }).annotate({ identifier: "QuestionReplied" }) export const Rejected = Schema.Struct({ sessionID: SessionID, requestID: QuestionID, }).annotate({ identifier: "QuestionRejected" }) export const Event = { Asked: EventV2.define({ type: "question.asked", schema: Request.fields }), Replied: EventV2.define({ type: "question.replied", schema: Replied.fields }), Rejected: EventV2.define({ type: "question.rejected", schema: Rejected.fields }), } export class RejectedError extends Schema.TaggedErrorClass()("QuestionRejectedError", {}) { override get message() { return "The user dismissed this question" } } export class NotFoundError extends Schema.TaggedErrorClass()("Question.NotFoundError", { requestID: QuestionID, }) {} interface PendingEntry { info: Request deferred: Deferred.Deferred, RejectedError> } interface State { pending: Map } // Service export interface Interface { readonly ask: (input: { sessionID: SessionID questions: ReadonlyArray tool?: Tool }) => Effect.Effect, RejectedError> readonly reply: (input: { requestID: QuestionID answers: ReadonlyArray }) => Effect.Effect readonly reject: (requestID: QuestionID) => Effect.Effect readonly list: () => Effect.Effect> } export class Service extends Context.Service()("@opencode/Question") {} export const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service const state = yield* InstanceState.make( Effect.fn("Question.state")(function* () { const state = { pending: new Map(), } yield* Effect.addFinalizer(() => Effect.gen(function* () { for (const item of state.pending.values()) { yield* Deferred.fail(item.deferred, new RejectedError()) } state.pending.clear() }), ) return state }), ) const ask = Effect.fn("Question.ask")(function* (input: { sessionID: SessionID questions: ReadonlyArray 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, RejectedError>() const info: Request = { id, sessionID: input.sessionID, questions: input.questions, tool: input.tool, } pending.set(id, { info, deferred }) yield* events.publish(Event.Asked, info) return yield* Effect.ensuring( Deferred.await(deferred), Effect.sync(() => { pending.delete(id) }), ) }) const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID answers: ReadonlyArray }) { const pending = (yield* InstanceState.get(state)).pending const existing = pending.get(input.requestID) if (!existing) { log.warn("reply for unknown request", { requestID: input.requestID }) return yield* new NotFoundError({ requestID: input.requestID }) } pending.delete(input.requestID) log.info("replied", { requestID: input.requestID, answers: input.answers }) yield* events.publish(Event.Replied, { sessionID: existing.info.sessionID, requestID: existing.info.id, answers: input.answers.map((a) => [...a]), }) yield* Deferred.succeed(existing.deferred, input.answers) }) const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) { const pending = (yield* InstanceState.get(state)).pending const existing = pending.get(requestID) if (!existing) { log.warn("reject for unknown request", { requestID }) return yield* new NotFoundError({ requestID }) } pending.delete(requestID) log.info("rejected", { requestID }) yield* events.publish(Event.Rejected, { sessionID: existing.info.sessionID, requestID: existing.info.id, }) yield* Deferred.fail(existing.deferred, new RejectedError()) }) const list = Effect.fn("Question.list")(function* () { const pending = (yield* InstanceState.get(state)).pending return Array.from(pending.values(), (x) => x.info) }) return Service.of({ ask, reply, reject, list }) }), ) export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) export * as Question from "."