user-interaction.spec.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import UserInteractionService, {
  4. UserInteractionError,
  5. type AskUserQuestionRequest,
  6. type UserInteractionProvider,
  7. } from '@deepseek-ai/dsh-user-interaction'
  8. function provider(answer = 'approved'): UserInteractionProvider & { seen: AskUserQuestionRequest[] } {
  9. const seen: AskUserQuestionRequest[] = []
  10. return {
  11. seen,
  12. async ask(request) {
  13. seen.push(request)
  14. return { answers: [{ id: request.questions[0]?.id ?? 'missing', selected: [answer] }] }
  15. },
  16. }
  17. }
  18. describe('UserInteractionService', () => {
  19. it('delegates ask requests to the registered provider', async () => {
  20. const ctx = new Context()
  21. await ctx.plugin(UserInteractionService)
  22. const p = provider('yes')
  23. ctx.userInteraction.registerProvider(p)
  24. const result = await ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] })
  25. expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] })
  26. expect(p.seen).toEqual([{ questions: [{ id: 'confirm', question: 'Proceed?' }] }])
  27. })
  28. it('rejects ask requests when no provider is registered', async () => {
  29. const ctx = new Context()
  30. await ctx.plugin(UserInteractionService)
  31. await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
  32. .rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_PROVIDER' })
  33. })
  34. it('registers providers with HMR-safe disposal', async () => {
  35. const ctx = new Context()
  36. await ctx.plugin(UserInteractionService)
  37. const p = provider()
  38. const dispose = ctx.userInteraction.registerProvider(p)
  39. dispose()
  40. dispose()
  41. await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
  42. .rejects.toMatchObject({ code: 'NO_PROVIDER' })
  43. })
  44. it('rejects duplicate providers instead of replacing the active UI', async () => {
  45. const ctx = new Context()
  46. await ctx.plugin(UserInteractionService)
  47. ctx.userInteraction.registerProvider(provider('first'))
  48. expect(() => ctx.userInteraction.registerProvider(provider('second')))
  49. .toThrow(UserInteractionError)
  50. })
  51. it('fails before reaching the provider when the signal is already aborted', async () => {
  52. const ctx = new Context()
  53. await ctx.plugin(UserInteractionService)
  54. const p = { ask: vi.fn(async () => ({ answers: [{ id: 'confirm', selected: ['too late'] }] })) }
  55. ctx.userInteraction.registerProvider(p)
  56. const controller = new AbortController()
  57. controller.abort()
  58. await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }], signal: controller.signal }))
  59. .rejects.toMatchObject({ code: 'ASK_ABORTED' })
  60. expect(p.ask).not.toHaveBeenCalled()
  61. })
  62. it('rejects empty question batches before reaching the provider', async () => {
  63. const ctx = new Context()
  64. await ctx.plugin(UserInteractionService)
  65. const p = { ask: vi.fn(async () => ({ answers: [] })) }
  66. ctx.userInteraction.registerProvider(p)
  67. await expect(ctx.userInteraction.ask({ questions: [] }))
  68. .rejects.toMatchObject({ name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' })
  69. expect(p.ask).not.toHaveBeenCalled()
  70. })
  71. it('rejects an intent whose approve label names none of its own options', async () => {
  72. const ctx = new Context()
  73. await ctx.plugin(UserInteractionService)
  74. const p = { ask: vi.fn(async () => ({ answers: [] })) }
  75. ctx.userInteraction.registerProvider(p)
  76. const question = { id: 'plan-review', question: 'Approve?', detail: '# Plan' }
  77. // A wrong label among offered options, and no options offered at all.
  78. for (const options of [[{ label: 'Approve' }], undefined]) {
  79. await expect(ctx.userInteraction.ask({
  80. questions: [{
  81. ...question,
  82. ...(options === undefined ? {} : { options }),
  83. intent: { kind: 'plan-review', approve: 'Ship it' },
  84. }],
  85. })).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' })
  86. }
  87. expect(p.ask).not.toHaveBeenCalled()
  88. })
  89. it('rejects a plan-review intent on a question carrying no plan to review', async () => {
  90. const ctx = new Context()
  91. await ctx.plugin(UserInteractionService)
  92. const p = { ask: vi.fn(async () => ({ answers: [] })) }
  93. ctx.userInteraction.registerProvider(p)
  94. // Detail IS the plan for this intent, so a UI honouring it would ask the
  95. // user to approve something they cannot see.
  96. await expect(ctx.userInteraction.ask({
  97. questions: [{
  98. id: 'plan-review', question: 'Approve?',
  99. options: [{ label: 'Approve' }, { label: 'Keep planning' }],
  100. intent: { kind: 'plan-review', approve: 'Approve' },
  101. }],
  102. })).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' })
  103. expect(p.ask).not.toHaveBeenCalled()
  104. })
  105. it('passes an intent through once its approve label names an offered option', async () => {
  106. const ctx = new Context()
  107. await ctx.plugin(UserInteractionService)
  108. const p = provider('Approve')
  109. ctx.userInteraction.registerProvider(p)
  110. const intent = { kind: 'plan-review', approve: 'Approve' } as const
  111. const result = await ctx.userInteraction.ask({
  112. questions: [
  113. { id: 'plain', question: 'Proceed?' },
  114. {
  115. id: 'plan-review', question: 'Approve?', detail: '# Plan',
  116. options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent,
  117. },
  118. ],
  119. })
  120. expect(result.answers).toEqual([{ id: 'plain', selected: ['Approve'] }])
  121. expect(p.seen[0]?.questions[1]?.intent).toEqual(intent)
  122. })
  123. })