import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import UserQuestionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-questions' 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(AgentRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(UserQuestionService) await ctx.plugin(toolAskUser) return ctx } function stubAgent(id: string, delegationDepth = 0): Agent { const agentId = id as Agent['id'] return { id: agentId, session: { id: agentId, header: { delegationDepth } }, } as unknown as Agent } 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-questions provider and projects structured answers to text', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] ctx.userQuestions.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.userQuestions.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.userQuestions.registerProvider({ async ask() { return { answers: [ { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' }, { id: 'labels-only', selected: ['tests'] }, { 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: 'labels-only', question: 'Which labels should I keep?', 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'], custom: 'release notes' }, { id: 'labels-only', selected: ['tests'] }, { id: 'notes', selected: [], custom: 'ship today' }, ], }) expect(result.content).toEqual([{ type: 'text', text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"labels-only","selected":["tests"]},{"id":"notes","selected":[],"custom":"ship today"}]}', }]) }) it('passes the tool abort signal to the user-questions request', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] ctx.userQuestions.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 a resumed runtime root through to the user-questions request', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] ctx.userQuestions.registerProvider({ async ask(request) { seen.push(request) return { answers: [{ id: 'continue', selected: ['ok'] }] } }, }) const agent = stubAgent('resumed-root', 1) ctx.agents.enter(agent, undefined) 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-questions 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: 'UserQuestionError', code: 'NO_PROVIDER' } }, }) }) it('rejects a live runtime-owned agent with a structured DELEGATED_CALLER error', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] ctx.userQuestions.registerProvider({ async ask(request) { seen.push(request) return { answers: [{ id: 'continue', selected: ['ok'] }] } }, }) const root = stubAgent('root', 0) const child = stubAgent('child', 0) ctx.agents.enter(root, undefined) ctx.agents.enter(child, root) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('ask-delegated'), name: 'ask_user_question', arguments: { questions: [{ id: 'continue', question: 'Continue?' }] }, agent: child, }) expect(result).toMatchObject({ isError: true, error: { info: { name: 'UserQuestionError', code: 'DELEGATED_CALLER' } }, content: [{ type: 'text', 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", }], }) expect(seen).toHaveLength(0) }) 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: 'UserQuestionError', 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(ToolRuntime) await ctx.plugin(UserQuestionService) 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() }) })