- Fix circular dependencies in agent/tools - Migrate from custom JSON to OpenAI tool calls format - Add async streaming (step_stream, complete_stream) - Simplify prompt system and remove token counting - Add 5 new API endpoints (/health, /v1/models, /api/memory/*) - Add 3 new tools (get_torrent_by_index, add_torrent_by_index, set_language) - Fix all 500 tests and add coverage config (80% threshold) - Add comprehensive docs (README, pytest guide) BREAKING: LLM interface changed, memory injection via get_memory()
330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""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
|