browser-plugin.client.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. /** Scoped Remote Event wiring for the browser question consumer. */
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { describe, expect, it, vi } from 'vitest'
  4. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  5. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  6. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  7. import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
  8. import { PendingQuestion } from '../src/client/contract/slots.ts'
  9. import { apply, inject } from '../src/client/index.ts'
  10. const SESSION_ID = 'session-question' as SessionId
  11. const SESSION_SCOPE = Symbol('question-session-scope')
  12. const QUESTIONS = [{ id: 'mode', question: 'Choose a mode' }] as const
  13. const PLAN_QUESTIONS: PendingQuestion['questions'] = [{
  14. id: 'plan',
  15. question: 'Approve this plan?',
  16. detail: '# Plan',
  17. options: [{ label: 'Approve' }, { label: 'Keep planning' }],
  18. intent: { kind: 'plan-review' as const, approve: 'Approve' },
  19. }]
  20. const ANSWER = { answers: [{ id: 'mode', selected: ['Fast'] }] }
  21. type QuestionRequest = {
  22. questions: PendingQuestion['questions']
  23. signal?: AbortSignal
  24. }
  25. type QuestionAnswer = typeof ANSWER
  26. type QuestionNext = () => Promise<QuestionAnswer>
  27. type QuestionListener = (
  28. this: Context,
  29. request: QuestionRequest,
  30. next: QuestionNext,
  31. ) => Promise<QuestionAnswer>
  32. async function bench(declare = true) {
  33. const ctx = new Context()
  34. await ctx.plugin(SlotRegistry).await()
  35. const slots = ctx.get('slots') as SlotRegistry
  36. if (declare) {
  37. slots.register(
  38. { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
  39. () => null,
  40. )
  41. }
  42. const locale = new LocaleRuntime(ctx)
  43. ctx.provide('locale', locale)
  44. const agent = ctx.extend({ [SESSION_SCOPE]: SESSION_ID })
  45. const scopeOf = vi.fn((candidate: Context) => (
  46. candidate as Context & { [SESSION_SCOPE]?: SessionId }
  47. )[SESSION_SCOPE])
  48. ctx.provide('sessions', { scopeOf } as never)
  49. const pending = new Map<PendingQuestion, () => Promise<void>>()
  50. const registerPendingInteraction = vi.fn((_precedence: (value: PendingQuestion) => number) => (
  51. value: PendingQuestion,
  52. delegate: () => Promise<void>,
  53. ) => {
  54. _precedence(value)
  55. pending.set(value, delegate)
  56. return () => { pending.delete(value) }
  57. })
  58. ctx.provide('uiSession', { registerPendingInteraction } as never)
  59. let listener: QuestionListener | undefined
  60. const on = vi.fn((event: string, value: QuestionListener) => {
  61. expect(event).toBe('user-questions/request')
  62. listener = value
  63. return () => { listener = undefined }
  64. })
  65. ctx.provide('remote', { $on: on } as never)
  66. const fiber = ctx.plugin({ inject: [...inject], apply })
  67. await fiber.await()
  68. const invoke = (
  69. owner: Context,
  70. request: QuestionRequest,
  71. next: QuestionNext,
  72. ): Promise<QuestionAnswer> => {
  73. if (listener === undefined) throw new Error('question listener was not installed')
  74. return listener.call(owner, request, next)
  75. }
  76. return {
  77. ctx,
  78. slots,
  79. locale,
  80. agent,
  81. scopeOf,
  82. pending: { getSnapshot: () => [...pending.keys()] },
  83. registerPendingInteraction,
  84. on,
  85. fiber,
  86. invoke,
  87. async releasePending() {
  88. const delegates = [...pending.values()]
  89. pending.clear()
  90. await Promise.allSettled(delegates.map(delegate => delegate()))
  91. },
  92. }
  93. }
  94. describe('apply', () => {
  95. it('declares the services it binds', () => {
  96. expect(inject).toEqual(['sessions', 'remote', 'uiSession', 'slots', 'locale'])
  97. })
  98. it('installs the Remote Event listener and delegates an unscoped request', async () => {
  99. const b = await bench(false)
  100. const next = vi.fn(async () => ANSWER)
  101. await expect(b.invoke(b.ctx, { questions: QUESTIONS }, next)).resolves.toBe(ANSWER)
  102. expect(b.on).toHaveBeenCalledOnce()
  103. expect(next).toHaveBeenCalledOnce()
  104. expect(b.slots.entries('conversation.composer')).toHaveLength(0)
  105. expect(b.pending.getSnapshot()).toEqual([])
  106. })
  107. it('projects a scoped request through one stable composer and returns its answer', async () => {
  108. const b = await bench()
  109. const next = vi.fn(async () => ANSWER)
  110. const result = b.invoke(b.agent, { questions: QUESTIONS }, next)
  111. await Promise.resolve()
  112. const entry = b.slots.entries('conversation.composer')[0]!
  113. expect(entry.component).toBe(QuestionComposer)
  114. expect(entry.inject).toBeUndefined()
  115. expect(entry.locale).toBe('question')
  116. const pending = b.pending.getSnapshot()[0]!
  117. const select = entry.select as (
  118. owner: { pendingInteraction: PendingQuestion | undefined },
  119. ) => PendingQuestion | null
  120. expect(select({ pendingInteraction: undefined })).toBeNull()
  121. expect(select({ pendingInteraction: pending })).toBe(pending)
  122. expect(pending).toMatchObject({ kind: 'question', sessionId: SESSION_ID, questions: QUESTIONS })
  123. await pending.answer(ANSWER)
  124. await expect(result).resolves.toBe(ANSWER)
  125. expect(next).not.toHaveBeenCalled()
  126. expect(b.pending.getSnapshot()).toEqual([])
  127. expect(b.slots.entries('conversation.composer')).toHaveLength(1)
  128. })
  129. it('preserves ASK_CANCELLED as a rejected waterfall result', async () => {
  130. const b = await bench()
  131. const result = b.invoke(b.agent, { questions: QUESTIONS }, async () => ANSWER)
  132. await Promise.resolve()
  133. const pending = b.pending.getSnapshot()[0]!
  134. const rejection = expect(result).rejects.toMatchObject({
  135. name: 'UserQuestionError',
  136. code: 'ASK_CANCELLED',
  137. message: 'the user cancelled ask_user_question',
  138. })
  139. await pending.cancel()
  140. await rejection
  141. expect(b.pending.getSnapshot()).toEqual([])
  142. expect(b.slots.entries('conversation.composer')).toHaveLength(1)
  143. })
  144. it('publishes a plan-review request with its distinct interaction kind', async () => {
  145. const b = await bench()
  146. const result = b.invoke(b.agent, { questions: PLAN_QUESTIONS }, async () => ANSWER)
  147. await Promise.resolve()
  148. const pending = b.pending.getSnapshot()[0]!
  149. expect(pending.kind).toBe('plan-review')
  150. await pending.answer(ANSWER)
  151. await expect(result).resolves.toBe(ANSWER)
  152. expect(b.pending.getSnapshot()).toEqual([])
  153. })
  154. it('removes a cancelled request while preserving the stable composer', async () => {
  155. const b = await bench()
  156. const controller = new AbortController()
  157. const result = b.invoke(b.agent, { questions: QUESTIONS, signal: controller.signal }, async () => ANSWER)
  158. await Promise.resolve()
  159. expect(b.pending.getSnapshot()).toHaveLength(1)
  160. controller.abort()
  161. await expect(result).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  162. expect(b.pending.getSnapshot()).toEqual([])
  163. expect(b.slots.entries('conversation.composer')).toHaveLength(1)
  164. })
  165. it('delegates an active request when its interaction domain unloads', async () => {
  166. const b = await bench()
  167. const next = vi.fn(async () => ANSWER)
  168. const result = b.invoke(b.agent, { questions: QUESTIONS }, next)
  169. await Promise.resolve()
  170. expect(b.pending.getSnapshot()).toHaveLength(1)
  171. await b.releasePending()
  172. await expect(result).resolves.toBe(ANSWER)
  173. expect(next).toHaveBeenCalledOnce()
  174. expect(b.pending.getSnapshot()).toEqual([])
  175. })
  176. it('removes the stable composer with the plugin lifetime', async () => {
  177. const b = await bench()
  178. expect(b.slots.entries('conversation.composer')).toHaveLength(1)
  179. await b.fiber.dispose()
  180. expect(b.slots.entries('conversation.composer')).toHaveLength(0)
  181. })
  182. })
  183. describe('PendingQuestion', () => {
  184. it('preserves an already-aborted request signal as ASK_ABORTED', async () => {
  185. const lifetime = new AbortController()
  186. lifetime.abort()
  187. const pending = new PendingQuestion(SESSION_ID, QUESTIONS, lifetime.signal)
  188. await expect(pending.result).rejects.toMatchObject({
  189. name: 'UserQuestionError',
  190. code: 'ASK_ABORTED',
  191. message: 'ask_user_question was aborted before the user answered',
  192. })
  193. })
  194. it('rejects on later request cancellation and removes the listener after settlement', async () => {
  195. const lifetime = new AbortController()
  196. const remove = vi.spyOn(lifetime.signal, 'removeEventListener')
  197. const pending = new PendingQuestion(SESSION_ID, QUESTIONS, lifetime.signal)
  198. const rejected = expect(pending.result).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  199. lifetime.abort()
  200. await rejected
  201. expect(remove).toHaveBeenCalledWith('abort', expect.any(Function))
  202. })
  203. it('ignores a lifecycle abort after the answer already settled', async () => {
  204. const lifetime = new AbortController()
  205. const pending = new PendingQuestion(SESSION_ID, QUESTIONS, lifetime.signal)
  206. await pending.answer(ANSWER)
  207. await expect(pending.result).resolves.toBe(ANSWER)
  208. pending.abort(new Error('late disposal'))
  209. pending.delegate()
  210. })
  211. it('rejects an unanswered request with its caller-owned lifecycle reason', async () => {
  212. const pending = new PendingQuestion(SESSION_ID, QUESTIONS)
  213. const reason = new Error('scope released')
  214. const rejected = expect(pending.result).rejects.toBe(reason)
  215. pending.abort(reason)
  216. await rejected
  217. })
  218. it('wraps a non-Error answer settlement failure with its cause', async () => {
  219. const failure = 'resolve failed'
  220. const completion = Promise.withResolvers<QuestionAnswer>()
  221. const withResolvers = vi.spyOn(Promise, 'withResolvers').mockImplementationOnce(() => ({
  222. promise: completion.promise,
  223. resolve: () => { throw failure },
  224. reject: completion.reject,
  225. }))
  226. const pending = new PendingQuestion(SESSION_ID, QUESTIONS)
  227. withResolvers.mockRestore()
  228. const settlement = await pending.answer(ANSWER).catch((error: unknown) => error)
  229. expect(settlement).toBeInstanceOf(Error)
  230. expect(settlement).toMatchObject({
  231. message: 'pending question settlement failed',
  232. cause: failure,
  233. })
  234. completion.resolve(ANSWER)
  235. await expect(pending.result).resolves.toBe(ANSWER)
  236. })
  237. it('wraps a non-Error cancellation settlement failure with its cause', async () => {
  238. const failure = 'reject failed'
  239. const completion = Promise.withResolvers<QuestionAnswer>()
  240. const withResolvers = vi.spyOn(Promise, 'withResolvers').mockImplementationOnce(<T>() => ({
  241. promise: completion.promise,
  242. resolve: completion.resolve as (value: T | PromiseLike<T>) => void,
  243. reject: () => { throw failure },
  244. }))
  245. const pending = new PendingQuestion(SESSION_ID, QUESTIONS)
  246. withResolvers.mockRestore()
  247. const settlement = await pending.cancel().catch((error: unknown) => error)
  248. expect(settlement).toBeInstanceOf(Error)
  249. expect(settlement).toMatchObject({
  250. message: 'pending question cancellation failed',
  251. cause: failure,
  252. })
  253. completion.resolve(ANSWER)
  254. await expect(pending.result).resolves.toBe(ANSWER)
  255. })
  256. })