"""Test function calling module.""" import json from unittest.mock import patch import pytest from litellm import ModelResponse from openhands.agenthub.codeact_agent.function_calling import response_to_actions from openhands.core.exceptions import FunctionCallValidationError from openhands.events.action import ( BrowseInteractiveAction, CmdRunAction, FileEditAction, FileReadAction, IPythonRunCellAction, ) from openhands.events.event import FileEditSource, FileReadSource def create_mock_response(function_name: str, arguments: dict) -> ModelResponse: """Helper function to create a mock response with a tool call.""" return ModelResponse( id='mock-id', choices=[ { 'message': { 'tool_calls': [ { 'function': { 'name': function_name, 'arguments': json.dumps(arguments), }, 'id': 'mock-tool-call-id', 'type': 'function', } ], 'content': None, 'role': 'assistant', }, 'index': 0, 'finish_reason': 'tool_calls', } ], ) def test_execute_bash_valid(): """Test execute_bash with valid arguments.""" response = create_mock_response( 'execute_bash', {'command': 'ls', 'is_input': 'false'} ) actions = response_to_actions(response) assert len(actions) == 1 assert isinstance(actions[0], CmdRunAction) assert actions[0].command == 'ls' assert actions[0].is_input is False # Test with timeout parameter with patch.object(CmdRunAction, 'set_hard_timeout') as mock_set_hard_timeout: response_with_timeout = create_mock_response( 'execute_bash', {'command': 'ls', 'is_input': 'false', 'timeout': 30} ) actions_with_timeout = response_to_actions(response_with_timeout) # Verify set_hard_timeout was called with the correct value mock_set_hard_timeout.assert_called_once_with(30.0) assert len(actions_with_timeout) == 1 assert isinstance(actions_with_timeout[0], CmdRunAction) assert actions_with_timeout[0].command == 'ls' assert actions_with_timeout[0].is_input is False def test_execute_bash_missing_command(): """Test execute_bash with missing command argument.""" response = create_mock_response('execute_bash', {'is_input': 'false'}) with pytest.raises(FunctionCallValidationError) as exc_info: response_to_actions(response) assert 'Missing required argument "command"' in str(exc_info.value) def test_execute_ipython_cell_valid(): """Test execute_ipython_cell with valid arguments.""" response = create_mock_response('execute_ipython_cell', {'code': "print('hello')"}) actions = response_to_actions(response) assert len(actions) == 1 assert isinstance(actions[0], IPythonRunCellAction) assert actions[0].code == "print('hello')" def test_execute_ipython_cell_missing_code(): """Test execute_ipython_cell with missing code argument.""" response = create_mock_response('execute_ipython_cell', {}) with pytest.raises(FunctionCallValidationError) as exc_info: response_to_actions(response) assert 'Missing required argument "code"' in str(exc_info.value) def test_edit_file_valid(): """Test edit_file with valid arguments.""" response = create_mock_response( 'edit_file', {'path': '/path/to/file', 'content': 'file content', 'start': 1, 'end': 10}, ) actions = response_to_actions(response) assert len(actions) == 1 assert isinstance(actions[0], FileEditAction) assert actions[0].path == '/path/to/file' assert actions[0].content == 'file content' assert actions[0].start == 1 assert actions[0].end == 10 def test_edit_file_missing_required(): """Test edit_file with missing required arguments.""" # Missing path response = create_mock_response('edit_file', {'content': 'content'}) with pytest.raises(FunctionCallValidationError) as exc_info: response_to_actions(response) assert 'Missing required argument "path"' in str(exc_info.value) # Missing content response = create_mock_response('edit_file', {'path': '/path/to/file'}) with pytest.raises(FunctionCallValidationError) as exc_info: response_to_actions(response) assert 'Missing required argument "content"' in str(exc_info.value) def test_str_replace_editor_valid(): """Test str_replace_editor with valid arguments.""" # Test view command response = create_mock_response( 'str_replace_editor', {'command': 'view', 'path': '/path/to/file'} ) actions = response_to_actions(response) assert len(actions) == 1 assert isinstance(actions[0], FileReadAction) assert actions[0].path == '/path/to/file' assert actions[0].impl_source == FileReadSource.OH_ACI # Test other commands response = create_mock_response( 'str_replace_editor', { 'command': 'str_replace', 'path': '/path/to/file', 'old_str': 'old', 'new_str': 'new', }, ) actions = response_to_actions(response) assert len(actions) == 1 assert isinstance(actions[0], FileEditAction) assert actions[0].path == '/path/to/file' assert actions[0].impl_source == FileEditSource.OH_ACI def test_str_replace_editor_missing_required(): """Test str_replace_editor with missing required arguments.""" # Missing command response = create_mock_response('str_replace_editor', {'path': '/path/to/file'}) with pytest.raises(FunctionCallValidationError) as exc_info: response_to_actions(response) assert 'Missing required argument "command"' in str(exc_info.value) # Missing path response = create_mock_response('str_replace_editor', {'command': 'view'}) with pytest.raises(FunctionCallValidationError) as exc_info: response_to_actions(response) assert 'Missing required argument "path"' in str(exc_info.value) def test_browser_valid(): """Test browser with valid arguments.""" response = create_mock_response('browser', {'code': "click('button-1')"}) actions = response_to_actions(response) assert len(actions) == 1 assert isinstance(actions[0], BrowseInteractiveAction) assert actions[0].browser_actions == "click('button-1')" def test_browser_missing_code(): """Test browser with missing code argument.""" response = create_mock_response('browser', {}) with pytest.raises(FunctionCallValidationError) as exc_info: response_to_actions(response) assert 'Missing required argument "code"' in str(exc_info.value) def test_invalid_json_arguments(): """Test handling of invalid JSON in arguments.""" response = ModelResponse( id='mock-id', choices=[ { 'message': { 'tool_calls': [ { 'function': { 'name': 'execute_bash', 'arguments': 'invalid json', }, 'id': 'mock-tool-call-id', 'type': 'function', } ], 'content': None, 'role': 'assistant', }, 'index': 0, 'finish_reason': 'tool_calls', } ], ) with pytest.raises(FunctionCallValidationError) as exc_info: response_to_actions(response) assert 'Failed to parse tool call arguments' in str(exc_info.value) def test_unexpected_argument_handling(): """Test that unexpected arguments in function calls are properly handled. This test reproduces issue #8369 Example 4 where an unexpected argument (old_str_prefix) causes a TypeError. """ response = create_mock_response( 'str_replace_editor', { 'command': 'str_replace', 'path': '/test/file.py', 'old_str': 'def test():\n pass', 'new_str': 'def test():\n return True', 'old_str_prefix': 'some prefix', # Unexpected argument }, ) # Test that the function raises a FunctionCallValidationError with pytest.raises(FunctionCallValidationError) as exc_info: response_to_actions(response) # Verify the error message mentions the unexpected argument assert 'old_str_prefix' in str(exc_info.value) assert 'Unexpected argument' in str(exc_info.value)