""" Tests for EvalSuite capture mode functionality. Capture mode allows running evaluations without scoring - it simply records the tool calls made by the model for debugging or generating expected calls. """ import json import tempfile from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from arcade_evals import ( CapturedCase, CapturedToolCall, CaptureResult, EvalSuite, ) # Mark all tests in this module as requiring evals dependencies pytestmark = pytest.mark.evals # --- CapturedToolCall Tests --- class TestCapturedToolCall: """Tests for CapturedToolCall dataclass.""" def test_create_with_name_and_args(self): """Test creating a captured tool call with name and args.""" tc = CapturedToolCall(name="Weather_GetCurrent", args={"location": "London"}) assert tc.name == "Weather_GetCurrent" assert tc.args == {"location": "London"} def test_create_with_name_only(self): """Test creating a captured tool call with default empty args.""" tc = CapturedToolCall(name="Weather_GetCurrent") assert tc.name == "Weather_GetCurrent" assert tc.args == {} def test_to_dict(self): """Test to_dict serialization.""" tc = CapturedToolCall(name="MyTool", args={"key": "value"}) result = tc.to_dict() assert result == {"name": "MyTool", "args": {"key": "value"}} def test_to_dict_empty_args(self): """Test to_dict with empty args.""" tc = CapturedToolCall(name="MyTool") result = tc.to_dict() assert result == {"name": "MyTool", "args": {}} # --- CapturedCase Tests --- class TestCapturedCase: """Tests for CapturedCase dataclass.""" def test_create_basic(self): """Test creating a captured case with minimal fields.""" case = CapturedCase( case_name="test_case", user_message="Hello", tool_calls=[CapturedToolCall(name="Tool1")], ) assert case.case_name == "test_case" assert case.user_message == "Hello" assert len(case.tool_calls) == 1 assert case.system_message is None assert case.additional_messages is None def test_create_with_context(self): """Test creating a captured case with full context.""" case = CapturedCase( case_name="test_case", user_message="Hello", tool_calls=[CapturedToolCall(name="Tool1")], system_message="You are an assistant", additional_messages=[{"role": "assistant", "content": "Hi"}], ) assert case.system_message == "You are an assistant" assert case.additional_messages == [{"role": "assistant", "content": "Hi"}] def test_to_dict_without_context(self): """Test to_dict without including context.""" case = CapturedCase( case_name="test_case", user_message="Hello", tool_calls=[CapturedToolCall(name="Tool1", args={"x": 1})], system_message="System message", additional_messages=[{"role": "user", "content": "msg"}], ) result = case.to_dict(include_context=False) assert result == { "case_name": "test_case", "user_message": "Hello", "tool_calls": [{"name": "Tool1", "args": {"x": 1}}], } # Context should NOT be included assert "system_message" not in result assert "additional_messages" not in result def test_to_dict_with_context(self): """Test to_dict including context.""" case = CapturedCase( case_name="test_case", user_message="Hello", tool_calls=[CapturedToolCall(name="Tool1", args={"x": 1})], system_message="System message", additional_messages=[{"role": "user", "content": "msg"}], ) result = case.to_dict(include_context=True) assert result == { "case_name": "test_case", "user_message": "Hello", "tool_calls": [{"name": "Tool1", "args": {"x": 1}}], "system_message": "System message", "additional_messages": [{"role": "user", "content": "msg"}], } def test_to_dict_with_context_null_messages(self): """Test to_dict with context when additional_messages is None.""" case = CapturedCase( case_name="test_case", user_message="Hello", tool_calls=[], system_message="Sys", additional_messages=None, ) result = case.to_dict(include_context=True) assert result["additional_messages"] == [] def test_to_dict_normalizes_json_string_arguments(self): """Test that JSON string arguments in additional_messages are parsed into objects.""" # This simulates OpenAI's format where arguments is a JSON string additional_messages = [ {"role": "user", "content": "List projects"}, { "role": "assistant", "content": "", "tool_calls": [ { "id": "call_123", "type": "function", "function": { "name": "Linear_ListProjects", "arguments": '{"state": "started"}', # JSON string }, } ], }, { "role": "tool", "content": '{"projects": []}', "tool_call_id": "call_123", }, ] case = CapturedCase( case_name="test_case", user_message="Hello", tool_calls=[], system_message="Sys", additional_messages=additional_messages, ) result = case.to_dict(include_context=True) # Arguments should be parsed into an object, not a string assistant_msg = result["additional_messages"][1] assert assistant_msg["tool_calls"][0]["function"]["arguments"] == {"state": "started"} def test_to_dict_handles_invalid_json_arguments(self): """Test that invalid JSON arguments are kept as strings.""" additional_messages = [ { "role": "assistant", "content": "", "tool_calls": [ { "id": "call_123", "type": "function", "function": { "name": "SomeTool", "arguments": "not valid json {", # Invalid JSON }, } ], }, ] case = CapturedCase( case_name="test_case", user_message="Hello", tool_calls=[], system_message="Sys", additional_messages=additional_messages, ) result = case.to_dict(include_context=True) # Invalid JSON should remain as string assistant_msg = result["additional_messages"][0] assert assistant_msg["tool_calls"][0]["function"]["arguments"] == "not valid json {" def test_to_dict_normalizes_tool_response_content(self): """Test that JSON content in tool response messages is parsed into objects.""" additional_messages = [ {"role": "user", "content": "Get the initiative"}, { "role": "assistant", "content": "", "tool_calls": [ { "id": "call_get_init", "type": "function", "function": { "name": "Linear_GetInitiative", "arguments": '{"id": "init_123"}', }, } ], }, { "role": "tool", "content": '{"id": "init_123", "name": "Q1 Goals", "status": "Planned"}', "tool_call_id": "call_get_init", "name": "Linear_GetInitiative", }, ] case = CapturedCase( case_name="test_case", user_message="Hello", tool_calls=[], system_message="Sys", additional_messages=additional_messages, ) result = case.to_dict(include_context=True) # Tool call arguments should be parsed assistant_msg = result["additional_messages"][1] assert assistant_msg["tool_calls"][0]["function"]["arguments"] == {"id": "init_123"} # Tool response content should be parsed tool_msg = result["additional_messages"][2] assert tool_msg["content"] == {"id": "init_123", "name": "Q1 Goals", "status": "Planned"} def test_to_dict_keeps_non_json_tool_content_as_string(self): """Test that non-JSON tool content is kept as string.""" additional_messages = [ { "role": "tool", "content": "Error: Tool not found", # Plain text, not JSON "tool_call_id": "call_123", "name": "SomeTool", }, ] case = CapturedCase( case_name="test_case", user_message="Hello", tool_calls=[], system_message="Sys", additional_messages=additional_messages, ) result = case.to_dict(include_context=True) # Non-JSON content should remain as string tool_msg = result["additional_messages"][0] assert tool_msg["content"] == "Error: Tool not found" def test_empty_tool_calls(self): """Test case with no tool calls.""" case = CapturedCase( case_name="no_tools", user_message="Just chat", tool_calls=[], ) result = case.to_dict() assert result["tool_calls"] == [] # --- CaptureResult Tests --- class TestCaptureResult: """Tests for CaptureResult dataclass.""" def test_create_basic(self): """Test creating a capture result.""" result = CaptureResult( suite_name="My Suite", model="gpt-4o", provider="openai", captured_cases=[ CapturedCase( case_name="case1", user_message="Hello", tool_calls=[CapturedToolCall(name="Tool1")], ) ], ) assert result.suite_name == "My Suite" assert result.model == "gpt-4o" assert result.provider == "openai" assert len(result.captured_cases) == 1 def test_to_dict_without_context(self): """Test to_dict without context.""" result = CaptureResult( suite_name="Suite", model="gpt-4o", provider="openai", captured_cases=[ CapturedCase( case_name="case1", user_message="Hello", tool_calls=[CapturedToolCall(name="Tool1", args={"a": 1})], system_message="System", ) ], ) d = result.to_dict(include_context=False) assert d["suite_name"] == "Suite" assert d["model"] == "gpt-4o" assert d["provider"] == "openai" assert len(d["captured_cases"]) == 1 assert "system_message" not in d["captured_cases"][0] def test_to_dict_with_context(self): """Test to_dict with context.""" result = CaptureResult( suite_name="Suite", model="gpt-4o", provider="openai", captured_cases=[ CapturedCase( case_name="case1", user_message="Hello", tool_calls=[], system_message="System", additional_messages=[], ) ], ) d = result.to_dict(include_context=True) assert d["captured_cases"][0]["system_message"] == "System" def test_to_json(self): """Test JSON serialization.""" result = CaptureResult( suite_name="Suite", model="gpt-4o", provider="openai", captured_cases=[ CapturedCase( case_name="case1", user_message="Hello", tool_calls=[CapturedToolCall(name="Tool1")], ) ], ) json_str = result.to_json(include_context=False) parsed = json.loads(json_str) assert parsed["suite_name"] == "Suite" assert parsed["model"] == "gpt-4o" def test_to_json_with_indent(self): """Test JSON serialization with custom indent.""" result = CaptureResult( suite_name="Suite", model="gpt-4o", provider="openai", captured_cases=[], ) json_str = result.to_json(indent=4) # Check that indentation is present (4 spaces) assert " " in json_str def test_write_to_file(self): """Test writing capture result to file.""" result = CaptureResult( suite_name="Suite", model="gpt-4o", provider="openai", captured_cases=[ CapturedCase( case_name="case1", user_message="Hello", tool_calls=[CapturedToolCall(name="Tool1", args={"x": 1})], ) ], ) with tempfile.TemporaryDirectory() as tmpdir: filepath = Path(tmpdir) / "capture_output.json" result.write_to_file(str(filepath)) # Verify file was created and has valid content assert filepath.exists() with open(filepath, encoding="utf-8") as f: data = json.load(f) assert data["suite_name"] == "Suite" assert len(data["captured_cases"]) == 1 def test_write_to_file_with_context(self): """Test writing capture result with context to file.""" result = CaptureResult( suite_name="Suite", model="gpt-4o", provider="openai", captured_cases=[ CapturedCase( case_name="case1", user_message="Hello", tool_calls=[], system_message="System", additional_messages=[{"role": "user", "content": "x"}], ) ], ) with tempfile.TemporaryDirectory() as tmpdir: filepath = Path(tmpdir) / "capture_output.json" result.write_to_file(str(filepath), include_context=True) with open(filepath, encoding="utf-8") as f: data = json.load(f) assert data["captured_cases"][0]["system_message"] == "System" def test_empty_captured_cases(self): """Test with no captured cases.""" result = CaptureResult( suite_name="Empty Suite", model="gpt-4o", provider="openai", captured_cases=[], ) d = result.to_dict() assert d["captured_cases"] == [] # --- Imports Test --- class TestCaptureImports: """Tests for capture mode imports.""" def test_import_from_arcade_evals(self): """Test that capture classes are importable from arcade_evals.""" from arcade_evals import CapturedCase, CapturedToolCall, CaptureResult assert CapturedToolCall is not None assert CapturedCase is not None assert CaptureResult is not None # --- EvalSuite.capture() Tests --- class TestEvalSuiteCapture: """Tests for EvalSuite.capture() method.""" @pytest.fixture def mock_openai_client(self): """Create a mock OpenAI client.""" client = AsyncMock() # Create mock response with tool calls mock_response = MagicMock() mock_response.choices = [MagicMock()] mock_response.choices[0].message.tool_calls = [MagicMock()] mock_response.choices[0].message.tool_calls[0].function.name = "Weather_GetCurrent" mock_response.choices[0].message.tool_calls[0].function.arguments = '{"location": "London"}' client.chat.completions.create = AsyncMock(return_value=mock_response) return client @pytest.fixture def basic_suite(self): """Create a basic EvalSuite with a tool and case.""" suite = EvalSuite( name="Test Suite", system_message="You are a helpful assistant", ) suite.add_tool_definitions([ {"name": "Weather_GetCurrent", "description": "Get weather", "inputSchema": {}} ]) suite.add_case( name="test_case", user_message="What's the weather in London?", expected_tool_calls=[], # No expectations in capture mode ) return suite @pytest.mark.asyncio async def test_capture_returns_capture_result(self, basic_suite, mock_openai_client): """Test that capture() returns a CaptureResult.""" result = await basic_suite.capture( client=mock_openai_client, model="gpt-4o", provider="openai", ) assert isinstance(result, CaptureResult) assert result.suite_name == "Test Suite" assert result.model == "gpt-4o" assert result.provider == "openai" @pytest.mark.asyncio async def test_capture_records_tool_calls(self, basic_suite, mock_openai_client): """Test that capture() records tool calls from model.""" result = await basic_suite.capture( client=mock_openai_client, model="gpt-4o", provider="openai", ) assert len(result.captured_cases) == 1 case = result.captured_cases[0] assert case.case_name == "test_case" assert len(case.tool_calls) == 1 assert case.tool_calls[0].name == "Weather_GetCurrent" assert case.tool_calls[0].args == {"location": "London"} @pytest.mark.asyncio async def test_capture_without_context(self, basic_suite, mock_openai_client): """Test that capture() without context doesn't include system message.""" result = await basic_suite.capture( client=mock_openai_client, model="gpt-4o", provider="openai", include_context=False, ) case = result.captured_cases[0] assert case.system_message is None assert case.additional_messages is None @pytest.mark.asyncio async def test_capture_with_context(self, basic_suite, mock_openai_client): """Test that capture() with context includes system message.""" result = await basic_suite.capture( client=mock_openai_client, model="gpt-4o", provider="openai", include_context=True, ) case = result.captured_cases[0] assert case.system_message == "You are a helpful assistant" assert case.additional_messages is not None @pytest.mark.asyncio async def test_capture_requires_tools(self): """Test that capture() raises error when no tools registered.""" suite = EvalSuite( name="Empty Suite", system_message="Test", ) suite.add_case( name="test_case", user_message="Hello", expected_tool_calls=[], ) mock_client = AsyncMock() with pytest.raises(ValueError, match="No tools registered"): await suite.capture(mock_client, "gpt-4o", provider="openai") @pytest.mark.asyncio async def test_capture_multiple_cases(self, mock_openai_client): """Test capture with multiple cases.""" suite = EvalSuite( name="Multi Case Suite", system_message="You are an assistant", ) suite.add_tool_definitions([ {"name": "Tool1", "description": "Tool 1"}, {"name": "Tool2", "description": "Tool 2"}, ]) suite.add_case(name="case1", user_message="Do thing 1", expected_tool_calls=[]) suite.add_case(name="case2", user_message="Do thing 2", expected_tool_calls=[]) result = await suite.capture( client=mock_openai_client, model="gpt-4o", provider="openai", ) assert len(result.captured_cases) == 2 assert result.captured_cases[0].case_name == "case1" assert result.captured_cases[1].case_name == "case2" @pytest.mark.asyncio async def test_capture_no_tool_calls(self): """Test capture when model doesn't call any tools.""" suite = EvalSuite( name="No Calls Suite", system_message="Test", ) suite.add_tool_definitions([{"name": "Tool1", "description": "Tool 1"}]) suite.add_case(name="case1", user_message="Hello", expected_tool_calls=[]) # Mock client that returns no tool calls mock_client = AsyncMock() mock_response = MagicMock() mock_response.choices = [MagicMock()] mock_response.choices[0].message.tool_calls = None mock_client.chat.completions.create = AsyncMock(return_value=mock_response) result = await suite.capture( client=mock_client, model="gpt-4o", provider="openai", ) assert len(result.captured_cases) == 1 assert len(result.captured_cases[0].tool_calls) == 0 @pytest.mark.asyncio async def test_capture_normalizes_tool_calls(self, mock_openai_client): """Test that capture() normalizes tool names and fills defaults.""" suite = EvalSuite( name="Normalization Suite", system_message="Test", ) # Add tool with default arg suite.add_tool_definitions([ { "name": "My.Tool", "description": "Tool with default", "inputSchema": { "type": "object", "properties": { "arg1": {"type": "string", "default": "default_val"}, "arg2": {"type": "string"}, }, }, } ]) suite.add_case(name="case1", user_message="Call it", expected_tool_calls=[]) # Mock client returning tool call with underscored name and missing default arg mock_response = MagicMock() mock_response.choices = [MagicMock()] mock_response.choices[0].message.tool_calls = [MagicMock()] tool_call = mock_response.choices[0].message.tool_calls[0] tool_call.function.name = "My_Tool" # Normalized name tool_call.function.arguments = '{"arg2": "provided"}' # Missing arg1 mock_openai_client.chat.completions.create = AsyncMock(return_value=mock_response) result = await suite.capture( client=mock_openai_client, model="gpt-4o", provider="openai", ) case = result.captured_cases[0] # Name is resolved to original format (My_Tool -> My.Tool) # This ensures consistency with expected tool names assert case.tool_calls[0].name == "My.Tool" # Args should include default value assert case.tool_calls[0].args == {"arg1": "default_val", "arg2": "provided"} # --- tool_eval decorator capture mode Tests --- class TestToolEvalCaptureMode: """Tests for tool_eval decorator with capture mode.""" @pytest.mark.asyncio async def test_tool_eval_capture_mode_flag(self): """Test that tool_eval wrapper passes capture_mode correctly.""" from arcade_evals import tool_eval @tool_eval() def my_eval(): suite = EvalSuite( name="Test Suite", system_message="Test", ) suite.add_tool_definitions([{"name": "Tool1", "description": "D"}]) suite.add_case(name="case1", user_message="Hello", expected_tool_calls=[]) return suite # Mock the underlying capture functions with patch("arcade_evals.eval._capture_with_openai") as mock_capture: mock_capture.return_value = CaptureResult( suite_name="Test", model="gpt-4o", provider="openai", captured_cases=[], ) results = await my_eval( provider_api_key="test-key", model="gpt-4o", capture_mode=True, include_context=False, ) mock_capture.assert_called_once() assert len(results) == 1 assert isinstance(results[0], CaptureResult) @pytest.mark.asyncio async def test_tool_eval_capture_mode_with_context(self): """Test that tool_eval wrapper passes include_context correctly.""" from arcade_evals import tool_eval @tool_eval() def my_eval(): suite = EvalSuite( name="Test Suite", system_message="Test", ) suite.add_tool_definitions([{"name": "Tool1", "description": "D"}]) suite.add_case(name="case1", user_message="Hello", expected_tool_calls=[]) return suite with patch("arcade_evals.eval._capture_with_openai") as mock_capture: mock_capture.return_value = CaptureResult( suite_name="Test", model="gpt-4o", provider="openai", captured_cases=[], ) await my_eval( provider_api_key="test-key", model="gpt-4o", capture_mode=True, include_context=True, ) # Verify include_context was passed call_args = mock_capture.call_args assert call_args.kwargs["include_context"] is True # --- Multiple Tool Calls per Case Tests --- class TestMultipleToolCalls: """Tests for capturing multiple tool calls from a single case.""" @pytest.mark.asyncio async def test_capture_multiple_tool_calls(self): """Test capturing multiple tool calls from one model response.""" suite = EvalSuite( name="Multi Tool Suite", system_message="Test", ) suite.add_tool_definitions([ {"name": "Tool1", "description": "D1"}, {"name": "Tool2", "description": "D2"}, ]) suite.add_case(name="case1", user_message="Do both", expected_tool_calls=[]) # Mock client returning multiple tool calls mock_client = AsyncMock() mock_response = MagicMock() mock_response.choices = [MagicMock()] tool_call_1 = MagicMock() tool_call_1.function.name = "Tool1" tool_call_1.function.arguments = '{"arg1": "val1"}' tool_call_2 = MagicMock() tool_call_2.function.name = "Tool2" tool_call_2.function.arguments = '{"arg2": "val2"}' mock_response.choices[0].message.tool_calls = [tool_call_1, tool_call_2] mock_client.chat.completions.create = AsyncMock(return_value=mock_response) result = await suite.capture( client=mock_client, model="gpt-4o", provider="openai", ) assert len(result.captured_cases) == 1 case = result.captured_cases[0] assert len(case.tool_calls) == 2 assert case.tool_calls[0].name == "Tool1" assert case.tool_calls[0].args == {"arg1": "val1"} assert case.tool_calls[1].name == "Tool2" assert case.tool_calls[1].args == {"arg2": "val2"} class TestCaptureWithAnthropic: """Tests for capture mode with Anthropic provider.""" @pytest.mark.asyncio async def test_capture_with_anthropic_provider(self): """Test capture mode using Anthropic provider.""" suite = EvalSuite( name="Anthropic Capture Suite", system_message="Test system message", ) suite.add_tool_definitions([ {"name": "Google.Search", "description": "Search"}, ]) suite.add_case( name="test_case", user_message="Search for something", expected_tool_calls=[], ) # Mock Anthropic client mock_client = AsyncMock() mock_response = MagicMock() # Anthropic returns tool_use blocks mock_tool_use = MagicMock() mock_tool_use.type = "tool_use" mock_tool_use.name = "Google_Search" # Anthropic uses underscores mock_tool_use.input = {"query": "test"} mock_response.content = [mock_tool_use] mock_client.messages.create = AsyncMock(return_value=mock_response) result = await suite.capture( client=mock_client, model="claude-3-opus", provider="anthropic", ) assert result.provider == "anthropic" assert len(result.captured_cases) == 1 # Should resolve Google_Search back to Google.Search assert result.captured_cases[0].tool_calls[0].name == "Google.Search" class TestCaptureHelperFunctions: """Tests for _capture_with_openai and _capture_with_anthropic helpers.""" @pytest.mark.asyncio async def test_capture_with_openai_helper(self): """Test the _capture_with_openai helper function.""" from arcade_evals.capture import _capture_with_openai suite = EvalSuite( name="OpenAI Helper Test", system_message="Test", ) suite.add_tool_definitions([{"name": "TestTool", "description": "A test tool"}]) suite.add_case(name="case1", user_message="Test", expected_tool_calls=[]) # Mock the suite.capture method directly instead of AsyncOpenAI mock_result = CaptureResult( suite_name="OpenAI Helper Test", provider="openai", model="gpt-4o", captured_cases=[ CapturedCase( case_name="case1", user_message="Test", tool_calls=[], system_message="Test", additional_messages=[], ) ], ) with patch.object(suite, "capture", return_value=mock_result) as mock_capture: result = await _capture_with_openai( suite=suite, api_key="test-key", model="gpt-4o", include_context=True, ) assert result.suite_name == "OpenAI Helper Test" assert result.provider == "openai" # Verify capture was called with correct arguments mock_capture.assert_called_once() call_args = mock_capture.call_args # Arguments: (client, model, provider=..., include_context=...) assert call_args.args[1] == "gpt-4o" # model assert call_args.kwargs.get("provider") == "openai" assert call_args.kwargs.get("include_context") is True def test_capture_with_anthropic_function_exists(self): """Test that _capture_with_anthropic helper function exists and is callable.""" # Verify the function exists and has the expected signature import inspect from arcade_evals.capture import _capture_with_anthropic sig = inspect.signature(_capture_with_anthropic) params = list(sig.parameters.keys()) assert "suite" in params assert "api_key" in params assert "model" in params assert "include_context" in params