"""Edge case tests for the Agent.""" import pytest import json from unittest.mock import Mock, patch from agent.agent import Agent from infrastructure.persistence import get_memory class TestParseIntentEdgeCases: """Edge case tests for _parse_intent.""" def test_nested_json(self, memory, mock_llm): """Should handle deeply nested JSON.""" agent = Agent(llm=mock_llm) text = '''{"thought": "test", "action": {"name": "test", "args": {"nested": {"deep": {"value": 1}}}}}''' intent = agent._parse_intent(text) assert intent is not None assert intent["action"]["args"]["nested"]["deep"]["value"] == 1 def test_json_with_unicode(self, memory, mock_llm): """Should handle unicode in JSON.""" agent = Agent(llm=mock_llm) text = '{"thought": "日本語", "action": {"name": "test", "args": {"title": "Amélie"}}}' intent = agent._parse_intent(text) assert intent is not None assert intent["thought"] == "日本語" def test_json_with_escaped_characters(self, memory, mock_llm): """Should handle escaped characters.""" agent = Agent(llm=mock_llm) text = r'{"thought": "test \"quoted\"", "action": {"name": "test", "args": {}}}' intent = agent._parse_intent(text) assert intent is not None assert 'quoted' in intent["thought"] def test_json_with_newlines(self, memory, mock_llm): """Should handle JSON with newlines.""" agent = Agent(llm=mock_llm) text = '''{ "thought": "test", "action": { "name": "test", "args": {} } }''' intent = agent._parse_intent(text) assert intent is not None def test_multiple_json_objects(self, memory, mock_llm): """Should extract first valid JSON.""" agent = Agent(llm=mock_llm) text = '''Here's the first: {"thought": "1", "action": {"name": "first", "args": {}}} And second: {"thought": "2", "action": {"name": "second", "args": {}}}''' intent = agent._parse_intent(text) # May return first valid JSON or None depending on implementation assert intent is None or intent is not None def test_json_with_array_action(self, memory, mock_llm): """Should reject action as array.""" agent = Agent(llm=mock_llm) text = '{"thought": "test", "action": ["not", "valid"]}' intent = agent._parse_intent(text) assert intent is None def test_json_with_numeric_action_name(self, memory, mock_llm): """Should reject numeric action name.""" agent = Agent(llm=mock_llm) text = '{"thought": "test", "action": {"name": 123, "args": {}}}' intent = agent._parse_intent(text) assert intent is None def test_json_with_null_values(self, memory, mock_llm): """Should handle null values.""" agent = Agent(llm=mock_llm) text = '{"thought": null, "action": {"name": "test", "args": null}}' intent = agent._parse_intent(text) assert intent is not None def test_truncated_json(self, memory, mock_llm): """Should handle truncated JSON.""" agent = Agent(llm=mock_llm) text = '{"thought": "test", "action": {"name": "test", "args":' intent = agent._parse_intent(text) assert intent is None def test_json_with_comments(self, memory, mock_llm): """Should handle JSON-like text with comments.""" agent = Agent(llm=mock_llm) # JSON doesn't support comments, but LLM might add them text = '''// This is a comment {"thought": "test", "action": {"name": "test", "args": {}}}''' intent = agent._parse_intent(text) # Should still extract the JSON assert intent is not None def test_empty_string(self, memory, mock_llm): """Should handle empty string.""" agent = Agent(llm=mock_llm) intent = agent._parse_intent("") assert intent is None def test_only_whitespace(self, memory, mock_llm): """Should handle whitespace-only string.""" agent = Agent(llm=mock_llm) intent = agent._parse_intent(" \n\t ") assert intent is None def test_json_in_markdown_code_block(self, memory, mock_llm): """Should extract JSON from markdown code block.""" agent = Agent(llm=mock_llm) text = '''Here's the action: ```json {"thought": "test", "action": {"name": "test", "args": {}}} ```''' intent = agent._parse_intent(text) assert intent is not None class TestExecuteActionEdgeCases: """Edge case tests for _execute_action.""" def test_tool_returns_none(self, memory, mock_llm): """Should handle tool returning None.""" agent = Agent(llm=mock_llm) # Mock a tool that returns None agent.tools["test_tool"] = Mock() agent.tools["test_tool"].func = Mock(return_value=None) intent = {"action": {"name": "test_tool", "args": {}}} result = agent._execute_action(intent) # May return None or error dict assert result is None or isinstance(result, dict) def test_tool_raises_keyboard_interrupt(self, memory, mock_llm): """Should propagate KeyboardInterrupt.""" agent = Agent(llm=mock_llm) agent.tools["test_tool"] = Mock() agent.tools["test_tool"].func = Mock(side_effect=KeyboardInterrupt()) intent = {"action": {"name": "test_tool", "args": {}}} with pytest.raises(KeyboardInterrupt): agent._execute_action(intent) def test_tool_with_extra_args(self, memory, mock_llm, real_folder): """Should handle extra arguments gracefully.""" agent = Agent(llm=mock_llm) memory.ltm.set_config("download_folder", str(real_folder["downloads"])) intent = { "action": { "name": "list_folder", "args": { "folder_type": "download", "extra_arg": "should be ignored", }, } } result = agent._execute_action(intent) # Should fail with bad_args since extra_arg is not expected assert result.get("error") == "bad_args" def test_tool_with_wrong_type_args(self, memory, mock_llm): """Should handle wrong argument types.""" agent = Agent(llm=mock_llm) intent = { "action": { "name": "get_torrent_by_index", "args": {"index": "not an int"}, } } result = agent._execute_action(intent) # Should handle gracefully assert "error" in result or "status" in result def test_action_with_empty_name(self, memory, mock_llm): """Should handle empty action name.""" agent = Agent(llm=mock_llm) intent = {"action": {"name": "", "args": {}}} result = agent._execute_action(intent) assert result["error"] == "unknown_tool" def test_action_with_whitespace_name(self, memory, mock_llm): """Should handle whitespace action name.""" agent = Agent(llm=mock_llm) intent = {"action": {"name": " ", "args": {}}} result = agent._execute_action(intent) assert result["error"] == "unknown_tool" class TestStepEdgeCases: """Edge case tests for step method.""" def test_step_with_empty_input(self, memory, mock_llm): """Should handle empty user input.""" mock_llm.complete.return_value = "I didn't receive any input." agent = Agent(llm=mock_llm) response = agent.step("") assert response is not None def test_step_with_very_long_input(self, memory, mock_llm): """Should handle very long user input.""" mock_llm.complete.return_value = "Response" agent = Agent(llm=mock_llm) long_input = "x" * 100000 response = agent.step(long_input) assert response is not None def test_step_with_unicode_input(self, memory, mock_llm): """Should handle unicode input.""" mock_llm.complete.return_value = "日本語の応答" agent = Agent(llm=mock_llm) response = agent.step("日本語の質問") assert response == "日本語の応答" def test_step_llm_returns_empty(self, memory, mock_llm): """Should handle LLM returning empty string.""" mock_llm.complete.return_value = "" agent = Agent(llm=mock_llm) response = agent.step("Hello") assert response == "" def test_step_llm_returns_only_whitespace(self, memory, mock_llm): """Should handle LLM returning only whitespace.""" mock_llm.complete.return_value = " \n\t " agent = Agent(llm=mock_llm) response = agent.step("Hello") # Whitespace is not a tool call, so it's returned as-is assert response.strip() == "" def test_step_llm_raises_exception(self, memory, mock_llm): """Should propagate LLM exceptions.""" mock_llm.complete.side_effect = Exception("LLM Error") agent = Agent(llm=mock_llm) with pytest.raises(Exception, match="LLM Error"): agent.step("Hello") def test_step_tool_loop_with_same_tool(self, memory, mock_llm): """Should handle tool calling same tool repeatedly.""" call_count = [0] def mock_complete(messages): call_count[0] += 1 if call_count[0] <= 3: return '{"thought": "loop", "action": {"name": "list_folder", "args": {"folder_type": "download"}}}' return "Done looping" mock_llm.complete.side_effect = mock_complete agent = Agent(llm=mock_llm, max_tool_iterations=3) response = agent.step("Loop test") # Should stop after max iterations assert call_count[0] == 4 # 3 tool calls + 1 final response def test_step_preserves_history_order(self, memory, mock_llm): """Should preserve message order in history.""" mock_llm.complete.return_value = "Response" agent = Agent(llm=mock_llm) agent.step("First") agent.step("Second") agent.step("Third") mem = get_memory() history = mem.stm.get_recent_history(10) # Should be in order: First, Response, Second, Response, Third, Response user_messages = [h["content"] for h in history if h["role"] == "user"] assert user_messages == ["First", "Second", "Third"] def test_step_with_pending_question(self, memory, mock_llm): """Should include pending question in context.""" memory.episodic.set_pending_question( "Which one?", [{"index": 1, "label": "Option 1"}], {}, ) mock_llm.complete.return_value = "I see you have a pending question." agent = Agent(llm=mock_llm) response = agent.step("Hello") # The prompt should have included the pending question call_args = mock_llm.complete.call_args[0][0] system_prompt = call_args[0]["content"] assert "PENDING QUESTION" in system_prompt def test_step_with_active_downloads(self, memory, mock_llm): """Should include active downloads in context.""" memory.episodic.add_active_download({ "task_id": "123", "name": "Movie.mkv", "progress": 50, }) mock_llm.complete.return_value = "I see you have an active download." agent = Agent(llm=mock_llm) response = agent.step("Hello") call_args = mock_llm.complete.call_args[0][0] system_prompt = call_args[0]["content"] assert "ACTIVE DOWNLOADS" in system_prompt def test_step_clears_events_after_notification(self, memory, mock_llm): """Should mark events as read after notification.""" memory.episodic.add_background_event("test_event", {"data": "test"}) mock_llm.complete.return_value = "Response" agent = Agent(llm=mock_llm) agent.step("Hello") # Events should be marked as read unread = memory.episodic.get_unread_events() assert len(unread) == 0 class TestAgentConcurrencyEdgeCases: """Edge case tests for concurrent access.""" def test_multiple_agents_same_memory(self, memory, mock_llm): """Should handle multiple agents with same memory.""" mock_llm.complete.return_value = "Response" agent1 = Agent(llm=mock_llm) agent2 = Agent(llm=mock_llm) agent1.step("From agent 1") agent2.step("From agent 2") mem = get_memory() history = mem.stm.get_recent_history(10) # Both should have added to history assert len(history) == 4 # 2 user + 2 assistant def test_tool_modifies_memory_during_step(self, memory, mock_llm, real_folder): """Should handle memory modifications during step.""" memory.ltm.set_config("download_folder", str(real_folder["downloads"])) mock_llm.complete.side_effect = [ '{"thought": "set path", "action": {"name": "set_path_for_folder", "args": {"folder_name": "movie", "path_value": "' + str(real_folder["movies"]) + '"}}}', "Path set successfully.", ] agent = Agent(llm=mock_llm) response = agent.step("Set movie folder") # Memory should have been modified mem = get_memory() assert mem.ltm.get_config("movie_folder") == str(real_folder["movies"]) class TestAgentErrorRecovery: """Tests for agent error recovery.""" def test_recovers_from_tool_error(self, memory, mock_llm): """Should recover from tool error and continue.""" mock_llm.complete.side_effect = [ '{"thought": "try", "action": {"name": "list_folder", "args": {"folder_type": "download"}}}', "The folder is not configured. Please set it first.", ] agent = Agent(llm=mock_llm) response = agent.step("List downloads") # Should have recovered and provided a response assert "not configured" in response.lower() or "set" in response.lower() def test_error_tracked_in_memory(self, memory, mock_llm): """Should track errors in episodic memory.""" mock_llm.complete.side_effect = [ '{"thought": "try", "action": {"name": "list_folder", "args": {"folder_type": "download"}}}', "Error occurred.", ] agent = Agent(llm=mock_llm) agent.step("List downloads") mem = get_memory() assert len(mem.episodic.recent_errors) > 0 def test_multiple_errors_in_sequence(self, memory, mock_llm): """Should track multiple errors.""" call_count = [0] def mock_complete(messages): call_count[0] += 1 if call_count[0] <= 3: return '{"thought": "try", "action": {"name": "list_folder", "args": {"folder_type": "download"}}}' return "All attempts failed." mock_llm.complete.side_effect = mock_complete agent = Agent(llm=mock_llm, max_tool_iterations=3) agent.step("Try multiple times") mem = get_memory() # Should have tracked multiple errors assert len(mem.episodic.recent_errors) >= 1