| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459 |
- import { afterEach, expect } from "bun:test"
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { Cause, Effect, Exit, Fiber, Layer, Queue } from "effect"
- import { Question } from "../../src/question"
- import { InstanceRef } from "../../src/effect/instance-ref"
- import { InstanceStore } from "../../src/project/instance-store"
- import { QuestionID } from "../../src/question/schema"
- import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
- import { SessionID } from "../../src/session/schema"
- import { testEffect } from "../lib/effect"
- import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
- import { EventV2Bridge } from "../../src/event-v2-bridge"
- const questionLayer = LayerNode.compile(LayerNode.group([Question.node, EventV2Bridge.node, CrossSpawnSpawner.node]))
- const it = testEffect(questionLayer)
- const lifecycle = testEffect(Layer.mergeAll(questionLayer, testInstanceStoreLayer))
- const askEffect = Effect.fn("QuestionTest.ask")(function* (input: {
- sessionID: SessionID
- questions: ReadonlyArray<Question.Info>
- tool?: Question.Tool
- }) {
- const question = yield* Question.Service
- return yield* question.ask(input)
- })
- const listEffect = Question.Service.use((svc) => svc.list())
- const replyEffect = Effect.fn("QuestionTest.reply")(function* (input: {
- requestID: QuestionID
- answers: ReadonlyArray<Question.Answer>
- }) {
- const question = yield* Question.Service
- yield* question.reply(input)
- })
- const rejectEffect = Effect.fn("QuestionTest.reject")(function* (id: QuestionID) {
- const question = yield* Question.Service
- yield* question.reject(id)
- })
- afterEach(async () => {
- await disposeAllInstances()
- })
- /** Reject all pending questions so dangling Deferred fibers don't hang the test. */
- const rejectAll = Effect.gen(function* () {
- yield* Effect.forEach(yield* listEffect, (req) => rejectEffect(req.id), { discard: true })
- })
- const waitForPending = Effect.fn("QuestionTest.waitForPending")(function* (count: number) {
- const question = yield* Question.Service
- const events = yield* EventV2Bridge.Service
- const asked = yield* Queue.unbounded<void>()
- const off = yield* events.listen((event) => {
- if (event.type === Question.Event.Asked.type) Queue.offerUnsafe(asked, undefined)
- return Effect.void
- })
- yield* Effect.addFinalizer(() => off)
- for (;;) {
- const pending = yield* question.list()
- if (pending.length === count) return pending
- yield* Queue.take(asked).pipe(Effect.timeout("2 seconds"))
- }
- })
- it.instance(
- "ask - remains pending until answered",
- () =>
- Effect.gen(function* () {
- const fiber = yield* askEffect({
- sessionID: SessionID.make("ses_test"),
- questions: [
- {
- question: "What would you like to do?",
- header: "Action",
- options: [
- { label: "Option 1", description: "First option" },
- { label: "Option 2", description: "Second option" },
- ],
- },
- ],
- }).pipe(Effect.forkScoped)
- expect(yield* waitForPending(1)).toHaveLength(1)
- yield* rejectAll
- expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
- }),
- { git: true },
- )
- it.instance(
- "ask - adds to pending list",
- () =>
- Effect.gen(function* () {
- const questions = [
- {
- question: "What would you like to do?",
- header: "Action",
- options: [
- { label: "Option 1", description: "First option" },
- { label: "Option 2", description: "Second option" },
- ],
- },
- ]
- const fiber = yield* askEffect({
- sessionID: SessionID.make("ses_test"),
- questions,
- }).pipe(Effect.forkScoped)
- const pending = yield* waitForPending(1)
- expect(pending.length).toBe(1)
- expect(pending[0].questions).toEqual(questions)
- yield* rejectAll
- expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
- }),
- { git: true },
- )
- // reply tests
- it.instance(
- "reply - resolves the pending ask with answers",
- () =>
- Effect.gen(function* () {
- const questions = [
- {
- question: "What would you like to do?",
- header: "Action",
- options: [
- { label: "Option 1", description: "First option" },
- { label: "Option 2", description: "Second option" },
- ],
- },
- ]
- const fiber = yield* askEffect({
- sessionID: SessionID.make("ses_test"),
- questions,
- }).pipe(Effect.forkScoped)
- const pending = yield* waitForPending(1)
- const requestID = pending[0].id
- yield* replyEffect({
- requestID,
- answers: [["Option 1"]],
- })
- expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]])
- }),
- { git: true },
- )
- it.instance(
- "reply - removes from pending list",
- () =>
- Effect.gen(function* () {
- const fiber = yield* askEffect({
- sessionID: SessionID.make("ses_test"),
- questions: [
- {
- question: "What would you like to do?",
- header: "Action",
- options: [
- { label: "Option 1", description: "First option" },
- { label: "Option 2", description: "Second option" },
- ],
- },
- ],
- }).pipe(Effect.forkScoped)
- const pending = yield* waitForPending(1)
- expect(pending.length).toBe(1)
- yield* replyEffect({
- requestID: pending[0].id,
- answers: [["Option 1"]],
- })
- yield* Fiber.join(fiber)
- const after = yield* listEffect
- expect(after.length).toBe(0)
- }),
- { git: true },
- )
- it.instance(
- "reply - fails for unknown requestID",
- () =>
- Effect.gen(function* () {
- const exit = yield* replyEffect({
- requestID: QuestionID.make("que_unknown"),
- answers: [["Option 1"]],
- }).pipe(Effect.exit)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "Question.NotFoundError", requestID: "que_unknown" })
- }
- }),
- { git: true },
- )
- // reject tests
- it.instance(
- "reject - throws RejectedError",
- () =>
- Effect.gen(function* () {
- const fiber = yield* askEffect({
- sessionID: SessionID.make("ses_test"),
- questions: [
- {
- question: "What would you like to do?",
- header: "Action",
- options: [
- { label: "Option 1", description: "First option" },
- { label: "Option 2", description: "Second option" },
- ],
- },
- ],
- }).pipe(Effect.forkScoped)
- const pending = yield* waitForPending(1)
- yield* rejectEffect(pending[0].id)
- const exit = yield* Fiber.await(fiber)
- expect(exit._tag).toBe("Failure")
- if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError")
- }),
- { git: true },
- )
- it.instance(
- "reject - removes from pending list",
- () =>
- Effect.gen(function* () {
- const fiber = yield* askEffect({
- sessionID: SessionID.make("ses_test"),
- questions: [
- {
- question: "What would you like to do?",
- header: "Action",
- options: [
- { label: "Option 1", description: "First option" },
- { label: "Option 2", description: "Second option" },
- ],
- },
- ],
- }).pipe(Effect.forkScoped)
- const pending = yield* waitForPending(1)
- expect(pending.length).toBe(1)
- yield* rejectEffect(pending[0].id)
- expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
- const after = yield* listEffect
- expect(after.length).toBe(0)
- }),
- { git: true },
- )
- it.instance(
- "reject - fails for unknown requestID",
- () =>
- Effect.gen(function* () {
- const exit = yield* rejectEffect(QuestionID.make("que_unknown")).pipe(Effect.exit)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "Question.NotFoundError", requestID: "que_unknown" })
- }
- }),
- { git: true },
- )
- // multiple questions tests
- it.instance(
- "ask - handles multiple questions",
- () =>
- Effect.gen(function* () {
- const questions = [
- {
- question: "What would you like to do?",
- header: "Action",
- options: [
- { label: "Build", description: "Build the project" },
- { label: "Test", description: "Run tests" },
- ],
- },
- {
- question: "Which environment?",
- header: "Env",
- options: [
- { label: "Dev", description: "Development" },
- { label: "Prod", description: "Production" },
- ],
- },
- ]
- const fiber = yield* askEffect({
- sessionID: SessionID.make("ses_test"),
- questions,
- }).pipe(Effect.forkScoped)
- const pending = yield* waitForPending(1)
- yield* replyEffect({
- requestID: pending[0].id,
- answers: [["Build"], ["Dev"]],
- })
- expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]])
- }),
- { git: true },
- )
- // list tests
- it.instance(
- "list - returns all pending requests",
- () =>
- Effect.gen(function* () {
- const fiber1 = yield* askEffect({
- sessionID: SessionID.make("ses_test1"),
- questions: [
- {
- question: "Question 1?",
- header: "Q1",
- options: [{ label: "A", description: "A" }],
- },
- ],
- }).pipe(Effect.forkScoped)
- const fiber2 = yield* askEffect({
- sessionID: SessionID.make("ses_test2"),
- questions: [
- {
- question: "Question 2?",
- header: "Q2",
- options: [{ label: "B", description: "B" }],
- },
- ],
- }).pipe(Effect.forkScoped)
- const pending = yield* waitForPending(2)
- expect(pending.length).toBe(2)
- yield* rejectAll
- expect((yield* Fiber.await(fiber1))._tag).toBe("Failure")
- expect((yield* Fiber.await(fiber2))._tag).toBe("Failure")
- }),
- { git: true },
- )
- it.instance(
- "list - returns empty when no pending",
- () =>
- Effect.gen(function* () {
- const pending = yield* listEffect
- expect(pending.length).toBe(0)
- }),
- { git: true },
- )
- lifecycle.live("questions stay isolated by directory", () =>
- Effect.gen(function* () {
- const one = yield* tmpdirScoped({ git: true })
- const two = yield* tmpdirScoped({ git: true })
- const fiber1 = yield* askEffect({
- sessionID: SessionID.make("ses_one"),
- questions: [
- {
- question: "Question 1?",
- header: "Q1",
- options: [{ label: "A", description: "A" }],
- },
- ],
- }).pipe(provideInstance(one), Effect.forkScoped)
- const fiber2 = yield* askEffect({
- sessionID: SessionID.make("ses_two"),
- questions: [
- {
- question: "Question 2?",
- header: "Q2",
- options: [{ label: "B", description: "B" }],
- },
- ],
- }).pipe(provideInstance(two), Effect.forkScoped)
- const onePending = yield* waitForPending(1).pipe(provideInstance(one))
- const twoPending = yield* waitForPending(1).pipe(provideInstance(two))
- expect(onePending.length).toBe(1)
- expect(twoPending.length).toBe(1)
- expect(onePending[0].sessionID).toBe(SessionID.make("ses_one"))
- expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two"))
- yield* rejectEffect(onePending[0].id).pipe(provideInstance(one))
- yield* rejectEffect(twoPending[0].id).pipe(provideInstance(two))
- expect((yield* Fiber.await(fiber1))._tag).toBe("Failure")
- expect((yield* Fiber.await(fiber2))._tag).toBe("Failure")
- }),
- )
- lifecycle.live("pending question rejects on instance dispose", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped({ git: true })
- const fiber = yield* askEffect({
- sessionID: SessionID.make("ses_dispose"),
- questions: [
- {
- question: "Dispose me?",
- header: "Dispose",
- options: [{ label: "Yes", description: "Yes" }],
- },
- ],
- }).pipe(provideInstance(dir), Effect.forkScoped)
- expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1)
- const ctx = yield* Effect.gen(function* () {
- return yield* InstanceRef
- }).pipe(provideInstance(dir))
- if (!ctx) return yield* Effect.die(new Error("missing test instance"))
- yield* InstanceStore.Service.use((store) => store.dispose(ctx))
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError)
- }),
- )
- lifecycle.live("pending question rejects on instance reload", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped({ git: true })
- const fiber = yield* askEffect({
- sessionID: SessionID.make("ses_reload"),
- questions: [
- {
- question: "Reload me?",
- header: "Reload",
- options: [{ label: "Yes", description: "Yes" }],
- },
- ],
- }).pipe(provideInstance(dir), Effect.forkScoped)
- expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1)
- yield* InstanceStore.Service.use((store) => store.reload({ directory: dir }))
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError)
- }),
- )
|