/** * Tests for the ask_user HITL clarification tool. * * Verifies: * - Tool creation and metadata (name, description, schema) * - Schema validation (min/max options, required fields) * - Legacy option selection flow (_selectedOption in args) * - Multi-step wizard flow (_selections in args) * - Fallback behavior when no selection is present */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { createAskUserTool, AskUserToolName, AskUserDescription, } from '../AskUser'; import { Constants } from '@/common'; describe('AskUser Tool', () => { const askUserTool = createAskUserTool(); // ============================================================================ // Metadata // ============================================================================ describe('metadata', () => { it('should have the correct tool name from Constants', () => { expect(AskUserToolName).toBe(Constants.ASK_USER); expect(AskUserToolName).toBe('ask_user'); expect(askUserTool.name).toBe('ask_user'); }); it('should have a non-empty description', () => { expect(AskUserDescription).toBeTruthy(); expect(typeof AskUserDescription).toBe('string'); expect(askUserTool.description).toBe(AskUserDescription); }); }); // ============================================================================ // Schema Validation // ============================================================================ describe('schema validation', () => { it('should accept valid input with question and 2 options', () => { const input = { question: 'Which format?', options: [ { label: 'PDF', value: 'pdf' }, { label: 'CSV', value: 'csv' }, ], }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(true); }); it('should accept valid input with optional context', () => { const input = { question: 'Which format?', options: [ { label: 'PDF', value: 'pdf' }, { label: 'CSV', value: 'csv' }, ], context: 'User requested a report export', }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(true); }); it('should accept options with optional description', () => { const input = { question: 'Pick one', options: [ { label: 'A', value: 'a', description: 'First option' }, { label: 'B', value: 'b' }, ], }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(true); }); it('should accept up to 6 options', () => { const input = { question: 'Pick one', options: Array.from({ length: 6 }, (_, i) => ({ label: `Option ${i + 1}`, value: `opt_${i + 1}`, })), }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(true); }); it('should reject fewer than 2 options', () => { const input = { question: 'Pick one', options: [{ label: 'Only', value: 'only' }], }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(false); }); it('should reject more than 6 options', () => { const input = { question: 'Pick one', options: Array.from({ length: 7 }, (_, i) => ({ label: `Option ${i + 1}`, value: `opt_${i + 1}`, })), }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(false); }); it('should reject when neither steps nor question+options provided', () => { const input = { context: 'some context only', }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(false); }); it('should reject missing question (legacy, no steps)', () => { const input = { options: [ { label: 'A', value: 'a' }, { label: 'B', value: 'b' }, ], }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(false); }); it('should reject missing options (legacy, no steps)', () => { const input = { question: 'Pick one' }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(false); }); it('should reject options without label', () => { const input = { question: 'Pick one', options: [{ value: 'a' }, { label: 'B', value: 'b' }], }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(false); }); it('should reject options without value', () => { const input = { question: 'Pick one', options: [{ label: 'A' }, { label: 'B', value: 'b' }], }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(false); }); }); // ============================================================================ // Tool Execution // ============================================================================ describe('execution', () => { it('should return selected option when _selectedOption is present', async () => { const result = await askUserTool.invoke({ question: 'Which format?', options: [ { label: 'PDF', value: 'pdf' }, { label: 'CSV', value: 'csv' }, ], _selectedOption: 'pdf', } as any); expect(result).toContain('User selected'); expect(result).toContain('PDF'); expect(result).toContain('pdf'); }); it('should return selected option label and value', async () => { const result = await askUserTool.invoke({ question: 'Pick approach', options: [ { label: 'Fast approach', value: 'fast', description: 'Quick but rough', }, { label: 'Thorough approach', value: 'thorough', description: 'Slow but complete', }, ], _selectedOption: 'thorough', } as any); expect(result).toContain('Thorough approach'); expect(result).toContain('thorough'); }); it('should handle _selectedOption that does not match any option value', async () => { const result = await askUserTool.invoke({ question: 'Pick one', options: [ { label: 'A', value: 'a' }, { label: 'B', value: 'b' }, ], _selectedOption: 'unknown', } as any); // Should still return a result with the raw value expect(result).toContain('unknown'); }); it('should return fallback when no _selectedOption', async () => { const result = await askUserTool.invoke({ question: 'Which format?', options: [ { label: 'PDF', value: 'pdf' }, { label: 'CSV', value: 'csv' }, ], }); expect(result).toBe('No user response received.'); }); it('should return custom input when _customInput is present', async () => { const result = await askUserTool.invoke({ question: 'Which format?', options: [ { label: 'PDF', value: 'pdf' }, { label: 'CSV', value: 'csv' }, ], _customInput: 'I want Excel format instead', } as any); expect(result).toContain('User provided custom response'); expect(result).toContain('I want Excel format instead'); }); it('should prioritize _customInput over _selectedOption', async () => { const result = await askUserTool.invoke({ question: 'Which format?', options: [ { label: 'PDF', value: 'pdf' }, { label: 'CSV', value: 'csv' }, ], _customInput: 'Neither, use XLSX', _selectedOption: 'pdf', } as any); // Custom input takes priority — user explicitly typed something expect(result).toContain('User provided custom response'); expect(result).toContain('Neither, use XLSX'); expect(result).not.toContain('User selected'); }); }); // ============================================================================ // Multi-Step Schema Validation // ============================================================================ describe('multi-step schema validation', () => { it('should accept steps[] format with single-select', () => { const input = { steps: [ { question: 'What format?', options: [ { label: 'PDF', value: 'pdf' }, { label: 'Word', value: 'docx' }, ], type: 'single', }, ], }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(true); }); it('should accept steps[] format with multi-select', () => { const input = { steps: [ { question: 'Select audiences', options: [ { label: 'Client', value: 'client' }, { label: 'Team', value: 'team' }, { label: 'Management', value: 'mgmt' }, ], type: 'multi', }, ], }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(true); }); it('should accept multiple steps', () => { const input = { steps: [ { question: 'Format?', options: [ { label: 'PDF', value: 'pdf' }, { label: 'Word', value: 'docx' }, ], }, { question: 'Audience?', options: [ { label: 'Client', value: 'client' }, { label: 'Internal', value: 'internal' }, ], }, ], }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(true); }); it('should default step type to single', () => { const input = { steps: [ { question: 'Format?', options: [ { label: 'PDF', value: 'pdf' }, { label: 'Word', value: 'docx' }, ], // no type specified }, ], }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(true); if (result.success) { expect(result.data.steps![0].type).toBe('single'); } }); it('should reject steps with fewer than 2 options', () => { const input = { steps: [ { question: 'Format?', options: [{ label: 'PDF', value: 'pdf' }], }, ], }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(false); }); it('should reject more than 5 steps', () => { const input = { steps: Array.from({ length: 6 }, (_, i) => ({ question: `Q${i + 1}?`, options: [ { label: 'A', value: 'a' }, { label: 'B', value: 'b' }, ], })), }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(false); }); it('should still accept legacy question+options format', () => { const input = { question: 'Which format?', options: [ { label: 'PDF', value: 'pdf' }, { label: 'CSV', value: 'csv' }, ], }; const result = askUserTool.schema.safeParse(input); expect(result.success).toBe(true); }); }); // ============================================================================ // Multi-Step Execution // ============================================================================ describe('multi-step execution', () => { it('should format multi-step response from _selections', async () => { const result = await askUserTool.invoke({ steps: [ { question: 'What format?', options: [ { label: 'PDF', value: 'pdf' }, { label: 'Word', value: 'docx' }, ], type: 'single', }, { question: 'What audience?', options: [ { label: 'Client', value: 'client' }, { label: 'Team', value: 'team' }, ], type: 'single', }, ], _selections: [{ values: ['pdf'] }, { values: ['team'] }], } as any); expect(result).toContain('User responses:'); expect(result).toContain('1. What format? → PDF'); expect(result).toContain('2. What audience? → Team'); }); it('should handle skipped steps in _selections', async () => { const result = await askUserTool.invoke({ steps: [ { question: 'Format?', options: [ { label: 'PDF', value: 'pdf' }, { label: 'Word', value: 'docx' }, ], type: 'single', }, { question: 'Tone?', options: [ { label: 'Formal', value: 'formal' }, { label: 'Casual', value: 'casual' }, ], type: 'single', }, ], _selections: [ { values: ['pdf'] }, { values: [], customInput: undefined }, ], } as any); expect(result).toContain('1. Format? → PDF'); expect(result).toContain('2. Tone? → (skipped)'); }); it('should handle customInput per step in _selections', async () => { const result = await askUserTool.invoke({ steps: [ { question: 'Format?', options: [ { label: 'PDF', value: 'pdf' }, { label: 'Word', value: 'docx' }, ], type: 'single', }, { question: 'Audience?', options: [ { label: 'Client', value: 'client' }, { label: 'Team', value: 'team' }, ], type: 'single', }, ], _selections: [ { values: ['pdf'] }, { values: [], customInput: 'Board of directors' }, ], } as any); expect(result).toContain('1. Format? → PDF'); expect(result).toContain('2. Audience? → "Board of directors"'); }); it('should handle multi-select values in _selections', async () => { const result = await askUserTool.invoke({ steps: [ { question: 'Select audiences', options: [ { label: 'Client', value: 'client' }, { label: 'Team', value: 'team' }, { label: 'Management', value: 'mgmt' }, ], type: 'multi', }, ], _selections: [{ values: ['client', 'mgmt'] }], } as any); expect(result).toContain('1. Select audiences → Client, Management'); }); it('should handle missing selections for a step (fewer _selections than steps)', async () => { const result = await askUserTool.invoke({ steps: [ { question: 'Format?', options: [ { label: 'PDF', value: 'pdf' }, { label: 'Word', value: 'docx' }, ], type: 'single', }, { question: 'Tone?', options: [ { label: 'Formal', value: 'formal' }, { label: 'Casual', value: 'casual' }, ], type: 'single', }, ], _selections: [{ values: ['pdf'] }], } as any); expect(result).toContain('1. Format? → PDF'); expect(result).toContain('2. Tone? → (skipped)'); }); it('backward compat: _selectedOption still works without steps', async () => { const result = await askUserTool.invoke({ question: 'Which format?', options: [ { label: 'PDF', value: 'pdf' }, { label: 'CSV', value: 'csv' }, ], _selectedOption: 'csv', } as any); expect(result).toContain('User selected'); expect(result).toContain('CSV'); expect(result).toContain('csv'); }); it('backward compat: _customInput still works without steps', async () => { const result = await askUserTool.invoke({ question: 'Which format?', options: [ { label: 'PDF', value: 'pdf' }, { label: 'CSV', value: 'csv' }, ], _customInput: 'I prefer Excel', } as any); expect(result).toContain('User provided custom response'); expect(result).toContain('I prefer Excel'); }); }); });