prompt-submit-race.test.ts 3.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. import { describe, expect, test } from "bun:test"
  2. // Regression test for the prompt submit race in
  3. // packages/tui/src/component/prompt/index.tsx (`submit`).
  4. //
  5. // Before the fix, two concurrent `submit()` calls (e.g. a double-pressed
  6. // Enter, or the input's native onSubmit racing another dispatch) each
  7. // passed the `if (!store.prompt.text) return false` guard, each
  8. // `await client.api.session.create(...)`, and each only captured
  9. // `inputText = store.prompt.text` AFTER that await. The first invocation
  10. // finished, sent the prompt, and cleared the store; the second invocation,
  11. // now past its await, read the cleared store and sent an empty prompt to a
  12. // second freshly-created session - leaving an orphaned session with the
  13. // user's actual text and a phantom session visible to the user containing
  14. // only an assistant reply.
  15. //
  16. // `submitMirror` below has the exact shape of the production `submit()`
  17. // after the fix: an in-flight `submitting` guard wraps the original body.
  18. // Two concurrent invocations must result in exactly one submission carrying
  19. // the user's text, with no empty-text submission.
  20. type Store = { input: string }
  21. type SubmitResult = { sessionID: string; text: string }
  22. type Harness = {
  23. store: Store
  24. submissions: SubmitResult[]
  25. createSession(): Promise<string>
  26. sendPrompt(sessionID: string, text: string): Promise<void>
  27. }
  28. function createHarness(opts: { sessionCreateDelayMs: number }): Harness {
  29. let sessionCounter = 0
  30. const submissions: SubmitResult[] = []
  31. return {
  32. store: { input: "" },
  33. submissions,
  34. async createSession() {
  35. sessionCounter += 1
  36. const id = `ses_${sessionCounter}`
  37. await Bun.sleep(opts.sessionCreateDelayMs)
  38. return id
  39. },
  40. async sendPrompt(sessionID, text) {
  41. submissions.push({ sessionID, text })
  42. },
  43. }
  44. }
  45. function createSubmit() {
  46. let submitting = false
  47. return async function submit(h: Harness) {
  48. if (submitting) return false
  49. submitting = true
  50. try {
  51. if (!h.store.input) return false
  52. const sessionID = await h.createSession()
  53. const inputText = h.store.input
  54. await h.sendPrompt(sessionID, inputText)
  55. h.store.input = ""
  56. return true
  57. } finally {
  58. submitting = false
  59. }
  60. }
  61. }
  62. describe("Prompt.submit race", () => {
  63. test("concurrent submits must not lose the user's text", async () => {
  64. const submit = createSubmit()
  65. const h = createHarness({ sessionCreateDelayMs: 5 })
  66. h.store.input = "Hello there."
  67. // Two invocations back-to-back, mimicking a double-Enter.
  68. await Promise.all([submit(h), submit(h)])
  69. // Every submission that did make it through must carry the actual user
  70. // text, and no submission may have an empty text payload.
  71. expect(h.submissions.every((s) => s.text === "Hello there.")).toBe(true)
  72. expect(h.submissions.some((s) => s.text === "")).toBe(false)
  73. })
  74. test("a sequential second submit after clear is a no-op, not a phantom session", async () => {
  75. const submit = createSubmit()
  76. const h = createHarness({ sessionCreateDelayMs: 1 })
  77. h.store.input = "Hello there."
  78. await submit(h)
  79. // After the first submission completes, the store is cleared; a second
  80. // Enter on an empty input must not create a phantom session.
  81. await submit(h)
  82. expect(h.submissions).toHaveLength(1)
  83. expect(h.submissions[0].text).toBe("Hello there.")
  84. })
  85. })