import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Layer, Schema, Context } from "effect" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "@/session/schema" import { QuestionID } from "./schema" import { EventV2Bridge } from "@/event-v2-bridge" import { QuestionV1 } from "@opencode-ai/schema/question-v1" export const Option = QuestionV1.Option export type Option = typeof Option.Type export const Info = QuestionV1.Info export type Info = typeof Info.Type export const Prompt = QuestionV1.Prompt export type Prompt = typeof Prompt.Type export const Tool = QuestionV1.Tool export type Tool = typeof Tool.Type export const Request = QuestionV1.Request export type Request = typeof Request.Type export const Answer = QuestionV1.Answer export type Answer = typeof Answer.Type export const Reply = QuestionV1.Reply export type Reply = typeof Reply.Type export const Replied = QuestionV1.Replied export const Rejected = QuestionV1.Rejected export const Event = QuestionV1.Event 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") {} 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() yield* Effect.logInfo("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) { yield* Effect.logWarning("reply for unknown request", { requestID: input.requestID }) return yield* new NotFoundError({ requestID: input.requestID }) } pending.delete(input.requestID) yield* Effect.logInfo("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) { yield* Effect.logWarning("reject for unknown request", { requestID }) return yield* new NotFoundError({ requestID }) } pending.delete(requestID) yield* Effect.logInfo("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 node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) export * as Question from "."