diff --git a/tests/integration/test_template_service_integration.py b/tests/integration/test_template_service_integration.py new file mode 100644 index 00000000..ad1ce229 --- /dev/null +++ b/tests/integration/test_template_service_integration.py @@ -0,0 +1,202 @@ +""" +Simplified integration tests for Template Service + +These tests verify the basic functionality of the template service +without the full message queue infrastructure. +""" + +import pytest +import json +from unittest.mock import AsyncMock, MagicMock + +from trustgraph.schema import PromptRequest, PromptResponse +from trustgraph.template.prompt_manager import PromptManager + + +@pytest.mark.integration +class TestTemplateServiceSimple: + """Simplified integration tests for Template Service components""" + + @pytest.fixture + def sample_config(self): + """Sample configuration for testing""" + return { + "system": json.dumps("You are a helpful assistant."), + "template-index": json.dumps(["greeting", "json_test"]), + "template.greeting": json.dumps({ + "prompt": "Hello {{ name }}, welcome to {{ system_name }}!", + "response-type": "text" + }), + "template.json_test": json.dumps({ + "prompt": "Generate profile for {{ username }}", + "response-type": "json", + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "role": {"type": "string"} + }, + "required": ["name", "role"] + } + }) + } + + @pytest.fixture + def prompt_manager(self, sample_config): + """Create a configured PromptManager""" + pm = PromptManager() + pm.load_config(sample_config) + pm.terms["system_name"] = "TrustGraph" + return pm + + @pytest.mark.asyncio + async def test_prompt_manager_text_invocation(self, prompt_manager): + """Test PromptManager text response invocation""" + # Mock LLM function + async def mock_llm(system, prompt): + assert system == "You are a helpful assistant." + assert "Hello Alice, welcome to TrustGraph!" in prompt + return "Welcome message processed!" + + result = await prompt_manager.invoke("greeting", {"name": "Alice"}, mock_llm) + + assert result == "Welcome message processed!" + + @pytest.mark.asyncio + async def test_prompt_manager_json_invocation(self, prompt_manager): + """Test PromptManager JSON response invocation""" + # Mock LLM function + async def mock_llm(system, prompt): + assert "Generate profile for johndoe" in prompt + return '{"name": "John Doe", "role": "user"}' + + result = await prompt_manager.invoke("json_test", {"username": "johndoe"}, mock_llm) + + assert isinstance(result, dict) + assert result["name"] == "John Doe" + assert result["role"] == "user" + + @pytest.mark.asyncio + async def test_prompt_manager_json_validation_error(self, prompt_manager): + """Test JSON schema validation failure""" + # Mock LLM function that returns invalid JSON + async def mock_llm(system, prompt): + return '{"name": "John Doe"}' # Missing required "role" + + with pytest.raises(RuntimeError) as exc_info: + await prompt_manager.invoke("json_test", {"username": "johndoe"}, mock_llm) + + assert "Schema validation fail" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_prompt_manager_json_parse_error(self, prompt_manager): + """Test JSON parsing failure""" + # Mock LLM function that returns non-JSON + async def mock_llm(system, prompt): + return "This is not JSON at all" + + with pytest.raises(RuntimeError) as exc_info: + await prompt_manager.invoke("json_test", {"username": "johndoe"}, mock_llm) + + assert "JSON parse fail" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_prompt_manager_unknown_prompt(self, prompt_manager): + """Test unknown prompt ID handling""" + async def mock_llm(system, prompt): + return "Response" + + with pytest.raises(RuntimeError) as exc_info: + await prompt_manager.invoke("unknown_prompt", {}, mock_llm) + + assert "ID invalid" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_prompt_manager_term_merging(self, prompt_manager): + """Test proper term merging (global + prompt + input)""" + # Add prompt-specific terms + prompt_manager.prompts["greeting"].terms = {"greeting_prefix": "Hi"} + + async def mock_llm(system, prompt): + # Should have global term (system_name), input term (name), and any prompt terms + assert "TrustGraph" in prompt # Global term + assert "Bob" in prompt # Input term + return "Merged correctly" + + result = await prompt_manager.invoke("greeting", {"name": "Bob"}, mock_llm) + assert result == "Merged correctly" + + def test_prompt_manager_template_rendering(self, prompt_manager): + """Test direct template rendering""" + result = prompt_manager.render("greeting", {"name": "Charlie"}) + + assert "Hello Charlie, welcome to TrustGraph!" == result.strip() + + def test_prompt_manager_configuration_loading(self): + """Test configuration loading with various formats""" + pm = PromptManager() + + # Test empty configuration + pm.load_config({}) + assert pm.config.system_template == "Be helpful." + assert len(pm.prompts) == 0 + + # Test configuration with single prompt + config = { + "system": json.dumps("Test system"), + "template-index": json.dumps(["test"]), + "template.test": json.dumps({ + "prompt": "Test {{ value }}", + "response-type": "text" + }) + } + pm.load_config(config) + + assert pm.config.system_template == "Test system" + assert "test" in pm.prompts + assert pm.prompts["test"].response_type == "text" + + @pytest.mark.asyncio + async def test_prompt_manager_json_with_markdown(self, prompt_manager): + """Test JSON extraction from markdown code blocks""" + async def mock_llm(system, prompt): + return ''' + Here's the profile: + ```json + {"name": "Jane Smith", "role": "admin"} + ``` + ''' + + result = await prompt_manager.invoke("json_test", {"username": "jane"}, mock_llm) + + assert isinstance(result, dict) + assert result["name"] == "Jane Smith" + assert result["role"] == "admin" + + def test_prompt_manager_error_handling_in_templates(self, prompt_manager): + """Test error handling in template rendering""" + # Test with missing variable + with pytest.raises(Exception): # ibis should raise on undefined variable + prompt_manager.render("greeting", {}) # Missing 'name' + + @pytest.mark.asyncio + async def test_concurrent_prompt_invocations(self, prompt_manager): + """Test concurrent invocations""" + async def mock_llm(system, prompt): + # Extract name from prompt for response + if "Alice" in prompt: + return "Alice response" + elif "Bob" in prompt: + return "Bob response" + else: + return "Default response" + + # Run concurrent invocations + import asyncio + results = await asyncio.gather( + prompt_manager.invoke("greeting", {"name": "Alice"}, mock_llm), + prompt_manager.invoke("greeting", {"name": "Bob"}, mock_llm), + ) + + assert "Alice response" in results + assert "Bob response" in results \ No newline at end of file diff --git a/tests/unit/test_prompt_manager.py b/tests/unit/test_prompt_manager.py new file mode 100644 index 00000000..957586cf --- /dev/null +++ b/tests/unit/test_prompt_manager.py @@ -0,0 +1,327 @@ +""" +Unit tests for PromptManager + +These tests verify the functionality of the PromptManager class, +including template rendering, term merging, JSON validation, and error handling. +""" + +import pytest +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from trustgraph.template.prompt_manager import PromptManager, PromptConfiguration, Prompt + + +@pytest.mark.unit +class TestPromptManager: + """Unit tests for PromptManager template functionality""" + + @pytest.fixture + def sample_config(self): + """Sample configuration dict for PromptManager""" + return { + "system": json.dumps("You are a helpful assistant."), + "template-index": json.dumps(["simple_text", "json_response", "complex_template"]), + "template.simple_text": json.dumps({ + "prompt": "Hello {{ name }}, welcome to {{ system_name }}!", + "response-type": "text" + }), + "template.json_response": json.dumps({ + "prompt": "Generate a user profile for {{ username }}", + "response-type": "json", + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "number"} + }, + "required": ["name", "age"] + } + }), + "template.complex_template": json.dumps({ + "prompt": """ + {% for item in items %} + - {{ item.name }}: {{ item.value }} + {% endfor %} + Total: {{ items|length }} + """, + "response-type": "text" + }) + } + + @pytest.fixture + def prompt_manager(self, sample_config): + """Create a PromptManager with sample configuration""" + pm = PromptManager() + pm.load_config(sample_config) + # Add global terms manually since load_config doesn't handle them + pm.terms["system_name"] = "TrustGraph" + pm.terms["version"] = "1.0" + return pm + + def test_prompt_manager_initialization(self, prompt_manager, sample_config): + """Test PromptManager initialization with configuration""" + assert prompt_manager.config.system_template == "You are a helpful assistant." + assert len(prompt_manager.prompts) == 3 + assert "simple_text" in prompt_manager.prompts + + def test_simple_text_template_rendering(self, prompt_manager): + """Test basic template rendering with text response""" + terms = {"name": "Alice"} + + rendered = prompt_manager.render("simple_text", terms) + + assert rendered == "Hello Alice, welcome to TrustGraph!" + + def test_global_terms_merging(self, prompt_manager): + """Test that global terms are properly merged""" + terms = {"name": "Bob"} + + # Global terms should be available in template + rendered = prompt_manager.render("simple_text", terms) + + assert "TrustGraph" in rendered # From global terms + assert "Bob" in rendered # From input terms + + def test_term_override_priority(self, prompt_manager): + """Test term override priority: input > prompt > global""" + # Add a test prompt with overlapping terms + test_config = { + "template.test": json.dumps({ + "prompt": "Value is: {{ value }}", + "response-type": "text" + }) + } + prompt_manager.load_config({**prompt_manager.config.__dict__, **test_config}) + prompt_manager.terms["value"] = "global" + prompt_manager.prompts["test"].terms["value"] = "prompt" + + # Test with no input override + rendered = prompt_manager.render("test", {}) + assert rendered == "Value is: prompt" # Prompt terms override global + + # Test with input override + rendered = prompt_manager.render("test", {"value": "input"}) + assert rendered == "Value is: input" # Input terms override all + + def test_complex_template_rendering(self, prompt_manager): + """Test complex template with loops and filters""" + terms = { + "items": [ + {"name": "Item1", "value": 10}, + {"name": "Item2", "value": 20}, + {"name": "Item3", "value": 30} + ] + } + + rendered = prompt_manager.render("complex_template", terms) + + assert "Item1: 10" in rendered + assert "Item2: 20" in rendered + assert "Item3: 30" in rendered + assert "Total: 3" in rendered + + @pytest.mark.asyncio + async def test_invoke_text_response(self, prompt_manager): + """Test invoking a prompt with text response""" + mock_llm = AsyncMock() + mock_llm.return_value = "Welcome Alice to TrustGraph!" + + result = await prompt_manager.invoke( + "simple_text", + {"name": "Alice"}, + mock_llm + ) + + assert result == "Welcome Alice to TrustGraph!" + + # Verify LLM was called with correct prompts + mock_llm.assert_called_once() + call_args = mock_llm.call_args[1] + assert call_args["system"] == "You are a helpful assistant." + assert "Hello Alice, welcome to TrustGraph!" in call_args["prompt"] + + @pytest.mark.asyncio + async def test_invoke_json_response_valid(self, prompt_manager): + """Test invoking a prompt with valid JSON response""" + mock_llm = AsyncMock() + mock_llm.return_value = '{"name": "John Doe", "age": 30}' + + result = await prompt_manager.invoke( + "json_response", + {"username": "johndoe"}, + mock_llm + ) + + assert isinstance(result, dict) + assert result["name"] == "John Doe" + assert result["age"] == 30 + + @pytest.mark.asyncio + async def test_invoke_json_response_with_markdown(self, prompt_manager): + """Test JSON extraction from markdown code blocks""" + mock_llm = AsyncMock() + mock_llm.return_value = """ + Here is the user profile: + + ```json + { + "name": "Jane Smith", + "age": 25 + } + ``` + + This is a valid profile. + """ + + result = await prompt_manager.invoke( + "json_response", + {"username": "janesmith"}, + mock_llm + ) + + assert isinstance(result, dict) + assert result["name"] == "Jane Smith" + assert result["age"] == 25 + + @pytest.mark.asyncio + async def test_invoke_json_validation_failure(self, prompt_manager): + """Test JSON schema validation failure""" + mock_llm = AsyncMock() + # Missing required 'age' field + mock_llm.return_value = '{"name": "Invalid User"}' + + with pytest.raises(ValueError) as exc_info: + await prompt_manager.invoke( + "json_response", + {"username": "invalid"}, + mock_llm + ) + + assert "JSON schema validation failed" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_invoke_json_parse_failure(self, prompt_manager): + """Test invalid JSON parsing""" + mock_llm = AsyncMock() + mock_llm.return_value = "This is not JSON at all" + + with pytest.raises(ValueError) as exc_info: + await prompt_manager.invoke( + "json_response", + {"username": "test"}, + mock_llm + ) + + assert "No JSON found in response" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_invoke_unknown_prompt(self, prompt_manager): + """Test invoking an unknown prompt ID""" + mock_llm = AsyncMock() + + with pytest.raises(KeyError): + await prompt_manager.invoke( + "nonexistent_prompt", + {}, + mock_llm + ) + + def test_template_rendering_with_undefined_variable(self, prompt_manager): + """Test template rendering with undefined variables""" + terms = {} # Missing 'name' variable + + # This should raise an error or use empty string depending on config + with pytest.raises(Exception): # ibis/Jinja2 will raise on undefined + prompt_manager.render("simple_text", terms) + + @pytest.mark.asyncio + async def test_json_response_without_schema(self): + """Test JSON response without schema validation""" + pm = PromptManager() + config = { + "system": json.dumps("Test"), + "template-index": json.dumps(["no_schema"]), + "template.no_schema": json.dumps({ + "prompt": "Generate any JSON", + "response-type": "json" + # No schema defined + }) + } + pm.load_config(config) + + mock_llm = AsyncMock() + mock_llm.return_value = '{"any": "json", "is": "valid"}' + + result = await pm.invoke("no_schema", {}, mock_llm) + + assert result == {"any": "json", "is": "valid"} + + def test_prompt_configuration_validation(self): + """Test PromptConfiguration validation""" + # Valid configuration + config = PromptConfiguration( + system_template="Test system", + prompts={ + "test": Prompt( + template="Hello {{ name }}", + response_type="text" + ) + } + ) + assert config.system_template == "Test system" + assert len(config.prompts) == 1 + + def test_nested_template_includes(self, prompt_manager): + """Test templates with nested variable references""" + # Add global terms + prompt_manager.terms["company"] = "TrustGraph" + prompt_manager.terms["year"] = 2024 + + # Add a nested template + config = { + "template.nested": json.dumps({ + "prompt": "{{ greeting }} from {{ company }} in {{ year }}!", + "response-type": "text" + }) + } + prompt_manager.load_config({**prompt_manager.config.__dict__, **config}) + prompt_manager.prompts["nested"].terms = {"greeting": "Welcome"} + + rendered = prompt_manager.render("nested", {"user": "Alice"}) + + # Should contain company and year from global terms + assert "TrustGraph" in rendered + assert "2024" in rendered + + @pytest.mark.asyncio + async def test_concurrent_invocations(self, prompt_manager): + """Test concurrent prompt invocations""" + mock_llm = AsyncMock() + mock_llm.side_effect = [ + "Response for Alice", + "Response for Bob", + "Response for Charlie" + ] + + # Simulate concurrent invocations + import asyncio + results = await asyncio.gather( + prompt_manager.invoke("simple_text", {"name": "Alice"}, mock_llm), + prompt_manager.invoke("simple_text", {"name": "Bob"}, mock_llm), + prompt_manager.invoke("simple_text", {"name": "Charlie"}, mock_llm) + ) + + assert len(results) == 3 + assert "Alice" in results[0] + assert "Bob" in results[1] + assert "Charlie" in results[2] + + def test_empty_configuration(self): + """Test PromptManager with minimal configuration""" + pm = PromptManager() + pm.load_config({}) # Empty config + + assert pm.config.system_template == "Be helpful." # Default system + assert pm.terms == {} # Default empty terms + assert len(pm.prompts) == 0 \ No newline at end of file diff --git a/tests/unit/test_prompt_manager_edge_cases.py b/tests/unit/test_prompt_manager_edge_cases.py new file mode 100644 index 00000000..7b8a81e6 --- /dev/null +++ b/tests/unit/test_prompt_manager_edge_cases.py @@ -0,0 +1,428 @@ +""" +Edge case and error handling tests for PromptManager + +These tests focus on boundary conditions, error scenarios, and +unusual but valid use cases for the PromptManager. +""" + +import pytest +import json +import asyncio +from unittest.mock import AsyncMock + +from trustgraph.template.prompt_manager import PromptManager, PromptConfiguration, Prompt + + +@pytest.mark.unit +class TestPromptManagerEdgeCases: + """Edge case tests for PromptManager""" + + @pytest.mark.asyncio + async def test_very_large_json_response(self): + """Test handling of very large JSON responses""" + pm = PromptManager() + config = { + "system": json.dumps("Test"), + "template-index": json.dumps(["large_json"]), + "template.large_json": json.dumps({ + "prompt": "Generate large dataset", + "response-type": "json" + }) + } + pm.load_config(config) + + # Create a large JSON structure + large_data = { + f"item_{i}": { + "name": f"Item {i}", + "data": list(range(100)), + "nested": { + "level1": { + "level2": f"Deep value {i}" + } + } + } + for i in range(100) + } + + mock_llm = AsyncMock() + mock_llm.return_value = json.dumps(large_data) + + result = await pm.invoke("large_json", {}, mock_llm) + + assert isinstance(result, dict) + assert len(result) == 100 + assert "item_50" in result + + @pytest.mark.asyncio + async def test_unicode_and_special_characters(self): + """Test handling of unicode and special characters""" + pm = PromptManager() + config = { + "system": json.dumps("Test"), + "template-index": json.dumps(["unicode"]), + "template.unicode": json.dumps({ + "prompt": "Process text: {{ text }}", + "response-type": "text" + }) + } + pm.load_config(config) + + special_text = "Hello 世界! 🌍 Привет мир! مرحبا بالعالم" + + mock_llm = AsyncMock() + mock_llm.return_value = f"Processed: {special_text}" + + result = await pm.invoke("unicode", {"text": special_text}, mock_llm) + + assert special_text in result + assert "🌍" in result + assert "世界" in result + + @pytest.mark.asyncio + async def test_nested_json_in_text_response(self): + """Test text response containing JSON-like structures""" + pm = PromptManager() + config = { + "system": json.dumps("Test"), + "template-index": json.dumps(["text_with_json"]), + "template.text_with_json": json.dumps({ + "prompt": "Explain this data", + "response-type": "text" # Text response, not JSON + }) + } + pm.load_config(config) + + mock_llm = AsyncMock() + mock_llm.return_value = """ + The data structure is: + { + "key": "value", + "nested": { + "array": [1, 2, 3] + } + } + This represents a nested object. + """ + + result = await pm.invoke("text_with_json", {}, mock_llm) + + assert isinstance(result, str) # Should remain as text + assert '"key": "value"' in result + + @pytest.mark.asyncio + async def test_multiple_json_blocks_in_response(self): + """Test response with multiple JSON blocks""" + pm = PromptManager() + config = { + "system": json.dumps("Test"), + "template-index": json.dumps(["multi_json"]), + "template.multi_json": json.dumps({ + "prompt": "Generate examples", + "response-type": "json" + }) + } + pm.load_config(config) + + mock_llm = AsyncMock() + mock_llm.return_value = """ + Here's the first example: + ```json + {"first": true, "value": 1} + ``` + + And here's another: + ```json + {"second": true, "value": 2} + ``` + """ + + # Should extract the first valid JSON block + result = await pm.invoke("multi_json", {}, mock_llm) + + assert result == {"first": True, "value": 1} + + @pytest.mark.asyncio + async def test_json_with_comments(self): + """Test JSON response with comment-like content""" + pm = PromptManager() + config = { + "system": json.dumps("Test"), + "template-index": json.dumps(["json_comments"]), + "template.json_comments": json.dumps({ + "prompt": "Generate config", + "response-type": "json" + }) + } + pm.load_config(config) + + mock_llm = AsyncMock() + # JSON with comment-like content that should be extracted + mock_llm.return_value = """ + // This is a configuration file + { + "setting": "value", // Important setting + "number": 42 + } + /* End of config */ + """ + + # Standard JSON parser won't handle comments + with pytest.raises(ValueError): + await pm.invoke("json_comments", {}, mock_llm) + + def test_template_with_raw_blocks(self): + """Test template with raw blocks that shouldn't be processed""" + pm = PromptManager() + config = { + "system": json.dumps("Test"), + "template-index": json.dumps(["raw_template"]), + "template.raw_template": json.dumps({ + "prompt": """ + Normal: {{ variable }} + {% raw %} + Raw block: {{ this_should_not_be_processed }} + {% endraw %} + Normal again: {{ another }} + """, + "response-type": "text" + }) + } + pm.load_config(config) + + result = pm.render( + "raw_template", + {"variable": "processed", "another": "also processed"} + ) + + assert "processed" in result + assert "{{ this_should_not_be_processed }}" in result # Should remain as-is + assert "also processed" in result + + @pytest.mark.asyncio + async def test_empty_json_response_variations(self): + """Test various empty JSON response formats""" + pm = PromptManager() + config = { + "system": json.dumps("Test"), + "template-index": json.dumps(["empty_json"]), + "template.empty_json": json.dumps({ + "prompt": "Generate empty data", + "response-type": "json" + }) + } + pm.load_config(config) + + empty_variations = [ + "{}", + "[]", + "null", + '""', + "0", + "false" + ] + + for empty_value in empty_variations: + mock_llm = AsyncMock() + mock_llm.return_value = empty_value + + result = await pm.invoke("empty_json", {}, mock_llm) + assert result == json.loads(empty_value) + + @pytest.mark.asyncio + async def test_malformed_json_recovery(self): + """Test recovery from slightly malformed JSON""" + pm = PromptManager() + config = { + "system": json.dumps("Test"), + "template-index": json.dumps(["malformed"]), + "template.malformed": json.dumps({ + "prompt": "Generate data", + "response-type": "json" + }) + } + pm.load_config(config) + + # Missing closing brace - should fail + mock_llm = AsyncMock() + mock_llm.return_value = '{"key": "value"' + + with pytest.raises(RuntimeError) as exc_info: + await pm.invoke("malformed", {}, mock_llm) + + assert "JSON parse fail" in str(exc_info.value) + + def test_template_infinite_loop_protection(self): + """Test protection against infinite template loops""" + pm = PromptManager() + config = { + "system": json.dumps("Test"), + "template-index": json.dumps(["recursive"]), + "template.recursive": json.dumps({ + "prompt": "{{ recursive_var }}", + "response-type": "text" + }) + } + pm.load_config(config) + pm.prompts["recursive"].terms = {"recursive_var": "This includes {{ recursive_var }}"} + + # This should not cause infinite recursion + result = pm.render("recursive", {}) + + # The exact behavior depends on the template engine + assert isinstance(result, str) + + @pytest.mark.asyncio + async def test_extremely_long_template(self): + """Test handling of extremely long templates""" + # Create a very long template + long_template = "Start\n" + "\n".join([ + f"Line {i}: " + "{{ var_" + str(i) + " }}" + for i in range(1000) + ]) + "\nEnd" + + pm = PromptManager() + config = { + "system": json.dumps("Test"), + "template-index": json.dumps(["long"]), + "template.long": json.dumps({ + "prompt": long_template, + "response-type": "text" + }) + } + pm.load_config(config) + + # Create corresponding variables + variables = {f"var_{i}": f"value_{i}" for i in range(1000)} + + mock_llm = AsyncMock() + mock_llm.return_value = "Processed long template" + + result = await pm.invoke("long", variables, mock_llm) + + assert result == "Processed long template" + + # Check that template was rendered correctly + call_args = mock_llm.call_args[1] + rendered = call_args["prompt"] + assert "Line 500: value_500" in rendered + + @pytest.mark.asyncio + async def test_json_schema_with_additional_properties(self): + """Test JSON schema validation with additional properties""" + pm = PromptManager() + config = { + "system": json.dumps("Test"), + "template-index": json.dumps(["strict_schema"]), + "template.strict_schema": json.dumps({ + "prompt": "Generate user", + "response-type": "json", + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"], + "additionalProperties": False + } + }) + } + pm.load_config(config) + + mock_llm = AsyncMock() + # Response with extra property + mock_llm.return_value = '{"name": "John", "age": 30}' + + # Should fail validation due to additionalProperties: false + with pytest.raises(RuntimeError) as exc_info: + await pm.invoke("strict_schema", {}, mock_llm) + + assert "Schema validation fail" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_llm_timeout_handling(self): + """Test handling of LLM timeouts""" + pm = PromptManager() + config = { + "system": json.dumps("Test"), + "template-index": json.dumps(["timeout_test"]), + "template.timeout_test": json.dumps({ + "prompt": "Test prompt", + "response-type": "text" + }) + } + pm.load_config(config) + + mock_llm = AsyncMock() + mock_llm.side_effect = asyncio.TimeoutError("LLM request timed out") + + with pytest.raises(asyncio.TimeoutError): + await pm.invoke("timeout_test", {}, mock_llm) + + def test_template_with_filters_and_tests(self): + """Test template with Jinja2 filters and tests""" + pm = PromptManager() + config = { + "system": json.dumps("Test"), + "template-index": json.dumps(["filters"]), + "template.filters": json.dumps({ + "prompt": """ + {% if items %} + Items ({{ items|length }}): + {% for item in items|sort %} + - {{ item|upper }} + {% endfor %} + {% else %} + No items + {% endif %} + """, + "response-type": "text" + }) + } + pm.load_config(config) + + # Test with items + result = pm.render( + "filters", + {"items": ["banana", "apple", "cherry"]} + ) + + assert "Items (3)" in result + assert "- APPLE" in result + assert "- BANANA" in result + assert "- CHERRY" in result + + # Test without items + result = pm.render("filters", {"items": []}) + assert "No items" in result + + @pytest.mark.asyncio + async def test_concurrent_template_modifications(self): + """Test thread safety of template operations""" + pm = PromptManager() + config = { + "system": json.dumps("Test"), + "template-index": json.dumps(["concurrent"]), + "template.concurrent": json.dumps({ + "prompt": "User: {{ user }}", + "response-type": "text" + }) + } + pm.load_config(config) + + mock_llm = AsyncMock() + mock_llm.side_effect = lambda **kwargs: f"Response for {kwargs['prompt'].split()[1]}" + + # Simulate concurrent invocations with different users + import asyncio + tasks = [] + for i in range(10): + tasks.append( + pm.invoke("concurrent", {"user": f"User{i}"}, mock_llm) + ) + + results = await asyncio.gather(*tasks) + + # Each result should correspond to its user + for i, result in enumerate(results): + assert f"User{i}" in result \ No newline at end of file