question.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. import { afterEach, expect } from "bun:test"
  2. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  3. import { Cause, Effect, Exit, Fiber, Layer, Queue } from "effect"
  4. import { Question } from "../../src/question"
  5. import { InstanceRef } from "../../src/effect/instance-ref"
  6. import { InstanceStore } from "../../src/project/instance-store"
  7. import { QuestionID } from "../../src/question/schema"
  8. import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
  9. import { SessionID } from "../../src/session/schema"
  10. import { testEffect } from "../lib/effect"
  11. import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
  12. import { EventV2Bridge } from "../../src/event-v2-bridge"
  13. const questionLayer = LayerNode.compile(LayerNode.group([Question.node, EventV2Bridge.node, CrossSpawnSpawner.node]))
  14. const it = testEffect(questionLayer)
  15. const lifecycle = testEffect(Layer.mergeAll(questionLayer, testInstanceStoreLayer))
  16. const askEffect = Effect.fn("QuestionTest.ask")(function* (input: {
  17. sessionID: SessionID
  18. questions: ReadonlyArray<Question.Info>
  19. tool?: Question.Tool
  20. }) {
  21. const question = yield* Question.Service
  22. return yield* question.ask(input)
  23. })
  24. const listEffect = Question.Service.use((svc) => svc.list())
  25. const replyEffect = Effect.fn("QuestionTest.reply")(function* (input: {
  26. requestID: QuestionID
  27. answers: ReadonlyArray<Question.Answer>
  28. }) {
  29. const question = yield* Question.Service
  30. yield* question.reply(input)
  31. })
  32. const rejectEffect = Effect.fn("QuestionTest.reject")(function* (id: QuestionID) {
  33. const question = yield* Question.Service
  34. yield* question.reject(id)
  35. })
  36. afterEach(async () => {
  37. await disposeAllInstances()
  38. })
  39. /** Reject all pending questions so dangling Deferred fibers don't hang the test. */
  40. const rejectAll = Effect.gen(function* () {
  41. yield* Effect.forEach(yield* listEffect, (req) => rejectEffect(req.id), { discard: true })
  42. })
  43. const waitForPending = Effect.fn("QuestionTest.waitForPending")(function* (count: number) {
  44. const question = yield* Question.Service
  45. const events = yield* EventV2Bridge.Service
  46. const asked = yield* Queue.unbounded<void>()
  47. const off = yield* events.listen((event) => {
  48. if (event.type === Question.Event.Asked.type) Queue.offerUnsafe(asked, undefined)
  49. return Effect.void
  50. })
  51. yield* Effect.addFinalizer(() => off)
  52. for (;;) {
  53. const pending = yield* question.list()
  54. if (pending.length === count) return pending
  55. yield* Queue.take(asked).pipe(Effect.timeout("2 seconds"))
  56. }
  57. })
  58. it.instance(
  59. "ask - remains pending until answered",
  60. () =>
  61. Effect.gen(function* () {
  62. const fiber = yield* askEffect({
  63. sessionID: SessionID.make("ses_test"),
  64. questions: [
  65. {
  66. question: "What would you like to do?",
  67. header: "Action",
  68. options: [
  69. { label: "Option 1", description: "First option" },
  70. { label: "Option 2", description: "Second option" },
  71. ],
  72. },
  73. ],
  74. }).pipe(Effect.forkScoped)
  75. expect(yield* waitForPending(1)).toHaveLength(1)
  76. yield* rejectAll
  77. expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
  78. }),
  79. { git: true },
  80. )
  81. it.instance(
  82. "ask - adds to pending list",
  83. () =>
  84. Effect.gen(function* () {
  85. const questions = [
  86. {
  87. question: "What would you like to do?",
  88. header: "Action",
  89. options: [
  90. { label: "Option 1", description: "First option" },
  91. { label: "Option 2", description: "Second option" },
  92. ],
  93. },
  94. ]
  95. const fiber = yield* askEffect({
  96. sessionID: SessionID.make("ses_test"),
  97. questions,
  98. }).pipe(Effect.forkScoped)
  99. const pending = yield* waitForPending(1)
  100. expect(pending.length).toBe(1)
  101. expect(pending[0].questions).toEqual(questions)
  102. yield* rejectAll
  103. expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
  104. }),
  105. { git: true },
  106. )
  107. // reply tests
  108. it.instance(
  109. "reply - resolves the pending ask with answers",
  110. () =>
  111. Effect.gen(function* () {
  112. const questions = [
  113. {
  114. question: "What would you like to do?",
  115. header: "Action",
  116. options: [
  117. { label: "Option 1", description: "First option" },
  118. { label: "Option 2", description: "Second option" },
  119. ],
  120. },
  121. ]
  122. const fiber = yield* askEffect({
  123. sessionID: SessionID.make("ses_test"),
  124. questions,
  125. }).pipe(Effect.forkScoped)
  126. const pending = yield* waitForPending(1)
  127. const requestID = pending[0].id
  128. yield* replyEffect({
  129. requestID,
  130. answers: [["Option 1"]],
  131. })
  132. expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]])
  133. }),
  134. { git: true },
  135. )
  136. it.instance(
  137. "reply - removes from pending list",
  138. () =>
  139. Effect.gen(function* () {
  140. const fiber = yield* askEffect({
  141. sessionID: SessionID.make("ses_test"),
  142. questions: [
  143. {
  144. question: "What would you like to do?",
  145. header: "Action",
  146. options: [
  147. { label: "Option 1", description: "First option" },
  148. { label: "Option 2", description: "Second option" },
  149. ],
  150. },
  151. ],
  152. }).pipe(Effect.forkScoped)
  153. const pending = yield* waitForPending(1)
  154. expect(pending.length).toBe(1)
  155. yield* replyEffect({
  156. requestID: pending[0].id,
  157. answers: [["Option 1"]],
  158. })
  159. yield* Fiber.join(fiber)
  160. const after = yield* listEffect
  161. expect(after.length).toBe(0)
  162. }),
  163. { git: true },
  164. )
  165. it.instance(
  166. "reply - fails for unknown requestID",
  167. () =>
  168. Effect.gen(function* () {
  169. const exit = yield* replyEffect({
  170. requestID: QuestionID.make("que_unknown"),
  171. answers: [["Option 1"]],
  172. }).pipe(Effect.exit)
  173. expect(Exit.isFailure(exit)).toBe(true)
  174. if (Exit.isFailure(exit)) {
  175. expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "Question.NotFoundError", requestID: "que_unknown" })
  176. }
  177. }),
  178. { git: true },
  179. )
  180. // reject tests
  181. it.instance(
  182. "reject - throws RejectedError",
  183. () =>
  184. Effect.gen(function* () {
  185. const fiber = yield* askEffect({
  186. sessionID: SessionID.make("ses_test"),
  187. questions: [
  188. {
  189. question: "What would you like to do?",
  190. header: "Action",
  191. options: [
  192. { label: "Option 1", description: "First option" },
  193. { label: "Option 2", description: "Second option" },
  194. ],
  195. },
  196. ],
  197. }).pipe(Effect.forkScoped)
  198. const pending = yield* waitForPending(1)
  199. yield* rejectEffect(pending[0].id)
  200. const exit = yield* Fiber.await(fiber)
  201. expect(exit._tag).toBe("Failure")
  202. if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError")
  203. }),
  204. { git: true },
  205. )
  206. it.instance(
  207. "reject - removes from pending list",
  208. () =>
  209. Effect.gen(function* () {
  210. const fiber = yield* askEffect({
  211. sessionID: SessionID.make("ses_test"),
  212. questions: [
  213. {
  214. question: "What would you like to do?",
  215. header: "Action",
  216. options: [
  217. { label: "Option 1", description: "First option" },
  218. { label: "Option 2", description: "Second option" },
  219. ],
  220. },
  221. ],
  222. }).pipe(Effect.forkScoped)
  223. const pending = yield* waitForPending(1)
  224. expect(pending.length).toBe(1)
  225. yield* rejectEffect(pending[0].id)
  226. expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
  227. const after = yield* listEffect
  228. expect(after.length).toBe(0)
  229. }),
  230. { git: true },
  231. )
  232. it.instance(
  233. "reject - fails for unknown requestID",
  234. () =>
  235. Effect.gen(function* () {
  236. const exit = yield* rejectEffect(QuestionID.make("que_unknown")).pipe(Effect.exit)
  237. expect(Exit.isFailure(exit)).toBe(true)
  238. if (Exit.isFailure(exit)) {
  239. expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "Question.NotFoundError", requestID: "que_unknown" })
  240. }
  241. }),
  242. { git: true },
  243. )
  244. // multiple questions tests
  245. it.instance(
  246. "ask - handles multiple questions",
  247. () =>
  248. Effect.gen(function* () {
  249. const questions = [
  250. {
  251. question: "What would you like to do?",
  252. header: "Action",
  253. options: [
  254. { label: "Build", description: "Build the project" },
  255. { label: "Test", description: "Run tests" },
  256. ],
  257. },
  258. {
  259. question: "Which environment?",
  260. header: "Env",
  261. options: [
  262. { label: "Dev", description: "Development" },
  263. { label: "Prod", description: "Production" },
  264. ],
  265. },
  266. ]
  267. const fiber = yield* askEffect({
  268. sessionID: SessionID.make("ses_test"),
  269. questions,
  270. }).pipe(Effect.forkScoped)
  271. const pending = yield* waitForPending(1)
  272. yield* replyEffect({
  273. requestID: pending[0].id,
  274. answers: [["Build"], ["Dev"]],
  275. })
  276. expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]])
  277. }),
  278. { git: true },
  279. )
  280. // list tests
  281. it.instance(
  282. "list - returns all pending requests",
  283. () =>
  284. Effect.gen(function* () {
  285. const fiber1 = yield* askEffect({
  286. sessionID: SessionID.make("ses_test1"),
  287. questions: [
  288. {
  289. question: "Question 1?",
  290. header: "Q1",
  291. options: [{ label: "A", description: "A" }],
  292. },
  293. ],
  294. }).pipe(Effect.forkScoped)
  295. const fiber2 = yield* askEffect({
  296. sessionID: SessionID.make("ses_test2"),
  297. questions: [
  298. {
  299. question: "Question 2?",
  300. header: "Q2",
  301. options: [{ label: "B", description: "B" }],
  302. },
  303. ],
  304. }).pipe(Effect.forkScoped)
  305. const pending = yield* waitForPending(2)
  306. expect(pending.length).toBe(2)
  307. yield* rejectAll
  308. expect((yield* Fiber.await(fiber1))._tag).toBe("Failure")
  309. expect((yield* Fiber.await(fiber2))._tag).toBe("Failure")
  310. }),
  311. { git: true },
  312. )
  313. it.instance(
  314. "list - returns empty when no pending",
  315. () =>
  316. Effect.gen(function* () {
  317. const pending = yield* listEffect
  318. expect(pending.length).toBe(0)
  319. }),
  320. { git: true },
  321. )
  322. lifecycle.live("questions stay isolated by directory", () =>
  323. Effect.gen(function* () {
  324. const one = yield* tmpdirScoped({ git: true })
  325. const two = yield* tmpdirScoped({ git: true })
  326. const fiber1 = yield* askEffect({
  327. sessionID: SessionID.make("ses_one"),
  328. questions: [
  329. {
  330. question: "Question 1?",
  331. header: "Q1",
  332. options: [{ label: "A", description: "A" }],
  333. },
  334. ],
  335. }).pipe(provideInstance(one), Effect.forkScoped)
  336. const fiber2 = yield* askEffect({
  337. sessionID: SessionID.make("ses_two"),
  338. questions: [
  339. {
  340. question: "Question 2?",
  341. header: "Q2",
  342. options: [{ label: "B", description: "B" }],
  343. },
  344. ],
  345. }).pipe(provideInstance(two), Effect.forkScoped)
  346. const onePending = yield* waitForPending(1).pipe(provideInstance(one))
  347. const twoPending = yield* waitForPending(1).pipe(provideInstance(two))
  348. expect(onePending.length).toBe(1)
  349. expect(twoPending.length).toBe(1)
  350. expect(onePending[0].sessionID).toBe(SessionID.make("ses_one"))
  351. expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two"))
  352. yield* rejectEffect(onePending[0].id).pipe(provideInstance(one))
  353. yield* rejectEffect(twoPending[0].id).pipe(provideInstance(two))
  354. expect((yield* Fiber.await(fiber1))._tag).toBe("Failure")
  355. expect((yield* Fiber.await(fiber2))._tag).toBe("Failure")
  356. }),
  357. )
  358. lifecycle.live("pending question rejects on instance dispose", () =>
  359. Effect.gen(function* () {
  360. const dir = yield* tmpdirScoped({ git: true })
  361. const fiber = yield* askEffect({
  362. sessionID: SessionID.make("ses_dispose"),
  363. questions: [
  364. {
  365. question: "Dispose me?",
  366. header: "Dispose",
  367. options: [{ label: "Yes", description: "Yes" }],
  368. },
  369. ],
  370. }).pipe(provideInstance(dir), Effect.forkScoped)
  371. expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1)
  372. const ctx = yield* Effect.gen(function* () {
  373. return yield* InstanceRef
  374. }).pipe(provideInstance(dir))
  375. if (!ctx) return yield* Effect.die(new Error("missing test instance"))
  376. yield* InstanceStore.Service.use((store) => store.dispose(ctx))
  377. const exit = yield* Fiber.await(fiber)
  378. expect(Exit.isFailure(exit)).toBe(true)
  379. if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError)
  380. }),
  381. )
  382. lifecycle.live("pending question rejects on instance reload", () =>
  383. Effect.gen(function* () {
  384. const dir = yield* tmpdirScoped({ git: true })
  385. const fiber = yield* askEffect({
  386. sessionID: SessionID.make("ses_reload"),
  387. questions: [
  388. {
  389. question: "Reload me?",
  390. header: "Reload",
  391. options: [{ label: "Yes", description: "Yes" }],
  392. },
  393. ],
  394. }).pipe(provideInstance(dir), Effect.forkScoped)
  395. expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1)
  396. yield* InstanceStore.Service.use((store) => store.reload({ directory: dir }))
  397. const exit = yield* Fiber.await(fiber)
  398. expect(Exit.isFailure(exit)).toBe(true)
  399. if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError)
  400. }),
  401. )