question.test.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. import { describe, expect } from "bun:test"
  2. import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
  3. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  4. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  5. import { EventV2 } from "@opencode-ai/core/event"
  6. import { QuestionV2 } from "@opencode-ai/core/question"
  7. import { SessionV2 } from "@opencode-ai/core/session"
  8. import { testEffect } from "./lib/effect"
  9. const questions = AppNodeBuilder.build(LayerNode.group([EventV2.node, QuestionV2.node]))
  10. const it = testEffect(questions)
  11. const sessionID = SessionV2.ID.make("ses_question_test")
  12. const question: QuestionV2.Info = {
  13. question: "Which option?",
  14. header: "Option",
  15. options: [{ label: "One", description: "First option" }],
  16. }
  17. const waitForAsk = Effect.fn("QuestionV2Test.waitForAsk")(function* (
  18. service: QuestionV2.Interface,
  19. input: QuestionV2.AskInput,
  20. ) {
  21. const events = yield* EventV2.Service
  22. const asked = yield* Deferred.make<QuestionV2.Request>()
  23. const unsubscribe = yield* events.listen((event) =>
  24. event.type === QuestionV2.Event.Asked.type
  25. ? Deferred.succeed(asked, event.data as QuestionV2.Request).pipe(Effect.asVoid)
  26. : Effect.void,
  27. )
  28. yield* Effect.addFinalizer(() => unsubscribe)
  29. const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
  30. return { fiber, request: yield* Deferred.await(asked) }
  31. })
  32. describe("QuestionV2", () => {
  33. it.effect("publishes lifecycle events and settles a pending reply", () =>
  34. Effect.gen(function* () {
  35. const service = yield* QuestionV2.Service
  36. const events = yield* EventV2.Service
  37. const published: EventV2.Payload[] = []
  38. const unsubscribe = yield* events.listen((event) =>
  39. Effect.sync(() => {
  40. if (event.type.startsWith("question.v2.")) published.push(event)
  41. }),
  42. )
  43. yield* Effect.addFinalizer(() => unsubscribe)
  44. const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
  45. expect(request.id).toMatch(/^que_/)
  46. expect(yield* service.list()).toEqual([request])
  47. yield* service.reply({ requestID: request.id, answers: [["One"]] })
  48. expect(yield* Fiber.join(fiber)).toEqual([["One"]])
  49. expect(yield* service.list()).toEqual([])
  50. expect(published.map((event) => [event.type, event.data])).toEqual([
  51. [QuestionV2.Event.Asked.type, request],
  52. [QuestionV2.Event.Replied.type, { sessionID, requestID: request.id, answers: [["One"]] }],
  53. ])
  54. }),
  55. )
  56. it.effect("publishes rejection, fails the ask, and rejects unknown IDs", () =>
  57. Effect.gen(function* () {
  58. const service = yield* QuestionV2.Service
  59. const events = yield* EventV2.Service
  60. const published: EventV2.Payload[] = []
  61. const unsubscribe = yield* events.listen((event) =>
  62. Effect.sync(() => {
  63. if (event.type === QuestionV2.Event.Rejected.type) published.push(event)
  64. }),
  65. )
  66. yield* Effect.addFinalizer(() => unsubscribe)
  67. const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
  68. yield* service.reject(request.id)
  69. const exit = yield* Fiber.await(fiber)
  70. expect(Exit.isFailure(exit)).toBe(true)
  71. if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError")
  72. expect(published.map((event) => event.data)).toEqual([{ sessionID, requestID: request.id }])
  73. const unknown = QuestionV2.ID.ascending("que_unknown")
  74. expect(yield* service.reply({ requestID: unknown, answers: [] }).pipe(Effect.flip)).toEqual(
  75. new QuestionV2.NotFoundError({ requestID: unknown }),
  76. )
  77. expect(yield* service.reject(unknown).pipe(Effect.flip)).toEqual(
  78. new QuestionV2.NotFoundError({ requestID: unknown }),
  79. )
  80. }),
  81. )
  82. it.effect("isolates pending requests by location-layer instance and rejects them on finalization", () =>
  83. Effect.gen(function* () {
  84. const firstScope = yield* Scope.make()
  85. const secondScope = yield* Scope.make()
  86. const first = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), firstScope), QuestionV2.Service)
  87. const second = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), secondScope), QuestionV2.Service)
  88. const fiber = yield* first.ask({ sessionID, questions: [question] }).pipe(Effect.forkScoped)
  89. yield* Effect.yieldNow
  90. const request = (yield* first.list())[0]!
  91. expect(yield* second.list()).toEqual([])
  92. expect(yield* second.reply({ requestID: request.id, answers: [["One"]] }).pipe(Effect.flip)).toEqual(
  93. new QuestionV2.NotFoundError({ requestID: request.id }),
  94. )
  95. yield* Scope.close(firstScope, Exit.void)
  96. const exit = yield* Fiber.await(fiber)
  97. expect(Exit.isFailure(exit)).toBe(true)
  98. if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError")
  99. yield* Scope.close(secondScope, Exit.void)
  100. }),
  101. )
  102. })