runtime.queue.test.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import { describe, expect, test } from "bun:test"
  2. import { runPromptQueue } from "@/cli/cmd/run/runtime.queue"
  3. import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@/cli/cmd/run/types"
  4. function footer() {
  5. const prompts = new Set<(input: RunPrompt) => void>()
  6. const closes = new Set<() => void>()
  7. const events: FooterEvent[] = []
  8. const commits: StreamCommit[] = []
  9. let closed = false
  10. const api: FooterApi = {
  11. get isClosed() {
  12. return closed
  13. },
  14. onPrompt(fn) {
  15. prompts.add(fn)
  16. return () => {
  17. prompts.delete(fn)
  18. }
  19. },
  20. onClose(fn) {
  21. if (closed) {
  22. fn()
  23. return () => {}
  24. }
  25. closes.add(fn)
  26. return () => {
  27. closes.delete(fn)
  28. }
  29. },
  30. event(next) {
  31. events.push(next)
  32. },
  33. append(next) {
  34. commits.push(next)
  35. },
  36. idle() {
  37. return Promise.resolve()
  38. },
  39. close() {
  40. if (closed) {
  41. return
  42. }
  43. closed = true
  44. for (const fn of [...closes]) {
  45. fn()
  46. }
  47. },
  48. destroy() {
  49. api.close()
  50. prompts.clear()
  51. closes.clear()
  52. },
  53. }
  54. return {
  55. api,
  56. events,
  57. commits,
  58. submit(text: string) {
  59. const next = { text, parts: [] as RunPrompt["parts"] }
  60. for (const fn of [...prompts]) {
  61. fn(next)
  62. }
  63. },
  64. }
  65. }
  66. describe("run runtime queue", () => {
  67. test("ignores empty prompts", async () => {
  68. const ui = footer()
  69. let calls = 0
  70. const task = runPromptQueue({
  71. footer: ui.api,
  72. run: async () => {
  73. calls += 1
  74. },
  75. })
  76. ui.submit(" ")
  77. ui.api.close()
  78. await task
  79. expect(calls).toBe(0)
  80. })
  81. test("treats /exit as a close command", async () => {
  82. const ui = footer()
  83. let calls = 0
  84. const task = runPromptQueue({
  85. footer: ui.api,
  86. run: async () => {
  87. calls += 1
  88. },
  89. })
  90. ui.submit("/exit")
  91. await task
  92. expect(calls).toBe(0)
  93. })
  94. test("preserves whitespace for initial input", async () => {
  95. const ui = footer()
  96. const seen: string[] = []
  97. await runPromptQueue({
  98. footer: ui.api,
  99. initialInput: " hello ",
  100. run: async (input) => {
  101. seen.push(input.text)
  102. ui.api.close()
  103. },
  104. })
  105. expect(seen).toEqual([" hello "])
  106. expect(ui.commits).toEqual([
  107. {
  108. kind: "user",
  109. text: " hello ",
  110. phase: "start",
  111. source: "system",
  112. },
  113. ])
  114. })
  115. test("runs queued prompts in order", async () => {
  116. const ui = footer()
  117. const seen: string[] = []
  118. let wake: (() => void) | undefined
  119. const gate = new Promise<void>((resolve) => {
  120. wake = resolve
  121. })
  122. const task = runPromptQueue({
  123. footer: ui.api,
  124. run: async (input) => {
  125. seen.push(input.text)
  126. if (seen.length === 1) {
  127. await gate
  128. return
  129. }
  130. ui.api.close()
  131. },
  132. })
  133. ui.submit("one")
  134. ui.submit("two")
  135. await Promise.resolve()
  136. expect(seen).toEqual(["one"])
  137. wake?.()
  138. await task
  139. expect(seen).toEqual(["one", "two"])
  140. })
  141. test("drains a prompt queued during an in-flight turn", async () => {
  142. const ui = footer()
  143. const seen: string[] = []
  144. let wake: (() => void) | undefined
  145. const gate = new Promise<void>((resolve) => {
  146. wake = resolve
  147. })
  148. const task = runPromptQueue({
  149. footer: ui.api,
  150. run: async (input) => {
  151. seen.push(input.text)
  152. if (seen.length === 1) {
  153. await gate
  154. return
  155. }
  156. ui.api.close()
  157. },
  158. })
  159. ui.submit("one")
  160. await Promise.resolve()
  161. expect(seen).toEqual(["one"])
  162. wake?.()
  163. await Promise.resolve()
  164. ui.submit("two")
  165. await task
  166. expect(seen).toEqual(["one", "two"])
  167. })
  168. test("close aborts the active run and drops pending queued work", async () => {
  169. const ui = footer()
  170. const seen: string[] = []
  171. let hit = false
  172. const task = runPromptQueue({
  173. footer: ui.api,
  174. run: async (input, signal) => {
  175. seen.push(input.text)
  176. await new Promise<void>((resolve) => {
  177. if (signal.aborted) {
  178. hit = true
  179. resolve()
  180. return
  181. }
  182. signal.addEventListener(
  183. "abort",
  184. () => {
  185. hit = true
  186. resolve()
  187. },
  188. { once: true },
  189. )
  190. })
  191. },
  192. })
  193. ui.submit("one")
  194. await Promise.resolve()
  195. ui.submit("two")
  196. ui.api.close()
  197. await task
  198. expect(hit).toBe(true)
  199. expect(seen).toEqual(["one"])
  200. })
  201. test("propagates run errors", async () => {
  202. const ui = footer()
  203. const task = runPromptQueue({
  204. footer: ui.api,
  205. run: async () => {
  206. throw new Error("boom")
  207. },
  208. })
  209. ui.submit("one")
  210. await expect(task).rejects.toThrow("boom")
  211. })
  212. })