AISE1_Project_Irina_Livio/tests/test_coding_agent.py
2026-05-27 07:54:07 +02:00

386 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Tests for CodingAgent (backend/agent/coding_agent.py)
Structure:
TestHelpers truncate_result, trim_messages, _strip_code_fences
TestCodingAgentInit __init__ and start_task
TestProposeNextAction propose_next_action with mocked API
TestApprove approve with mocked API + real tool execution
TestReject reject injects feedback correctly
TestFullLoop integration: real API, skipped if unreachable
Note: TestTools (write_file, read_file, etc.) and TestDispatcher were removed
because those tool functions are now MCP server tools, not standalone functions
in coding_agent.py. They will be tested via test_mcp_server_*.py once the MCP
servers are finalised.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from backend.agent.coding_agent import (
MAX_ITERATIONS,
MAX_RESULT_LENGTH,
CodingAgent,
_strip_code_fences,
truncate_result,
trim_messages,
)
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
def _make_api_response(content: str, status_code: int = 200):
"""Build a mock requests.Response that returns *content* as the AI message."""
mock = MagicMock()
mock.status_code = status_code
mock.json.return_value = {
"choices": [{"message": {"role": "assistant", "content": content}}]
}
mock.text = "error body"
return mock
def _agent_action_json(tool: str, thought: str = "thinking...", **arguments) -> str:
"""Return a JSON string in the exact format the agent expects from the LLM:
{"thought": "...", "tool": "<name>", "arguments": {...}}."""
return json.dumps({"thought": thought, "tool": tool, "arguments": arguments})
# ═════════════════════════════════════════════════════════════════════════════
# TestHelpers
# ═════════════════════════════════════════════════════════════════════════════
class TestTruncateResult:
"""Tests for truncate_result(): ensures long tool outputs are capped before entering the message history."""
def test_short_result_unchanged(self):
assert truncate_result("hello") == "hello"
def test_long_result_is_truncated(self):
long = "x" * (MAX_RESULT_LENGTH + 100)
result = truncate_result(long)
assert len(result) < len(long)
assert "TRUNCATED" in result
def test_truncated_keeps_start_and_end(self):
text = "START" + "x" * MAX_RESULT_LENGTH + "END"
result = truncate_result(text)
assert "START" in result
assert "END" in result
class TestTrimMessages:
"""Tests for trim_messages(): keeps system + original task, drops old turns when history grows too large."""
def _make_messages(self, n_extra: int, chars_each: int = 100) -> list:
"""Build a message list with a fixed system + user header followed by
n_extra assistant/user pairs, each pair consuming 2*chars_each characters."""
msgs = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "original task"},
]
for i in range(n_extra):
msgs.append({"role": "assistant", "content": "x" * chars_each})
msgs.append({"role": "user", "content": "y" * chars_each})
return msgs
def test_short_history_unchanged(self):
msgs = self._make_messages(2)
assert trim_messages(msgs) == msgs
def test_long_history_is_trimmed(self):
msgs = self._make_messages(n_extra=500, chars_each=200)
original_total = sum(len(m["content"]) for m in msgs)
trimmed = trim_messages(msgs)
trimmed_total = sum(len(m["content"]) for m in trimmed)
assert trimmed_total < original_total
assert len(trimmed) < len(msgs)
def test_system_message_always_kept(self):
msgs = self._make_messages(n_extra=500, chars_each=200)
trimmed = trim_messages(msgs)
assert trimmed[0]["role"] == "system"
def test_original_task_always_kept(self):
msgs = self._make_messages(n_extra=500, chars_each=200)
trimmed = trim_messages(msgs)
assert trimmed[1]["content"] == "original task"
def test_reminder_injected_when_trimmed(self):
# trim_messages inserts a "system_note" message so the agent knows that
# earlier turns were dropped and it should not reference missing context.
msgs = self._make_messages(n_extra=500, chars_each=200)
trimmed = trim_messages(msgs)
contents = [m["content"] for m in trimmed]
assert any("system_note" in c for c in contents)
class TestStripCodeFences:
"""Tests for _strip_code_fences(): the LLM sometimes wraps its JSON in markdown fences — this strips them."""
def test_plain_text_unchanged(self):
assert _strip_code_fences("hello") == "hello"
def test_removes_json_fence(self):
text = "```json\n{\"key\": 1}\n```"
assert _strip_code_fences(text) == '{"key": 1}'
def test_removes_plain_fence(self):
text = "```\nhello\n```"
assert _strip_code_fences(text) == "hello"
# ═════════════════════════════════════════════════════════════════════════════
# TestCodingAgentInit
# ═════════════════════════════════════════════════════════════════════════════
class TestCodingAgentInit:
"""Tests for CodingAgent.__init__ and start_task(): state is clean before and after task setup."""
def test_initial_state_is_clean(self):
agent = CodingAgent()
assert agent.messages == []
assert agent.pending_action is None
assert agent.is_done is False
assert agent.iteration == 0
def test_start_task_sets_messages(self):
agent = CodingAgent()
agent.start_task("Write fibonacci.py")
assert len(agent.messages) == 2
assert agent.messages[0]["role"] == "system"
assert agent.messages[1]["role"] == "user"
assert "fibonacci" in agent.messages[1]["content"]
def test_start_task_resets_state(self):
agent = CodingAgent()
agent.is_done = True
agent.iteration = 5
agent.start_task("New task")
assert agent.is_done is False
assert agent.iteration == 0
assert agent.pending_action is None
def test_start_task_twice_resets_messages(self):
agent = CodingAgent()
agent.start_task("First task")
agent.start_task("Second task")
assert "Second task" in agent.messages[1]["content"]
assert len(agent.messages) == 2
# ═════════════════════════════════════════════════════════════════════════════
# TestProposeNextAction (mocked API)
# ═════════════════════════════════════════════════════════════════════════════
class TestProposeNextAction:
"""Tests for propose_next_action(): API is mocked so no real HTTP calls are made."""
@pytest.fixture
def agent(self):
a = CodingAgent()
a.start_task("Write hello.py")
return a
def _mock_api(self, agent, tool="list_files", thought="planning", **args):
payload = _agent_action_json(tool, thought, **args)
agent._call_api = MagicMock(return_value=payload)
@pytest.mark.asyncio
async def test_returns_dict_with_required_keys(self, agent):
self._mock_api(agent)
action = await agent.propose_next_action()
assert "thought" in action
assert "tool" in action
assert "arguments" in action
@pytest.mark.asyncio
async def test_increments_iteration(self, agent):
self._mock_api(agent)
await agent.propose_next_action()
assert agent.iteration == 1
@pytest.mark.asyncio
async def test_stores_pending_action(self, agent):
self._mock_api(agent)
await agent.propose_next_action()
assert agent.pending_action is not None
@pytest.mark.asyncio
async def test_returns_correct_tool(self, agent):
self._mock_api(agent, tool="list_files")
action = await agent.propose_next_action()
assert action["tool"] == "list_files"
@pytest.mark.asyncio
async def test_handles_json_parse_error_gracefully(self, agent):
agent._call_api = MagicMock(return_value="this is not json {{")
action = await agent.propose_next_action()
assert action["tool"] == "done"
@pytest.mark.asyncio
async def test_handles_api_exception_gracefully(self, agent):
agent._call_api = MagicMock(side_effect=Exception("connection refused"))
action = await agent.propose_next_action()
assert action["tool"] == "done"
@pytest.mark.asyncio
async def test_strips_code_fences_from_response(self, agent):
payload = "```json\n" + _agent_action_json("list_files", "thinking") + "\n```"
agent._call_api = MagicMock(return_value=payload)
action = await agent.propose_next_action()
assert action["tool"] == "list_files"
@pytest.mark.asyncio
async def test_already_done_returns_done_action(self, agent):
agent.is_done = True
action = await agent.propose_next_action()
assert action["tool"] == "done"
@pytest.mark.asyncio
async def test_max_iterations_returns_done_without_api_call(self, agent):
agent.iteration = MAX_ITERATIONS
agent._call_api = MagicMock(side_effect=AssertionError("API must not be called"))
action = await agent.propose_next_action()
assert action["tool"] == "done"
agent._call_api.assert_not_called()
# ═════════════════════════════════════════════════════════════════════════════
# TestApprove (mocked API + mocked dispatch_tool)
# ═════════════════════════════════════════════════════════════════════════════
class TestApprove:
"""Tests for approve(): dispatch_tool is mocked so no filesystem or subprocess side-effects occur."""
@pytest.fixture
def agent(self):
a = CodingAgent()
a.start_task("Do something")
return a
def _set_pending(self, agent, tool: str, **arguments):
"""Inject a pending_action into the agent as if propose_next_action() had just run.
'raw' holds the original JSON string; 'action' holds the parsed dict."""
raw = _agent_action_json(tool, "thought", **arguments)
agent.pending_action = {
"raw": raw,
"action": {"thought": "thought", "tool": tool, "arguments": arguments},
}
@pytest.mark.asyncio
async def test_approve_without_pending_raises(self, agent):
with pytest.raises(Exception):
await agent.approve()
@pytest.mark.asyncio
async def test_approve_done_sets_is_done(self, agent):
self._set_pending(agent, "done", summary="all done")
result = await agent.approve()
assert result["is_done"] is True
assert agent.is_done is True
@pytest.mark.asyncio
async def test_approve_done_returns_summary(self, agent):
self._set_pending(agent, "done", summary="finished successfully")
result = await agent.approve()
assert "finished successfully" in result["result"]
@pytest.mark.asyncio
async def test_approve_clears_pending_action(self, agent):
self._set_pending(agent, "done", summary="x")
await agent.approve()
assert agent.pending_action is None
@pytest.mark.asyncio
async def test_approve_appends_assistant_message(self, agent):
self._set_pending(agent, "done", summary="x")
before = len(agent.messages)
await agent.approve()
assert len(agent.messages) > before
@pytest.mark.asyncio
async def test_approve_tool_result_appended_to_messages(self, agent):
with patch("backend.agent.coding_agent.dispatch_tool", return_value="file list"):
self._set_pending(agent, "list_files")
await agent.approve()
tool_results = [m for m in agent.messages if "tool_result" in m["content"]]
assert len(tool_results) == 1
@pytest.mark.asyncio
async def test_approve_error_result_adds_replan_tag(self, agent):
# When a tool returns an error, approve() adds a "replan" tag to the message
# so the LLM knows the last action failed and must choose a different approach.
with patch("backend.agent.coding_agent.dispatch_tool", return_value="ERROR: file not found"):
self._set_pending(agent, "read_file", path="nonexistent.py")
await agent.approve()
last_msg = agent.messages[-1]["content"]
assert "replan" in last_msg
@pytest.mark.asyncio
async def test_approve_returns_tool_name_in_result(self, agent):
with patch("backend.agent.coding_agent.dispatch_tool", return_value="(empty)"):
self._set_pending(agent, "list_files")
result = await agent.approve()
assert result["tool"] == "list_files"
assert result["is_done"] is False
# ═════════════════════════════════════════════════════════════════════════════
# TestReject
# ═════════════════════════════════════════════════════════════════════════════
class TestReject:
"""Tests for reject(): user feedback is injected into the history and the pending action is discarded."""
@pytest.fixture
def agent(self):
a = CodingAgent()
a.start_task("Do something")
return a
def _set_pending(self, agent, tool="write_file"):
raw = _agent_action_json(tool, "thought")
agent.pending_action = {
"raw": raw,
"action": {"thought": "thought", "tool": tool, "arguments": {}},
}
def test_reject_clears_pending_action(self, agent):
self._set_pending(agent)
agent.reject("Use a different approach")
assert agent.pending_action is None
def test_reject_appends_human_message(self, agent):
self._set_pending(agent)
agent.reject("Do it differently")
user_msgs = [m for m in agent.messages if m["role"] == "user"]
assert any("Do it differently" in m["content"] for m in user_msgs)
def test_reject_adds_replan_tag(self, agent):
self._set_pending(agent)
agent.reject("feedback")
last = agent.messages[-1]["content"]
assert "replan" in last.lower()
def test_reject_without_pending_does_not_crash(self, agent):
agent.pending_action = None
agent.reject("no pending action")
def test_reject_does_not_execute_tool(self, agent, tmp_path):
self._set_pending(agent, "write_file")
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
agent.reject("Do not write anything")
assert not list(tmp_path.glob("*"))