import { describe, expect, it, vi } from 'vitest' import { createExecutionToolSchema, createLLMToolSchema, createUserToolSchema, filterSchemaForLLM, formatParameterLabel, getToolParametersConfig, isPasswordParameter, mergeToolParameters, type ToolParameterConfig, type ToolSchema, type ValidationResult, validateToolParameters, } from '@/tools/params' import type { HttpMethod, ParameterVisibility } from '@/tools/types' const mockToolConfig = { id: 'test_tool', name: 'Test Tool', description: 'A test tool for parameter handling', version: '1.0.0', params: { apiKey: { type: 'string', required: true, visibility: 'user-only' as ParameterVisibility, description: 'API key for authentication', }, message: { type: 'string', required: true, visibility: 'user-or-llm' as ParameterVisibility, description: 'Message to send', }, channel: { type: 'string', required: false, visibility: 'user-only' as ParameterVisibility, description: 'Channel to send message to', }, timeout: { type: 'number', required: false, visibility: 'user-only' as ParameterVisibility, default: 5000, description: 'Request timeout in milliseconds', }, }, request: { url: 'https://api.example.com/test', method: 'POST' as HttpMethod, headers: () => ({}), }, } vi.mock('@/tools/utils', () => ({ getTool: vi.fn((toolId: string) => { if (toolId === 'test_tool') { return mockToolConfig } return null }), })) describe('Tool Parameters Utils', () => { describe('getToolParametersConfig', () => { it.concurrent('should return tool parameters configuration', () => { const result = getToolParametersConfig('test_tool') expect(result).toBeDefined() expect(result?.toolConfig).toEqual(mockToolConfig) expect(result?.allParameters).toHaveLength(4) expect(result?.userInputParameters).toHaveLength(4) // apiKey, message, channel, timeout (all have visibility) expect(result?.requiredParameters).toHaveLength(2) // apiKey, message (both required: true) expect(result?.optionalParameters).toHaveLength(2) // channel, timeout (both user-only + required: false) }) it.concurrent('should return null for non-existent tool', () => { const result = getToolParametersConfig('non_existent_tool') expect(result).toBeNull() }) }) describe('createLLMToolSchema', () => { it.concurrent('should create schema excluding user-provided parameters', async () => { const userProvidedParams = { apiKey: 'user-provided-key', channel: '#general', } const { schema } = await createLLMToolSchema(mockToolConfig, userProvidedParams) expect(schema.properties).not.toHaveProperty('apiKey') // user-only, excluded expect(schema.properties).not.toHaveProperty('channel') // user-provided, excluded expect(schema.properties).toHaveProperty('message') // user-or-llm, included expect(schema.properties).not.toHaveProperty('timeout') // user-only, excluded expect(schema.required).toContain('message') // user-or-llm + required: true expect(schema.required).not.toContain('apiKey') // user-only, never required for LLM }) it.concurrent('should include all parameters when none are user-provided', async () => { const { schema } = await createLLMToolSchema(mockToolConfig, {}) expect(schema.properties).not.toHaveProperty('apiKey') // user-only, never shown to LLM expect(schema.properties).toHaveProperty('message') // user-or-llm, shown to LLM expect(schema.properties).not.toHaveProperty('channel') // user-only, never shown to LLM expect(schema.properties).not.toHaveProperty('timeout') // user-only, never shown to LLM expect(schema.required).not.toContain('apiKey') // user-only, never required for LLM expect(schema.required).toContain('message') // user-or-llm + required: true }) }) describe('createUserToolSchema', () => { it.concurrent('should include user-only parameters and omit hidden ones', () => { const toolWithHiddenParam = { ...mockToolConfig, id: 'user_schema_tool', params: { ...mockToolConfig.params, spreadsheetId: { type: 'string', required: true, visibility: 'user-only' as ParameterVisibility, description: 'Spreadsheet ID to operate on', }, accessToken: { type: 'string', required: true, visibility: 'hidden' as ParameterVisibility, description: 'OAuth access token', }, }, } const schema = createUserToolSchema(toolWithHiddenParam) expect(schema.properties).toHaveProperty('spreadsheetId') expect(schema.required).toContain('spreadsheetId') expect(schema.properties).not.toHaveProperty('accessToken') expect(schema.required).not.toContain('accessToken') expect(schema.properties).toHaveProperty('message') }) it.concurrent('adds credentialId only for copilot-facing oauth schemas', () => { const oauthTool = { ...mockToolConfig, id: 'oauth_schema_tool', oauth: { required: true, provider: 'google-email', }, params: { message: { type: 'string', required: true, visibility: 'user-or-llm' as ParameterVisibility, description: 'Message to send', }, accessToken: { type: 'string', required: true, visibility: 'hidden' as ParameterVisibility, description: 'OAuth access token', }, }, } const defaultSchema = createUserToolSchema(oauthTool) const copilotSchema = createUserToolSchema(oauthTool, { surface: 'copilot' }) expect(defaultSchema.properties).not.toHaveProperty('credentialId') expect(copilotSchema.properties).toHaveProperty('credentialId') expect(copilotSchema.properties.credentialId).toMatchObject({ type: 'string', }) expect(copilotSchema.required).toContain('credentialId') }) it.concurrent('keeps shared file params unchanged by default', () => { const toolWithFileParam = { ...mockToolConfig, id: 'file_schema_tool', params: { attachment: { type: 'file', required: true, visibility: 'user-or-llm' as ParameterVisibility, description: 'Attachment file', }, }, } const schema = createUserToolSchema(toolWithFileParam) expect(schema.properties.attachment).toMatchObject({ type: 'file', description: 'Attachment file', }) }) it.concurrent('expands file params for copilot-facing schemas', () => { const toolWithFileParams = { ...mockToolConfig, id: 'copilot_file_schema_tool', params: { attachment: { type: 'file', required: true, visibility: 'user-or-llm' as ParameterVisibility, description: 'Attachment file', }, attachments: { type: 'file[]', required: false, visibility: 'user-or-llm' as ParameterVisibility, description: 'Attachment files', }, }, } const schema = createUserToolSchema(toolWithFileParams, { surface: 'copilot' }) expect(schema.properties.attachment).toMatchObject({ type: 'object', required: ['id', 'name', 'url', 'size', 'type', 'key'], }) expect(schema.properties.attachment.description).toContain('canonical workspace file IDs') expect(schema.properties.attachments).toMatchObject({ type: 'array', }) expect(schema.properties.attachments.description).toContain('canonical workspace file IDs') }) }) describe('createExecutionToolSchema', () => { it.concurrent('should create complete schema with all parameters', () => { const schema = createExecutionToolSchema(mockToolConfig) expect(schema.properties).toHaveProperty('apiKey') expect(schema.properties).toHaveProperty('message') expect(schema.properties).toHaveProperty('channel') expect(schema.properties).toHaveProperty('timeout') expect(schema.required).toContain('apiKey') expect(schema.required).toContain('message') expect(schema.required).not.toContain('channel') expect(schema.required).not.toContain('timeout') }) }) describe('mergeToolParameters', () => { it.concurrent('should merge parameters with user-provided taking precedence', () => { const userProvided = { apiKey: 'user-key', channel: '#general', } const llmGenerated = { message: 'Hello world', channel: '#random', timeout: 10000, } const merged = mergeToolParameters(userProvided, llmGenerated) expect(merged.apiKey).toBe('user-key') expect(merged.channel).toBe('#general') expect(merged.message).toBe('Hello world') expect(merged.timeout).toBe(10000) }) it.concurrent('should skip empty strings so LLM values are used', () => { const userProvided = { apiKey: 'user-key', channel: '', // User cleared this field message: '', // User cleared this field too } const llmGenerated = { message: 'Hello world', channel: '#random', timeout: 10000, } const merged = mergeToolParameters(userProvided, llmGenerated) expect(merged.apiKey).toBe('user-key') // Non-empty user value preserved expect(merged.channel).toBe('#random') // LLM value used because user value was empty expect(merged.message).toBe('Hello world') // LLM value used because user value was empty expect(merged.timeout).toBe(10000) }) it.concurrent('should skip null and undefined values', () => { const userProvided = { apiKey: 'user-key', channel: null, message: undefined, } const llmGenerated = { message: 'Hello world', channel: '#random', } const merged = mergeToolParameters(userProvided, llmGenerated) expect(merged.apiKey).toBe('user-key') expect(merged.channel).toBe('#random') // LLM value used expect(merged.message).toBe('Hello world') // LLM value used }) }) describe('validateToolParameters', () => { it.concurrent('should validate successfully with all required parameters', () => { const finalParams = { apiKey: 'test-key', message: 'Hello world', channel: '#general', } const result = validateToolParameters(mockToolConfig, finalParams) expect(result.valid).toBe(true) expect(result.missingParams).toHaveLength(0) }) it.concurrent('should fail validation with missing required parameters', () => { const finalParams = { channel: '#general', } const result = validateToolParameters(mockToolConfig, finalParams) expect(result.valid).toBe(false) expect(result.missingParams).toContain('apiKey') expect(result.missingParams).toContain('message') }) }) describe('filterSchemaForLLM', () => { it.concurrent('should filter out user-provided parameters from schema', () => { const originalSchema: ToolSchema = { type: 'object' as const, properties: { apiKey: { type: 'string', description: 'API key' }, message: { type: 'string', description: 'Message' }, channel: { type: 'string', description: 'Channel' }, }, required: ['apiKey', 'message'], } const userProvidedParams = { apiKey: 'user-key', channel: '#general', } const filtered = filterSchemaForLLM(originalSchema, userProvidedParams) expect(filtered.properties).not.toHaveProperty('apiKey') expect(filtered.properties).not.toHaveProperty('channel') expect(filtered.properties).toHaveProperty('message') expect(filtered.required).not.toContain('apiKey') expect(filtered.required).toContain('message') }) }) describe('formatParameterLabel', () => { it.concurrent('should format parameter labels correctly', () => { expect(formatParameterLabel('apiKey')).toBe('API Key') expect(formatParameterLabel('apiVersion')).toBe('API Version') expect(formatParameterLabel('userName')).toBe('User Name') expect(formatParameterLabel('user_name')).toBe('User Name') expect(formatParameterLabel('user-name')).toBe('User Name') expect(formatParameterLabel('message')).toBe('Message') expect(formatParameterLabel('a')).toBe('A') }) }) describe('isPasswordParameter', () => { it.concurrent('should identify password parameters correctly', () => { expect(isPasswordParameter('password')).toBe(true) expect(isPasswordParameter('apiKey')).toBe(true) expect(isPasswordParameter('token')).toBe(true) expect(isPasswordParameter('secret')).toBe(true) expect(isPasswordParameter('accessToken')).toBe(true) expect(isPasswordParameter('message')).toBe(false) expect(isPasswordParameter('channel')).toBe(false) expect(isPasswordParameter('timeout')).toBe(false) }) }) describe('workflow_executor inputMapping handling', () => { const mockWorkflowExecutorConfig = { id: 'workflow_executor', name: 'Workflow Executor', description: 'Execute another workflow', version: '1.0.0', params: { workflowId: { type: 'string', required: true, visibility: 'user-or-llm' as ParameterVisibility, description: 'The ID of the workflow to execute', }, inputMapping: { type: 'object', required: false, visibility: 'user-or-llm' as ParameterVisibility, description: 'Map inputs to the selected workflow', }, }, request: { url: 'https://api.example.com/workflows', method: 'POST' as HttpMethod, headers: () => ({}), }, } describe('createLLMToolSchema - inputMapping always included', () => { it.concurrent( 'should include inputMapping in schema even when user provides empty object', async () => { const userProvidedParams = { workflowId: 'workflow-123', inputMapping: '{}', } const { schema } = await createLLMToolSchema( mockWorkflowExecutorConfig, userProvidedParams ) expect(schema.properties).toHaveProperty('inputMapping') expect(schema.properties.inputMapping.type).toBe('object') } ) it.concurrent( 'should include inputMapping in schema even when user provides object with empty values', async () => { const userProvidedParams = { workflowId: 'workflow-123', inputMapping: '{"query": "", "limit": ""}', } const { schema } = await createLLMToolSchema( mockWorkflowExecutorConfig, userProvidedParams ) expect(schema.properties).toHaveProperty('inputMapping') } ) it.concurrent( 'should include inputMapping when user has not provided it at all', async () => { const userProvidedParams = { workflowId: 'workflow-123', } const { schema } = await createLLMToolSchema( mockWorkflowExecutorConfig, userProvidedParams ) expect(schema.properties).toHaveProperty('inputMapping') } ) it.concurrent('should exclude workflowId from schema when user provides it', async () => { const userProvidedParams = { workflowId: 'workflow-123', } const { schema } = await createLLMToolSchema(mockWorkflowExecutorConfig, userProvidedParams) expect(schema.properties).not.toHaveProperty('workflowId') expect(schema.properties).toHaveProperty('inputMapping') }) }) describe('mergeToolParameters - inputMapping deep merge', () => { it.concurrent('should deep merge inputMapping when user provides empty object', () => { const userProvided = { workflowId: 'workflow-123', inputMapping: '{}', } const llmGenerated = { inputMapping: { query: 'search term', limit: 10 }, } const merged = mergeToolParameters(userProvided, llmGenerated) expect(merged.inputMapping).toEqual({ query: 'search term', limit: 10 }) expect(merged.workflowId).toBe('workflow-123') }) it.concurrent('should deep merge inputMapping when user provides partial values', () => { const userProvided = { workflowId: 'workflow-123', inputMapping: '{"query": "", "customField": "user-value"}', } const llmGenerated = { inputMapping: { query: 'llm-search', limit: 10 }, } const merged = mergeToolParameters(userProvided, llmGenerated) expect(merged.inputMapping).toEqual({ query: 'llm-search', limit: 10, customField: 'user-value', }) }) it.concurrent('should preserve user inputMapping values when they are non-empty', () => { const userProvided = { workflowId: 'workflow-123', inputMapping: '{"query": "user-search", "limit": 5}', } const llmGenerated = { inputMapping: { query: 'llm-search', limit: 10, extra: 'field' }, } const merged = mergeToolParameters(userProvided, llmGenerated) expect(merged.inputMapping).toEqual({ query: 'user-search', limit: 5, extra: 'field', }) }) it.concurrent('should handle inputMapping as object (not JSON string)', () => { const userProvided = { workflowId: 'workflow-123', inputMapping: { query: '', customField: 'user-value' }, } const llmGenerated = { inputMapping: { query: 'llm-search', limit: 10 }, } const merged = mergeToolParameters(userProvided, llmGenerated) expect(merged.inputMapping).toEqual({ query: 'llm-search', limit: 10, customField: 'user-value', }) }) it.concurrent('should use LLM inputMapping when user does not provide it', () => { const userProvided = { workflowId: 'workflow-123', } const llmGenerated = { inputMapping: { query: 'llm-search', limit: 10 }, } const merged = mergeToolParameters(userProvided, llmGenerated) expect(merged.inputMapping).toEqual({ query: 'llm-search', limit: 10 }) }) it.concurrent('should use user inputMapping when LLM does not provide it', () => { const userProvided = { workflowId: 'workflow-123', inputMapping: '{"query": "user-search"}', } const llmGenerated = {} const merged = mergeToolParameters(userProvided, llmGenerated) expect(merged.inputMapping).toEqual({ query: 'user-search' }) }) it.concurrent('should handle invalid JSON in user inputMapping gracefully', () => { const userProvided = { workflowId: 'workflow-123', inputMapping: 'not valid json {', } const llmGenerated = { inputMapping: { query: 'llm-search' }, } const merged = mergeToolParameters(userProvided, llmGenerated) expect(merged.inputMapping).toEqual({ query: 'llm-search' }) }) it.concurrent( 'should fill field when user typed something then removed it (field becomes empty string)', () => { const userProvided = { workflowId: 'workflow-123', inputMapping: '{"query": ""}', } const llmGenerated = { inputMapping: { query: 'llm-generated-search' }, } const merged = mergeToolParameters(userProvided, llmGenerated) expect(merged.inputMapping).toEqual({ query: 'llm-generated-search' }) } ) it.concurrent('should not affect other parameters - normal override behavior', () => { const userProvided = { apiKey: 'user-key', channel: '#general', } const llmGenerated = { message: 'Hello world', channel: '#random', } const merged = mergeToolParameters(userProvided, llmGenerated) expect(merged.apiKey).toBe('user-key') expect(merged.channel).toBe('#general') expect(merged.message).toBe('Hello world') }) it.concurrent('should preserve 0 and false as valid user values in inputMapping', () => { const userProvided = { workflowId: 'workflow-123', inputMapping: '{"limit": 0, "enabled": false, "query": ""}', } const llmGenerated = { inputMapping: { limit: 10, enabled: true, query: 'llm-search' }, } const merged = mergeToolParameters(userProvided, llmGenerated) // 0 and false should be preserved (they're valid values) // empty string should be filled by LLM expect(merged.inputMapping).toEqual({ limit: 0, enabled: false, query: 'llm-search', }) }) }) }) describe('Type Interface Validation', () => { it.concurrent('should have properly typed ToolSchema', async () => { const { schema } = await createLLMToolSchema(mockToolConfig, {}) expect(schema.type).toBe('object') expect(typeof schema.properties).toBe('object') expect(Array.isArray(schema.required)).toBe(true) Object.values(schema.properties).forEach((prop) => { expect(prop).toHaveProperty('type') expect(prop).toHaveProperty('description') expect(typeof prop.type).toBe('string') expect(typeof prop.description).toBe('string') }) }) it.concurrent('should have properly typed ValidationResult', () => { const result: ValidationResult = validateToolParameters(mockToolConfig, {}) expect(typeof result.valid).toBe('boolean') expect(Array.isArray(result.missingParams)).toBe(true) expect(result.missingParams.every((param) => typeof param === 'string')).toBe(true) }) it.concurrent('should have properly typed ToolParameterConfig', () => { const config = getToolParametersConfig('test_tool') expect(config).toBeDefined() if (config) { config.allParameters.forEach((param: ToolParameterConfig) => { expect(typeof param.id).toBe('string') expect(typeof param.type).toBe('string') expect(typeof param.required).toBe('boolean') expect( ['user-or-llm', 'user-only', 'llm-only', 'hidden'].includes(param.visibility!) ).toBe(true) if (param.description) expect(typeof param.description).toBe('string') if (param.uiComponent) { expect(typeof param.uiComponent.type).toBe('string') } }) } }) }) })