386 lines
16 KiB
Python
386 lines
16 KiB
Python
"""
|
||
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("*"))
|
||
|
||
|