tool-ask-user.spec.ts 8.4 KB

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