tool-ask-user.spec.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { CallId } from '@deepseek-ai/dsh-llm'
  4. import type { Agent } from '@deepseek-ai/dsh-agent'
  5. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  6. import ToolRegistry from '@deepseek-ai/dsh-tools'
  7. import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
  8. import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
  9. interface OptionSchemaShape {
  10. properties: {
  11. questions: {
  12. items: {
  13. properties: {
  14. options: {
  15. items: {
  16. properties: Record<string, { type: string }>
  17. }
  18. }
  19. } & Record<string, unknown>
  20. }
  21. }
  22. }
  23. }
  24. async function setup() {
  25. const ctx = new Context()
  26. await ctx.plugin(SystemPrompt)
  27. await ctx.plugin(ToolRegistry)
  28. await ctx.plugin(UserInteractionService)
  29. await ctx.plugin(toolAskUser)
  30. return ctx
  31. }
  32. describe('ask_user_question tool', () => {
  33. it('registers a model-facing tool schema', async () => {
  34. const ctx = await setup()
  35. const schema = ctx.tools.schemas().find(tool => tool.name === 'ask_user_question')
  36. expect(schema).toMatchObject({
  37. name: 'ask_user_question',
  38. parameters: {
  39. type: 'object',
  40. properties: {
  41. questions: { type: 'array' },
  42. },
  43. required: ['questions'],
  44. },
  45. })
  46. const parameters = schema?.parameters as unknown as OptionSchemaShape
  47. expect(parameters.properties.questions.items.properties).toMatchObject({
  48. id: { type: 'string' },
  49. question: { type: 'string' },
  50. header: { type: 'string' },
  51. options: { type: 'array' },
  52. multi_select: { type: 'boolean' },
  53. })
  54. expect(parameters.properties.questions.items.properties.options.items.properties).toMatchObject({
  55. label: { type: 'string' },
  56. description: { type: 'string' },
  57. })
  58. expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('value')
  59. expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('recommended')
  60. expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('preview')
  61. })
  62. it('asks the registered user-interaction provider and projects structured answers to text', async () => {
  63. const ctx = await setup()
  64. const seen: AskUserQuestionRequest[] = []
  65. ctx.userInteraction.registerProvider({
  66. async ask(request) {
  67. seen.push(request)
  68. return { answers: [{ id: 'pkg', selected: ['pnpm'] }] }
  69. },
  70. })
  71. const result = await ctx.tools.execute({
  72. callId: CallId('ask-1'),
  73. name: 'ask_user_question',
  74. arguments: {
  75. questions: [{
  76. id: 'pkg',
  77. question: 'Which package manager should I use?',
  78. options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }],
  79. }],
  80. },
  81. })
  82. expect(result).toMatchObject({
  83. isError: false,
  84. content: [{ type: 'text', text: '{"answers":[{"id":"pkg","selected":["pnpm"]}]}' }],
  85. })
  86. expect(seen).toMatchObject([{
  87. questions: [{
  88. id: 'pkg',
  89. question: 'Which package manager should I use?',
  90. options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }],
  91. }],
  92. }])
  93. })
  94. it('passes recommended option labels through without adding schema fields', async () => {
  95. const ctx = await setup()
  96. const seen: AskUserQuestionRequest[] = []
  97. ctx.userInteraction.registerProvider({
  98. async ask(request) {
  99. seen.push(request)
  100. return { answers: [{ id: 'pkg', selected: ['pnpm (Recommended)'] }] }
  101. },
  102. })
  103. await ctx.tools.execute({
  104. callId: CallId('ask-recommended'),
  105. name: 'ask_user_question',
  106. arguments: {
  107. questions: [{
  108. id: 'pkg',
  109. question: 'Which package manager should I use?',
  110. options: [
  111. { label: 'pnpm (Recommended)' },
  112. { label: 'npm' },
  113. ],
  114. }],
  115. },
  116. })
  117. expect(seen[0]?.questions[0]?.options).toEqual([
  118. { label: 'pnpm (Recommended)' },
  119. { label: 'npm' },
  120. ])
  121. })
  122. it('projects custom answers and multi-select choices', async () => {
  123. const ctx = await setup()
  124. ctx.userInteraction.registerProvider({
  125. async ask() {
  126. return {
  127. answers: [
  128. { id: 'targets', selected: ['tests', 'docs'] },
  129. { id: 'notes', selected: [], custom: 'ship today' },
  130. ],
  131. }
  132. },
  133. })
  134. const result = await ctx.tools.execute({
  135. callId: CallId('ask-multi'),
  136. name: 'ask_user_question',
  137. arguments: {
  138. questions: [
  139. {
  140. id: 'targets',
  141. question: 'What should I update?',
  142. options: [{ label: 'tests' }, { label: 'docs' }],
  143. multi_select: true,
  144. },
  145. { id: 'notes', question: 'Any note?' },
  146. ],
  147. },
  148. })
  149. expect(result.content).toEqual([{
  150. type: 'text',
  151. text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
  152. }])
  153. })
  154. it('passes the tool abort signal to the user-interaction request', async () => {
  155. const ctx = await setup()
  156. const seen: AskUserQuestionRequest[] = []
  157. ctx.userInteraction.registerProvider({
  158. async ask(request) {
  159. seen.push(request)
  160. return { answers: [{ id: 'continue', selected: ['ok'] }] }
  161. },
  162. })
  163. const controller = new AbortController()
  164. await ctx.tools.execute({
  165. callId: CallId('ask-2'),
  166. name: 'ask_user_question',
  167. arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
  168. signal: controller.signal,
  169. })
  170. expect(seen[0]?.signal).toBe(controller.signal)
  171. })
  172. it('passes optional header and agent through to the user-interaction request', async () => {
  173. const ctx = await setup()
  174. const seen: AskUserQuestionRequest[] = []
  175. ctx.userInteraction.registerProvider({
  176. async ask(request) {
  177. seen.push(request)
  178. return { answers: [{ id: 'continue', selected: ['ok'] }] }
  179. },
  180. })
  181. const agent = { id: 'main' } as unknown as Agent
  182. const result = await ctx.tools.execute({
  183. callId: CallId('ask-3'),
  184. name: 'ask_user_question',
  185. arguments: { questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }] },
  186. agent,
  187. })
  188. expect(result.content).toEqual([{ type: 'text', text: '{"answers":[{"id":"continue","selected":["ok"]}]}' }])
  189. expect(seen[0]).toMatchObject({ questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }], agent })
  190. })
  191. it('returns structured user-interaction errors through tool execution', async () => {
  192. const ctx = await setup()
  193. const result = await ctx.tools.execute({
  194. callId: CallId('ask-no-provider'),
  195. name: 'ask_user_question',
  196. arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
  197. })
  198. expect(result).toMatchObject({
  199. isError: true,
  200. error: { name: 'UserInteractionError', code: 'NO_PROVIDER' },
  201. })
  202. })
  203. it('returns a structured error for empty question batches', async () => {
  204. const ctx = await setup()
  205. const result = await ctx.tools.execute({
  206. callId: CallId('ask-empty'),
  207. name: 'ask_user_question',
  208. arguments: { questions: [] },
  209. })
  210. expect(result).toMatchObject({
  211. isError: true,
  212. error: { name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' },
  213. })
  214. })
  215. it('unregisters the tool when its plugin fiber is disposed', async () => {
  216. const ctx = new Context()
  217. await ctx.plugin(SystemPrompt)
  218. await ctx.plugin(ToolRegistry)
  219. await ctx.plugin(UserInteractionService)
  220. const fiber = await ctx.plugin(toolAskUser)
  221. expect(ctx.tools.get('ask_user_question')).toBeDefined()
  222. await fiber.dispose()
  223. expect(ctx.tools.get('ask_user_question')).toBeUndefined()
  224. })
  225. })