tool-ask-user.spec.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  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'], custom: 'release notes' },
  132. { id: 'labels-only', selected: ['tests'] },
  133. { id: 'notes', selected: [], custom: 'ship today' },
  134. ],
  135. }
  136. },
  137. })
  138. const result = await ctx.tools.execute({
  139. signal: testToolSignal,
  140. callId: CallId('ask-multi'),
  141. name: 'ask_user_question',
  142. arguments: {
  143. questions: [
  144. {
  145. id: 'targets',
  146. question: 'What should I update?',
  147. options: [{ label: 'tests' }, { label: 'docs' }],
  148. multi_select: true,
  149. },
  150. {
  151. id: 'labels-only',
  152. question: 'Which labels should I keep?',
  153. options: [{ label: 'tests' }, { label: 'docs' }],
  154. multi_select: true,
  155. },
  156. { id: 'notes', question: 'Any note?' },
  157. ],
  158. },
  159. })
  160. expect(result.isError).toBe(false)
  161. if (result.isError) throw new Error('expected ask_user_question success')
  162. expect(result.value).toEqual({
  163. answers: [
  164. { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' },
  165. { id: 'labels-only', selected: ['tests'] },
  166. { id: 'notes', selected: [], custom: 'ship today' },
  167. ],
  168. })
  169. expect(result.content).toEqual([{
  170. type: 'text',
  171. text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"labels-only","selected":["tests"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
  172. }])
  173. })
  174. it('passes the tool abort signal to the user-interaction request', async () => {
  175. const ctx = await setup()
  176. const seen: AskUserQuestionRequest[] = []
  177. ctx.userInteraction.registerProvider({
  178. async ask(request) {
  179. seen.push(request)
  180. return { answers: [{ id: 'continue', selected: ['ok'] }] }
  181. },
  182. })
  183. const controller = new AbortController()
  184. await ctx.tools.execute({
  185. callId: CallId('ask-2'),
  186. name: 'ask_user_question',
  187. arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
  188. signal: controller.signal,
  189. })
  190. expect(seen[0]?.signal).toBe(controller.signal)
  191. })
  192. it('passes optional header and agent through to the user-interaction request', async () => {
  193. const ctx = await setup()
  194. const seen: AskUserQuestionRequest[] = []
  195. ctx.userInteraction.registerProvider({
  196. async ask(request) {
  197. seen.push(request)
  198. return { answers: [{ id: 'continue', selected: ['ok'] }] }
  199. },
  200. })
  201. const agent = { id: 'main' } as unknown as Agent
  202. const result = await ctx.tools.execute({
  203. signal: testToolSignal,
  204. callId: CallId('ask-3'),
  205. name: 'ask_user_question',
  206. arguments: { questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }] },
  207. agent,
  208. })
  209. expect(result.content).toEqual([{ type: 'text', text: '{"answers":[{"id":"continue","selected":["ok"]}]}' }])
  210. expect(seen[0]).toMatchObject({ questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }], agent })
  211. })
  212. it('returns structured user-interaction errors through tool execution', async () => {
  213. const ctx = await setup()
  214. const result = await ctx.tools.execute({
  215. signal: testToolSignal,
  216. callId: CallId('ask-no-provider'),
  217. name: 'ask_user_question',
  218. arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
  219. })
  220. expect(result).toMatchObject({
  221. isError: true,
  222. error: { info: { name: 'UserInteractionError', code: 'NO_PROVIDER' } },
  223. })
  224. })
  225. it('returns a structured error for empty question batches', async () => {
  226. const ctx = await setup()
  227. const result = await ctx.tools.execute({
  228. signal: testToolSignal,
  229. callId: CallId('ask-empty'),
  230. name: 'ask_user_question',
  231. arguments: { questions: [] },
  232. })
  233. expect(result).toMatchObject({
  234. isError: true,
  235. error: { info: { name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' } },
  236. })
  237. })
  238. it('unregisters the tool when its plugin fiber is disposed', async () => {
  239. const ctx = new Context()
  240. await ctx.plugin(SystemPrompt)
  241. await ctx.plugin(ToolRegistry)
  242. await ctx.plugin(UserInteractionService)
  243. const fiber = await ctx.plugin(toolAskUser)
  244. expect(ctx.tools.get('ask_user_question')).toBeDefined()
  245. await fiber.dispose()
  246. expect(ctx.tools.get('ask_user_question')).toBeUndefined()
  247. })
  248. })