question.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. export * as QuestionV2 from "./question"
  2. import { Context, Deferred, Effect, Layer, Schema } from "effect"
  3. import { EventV2 } from "./event"
  4. import { Identifier } from "./id/id"
  5. import { withStatics } from "./schema"
  6. import { SessionSchema } from "./session/schema"
  7. export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe(
  8. Schema.brand("QuestionV2.ID"),
  9. withStatics((schema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("question", id)) })),
  10. )
  11. export type ID = typeof ID.Type
  12. export const Option = Schema.Struct({
  13. label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }),
  14. description: Schema.String.annotate({ description: "Explanation of choice" }),
  15. }).annotate({ identifier: "QuestionV2.Option" })
  16. export type Option = typeof Option.Type
  17. const base = {
  18. question: Schema.String.annotate({ description: "Complete question" }),
  19. header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }),
  20. options: Schema.Array(Option).annotate({ description: "Available choices" }),
  21. multiple: Schema.Boolean.pipe(Schema.optional).annotate({ description: "Allow selecting multiple choices" }),
  22. }
  23. export const Info = Schema.Struct({
  24. ...base,
  25. custom: Schema.Boolean.pipe(Schema.optional).annotate({
  26. description: "Allow typing a custom answer (default: true)",
  27. }),
  28. }).annotate({ identifier: "QuestionV2.Info" })
  29. export type Info = typeof Info.Type
  30. export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" })
  31. export type Prompt = typeof Prompt.Type
  32. export const Tool = Schema.Struct({
  33. messageID: Schema.String,
  34. callID: Schema.String,
  35. }).annotate({ identifier: "QuestionV2.Tool" })
  36. export type Tool = typeof Tool.Type
  37. export const Request = Schema.Struct({
  38. id: ID,
  39. sessionID: SessionSchema.ID,
  40. questions: Schema.Array(Info).annotate({ description: "Questions to ask" }),
  41. tool: Tool.pipe(Schema.optional),
  42. }).annotate({ identifier: "QuestionV2.Request" })
  43. export type Request = typeof Request.Type
  44. export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" })
  45. export type Answer = typeof Answer.Type
  46. export const Reply = Schema.Struct({
  47. answers: Schema.Array(Answer).annotate({
  48. description: "User answers in order of questions (each answer is an array of selected labels)",
  49. }),
  50. }).annotate({ identifier: "QuestionV2.Reply" })
  51. export type Reply = typeof Reply.Type
  52. export const Event = {
  53. Asked: EventV2.define({ type: "question.v2.asked", schema: Request.fields }),
  54. Replied: EventV2.define({
  55. type: "question.v2.replied",
  56. schema: {
  57. sessionID: SessionSchema.ID,
  58. requestID: ID,
  59. answers: Schema.Array(Answer),
  60. },
  61. }),
  62. Rejected: EventV2.define({
  63. type: "question.v2.rejected",
  64. schema: {
  65. sessionID: SessionSchema.ID,
  66. requestID: ID,
  67. },
  68. }),
  69. }
  70. export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionV2.RejectedError", {}) {
  71. override get message() {
  72. return "The user dismissed this question"
  73. }
  74. }
  75. export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("QuestionV2.NotFoundError", {
  76. requestID: ID,
  77. }) {}
  78. export interface AskInput {
  79. readonly sessionID: SessionSchema.ID
  80. readonly questions: ReadonlyArray<Info>
  81. readonly tool?: Tool
  82. }
  83. export interface ReplyInput {
  84. readonly requestID: ID
  85. readonly answers: ReadonlyArray<Answer>
  86. }
  87. export interface Interface {
  88. readonly ask: (input: AskInput) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
  89. readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
  90. readonly reject: (requestID: ID) => Effect.Effect<void, NotFoundError>
  91. readonly list: () => Effect.Effect<ReadonlyArray<Request>>
  92. }
  93. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Question") {}
  94. interface Pending {
  95. readonly request: Request
  96. readonly deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
  97. }
  98. /**
  99. * Location-owned pending prompts. The Location layer map must materialize this
  100. * layer once per embedded Location so replies cannot settle another Location's
  101. * deferred request.
  102. */
  103. export const layer = Layer.effect(
  104. Service,
  105. Effect.gen(function* () {
  106. const events = yield* EventV2.Service
  107. const pending = new Map<ID, Pending>()
  108. yield* Effect.addFinalizer(() =>
  109. Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
  110. discard: true,
  111. }).pipe(
  112. Effect.ensuring(
  113. Effect.sync(() => {
  114. pending.clear()
  115. }),
  116. ),
  117. ),
  118. )
  119. const ask = Effect.fn("QuestionV2.ask")((input: AskInput) =>
  120. Effect.uninterruptibleMask((restore) =>
  121. Effect.gen(function* () {
  122. const id = ID.ascending()
  123. const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
  124. const request: Request = { id, ...input }
  125. pending.set(id, { request, deferred })
  126. return yield* events.publish(Event.Asked, request).pipe(
  127. Effect.andThen(restore(Deferred.await(deferred))),
  128. Effect.ensuring(
  129. Effect.sync(() => {
  130. pending.delete(id)
  131. }),
  132. ),
  133. )
  134. }),
  135. ),
  136. )
  137. const reply = Effect.fn("QuestionV2.reply")((input: ReplyInput) =>
  138. Effect.uninterruptible(
  139. Effect.gen(function* () {
  140. const existing = pending.get(input.requestID)
  141. if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
  142. yield* events.publish(Event.Replied, {
  143. sessionID: existing.request.sessionID,
  144. requestID: existing.request.id,
  145. answers: input.answers.map((answer) => [...answer]),
  146. })
  147. yield* Deferred.succeed(existing.deferred, input.answers)
  148. pending.delete(input.requestID)
  149. }),
  150. ),
  151. )
  152. const reject = Effect.fn("QuestionV2.reject")((requestID: ID) =>
  153. Effect.uninterruptible(
  154. Effect.gen(function* () {
  155. const existing = pending.get(requestID)
  156. if (!existing) return yield* new NotFoundError({ requestID })
  157. yield* events.publish(Event.Rejected, {
  158. sessionID: existing.request.sessionID,
  159. requestID: existing.request.id,
  160. })
  161. yield* Deferred.fail(existing.deferred, new RejectedError())
  162. pending.delete(requestID)
  163. }),
  164. ),
  165. )
  166. const list = Effect.fn("QuestionV2.list")(function* () {
  167. return Array.from(pending.values(), (item) => item.request)
  168. })
  169. return Service.of({ ask, reply, reject, list })
  170. }),
  171. )
  172. export const locationLayer = layer