import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction' import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' const testToolSignal = new AbortController().signal interface OptionSchemaShape { properties: { questions: { items: { properties: { options: { items: { properties: Record } } } & Record } } } } async function setup() { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(UserInteractionService) await ctx.plugin(toolAskUser) return ctx } describe('ask_user_question tool', () => { it('registers a model-facing tool schema', async () => { const ctx = await setup() const schema = ctx.tools.schemas().find(tool => tool.name === 'ask_user_question') expect(schema).toMatchObject({ name: 'ask_user_question', parameters: { type: 'object', properties: { questions: { type: 'array' }, }, required: ['questions'], }, }) const parameters = schema?.parameters as unknown as OptionSchemaShape expect(parameters.properties.questions.items.properties).toMatchObject({ id: { type: 'string' }, question: { type: 'string' }, header: { type: 'string' }, options: { type: 'array' }, multi_select: { type: 'boolean' }, }) expect(parameters.properties.questions.items.properties.options.items.properties).toMatchObject({ label: { type: 'string' }, description: { type: 'string' }, }) expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('value') expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('recommended') expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('preview') }) it('asks the registered user-interaction provider and projects structured answers to text', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] ctx.userInteraction.registerProvider({ async ask(request) { seen.push(request) return { answers: [{ id: 'pkg', selected: ['pnpm'] }] } }, }) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('ask-1'), name: 'ask_user_question', arguments: { questions: [{ id: 'pkg', question: 'Which package manager should I use?', options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }], }], }, }) expect(result).toMatchObject({ isError: false, content: [{ type: 'text', text: '{"answers":[{"id":"pkg","selected":["pnpm"]}]}' }], }) expect(seen).toMatchObject([{ questions: [{ id: 'pkg', question: 'Which package manager should I use?', options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }], }], }]) }) it('passes recommended option labels through without adding schema fields', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] ctx.userInteraction.registerProvider({ async ask(request) { seen.push(request) return { answers: [{ id: 'pkg', selected: ['pnpm (Recommended)'] }] } }, }) await ctx.tools.execute({ signal: testToolSignal, callId: CallId('ask-recommended'), name: 'ask_user_question', arguments: { questions: [{ id: 'pkg', question: 'Which package manager should I use?', options: [ { label: 'pnpm (Recommended)' }, { label: 'npm' }, ], }], }, }) expect(seen[0]?.questions[0]?.options).toEqual([ { label: 'pnpm (Recommended)' }, { label: 'npm' }, ]) }) it('projects custom answers and multi-select choices', async () => { const ctx = await setup() ctx.userInteraction.registerProvider({ async ask() { return { answers: [ { id: 'targets', selected: ['tests', 'docs'] }, { id: 'notes', selected: [], custom: 'ship today' }, ], } }, }) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('ask-multi'), name: 'ask_user_question', arguments: { questions: [ { id: 'targets', question: 'What should I update?', options: [{ label: 'tests' }, { label: 'docs' }], multi_select: true, }, { id: 'notes', question: 'Any note?' }, ], }, }) expect(result.isError).toBe(false) if (result.isError) throw new Error('expected ask_user_question success') expect(result.value).toEqual({ answers: [ { id: 'targets', selected: ['tests', 'docs'] }, { id: 'notes', selected: [], custom: 'ship today' }, ], }) expect(result.content).toEqual([{ type: 'text', text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}', }]) }) it('passes the tool abort signal to the user-interaction request', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] ctx.userInteraction.registerProvider({ async ask(request) { seen.push(request) return { answers: [{ id: 'continue', selected: ['ok'] }] } }, }) const controller = new AbortController() await ctx.tools.execute({ callId: CallId('ask-2'), name: 'ask_user_question', arguments: { questions: [{ id: 'continue', question: 'Continue?' }] }, signal: controller.signal, }) expect(seen[0]?.signal).toBe(controller.signal) }) it('passes optional header and agent through to the user-interaction request', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] ctx.userInteraction.registerProvider({ async ask(request) { seen.push(request) return { answers: [{ id: 'continue', selected: ['ok'] }] } }, }) const agent = { id: 'main' } as unknown as Agent const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('ask-3'), name: 'ask_user_question', arguments: { questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }] }, agent, }) expect(result.content).toEqual([{ type: 'text', text: '{"answers":[{"id":"continue","selected":["ok"]}]}' }]) expect(seen[0]).toMatchObject({ questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }], agent }) }) it('returns structured user-interaction errors through tool execution', async () => { const ctx = await setup() const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('ask-no-provider'), name: 'ask_user_question', arguments: { questions: [{ id: 'continue', question: 'Continue?' }] }, }) expect(result).toMatchObject({ isError: true, error: { info: { name: 'UserInteractionError', code: 'NO_PROVIDER' } }, }) }) it('returns a structured error for empty question batches', async () => { const ctx = await setup() const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('ask-empty'), name: 'ask_user_question', arguments: { questions: [] }, }) expect(result).toMatchObject({ isError: true, error: { info: { name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' } }, }) }) it('unregisters the tool when its plugin fiber is disposed', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(UserInteractionService) const fiber = await ctx.plugin(toolAskUser) expect(ctx.tools.get('ask_user_question')).toBeDefined() await fiber.dispose() expect(ctx.tools.get('ask_user_question')).toBeUndefined() }) })