renderer-msg-queue.test.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. // Renderer-level tests for the composer message queue (lane-msg-queue).
  2. //
  3. // Covers the wiring the pure msg-queue-model.js tests can't reach:
  4. // - send() enqueues instead of sendPrompt when a turn is in flight
  5. // - the queue strip renders/hides per the active session's queue
  6. // - turn/end auto-drains exactly ONE queued item through sendPrompt
  7. // - session.finished drains once too, and doesn't double-drain with turn/end
  8. // - per-session isolation: a background session's turn/end never touches
  9. // the foreground session's composer
  10. // - runtime restart (onInitialized) clears every queue
  11. //
  12. // The harness loads the whole renderer against a DOM stub and exposes the
  13. // send entry point + queue handle via window.__dshRenderer.
  14. 'use strict'
  15. const test = require('node:test')
  16. const assert = require('node:assert/strict')
  17. const { loadRenderer } = require('./renderer-harness.js')
  18. // Drive an in-flight turn on `sid` and make it active, returning refs.
  19. async function activeInflight(renderer, document, sid = 's1') {
  20. renderer.ensureSession(sid, { title: 'sess', header: {}, hasUserMessage: true })
  21. await renderer.selectSession(sid)
  22. renderer.onSessionEvent(sid, { type: 'turn/start', seq: 1 })
  23. return { input: document.getElementById('input') }
  24. }
  25. function sendCalls(dsh) {
  26. return dsh.__calls.filter((c) => c[0] === 'sendPrompt')
  27. }
  28. test('send() enqueues instead of sendPrompt while a turn is in flight', async () => {
  29. const { renderer, document, dsh } = await loadRenderer()
  30. const { input } = await activeInflight(renderer, document)
  31. const before = sendCalls(dsh).length
  32. input.value = 'queued follow-up'
  33. await renderer.send()
  34. assert.equal(sendCalls(dsh).length, before, 'no sendPrompt fired mid-turn')
  35. assert.deepEqual(renderer.listMsgQueue('s1').map((x) => x.text), ['queued follow-up'])
  36. assert.equal(input.value, '', 'composer cleared as if sent')
  37. })
  38. test('whitespace-only mid-turn input never enqueues', async () => {
  39. const { renderer, document } = await loadRenderer()
  40. const { input } = await activeInflight(renderer, document)
  41. input.value = ' '
  42. await renderer.send()
  43. assert.equal(renderer.listMsgQueue('s1').length, 0)
  44. })
  45. test('queue strip renders one chip per queued message + counter, hides when empty', async () => {
  46. const { renderer, document } = await loadRenderer()
  47. const { input } = await activeInflight(renderer, document)
  48. const strip = document.getElementById('msg-queue-strip')
  49. assert.equal(strip.hidden, true, 'hidden with an empty queue')
  50. input.value = 'first'; await renderer.send()
  51. input.value = 'second'; await renderer.send()
  52. assert.equal(strip.hidden, false, 'shown once queued')
  53. assert.equal(strip.querySelectorAll('.msg-queue-chip').length, 2)
  54. const badge = strip.querySelector('.msg-queue-count')
  55. assert.match(badge.textContent, /queued 2/)
  56. })
  57. test('turn/end auto-drains exactly one queued item through sendPrompt', async () => {
  58. const { renderer, document, dsh } = await loadRenderer()
  59. const { input } = await activeInflight(renderer, document)
  60. input.value = 'one'; await renderer.send()
  61. input.value = 'two'; await renderer.send()
  62. const before = sendCalls(dsh).length
  63. renderer.onSessionEvent('s1', { type: 'turn/end', seq: 2 })
  64. // drain is async (dispatchPrompt awaits sendPrompt) — let microtasks settle.
  65. await new Promise((r) => setTimeout(r, 5))
  66. const after = sendCalls(dsh)
  67. assert.equal(after.length - before, 1, 'exactly one drained send')
  68. assert.equal(after[after.length - 1][2], 'one', 'FIFO: head sent first')
  69. assert.deepEqual(renderer.listMsgQueue('s1').map((x) => x.text), ['two'],
  70. 'the second item waits for its own turn to finish')
  71. })
  72. test('a second turn/end drains the next item (one per completion)', async () => {
  73. const { renderer, document, dsh } = await loadRenderer()
  74. const { input } = await activeInflight(renderer, document)
  75. input.value = 'one'; await renderer.send()
  76. input.value = 'two'; await renderer.send()
  77. renderer.onSessionEvent('s1', { type: 'turn/end', seq: 2 })
  78. await new Promise((r) => setTimeout(r, 5))
  79. // the drained 'one' send flipped inflightTurn back on; simulate its turn.
  80. renderer.onSessionEvent('s1', { type: 'turn/start', seq: 3 })
  81. renderer.onSessionEvent('s1', { type: 'turn/end', seq: 4 })
  82. await new Promise((r) => setTimeout(r, 5))
  83. const sent = sendCalls(dsh).map((c) => c[2])
  84. assert.deepEqual(sent, ['one', 'two'], 'both drained in FIFO order across two turns')
  85. assert.equal(renderer.listMsgQueue('s1').length, 0)
  86. })
  87. test('turn/end + session.finished for one turn drains only once', async () => {
  88. const { renderer, document, dsh, listeners } = await loadRenderer()
  89. const { input } = await activeInflight(renderer, document)
  90. input.value = 'one'; await renderer.send()
  91. input.value = 'two'; await renderer.send()
  92. const before = sendCalls(dsh).length
  93. // Some daemons emit both boundaries for the same turn.
  94. renderer.onSessionEvent('s1', { type: 'turn/end', seq: 2 })
  95. await new Promise((r) => setTimeout(r, 5))
  96. listeners.onNotify({ method: 'session.finished', params: { sessionId: 's1', status: 'ok' } })
  97. await new Promise((r) => setTimeout(r, 5))
  98. assert.equal(sendCalls(dsh).length - before, 1, 'drain-once guard held across both signals')
  99. assert.deepEqual(renderer.listMsgQueue('s1').map((x) => x.text), ['two'])
  100. })
  101. test('session.finished alone (no turn/end) still drains once', async () => {
  102. const { renderer, document, dsh, listeners } = await loadRenderer()
  103. const { input } = await activeInflight(renderer, document)
  104. input.value = 'only'; await renderer.send()
  105. const before = sendCalls(dsh).length
  106. listeners.onNotify({ method: 'session.finished', params: { sessionId: 's1', status: 'error' } })
  107. await new Promise((r) => setTimeout(r, 5))
  108. assert.equal(sendCalls(dsh).length - before, 1, 'error-path completion drained the head')
  109. })
  110. test('cancelled turn (turn/end after cancel) still drains — queue survives Cancel', async () => {
  111. const { renderer, document, dsh } = await loadRenderer()
  112. const { input } = await activeInflight(renderer, document)
  113. input.value = 'after cancel'; await renderer.send()
  114. // User cancels; the wire still closes the turn with turn/end.
  115. const before = sendCalls(dsh).length
  116. renderer.onSessionEvent('s1', { type: 'turn/end', seq: 2 })
  117. await new Promise((r) => setTimeout(r, 5))
  118. assert.equal(sendCalls(dsh).length - before, 1, 'queued follow-up sent on the cancelled turn end')
  119. })
  120. test('per-session isolation: a background turn/end never touches the active composer', async () => {
  121. const { renderer, document, dsh } = await loadRenderer()
  122. // s1 active + in flight, one queued item.
  123. const { input } = await activeInflight(renderer, document, 's1')
  124. input.value = 's1-queued'; await renderer.send()
  125. // s2 exists, has its own in-flight turn + queue, but is NOT active.
  126. renderer.ensureSession('s2', { title: 's2', header: {}, hasUserMessage: true, running: true })
  127. renderer.getMsgQueue().enqueue('s2', 's2-queued')
  128. // Arm s2's drain flag via a background turn/start (not the active session,
  129. // so it doesn't touch the composer), then end it.
  130. renderer.onSessionEvent('s2', { type: 'turn/start', seq: 8 })
  131. const before = sendCalls(dsh).length
  132. renderer.onSessionEvent('s2', { type: 'turn/end', seq: 9 })
  133. await new Promise((r) => setTimeout(r, 5))
  134. const sent = sendCalls(dsh).slice(before).map((c) => [c[1], c[2]])
  135. assert.deepEqual(sent, [['s2', 's2-queued']], 'only s2 drained, sent against s2')
  136. // s1's queue + composer untouched.
  137. assert.deepEqual(renderer.listMsgQueue('s1').map((x) => x.text), ['s1-queued'])
  138. const strip = document.getElementById('msg-queue-strip')
  139. assert.equal(strip.querySelectorAll('.msg-queue-chip').length, 1, 'active strip still shows s1')
  140. })
  141. test('switching sessions shows the target session\'s queue (strict isolation)', async () => {
  142. const { renderer, document } = await loadRenderer()
  143. const { input } = await activeInflight(renderer, document, 's1')
  144. input.value = 's1-only'; await renderer.send()
  145. const strip = document.getElementById('msg-queue-strip')
  146. assert.equal(strip.querySelectorAll('.msg-queue-chip').length, 1)
  147. // Switch to a fresh idle session — its queue is empty, strip hides.
  148. renderer.ensureSession('s2', { title: 's2', header: {}, hasUserMessage: true })
  149. await renderer.selectSession('s2')
  150. assert.equal(strip.hidden, true, 'empty queue on s2 hides the strip')
  151. // Switch back — s1's queue is intact.
  152. await renderer.selectSession('s1')
  153. assert.equal(strip.querySelectorAll('.msg-queue-chip').length, 1)
  154. })
  155. test('runtime restart (onInitialized) clears every queue and posts a notice', async () => {
  156. const { renderer, document, listeners } = await loadRenderer()
  157. const { input } = await activeInflight(renderer, document, 's1')
  158. input.value = 'doomed'; await renderer.send()
  159. renderer.getMsgQueue().enqueue('s2', 'also doomed')
  160. // Fire the initialize handshake (new daemon / profile switch).
  161. listeners.onInitialized({ serverInfo: { name: 'echo', version: '1' }, protocolVersion: 1 })
  162. await new Promise((r) => setTimeout(r, 5))
  163. assert.equal(renderer.getMsgQueue().size('s1'), 0)
  164. assert.equal(renderer.getMsgQueue().size('s2'), 0)
  165. assert.match(renderer.getStreamText(), /cleared 2 queued messages/)
  166. })
  167. test('drained send re-arms inflightTurn so a follow-up Enter queues again', async () => {
  168. const { renderer, document, dsh } = await loadRenderer()
  169. const { input } = await activeInflight(renderer, document)
  170. input.value = 'one'; await renderer.send()
  171. input.value = 'two'; await renderer.send()
  172. renderer.onSessionEvent('s1', { type: 'turn/end', seq: 2 })
  173. await new Promise((r) => setTimeout(r, 5))
  174. // 'one' is now in flight (dispatchPrompt set inflightTurn). A new Enter
  175. // should queue behind 'two', not fire a second concurrent sendPrompt.
  176. const before = sendCalls(dsh).length
  177. input.value = 'three'; await renderer.send()
  178. assert.equal(sendCalls(dsh).length, before, 'follow-up queued, not sent concurrently')
  179. assert.deepEqual(renderer.listMsgQueue('s1').map((x) => x.text), ['two', 'three'])
  180. })
  181. test('drain retries "already has an active prompt" then delivers (settle race)', async () => {
  182. const { renderer, document, dsh } = await loadRenderer()
  183. const { input } = await activeInflight(renderer, document)
  184. input.value = 'parked message'; await renderer.send()
  185. let calls = 0
  186. dsh.sendPrompt = async (...args) => {
  187. dsh.__calls.push(['sendPrompt', ...args])
  188. calls += 1
  189. if (calls === 1) throw new Error("Error invoking remote method 'session:prompt': Error: session already has an active prompt: x")
  190. return { accepted: true }
  191. }
  192. renderer.onSessionEvent('s1', { type: 'turn/end', seq: 2 })
  193. // Retry ladder's first rung is 250ms — wait past it.
  194. await new Promise((resolve) => setTimeout(resolve, 450))
  195. assert.equal(calls, 2, 'second attempt fired after the settle retry')
  196. assert.equal(renderer.listMsgQueue('s1').length, 0, 'queue emptied — message delivered, not dropped')
  197. })
  198. test('drain requeues at head when the send keeps failing (no message loss)', async () => {
  199. const { renderer, document, dsh } = await loadRenderer()
  200. const { input } = await activeInflight(renderer, document)
  201. input.value = 'doomed message'; await renderer.send()
  202. dsh.sendPrompt = async () => { throw new Error('some hard transport failure') }
  203. renderer.onSessionEvent('s1', { type: 'turn/end', seq: 2 })
  204. await new Promise((resolve) => setTimeout(resolve, 30))
  205. const parked = renderer.listMsgQueue('s1')
  206. assert.equal(parked.length, 1, 'failed drain went back to the queue')
  207. assert.equal(parked[0].text, 'doomed message')
  208. })