user-interaction.spec.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  4. import UserInteractionService, {
  5. UserInteractionError,
  6. type AskUserQuestionRequest,
  7. type UserInteractionProvider,
  8. } from '@deepseek-ai/dsh-user-interaction'
  9. function provider(answer = 'approved'): UserInteractionProvider & { seen: AskUserQuestionRequest[] } {
  10. const seen: AskUserQuestionRequest[] = []
  11. return {
  12. seen,
  13. async ask(request) {
  14. seen.push(request)
  15. return { answers: [{ id: request.questions[0]?.id ?? 'missing', selected: [answer] }] }
  16. },
  17. }
  18. }
  19. function stubAgent(id: string, delegationDepth = 0): Agent {
  20. const agentId = id as Agent['id']
  21. return {
  22. id: agentId,
  23. session: { id: agentId, header: { delegationDepth } },
  24. } as unknown as Agent
  25. }
  26. describe('UserInteractionService', () => {
  27. it('delegates ask requests to the registered provider', async () => {
  28. const ctx = new Context()
  29. await ctx.plugin(UserInteractionService)
  30. const p = provider('yes')
  31. ctx.userInteraction.registerProvider(p)
  32. const result = await ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] })
  33. expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] })
  34. expect(p.seen).toEqual([{ questions: [{ id: 'confirm', question: 'Proceed?' }] }])
  35. })
  36. it('rejects ask requests when no provider is registered', async () => {
  37. const ctx = new Context()
  38. await ctx.plugin(UserInteractionService)
  39. await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
  40. .rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_PROVIDER' })
  41. })
  42. it('registers providers with HMR-safe disposal', async () => {
  43. const ctx = new Context()
  44. await ctx.plugin(UserInteractionService)
  45. const p = provider()
  46. const dispose = ctx.userInteraction.registerProvider(p)
  47. dispose()
  48. dispose()
  49. await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
  50. .rejects.toMatchObject({ code: 'NO_PROVIDER' })
  51. })
  52. it('rejects duplicate providers instead of replacing the active UI', async () => {
  53. const ctx = new Context()
  54. await ctx.plugin(UserInteractionService)
  55. ctx.userInteraction.registerProvider(provider('first'))
  56. expect(() => ctx.userInteraction.registerProvider(provider('second')))
  57. .toThrow(UserInteractionError)
  58. })
  59. it('fails before reaching the provider when the signal is already aborted', async () => {
  60. const ctx = new Context()
  61. await ctx.plugin(UserInteractionService)
  62. const p = { ask: vi.fn(async () => ({ answers: [{ id: 'confirm', selected: ['too late'] }] })) }
  63. ctx.userInteraction.registerProvider(p)
  64. const controller = new AbortController()
  65. controller.abort()
  66. await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }], signal: controller.signal }))
  67. .rejects.toMatchObject({ code: 'ASK_ABORTED' })
  68. expect(p.ask).not.toHaveBeenCalled()
  69. })
  70. it('rejects empty question batches before reaching the provider', async () => {
  71. const ctx = new Context()
  72. await ctx.plugin(UserInteractionService)
  73. const p = { ask: vi.fn(async () => ({ answers: [] })) }
  74. ctx.userInteraction.registerProvider(p)
  75. await expect(ctx.userInteraction.ask({ questions: [] }))
  76. .rejects.toMatchObject({ name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' })
  77. expect(p.ask).not.toHaveBeenCalled()
  78. })
  79. it('rejects a live runtime-owned agent before reaching the provider', async () => {
  80. const ctx = new Context()
  81. await ctx.plugin(AgentRegistry)
  82. await ctx.plugin(UserInteractionService)
  83. const p = { ask: vi.fn(async () => ({ answers: [] })) }
  84. ctx.userInteraction.registerProvider(p)
  85. const root = stubAgent('root', 0)
  86. const child = stubAgent('child', 0)
  87. ctx.agents.enter(root, undefined)
  88. ctx.agents.enter(child, root)
  89. await expect(ctx.userInteraction.ask({
  90. questions: [{ id: 'confirm', question: 'Proceed?' }],
  91. agent: child,
  92. })).rejects.toMatchObject({
  93. name: 'UserInteractionError',
  94. code: 'DELEGATED_CALLER',
  95. message: "human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result",
  96. })
  97. expect(p.ask).not.toHaveBeenCalled()
  98. })
  99. it('reaches the provider for a lineage-bearing session resumed as a runtime root', async () => {
  100. const ctx = new Context()
  101. await ctx.plugin(AgentRegistry)
  102. await ctx.plugin(UserInteractionService)
  103. const p = provider('yes')
  104. ctx.userInteraction.registerProvider(p)
  105. const agent = stubAgent('resumed-root', 1)
  106. ctx.agents.enter(agent, undefined)
  107. const result = await ctx.userInteraction.ask({
  108. questions: [{ id: 'confirm', question: 'Proceed?' }],
  109. agent,
  110. })
  111. expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] })
  112. })
  113. it('rejects a supplied agent when no live registry can attest it', async () => {
  114. const ctx = new Context()
  115. await ctx.plugin(UserInteractionService)
  116. const p = { ask: vi.fn(async () => ({ answers: [] })) }
  117. ctx.userInteraction.registerProvider(p)
  118. await expect(ctx.userInteraction.ask({
  119. questions: [{ id: 'confirm', question: 'Proceed?' }],
  120. agent: stubAgent('unattested'),
  121. })).rejects.toMatchObject({ name: 'UserInteractionError', code: 'CALLER_NOT_LIVE' })
  122. expect(p.ask).not.toHaveBeenCalled()
  123. })
  124. it('rejects a stale agent object that reuses a live id', async () => {
  125. const ctx = new Context()
  126. await ctx.plugin(AgentRegistry)
  127. await ctx.plugin(UserInteractionService)
  128. const p = { ask: vi.fn(async () => ({ answers: [] })) }
  129. ctx.userInteraction.registerProvider(p)
  130. const live = stubAgent('same-id')
  131. ctx.agents.enter(live, undefined)
  132. await expect(ctx.userInteraction.ask({
  133. questions: [{ id: 'confirm', question: 'Proceed?' }],
  134. agent: stubAgent('same-id'),
  135. })).rejects.toMatchObject({ name: 'UserInteractionError', code: 'CALLER_NOT_LIVE' })
  136. expect(p.ask).not.toHaveBeenCalled()
  137. })
  138. it('rejects an intent whose approve label names none of its own options', async () => {
  139. const ctx = new Context()
  140. await ctx.plugin(UserInteractionService)
  141. const p = { ask: vi.fn(async () => ({ answers: [] })) }
  142. ctx.userInteraction.registerProvider(p)
  143. const question = { id: 'plan-review', question: 'Approve?', detail: '# Plan' }
  144. // A wrong label among offered options, and no options offered at all.
  145. for (const options of [[{ label: 'Approve' }], undefined]) {
  146. await expect(ctx.userInteraction.ask({
  147. questions: [{
  148. ...question,
  149. ...(options === undefined ? {} : { options }),
  150. intent: { kind: 'plan-review', approve: 'Ship it' },
  151. }],
  152. })).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' })
  153. }
  154. expect(p.ask).not.toHaveBeenCalled()
  155. })
  156. it('rejects a plan-review intent on a question carrying no plan to review', async () => {
  157. const ctx = new Context()
  158. await ctx.plugin(UserInteractionService)
  159. const p = { ask: vi.fn(async () => ({ answers: [] })) }
  160. ctx.userInteraction.registerProvider(p)
  161. // Detail IS the plan for this intent, so a UI honouring it would ask the
  162. // user to approve something they cannot see.
  163. await expect(ctx.userInteraction.ask({
  164. questions: [{
  165. id: 'plan-review', question: 'Approve?',
  166. options: [{ label: 'Approve' }, { label: 'Keep planning' }],
  167. intent: { kind: 'plan-review', approve: 'Approve' },
  168. }],
  169. })).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' })
  170. expect(p.ask).not.toHaveBeenCalled()
  171. })
  172. it('passes an intent through once its approve label names an offered option', async () => {
  173. const ctx = new Context()
  174. await ctx.plugin(UserInteractionService)
  175. const p = provider('Approve')
  176. ctx.userInteraction.registerProvider(p)
  177. const intent = { kind: 'plan-review', approve: 'Approve' } as const
  178. const result = await ctx.userInteraction.ask({
  179. questions: [
  180. { id: 'plain', question: 'Proceed?' },
  181. {
  182. id: 'plan-review', question: 'Approve?', detail: '# Plan',
  183. options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent,
  184. },
  185. ],
  186. })
  187. expect(result.answers).toEqual([{ id: 'plain', selected: ['Approve'] }])
  188. expect(p.seen[0]?.questions[1]?.intent).toEqual(intent)
  189. })
  190. })