browser-plugin.client.spec.ts 11 KB

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