tool-ask-user.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { CallId } from '@deepseek-ai/dsh-llm'
  4. import AgentRegistry, { 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(AgentRegistry)
  28. await ctx.plugin(SystemPrompt)
  29. await ctx.plugin(ToolRegistry)
  30. await ctx.plugin(UserInteractionService)
  31. await ctx.plugin(toolAskUser)
  32. return ctx
  33. }
  34. function stubAgent(id: string, delegationDepth = 0): Agent {
  35. const agentId = id as Agent['id']
  36. return {
  37. id: agentId,
  38. session: { id: agentId, header: { delegationDepth } },
  39. } as unknown as Agent
  40. }
  41. describe('ask_user_question tool', () => {
  42. it('registers a model-facing tool schema', async () => {
  43. const ctx = await setup()
  44. const schema = ctx.tools.schemas().find(tool => tool.name === 'ask_user_question')
  45. expect(schema).toMatchObject({
  46. name: 'ask_user_question',
  47. parameters: {
  48. type: 'object',
  49. properties: {
  50. questions: { type: 'array' },
  51. },
  52. required: ['questions'],
  53. },
  54. })
  55. const parameters = schema?.parameters as unknown as OptionSchemaShape
  56. expect(parameters.properties.questions.items.properties).toMatchObject({
  57. id: { type: 'string' },
  58. question: { type: 'string' },
  59. header: { type: 'string' },
  60. options: { type: 'array' },
  61. multi_select: { type: 'boolean' },
  62. })
  63. expect(parameters.properties.questions.items.properties.options.items.properties).toMatchObject({
  64. label: { type: 'string' },
  65. description: { type: 'string' },
  66. })
  67. expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('value')
  68. expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('recommended')
  69. expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('preview')
  70. })
  71. it('asks the registered user-interaction provider and projects structured answers to text', async () => {
  72. const ctx = await setup()
  73. const seen: AskUserQuestionRequest[] = []
  74. ctx.userInteraction.registerProvider({
  75. async ask(request) {
  76. seen.push(request)
  77. return { answers: [{ id: 'pkg', selected: ['pnpm'] }] }
  78. },
  79. })
  80. const result = await ctx.tools.execute({
  81. signal: testToolSignal,
  82. callId: CallId('ask-1'),
  83. name: 'ask_user_question',
  84. arguments: {
  85. questions: [{
  86. id: 'pkg',
  87. question: 'Which package manager should I use?',
  88. options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }],
  89. }],
  90. },
  91. })
  92. expect(result).toMatchObject({
  93. isError: false,
  94. content: [{ type: 'text', text: '{"answers":[{"id":"pkg","selected":["pnpm"]}]}' }],
  95. })
  96. expect(seen).toMatchObject([{
  97. questions: [{
  98. id: 'pkg',
  99. question: 'Which package manager should I use?',
  100. options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }],
  101. }],
  102. }])
  103. })
  104. it('passes recommended option labels through without adding schema fields', async () => {
  105. const ctx = await setup()
  106. const seen: AskUserQuestionRequest[] = []
  107. ctx.userInteraction.registerProvider({
  108. async ask(request) {
  109. seen.push(request)
  110. return { answers: [{ id: 'pkg', selected: ['pnpm (Recommended)'] }] }
  111. },
  112. })
  113. await ctx.tools.execute({
  114. signal: testToolSignal,
  115. callId: CallId('ask-recommended'),
  116. name: 'ask_user_question',
  117. arguments: {
  118. questions: [{
  119. id: 'pkg',
  120. question: 'Which package manager should I use?',
  121. options: [
  122. { label: 'pnpm (Recommended)' },
  123. { label: 'npm' },
  124. ],
  125. }],
  126. },
  127. })
  128. expect(seen[0]?.questions[0]?.options).toEqual([
  129. { label: 'pnpm (Recommended)' },
  130. { label: 'npm' },
  131. ])
  132. })
  133. it('projects custom answers and multi-select choices', async () => {
  134. const ctx = await setup()
  135. ctx.userInteraction.registerProvider({
  136. async ask() {
  137. return {
  138. answers: [
  139. { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' },
  140. { id: 'labels-only', selected: ['tests'] },
  141. { id: 'notes', selected: [], custom: 'ship today' },
  142. ],
  143. }
  144. },
  145. })
  146. const result = await ctx.tools.execute({
  147. signal: testToolSignal,
  148. callId: CallId('ask-multi'),
  149. name: 'ask_user_question',
  150. arguments: {
  151. questions: [
  152. {
  153. id: 'targets',
  154. question: 'What should I update?',
  155. options: [{ label: 'tests' }, { label: 'docs' }],
  156. multi_select: true,
  157. },
  158. {
  159. id: 'labels-only',
  160. question: 'Which labels should I keep?',
  161. options: [{ label: 'tests' }, { label: 'docs' }],
  162. multi_select: true,
  163. },
  164. { id: 'notes', question: 'Any note?' },
  165. ],
  166. },
  167. })
  168. expect(result.isError).toBe(false)
  169. if (result.isError) throw new Error('expected ask_user_question success')
  170. expect(result.value).toEqual({
  171. answers: [
  172. { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' },
  173. { id: 'labels-only', selected: ['tests'] },
  174. { id: 'notes', selected: [], custom: 'ship today' },
  175. ],
  176. })
  177. expect(result.content).toEqual([{
  178. type: 'text',
  179. text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"labels-only","selected":["tests"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
  180. }])
  181. })
  182. it('passes the tool abort signal to the user-interaction request', async () => {
  183. const ctx = await setup()
  184. const seen: AskUserQuestionRequest[] = []
  185. ctx.userInteraction.registerProvider({
  186. async ask(request) {
  187. seen.push(request)
  188. return { answers: [{ id: 'continue', selected: ['ok'] }] }
  189. },
  190. })
  191. const controller = new AbortController()
  192. await ctx.tools.execute({
  193. callId: CallId('ask-2'),
  194. name: 'ask_user_question',
  195. arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
  196. signal: controller.signal,
  197. })
  198. expect(seen[0]?.signal).toBe(controller.signal)
  199. })
  200. it('passes optional header and a resumed runtime root through to the user-interaction request', async () => {
  201. const ctx = await setup()
  202. const seen: AskUserQuestionRequest[] = []
  203. ctx.userInteraction.registerProvider({
  204. async ask(request) {
  205. seen.push(request)
  206. return { answers: [{ id: 'continue', selected: ['ok'] }] }
  207. },
  208. })
  209. const agent = stubAgent('resumed-root', 1)
  210. ctx.agents.enter(agent, undefined)
  211. const result = await ctx.tools.execute({
  212. signal: testToolSignal,
  213. callId: CallId('ask-3'),
  214. name: 'ask_user_question',
  215. arguments: { questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }] },
  216. agent,
  217. })
  218. expect(result.content).toEqual([{ type: 'text', text: '{"answers":[{"id":"continue","selected":["ok"]}]}' }])
  219. expect(seen[0]).toMatchObject({ questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }], agent })
  220. })
  221. it('returns structured user-interaction errors through tool execution', async () => {
  222. const ctx = await setup()
  223. const result = await ctx.tools.execute({
  224. signal: testToolSignal,
  225. callId: CallId('ask-no-provider'),
  226. name: 'ask_user_question',
  227. arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
  228. })
  229. expect(result).toMatchObject({
  230. isError: true,
  231. error: { info: { name: 'UserInteractionError', code: 'NO_PROVIDER' } },
  232. })
  233. })
  234. it('rejects a live runtime-owned agent with a structured DELEGATED_CALLER error', async () => {
  235. const ctx = await setup()
  236. const seen: AskUserQuestionRequest[] = []
  237. ctx.userInteraction.registerProvider({
  238. async ask(request) {
  239. seen.push(request)
  240. return { answers: [{ id: 'continue', selected: ['ok'] }] }
  241. },
  242. })
  243. const root = stubAgent('root', 0)
  244. const child = stubAgent('child', 0)
  245. ctx.agents.enter(root, undefined)
  246. ctx.agents.enter(child, root)
  247. const result = await ctx.tools.execute({
  248. signal: testToolSignal,
  249. callId: CallId('ask-delegated'),
  250. name: 'ask_user_question',
  251. arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
  252. agent: child,
  253. })
  254. expect(result).toMatchObject({
  255. isError: true,
  256. error: { info: { name: 'UserInteractionError', code: 'DELEGATED_CALLER' } },
  257. content: [{
  258. type: 'text',
  259. text: "Error: 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",
  260. }],
  261. })
  262. expect(seen).toHaveLength(0)
  263. })
  264. it('returns a structured error for empty question batches', async () => {
  265. const ctx = await setup()
  266. const result = await ctx.tools.execute({
  267. signal: testToolSignal,
  268. callId: CallId('ask-empty'),
  269. name: 'ask_user_question',
  270. arguments: { questions: [] },
  271. })
  272. expect(result).toMatchObject({
  273. isError: true,
  274. error: { info: { name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' } },
  275. })
  276. })
  277. it('unregisters the tool when its plugin fiber is disposed', async () => {
  278. const ctx = new Context()
  279. await ctx.plugin(SystemPrompt)
  280. await ctx.plugin(ToolRegistry)
  281. await ctx.plugin(UserInteractionService)
  282. const fiber = await ctx.plugin(toolAskUser)
  283. expect(ctx.tools.get('ask_user_question')).toBeDefined()
  284. await fiber.dispose()
  285. expect(ctx.tools.get('ask_user_question')).toBeUndefined()
  286. })
  287. })