"""Tests for the Agent.""" from unittest.mock import Mock, patch from agent.agent import Agent from infrastructure.persistence import get_memory class TestAgentInit: """Tests for Agent initialization.""" def test_init(self, memory, mock_llm): """Should initialize agent with LLM.""" agent = Agent(llm=mock_llm) assert agent.llm is mock_llm assert agent.tools is not None assert agent.prompt_builder is not None assert agent.max_tool_iterations == 5 def test_init_custom_iterations(self, memory, mock_llm): """Should accept custom max iterations.""" agent = Agent(llm=mock_llm, max_tool_iterations=10) assert agent.max_tool_iterations == 10 def test_tools_registered(self, memory, mock_llm): """Should register all tools.""" agent = Agent(llm=mock_llm) expected_tools = [ "set_path_for_folder", "list_folder", "find_media_imdb_id", "find_torrents", "add_torrent_by_index", "add_torrent_to_qbittorrent", "get_torrent_by_index", ] for tool_name in expected_tools: assert tool_name in agent.tools class TestParseIntent: """Tests for _parse_intent method.""" def test_parse_valid_json(self, memory, mock_llm): """Should parse valid tool call JSON.""" agent = Agent(llm=mock_llm) text = '{"thought": "test", "action": {"name": "find_torrents", "args": {"media_title": "Inception"}}}' intent = agent._parse_intent(text) assert intent is not None assert intent["action"]["name"] == "find_torrents" assert intent["action"]["args"]["media_title"] == "Inception" def test_parse_json_with_surrounding_text(self, memory, mock_llm): """Should extract JSON from surrounding text.""" agent = Agent(llm=mock_llm) text = 'Let me search for that. {"thought": "searching", "action": {"name": "find_torrents", "args": {}}} Done.' intent = agent._parse_intent(text) assert intent is not None assert intent["action"]["name"] == "find_torrents" def test_parse_plain_text(self, memory, mock_llm): """Should return None for plain text.""" agent = Agent(llm=mock_llm) text = "I found 3 torrents for Inception!" intent = agent._parse_intent(text) assert intent is None def test_parse_invalid_json(self, memory, mock_llm): """Should return None for invalid JSON.""" agent = Agent(llm=mock_llm) text = '{"thought": "test", "action": {invalid}}' intent = agent._parse_intent(text) assert intent is None def test_parse_json_without_action(self, memory, mock_llm): """Should return None for JSON without action.""" agent = Agent(llm=mock_llm) text = '{"thought": "test", "result": "something"}' intent = agent._parse_intent(text) assert intent is None def test_parse_json_with_invalid_action(self, memory, mock_llm): """Should return None for invalid action structure.""" agent = Agent(llm=mock_llm) text = '{"thought": "test", "action": "not_an_object"}' intent = agent._parse_intent(text) assert intent is None def test_parse_json_without_action_name(self, memory, mock_llm): """Should return None if action has no name.""" agent = Agent(llm=mock_llm) text = '{"thought": "test", "action": {"args": {}}}' intent = agent._parse_intent(text) assert intent is None def test_parse_whitespace(self, memory, mock_llm): """Should handle whitespace around JSON.""" agent = Agent(llm=mock_llm) text = ( ' \n {"thought": "test", "action": {"name": "test", "args": {}}} \n ' ) intent = agent._parse_intent(text) assert intent is not None class TestExecuteAction: """Tests for _execute_action method.""" def test_execute_known_tool(self, memory, mock_llm, real_folder): """Should execute known tool.""" agent = Agent(llm=mock_llm) memory.ltm.set_config("download_folder", str(real_folder["downloads"])) intent = { "action": {"name": "list_folder", "args": {"folder_type": "download"}} } result = agent._execute_action(intent) assert result["status"] == "ok" def test_execute_unknown_tool(self, memory, mock_llm): """Should return error for unknown tool.""" agent = Agent(llm=mock_llm) intent = {"action": {"name": "unknown_tool", "args": {}}} result = agent._execute_action(intent) assert result["error"] == "unknown_tool" assert "available_tools" in result def test_execute_with_bad_args(self, memory, mock_llm): """Should return error for bad arguments.""" agent = Agent(llm=mock_llm) # Missing required argument intent = {"action": {"name": "set_path_for_folder", "args": {}}} result = agent._execute_action(intent) assert result["error"] == "bad_args" def test_execute_tracks_errors(self, memory, mock_llm): """Should track errors in episodic memory.""" agent = Agent(llm=mock_llm) intent = { "action": {"name": "list_folder", "args": {"folder_type": "download"}} } result = agent._execute_action(intent) # Will fail - folder not configured mem = get_memory() assert len(mem.episodic.recent_errors) > 0 def test_execute_with_none_args(self, memory, mock_llm, real_folder): """Should handle None args.""" agent = Agent(llm=mock_llm) memory.ltm.set_config("download_folder", str(real_folder["downloads"])) intent = {"action": {"name": "list_folder", "args": None}} result = agent._execute_action(intent) # Should fail gracefully with bad_args, not crash assert "error" in result class TestStep: """Tests for step method.""" def test_step_text_response(self, memory, mock_llm): """Should return text response when no tool call.""" mock_llm.complete.return_value = "Hello! How can I help you?" agent = Agent(llm=mock_llm) response = agent.step("Hello") assert response == "Hello! How can I help you?" def test_step_saves_to_history(self, memory, mock_llm): """Should save conversation to STM history.""" mock_llm.complete.return_value = "Hello!" agent = Agent(llm=mock_llm) agent.step("Hi there") mem = get_memory() history = mem.stm.get_recent_history(10) assert len(history) == 2 assert history[0]["role"] == "user" assert history[0]["content"] == "Hi there" assert history[1]["role"] == "assistant" def test_step_with_tool_call(self, memory, mock_llm, real_folder): """Should execute tool and continue.""" memory.ltm.set_config("download_folder", str(real_folder["downloads"])) mock_llm.complete.side_effect = [ '{"thought": "listing", "action": {"name": "list_folder", "args": {"folder_type": "download"}}}', "I found 2 items in your download folder.", ] agent = Agent(llm=mock_llm) response = agent.step("List my downloads") assert "2 items" in response or "found" in response.lower() assert mock_llm.complete.call_count == 2 def test_step_max_iterations(self, memory, mock_llm): """Should stop after max iterations.""" # Always return tool call mock_llm.complete.return_value = '{"thought": "loop", "action": {"name": "list_folder", "args": {"folder_type": "download"}}}' agent = Agent(llm=mock_llm, max_tool_iterations=3) # Mock the final response after max iterations def side_effect(messages): if "final response" in str(messages[-1].get("content", "")).lower(): return "I couldn't complete the task." return '{"thought": "loop", "action": {"name": "list_folder", "args": {"folder_type": "download"}}}' mock_llm.complete.side_effect = side_effect response = agent.step("Do something") # Should have called LLM max_iterations + 1 times (for final response) assert mock_llm.complete.call_count == 4 def test_step_includes_history(self, memory_with_history, mock_llm): """Should include conversation history in prompt.""" mock_llm.complete.return_value = "Response" agent = Agent(llm=mock_llm) agent.step("New message") # Check that history was included in the call call_args = mock_llm.complete.call_args[0][0] messages_content = [m.get("content", "") for m in call_args] assert any("Hello" in c for c in messages_content) def test_step_includes_events(self, memory, mock_llm): """Should include unread events in prompt.""" memory.episodic.add_background_event("download_complete", {"name": "Movie.mkv"}) mock_llm.complete.return_value = "Response" agent = Agent(llm=mock_llm) agent.step("What's new?") call_args = mock_llm.complete.call_args[0][0] messages_content = [m.get("content", "") for m in call_args] assert any("download" in c.lower() for c in messages_content) def test_step_saves_ltm(self, memory, mock_llm, temp_dir): """Should save LTM after step.""" mock_llm.complete.return_value = "Response" agent = Agent(llm=mock_llm) agent.step("Hello") # Check that LTM file was written ltm_file = temp_dir / "ltm.json" assert ltm_file.exists() class TestAgentIntegration: """Integration tests for Agent.""" @patch("agent.tools.api.SearchTorrentsUseCase") def test_search_and_select_workflow(self, mock_use_case_class, memory, mock_llm): """Should handle search and select workflow.""" # Mock torrent search mock_response = Mock() mock_response.to_dict.return_value = { "status": "ok", "torrents": [ {"name": "Inception.1080p", "seeders": 100, "magnet": "magnet:?xt=..."}, ], "count": 1, } mock_use_case = Mock() mock_use_case.execute.return_value = mock_response mock_use_case_class.return_value = mock_use_case # First call: tool call, second call: response mock_llm.complete.side_effect = [ '{"thought": "searching", "action": {"name": "find_torrents", "args": {"media_title": "Inception"}}}', "I found 1 torrent for Inception!", ] agent = Agent(llm=mock_llm) response = agent.step("Find Inception") assert "found" in response.lower() or "torrent" in response.lower() # Check that results are in episodic memory mem = get_memory() assert mem.episodic.last_search_results is not None def test_multiple_tool_calls(self, memory, mock_llm, real_folder): """Should handle multiple tool calls in sequence.""" memory.ltm.set_config("download_folder", str(real_folder["downloads"])) memory.ltm.set_config("movie_folder", str(real_folder["movies"])) mock_llm.complete.side_effect = [ '{"thought": "list downloads", "action": {"name": "list_folder", "args": {"folder_type": "download"}}}', '{"thought": "list movies", "action": {"name": "list_folder", "args": {"folder_type": "movie"}}}', "I listed both folders for you.", ] agent = Agent(llm=mock_llm) response = agent.step("List my downloads and movies") assert mock_llm.complete.call_count == 3