runtime.queue.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  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 queuedRemoves = new Set<(messageID: string) => void>()
  7. const closes = new Set<() => void>()
  8. const events: FooterEvent[] = []
  9. const commits: StreamCommit[] = []
  10. let closed = false
  11. const api: FooterApi = {
  12. get isClosed() {
  13. return closed
  14. },
  15. onPrompt(fn) {
  16. prompts.add(fn)
  17. return () => {
  18. prompts.delete(fn)
  19. }
  20. },
  21. onQueuedRemove(fn) {
  22. queuedRemoves.add(fn)
  23. return () => {
  24. queuedRemoves.delete(fn)
  25. }
  26. },
  27. onClose(fn) {
  28. if (closed) {
  29. fn()
  30. return () => {}
  31. }
  32. closes.add(fn)
  33. return () => {
  34. closes.delete(fn)
  35. }
  36. },
  37. event(next) {
  38. events.push(next)
  39. },
  40. append(next) {
  41. commits.push(next)
  42. },
  43. idle() {
  44. return Promise.resolve()
  45. },
  46. close() {
  47. if (closed) {
  48. return
  49. }
  50. closed = true
  51. for (const fn of [...closes]) {
  52. fn()
  53. }
  54. },
  55. destroy() {
  56. api.close()
  57. prompts.clear()
  58. closes.clear()
  59. },
  60. }
  61. return {
  62. api,
  63. events,
  64. commits,
  65. submit(text: string, mode?: RunPrompt["mode"]) {
  66. const next = mode ? { text, parts: [] as RunPrompt["parts"], mode } : { text, parts: [] as RunPrompt["parts"] }
  67. for (const fn of [...prompts]) {
  68. fn(next)
  69. }
  70. },
  71. removeQueued(messageID: string) {
  72. for (const fn of [...queuedRemoves]) fn(messageID)
  73. },
  74. }
  75. }
  76. describe("run runtime queue", () => {
  77. test("ignores empty prompts", async () => {
  78. const ui = footer()
  79. let calls = 0
  80. const task = runPromptQueue({
  81. footer: ui.api,
  82. run: async () => {
  83. calls += 1
  84. },
  85. })
  86. ui.submit(" ")
  87. ui.api.close()
  88. await task
  89. expect(calls).toBe(0)
  90. })
  91. test("treats /exit as a close command", async () => {
  92. const ui = footer()
  93. let calls = 0
  94. const task = runPromptQueue({
  95. footer: ui.api,
  96. run: async () => {
  97. calls += 1
  98. },
  99. })
  100. ui.submit("/exit")
  101. await task
  102. expect(calls).toBe(0)
  103. })
  104. test("treats /new as a local session command", async () => {
  105. const ui = footer()
  106. const seen: string[] = []
  107. let created = 0
  108. const task = runPromptQueue({
  109. footer: ui.api,
  110. onNewSession: async () => {
  111. created += 1
  112. },
  113. run: async (input) => {
  114. seen.push(input.text)
  115. ui.api.close()
  116. },
  117. })
  118. ui.submit("/new")
  119. ui.submit("hello")
  120. await task
  121. expect(created).toBe(1)
  122. expect(seen).toEqual(["hello"])
  123. expect(ui.commits).toEqual([
  124. {
  125. kind: "user",
  126. text: "hello",
  127. phase: "start",
  128. source: "system",
  129. messageID: expect.any(String),
  130. },
  131. ])
  132. })
  133. test("shell mode submits /exit as a shell command", async () => {
  134. const ui = footer()
  135. const seen: RunPrompt[] = []
  136. const task = runPromptQueue({
  137. footer: ui.api,
  138. run: async (input) => {
  139. seen.push(input)
  140. ui.api.close()
  141. },
  142. })
  143. ui.submit("/exit", "shell")
  144. await task
  145. expect(seen).toEqual([{ text: "/exit", parts: [], mode: "shell" }])
  146. expect(ui.commits).toEqual([])
  147. })
  148. test("shell mode submits /new instead of creating a session", async () => {
  149. const ui = footer()
  150. const seen: RunPrompt[] = []
  151. let created = 0
  152. const task = runPromptQueue({
  153. footer: ui.api,
  154. onNewSession: async () => {
  155. created += 1
  156. },
  157. run: async (input) => {
  158. seen.push(input)
  159. ui.api.close()
  160. },
  161. })
  162. ui.submit("/new", "shell")
  163. await task
  164. expect(created).toBe(0)
  165. expect(seen).toEqual([{ text: "/new", parts: [], mode: "shell" }])
  166. expect(ui.commits).toEqual([])
  167. })
  168. test("shell mode does not append a synthetic user row", async () => {
  169. const ui = footer()
  170. const task = runPromptQueue({
  171. footer: ui.api,
  172. run: async () => {
  173. expect(ui.commits).toEqual([])
  174. ui.api.close()
  175. },
  176. })
  177. ui.submit("ls", "shell")
  178. await task
  179. })
  180. test("shell mode does not emit a turn duration summary", async () => {
  181. const ui = footer()
  182. const task = runPromptQueue({
  183. footer: ui.api,
  184. run: async () => {
  185. ui.api.close()
  186. },
  187. })
  188. ui.submit("ls", "shell")
  189. await task
  190. expect(ui.events.some((event) => event.type === "turn.duration")).toBe(false)
  191. })
  192. test("preserves whitespace for initial input", async () => {
  193. const ui = footer()
  194. const seen: string[] = []
  195. await runPromptQueue({
  196. footer: ui.api,
  197. initialInput: " hello ",
  198. run: async (input) => {
  199. seen.push(input.text)
  200. ui.api.close()
  201. },
  202. })
  203. expect(seen).toEqual([" hello "])
  204. expect(ui.commits).toEqual([
  205. {
  206. kind: "user",
  207. text: " hello ",
  208. phase: "start",
  209. source: "system",
  210. messageID: expect.any(String),
  211. },
  212. ])
  213. })
  214. test("passes prompts to onSend", async () => {
  215. const ui = footer()
  216. const seen: string[] = []
  217. await runPromptQueue({
  218. footer: ui.api,
  219. initialInput: " hello ",
  220. onSend: (input) => {
  221. seen.push(input.text)
  222. },
  223. run: async () => {
  224. ui.api.close()
  225. },
  226. })
  227. expect(seen).toEqual([" hello "])
  228. })
  229. test("appends the user row before the turn starts", async () => {
  230. const ui = footer()
  231. await runPromptQueue({
  232. footer: ui.api,
  233. initialInput: "/fmt bash",
  234. run: async () => {
  235. expect(ui.commits).toEqual([
  236. {
  237. kind: "user",
  238. text: "/fmt bash",
  239. phase: "start",
  240. source: "system",
  241. messageID: expect.any(String),
  242. },
  243. ])
  244. ui.api.close()
  245. },
  246. })
  247. })
  248. test("runs queued prompts in order", async () => {
  249. const ui = footer()
  250. const seen: string[] = []
  251. let wake: (() => void) | undefined
  252. const gate = new Promise<void>((resolve) => {
  253. wake = resolve
  254. })
  255. const task = runPromptQueue({
  256. footer: ui.api,
  257. run: async (input) => {
  258. seen.push(input.text)
  259. if (seen.length === 1) {
  260. await gate
  261. return
  262. }
  263. ui.api.close()
  264. },
  265. })
  266. ui.submit("one")
  267. ui.submit("two")
  268. await Promise.resolve()
  269. expect(seen).toEqual(["one"])
  270. wake?.()
  271. await task
  272. expect(seen).toEqual(["one", "two"])
  273. })
  274. test("exposes ordinary in-flight prompts for removal before sending", async () => {
  275. const ui = footer()
  276. const turns: RunPrompt[] = []
  277. let wake: (() => void) | undefined
  278. const gate = new Promise<void>((resolve) => {
  279. wake = resolve
  280. })
  281. const task = runPromptQueue({
  282. footer: ui.api,
  283. run: async (input) => {
  284. turns.push(input)
  285. await gate
  286. },
  287. })
  288. ui.submit("one")
  289. ui.submit("two")
  290. await Promise.resolve()
  291. await Promise.resolve()
  292. expect(turns.map((item) => item.text)).toEqual(["one"])
  293. expect(turns[0]?.messageID).toEqual(expect.any(String))
  294. expect(ui.commits.map((item) => item.text)).toEqual(["one"])
  295. const first = ui.events.find((item) => item.type === "queued.prompts")
  296. const event = ui.events.findLast((item) => item.type === "queued.prompts")
  297. expect(first?.type === "queued.prompts" ? first.prompts : []).toEqual([])
  298. expect(
  299. first?.type === "queued.prompts" && event?.type === "queued.prompts" ? first.prompts === event.prompts : true,
  300. ).toBe(false)
  301. expect(ui.events.findLast((item) => item.type === "queue")).toEqual({ type: "queue", queue: 1 })
  302. expect(event?.type === "queued.prompts" ? event.prompts.map((item) => item.prompt.text) : []).toEqual(["two"])
  303. if (event?.type === "queued.prompts") ui.removeQueued(event.prompts[0]!.messageID)
  304. await Promise.resolve()
  305. wake?.()
  306. ui.api.close()
  307. await task
  308. expect(turns.map((item) => item.text)).toEqual(["one"])
  309. })
  310. test("removing one managed queued prompt preserves the others", async () => {
  311. const ui = footer()
  312. const turns: string[] = []
  313. let wake: (() => void) | undefined
  314. const gate = new Promise<void>((resolve) => {
  315. wake = resolve
  316. })
  317. const task = runPromptQueue({
  318. footer: ui.api,
  319. run: async (input) => {
  320. turns.push(input.text)
  321. if (input.text === "active") await gate
  322. if (input.text === "queued three") ui.api.close()
  323. },
  324. })
  325. ui.submit("active")
  326. ui.submit("queued one")
  327. ui.submit("queued two")
  328. ui.submit("queued three")
  329. await Promise.resolve()
  330. await Promise.resolve()
  331. const event = ui.events.findLast((item) => item.type === "queued.prompts")
  332. if (event?.type === "queued.prompts") {
  333. const second = event.prompts.find((item) => item.prompt.text === "queued two")
  334. if (second) ui.removeQueued(second.messageID)
  335. }
  336. wake?.()
  337. await task
  338. expect(turns).toEqual(["active", "queued one", "queued three"])
  339. })
  340. test("drains a prompt queued during an in-flight turn", async () => {
  341. const ui = footer()
  342. const seen: string[] = []
  343. let wake: (() => void) | undefined
  344. const gate = new Promise<void>((resolve) => {
  345. wake = resolve
  346. })
  347. const task = runPromptQueue({
  348. footer: ui.api,
  349. run: async (input) => {
  350. seen.push(input.text)
  351. if (seen.length === 1) {
  352. await gate
  353. return
  354. }
  355. ui.api.close()
  356. },
  357. })
  358. ui.submit("one")
  359. await Promise.resolve()
  360. expect(seen).toEqual(["one"])
  361. wake?.()
  362. await Promise.resolve()
  363. ui.submit("two")
  364. await task
  365. expect(seen).toEqual(["one", "two"])
  366. })
  367. test("close aborts the active run and drops pending queued work", async () => {
  368. const ui = footer()
  369. const seen: string[] = []
  370. let hit = false
  371. const task = runPromptQueue({
  372. footer: ui.api,
  373. run: async (input, signal) => {
  374. seen.push(input.text)
  375. await new Promise<void>((resolve) => {
  376. if (signal.aborted) {
  377. hit = true
  378. resolve()
  379. return
  380. }
  381. signal.addEventListener(
  382. "abort",
  383. () => {
  384. hit = true
  385. resolve()
  386. },
  387. { once: true },
  388. )
  389. })
  390. },
  391. })
  392. ui.submit("one")
  393. await Promise.resolve()
  394. ui.submit("two")
  395. ui.api.close()
  396. await task
  397. expect(hit).toBe(true)
  398. expect(seen).toEqual(["one"])
  399. })
  400. test("propagates run errors", async () => {
  401. const ui = footer()
  402. const task = runPromptQueue({
  403. footer: ui.api,
  404. run: async () => {
  405. throw new Error("boom")
  406. },
  407. })
  408. ui.submit("one")
  409. await expect(task).rejects.toThrow("boom")
  410. })
  411. })