question.test.ts 12 KB

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