tool-ask-user.spec.ts 11 KB

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