Update tests and add some new test Cases. Add new requirement
This commit is contained in:
parent
855c4edde4
commit
ee64b8c706
@ -33,8 +33,8 @@ class ChatManager:
|
||||
self.chat_history.append({"role": role, "content": content})
|
||||
|
||||
def get_history(self) -> list:
|
||||
"""Return the full conversation history."""
|
||||
return self.chat_history
|
||||
"""Return a copy of the conversation history."""
|
||||
return list(self.chat_history)
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Wipe the conversation history (starts a fresh chat)."""
|
||||
|
||||
@ -28,8 +28,8 @@ class DebugLogger:
|
||||
})
|
||||
|
||||
def get_logs(self) -> list[dict]:
|
||||
"""Return all collected log entries."""
|
||||
return self.logs
|
||||
"""Return a copy of all collected log entries."""
|
||||
return list(self.logs)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Reset the log — call before each new execution."""
|
||||
|
||||
@ -105,10 +105,11 @@ class FileManager:
|
||||
def read_file(self, relative_path: Path) -> str:
|
||||
"""
|
||||
Reads the content of a file.
|
||||
The relative_path should be the path to the file relative to the base path.
|
||||
Accepts an absolute Path object (as stored in st.session_state.open_files).
|
||||
The path is validated to ensure it stays inside the workspace.
|
||||
|
||||
Args:
|
||||
relative_path (str): The relative path (without base path) to the file to read, including the file name
|
||||
relative_path (Path): Absolute path to the file to read.
|
||||
Returns:
|
||||
str: The content of the file, or an empty string if there was an error.
|
||||
"""
|
||||
@ -137,12 +138,13 @@ class FileManager:
|
||||
|
||||
def save_file(self, relative_path: str, content: str) -> bool:
|
||||
"""
|
||||
Saves content to a file.
|
||||
The relative_path should be the path to the file relative to the base path.
|
||||
|
||||
Saves content to a file.
|
||||
Accepts an absolute path string (as stored in st.session_state.open_files).
|
||||
The path is validated to ensure it stays inside the workspace.
|
||||
|
||||
Args:
|
||||
relative_path (str): The relative path(without base path) to the file to save, including the file name
|
||||
content (str): The content to write to the file
|
||||
relative_path (str): Absolute path to the file to save, including the file name.
|
||||
content (str): The content to write to the file.
|
||||
Returns:
|
||||
bool: True if save was successful, False otherwise.
|
||||
"""
|
||||
|
||||
@ -17,6 +17,7 @@ pandas>=2.0.0
|
||||
# Testing
|
||||
pytest>=7.0.0
|
||||
pytest-cov>=4.0.0
|
||||
pytest-asyncio>=0.23.0
|
||||
|
||||
# Development & Utilities
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
23
tests/conftest.py
Normal file
23
tests/conftest.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""Shared pytest configuration — runs before any test module is imported.
|
||||
|
||||
Patches MCPToolAdapter at the sys.modules level so that importing
|
||||
backend.agent.coding_agent never tries to start real MCP subprocess servers.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
# Build a fake adapter instance whose async methods return immediately.
|
||||
_mock_adapter = MagicMock()
|
||||
_mock_adapter.initialize_all_servers = AsyncMock(return_value=None)
|
||||
_mock_adapter.get_all_tools = MagicMock(return_value=[])
|
||||
_mock_adapter.call_tool = AsyncMock(return_value=MagicMock(isError=False, content=[]))
|
||||
|
||||
_mock_adapter_cls = MagicMock(return_value=_mock_adapter)
|
||||
|
||||
# Inject before any test imports coding_agent so the module-level
|
||||
# asyncio.run(adapter.initialize_all_servers()) uses the mock.
|
||||
sys.modules.setdefault(
|
||||
"backend.agent.mcp_server_adapter",
|
||||
MagicMock(MCPToolAdapter=_mock_adapter_cls),
|
||||
)
|
||||
@ -54,9 +54,11 @@ class TestHistory:
|
||||
assert cm.chat_history[0]["role"] == "user"
|
||||
assert cm.chat_history[1]["role"] == "assistant"
|
||||
|
||||
def test_get_history_returns_internal_list(self, cm):
|
||||
def test_get_history_returns_copy_not_reference(self, cm):
|
||||
cm.add_message("user", "Hi")
|
||||
assert cm.get_history() is cm.chat_history
|
||||
history = cm.get_history()
|
||||
assert history == cm.chat_history
|
||||
assert history is not cm.chat_history
|
||||
|
||||
def test_clear_history_empties_list(self, cm):
|
||||
cm.add_message("user", "Hi")
|
||||
@ -147,13 +149,20 @@ class TestSendMessage:
|
||||
headers = mock_post.call_args.kwargs["headers"]
|
||||
assert headers.get("Authorization") == "Bearer test-key-123"
|
||||
|
||||
def test_api_key_excluded_from_header_when_empty(self, cm):
|
||||
def test_api_key_excluded_from_header_when_empty_sentinel(self, cm):
|
||||
cm.api_key = "EMPTY"
|
||||
with patch("requests.post", return_value=_mock_ok()) as mock_post:
|
||||
cm.send_message("Hello")
|
||||
headers = mock_post.call_args.kwargs["headers"]
|
||||
assert "Authorization" not in headers
|
||||
|
||||
def test_api_key_excluded_from_header_when_empty_string(self, cm):
|
||||
cm.api_key = ""
|
||||
with patch("requests.post", return_value=_mock_ok()) as mock_post:
|
||||
cm.send_message("Hello")
|
||||
headers = mock_post.call_args.kwargs["headers"]
|
||||
assert "Authorization" not in headers
|
||||
|
||||
def test_json_decode_error_raises(self, cm):
|
||||
import json
|
||||
mock = MagicMock()
|
||||
|
||||
@ -3,13 +3,16 @@ Tests for CodingAgent (backend/agent/coding_agent.py)
|
||||
|
||||
Structure:
|
||||
TestHelpers – truncate_result, trim_messages, _strip_code_fences
|
||||
TestDispatcher – dispatch_tool routing
|
||||
TestTools – tool functions (read_file, write_file, …) using tmp workspace
|
||||
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
|
||||
@ -18,24 +21,18 @@ from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from backend.agent.coding_agent import (
|
||||
MAX_HISTORY_CHARS,
|
||||
MAX_ITERATIONS,
|
||||
MAX_RESULT_LENGTH,
|
||||
CodingAgent,
|
||||
_strip_code_fences,
|
||||
dispatch_tool,
|
||||
done,
|
||||
grep_search,
|
||||
list_files,
|
||||
read_file,
|
||||
run_python,
|
||||
truncate_result,
|
||||
trim_messages,
|
||||
validate_python,
|
||||
write_file,
|
||||
)
|
||||
|
||||
|
||||
@ -105,8 +102,6 @@ class TestTrimMessages:
|
||||
original_total = sum(len(m["content"]) for m in msgs)
|
||||
trimmed = trim_messages(msgs)
|
||||
trimmed_total = sum(len(m["content"]) for m in trimmed)
|
||||
# Must be significantly shorter than the original
|
||||
# (slightly above MAX_HISTORY_CHARS is acceptable due to the injected reminder message)
|
||||
assert trimmed_total < original_total
|
||||
assert len(trimmed) < len(msgs)
|
||||
|
||||
@ -144,162 +139,6 @@ class TestStripCodeFences:
|
||||
assert _strip_code_fences(" hello ") == "hello"
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# TestDispatcher
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestDispatcher:
|
||||
|
||||
def test_unknown_tool_returns_error(self):
|
||||
result = dispatch_tool("nonexistent_tool", {})
|
||||
assert "ERROR" in result
|
||||
assert "nonexistent_tool" in result
|
||||
|
||||
def test_done_tool_dispatched(self):
|
||||
result = dispatch_tool("done", {"summary": "finished"})
|
||||
assert "finished" in result
|
||||
|
||||
def test_wrong_arguments_returns_error(self):
|
||||
result = dispatch_tool("read_file", {"wrong_param": "x"})
|
||||
assert "ERROR" in result
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# TestTools (patched WORKSPACE → tmp_path)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestWriteFile:
|
||||
|
||||
def test_write_creates_file(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = write_file("hello.py", "print('hi')")
|
||||
assert result.startswith("OK:")
|
||||
assert (tmp_path / "hello.py").read_text() == "print('hi')"
|
||||
|
||||
def test_write_outside_workspace_blocked(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = write_file("../evil.py", "bad")
|
||||
assert "ERROR" in result
|
||||
|
||||
def test_write_unsupported_extension_blocked(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = write_file("script.sh", "echo hi")
|
||||
assert "ERROR" in result
|
||||
|
||||
|
||||
class TestReadFile:
|
||||
|
||||
def test_read_existing_file(self, tmp_path):
|
||||
(tmp_path / "data.txt").write_text("hello world")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = read_file("data.txt")
|
||||
assert result == "hello world"
|
||||
|
||||
def test_read_nonexistent_file(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = read_file("ghost.py")
|
||||
assert "ERROR" in result
|
||||
|
||||
def test_read_outside_workspace_blocked(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = read_file("../secret.py")
|
||||
assert "ERROR" in result
|
||||
|
||||
def test_read_unsupported_extension(self, tmp_path):
|
||||
(tmp_path / "data.csv").write_text("a,b")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = read_file("data.csv")
|
||||
assert "ERROR" in result
|
||||
|
||||
|
||||
class TestListFiles:
|
||||
|
||||
def test_empty_workspace(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = list_files()
|
||||
assert "No files" in result
|
||||
|
||||
def test_lists_existing_files(self, tmp_path):
|
||||
(tmp_path / "a.py").touch()
|
||||
(tmp_path / "b.txt").touch()
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = list_files()
|
||||
assert "a.py" in result
|
||||
assert "b.txt" in result
|
||||
|
||||
def test_glob_filter(self, tmp_path):
|
||||
(tmp_path / "a.py").touch()
|
||||
(tmp_path / "b.txt").touch()
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = list_files("*.py")
|
||||
assert "a.py" in result
|
||||
assert "b.txt" not in result
|
||||
|
||||
|
||||
class TestGrepSearch:
|
||||
|
||||
def test_finds_pattern(self, tmp_path):
|
||||
(tmp_path / "code.py").write_text("def hello():\n pass\n")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = grep_search("def hello")
|
||||
assert "code.py" in result
|
||||
assert "def hello" in result
|
||||
|
||||
def test_no_match_returns_message(self, tmp_path):
|
||||
(tmp_path / "code.py").write_text("x = 1\n")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = grep_search("nonexistent_pattern")
|
||||
assert "No matches" in result
|
||||
|
||||
def test_returns_line_number(self, tmp_path):
|
||||
(tmp_path / "code.py").write_text("x = 1\ndef foo():\n pass\n")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = grep_search("def foo")
|
||||
assert ":2:" in result
|
||||
|
||||
|
||||
class TestValidatePython:
|
||||
|
||||
def test_valid_syntax(self, tmp_path):
|
||||
(tmp_path / "good.py").write_text("def f(x):\n return x * 2\n")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = validate_python("good.py")
|
||||
assert result == "OK: syntax is valid."
|
||||
|
||||
def test_invalid_syntax(self, tmp_path):
|
||||
(tmp_path / "bad.py").write_text("def f(x)\n return x\n")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = validate_python("bad.py")
|
||||
assert "SYNTAX ERROR" in result
|
||||
|
||||
def test_file_not_found(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = validate_python("ghost.py")
|
||||
assert "ERROR" in result
|
||||
|
||||
|
||||
class TestRunPython:
|
||||
|
||||
def test_successful_execution(self, tmp_path):
|
||||
(tmp_path / "hello.py").write_text("print('hello world')\n")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = run_python("hello.py")
|
||||
assert "hello world" in result
|
||||
assert "Exit code: 0" in result
|
||||
|
||||
def test_runtime_error_captured(self, tmp_path):
|
||||
(tmp_path / "bad.py").write_text("raise ValueError('oops')\n")
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = run_python("bad.py")
|
||||
assert "ValueError" in result
|
||||
assert "Exit code: 1" in result
|
||||
|
||||
def test_file_not_found(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
result = run_python("ghost.py")
|
||||
assert "ERROR" in result
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# TestCodingAgentInit
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
@ -359,52 +198,68 @@ class TestProposeNextAction:
|
||||
payload = _agent_action_json(tool, thought, **args)
|
||||
agent._call_api = MagicMock(return_value=payload)
|
||||
|
||||
def test_returns_dict_with_required_keys(self, agent):
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_dict_with_required_keys(self, agent):
|
||||
self._mock_api(agent)
|
||||
action = agent.propose_next_action()
|
||||
action = await agent.propose_next_action()
|
||||
assert "thought" in action
|
||||
assert "tool" in action
|
||||
assert "arguments" in action
|
||||
|
||||
def test_increments_iteration(self, agent):
|
||||
@pytest.mark.asyncio
|
||||
async def test_increments_iteration(self, agent):
|
||||
self._mock_api(agent)
|
||||
agent.propose_next_action()
|
||||
await agent.propose_next_action()
|
||||
assert agent.iteration == 1
|
||||
|
||||
def test_stores_pending_action(self, agent):
|
||||
@pytest.mark.asyncio
|
||||
async def test_stores_pending_action(self, agent):
|
||||
self._mock_api(agent)
|
||||
agent.propose_next_action()
|
||||
await agent.propose_next_action()
|
||||
assert agent.pending_action is not None
|
||||
|
||||
def test_returns_correct_tool(self, agent):
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_correct_tool(self, agent):
|
||||
self._mock_api(agent, tool="list_files")
|
||||
action = agent.propose_next_action()
|
||||
action = await agent.propose_next_action()
|
||||
assert action["tool"] == "list_files"
|
||||
|
||||
def test_handles_json_parse_error_gracefully(self, agent):
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_json_parse_error_gracefully(self, agent):
|
||||
agent._call_api = MagicMock(return_value="this is not json {{")
|
||||
action = agent.propose_next_action()
|
||||
action = await agent.propose_next_action()
|
||||
assert action["tool"] == "done"
|
||||
|
||||
def test_handles_api_exception_gracefully(self, agent):
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_api_exception_gracefully(self, agent):
|
||||
agent._call_api = MagicMock(side_effect=Exception("connection refused"))
|
||||
action = agent.propose_next_action()
|
||||
action = await agent.propose_next_action()
|
||||
assert action["tool"] == "done"
|
||||
|
||||
def test_strips_code_fences_from_response(self, agent):
|
||||
@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 = agent.propose_next_action()
|
||||
action = await agent.propose_next_action()
|
||||
assert action["tool"] == "list_files"
|
||||
|
||||
def test_already_done_returns_done_action(self, agent):
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_done_returns_done_action(self, agent):
|
||||
agent.is_done = True
|
||||
action = agent.propose_next_action()
|
||||
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 + real tool execution via tmp_path)
|
||||
# TestApprove (mocked API + mocked dispatch_tool)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestApprove:
|
||||
@ -422,50 +277,58 @@ class TestApprove:
|
||||
"action": {"thought": "thought", "tool": tool, "arguments": arguments},
|
||||
}
|
||||
|
||||
def test_approve_without_pending_raises(self, agent):
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_without_pending_raises(self, agent):
|
||||
with pytest.raises(Exception):
|
||||
agent.approve()
|
||||
await agent.approve()
|
||||
|
||||
def test_approve_done_sets_is_done(self, agent):
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_done_sets_is_done(self, agent):
|
||||
self._set_pending(agent, "done", summary="all done")
|
||||
result = agent.approve()
|
||||
result = await agent.approve()
|
||||
assert result["is_done"] is True
|
||||
assert agent.is_done is True
|
||||
|
||||
def test_approve_done_returns_summary(self, agent):
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_done_returns_summary(self, agent):
|
||||
self._set_pending(agent, "done", summary="finished successfully")
|
||||
result = agent.approve()
|
||||
result = await agent.approve()
|
||||
assert "finished successfully" in result["result"]
|
||||
|
||||
def test_approve_clears_pending_action(self, agent):
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_clears_pending_action(self, agent):
|
||||
self._set_pending(agent, "done", summary="x")
|
||||
agent.approve()
|
||||
await agent.approve()
|
||||
assert agent.pending_action is None
|
||||
|
||||
def test_approve_appends_assistant_message(self, agent):
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_appends_assistant_message(self, agent):
|
||||
self._set_pending(agent, "done", summary="x")
|
||||
before = len(agent.messages)
|
||||
agent.approve()
|
||||
await agent.approve()
|
||||
assert len(agent.messages) > before
|
||||
|
||||
def test_approve_tool_result_appended_to_messages(self, agent, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
@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")
|
||||
agent.approve()
|
||||
await agent.approve()
|
||||
tool_results = [m for m in agent.messages if "tool_result" in m["content"]]
|
||||
assert len(tool_results) == 1
|
||||
|
||||
def test_approve_error_result_adds_replan_tag(self, agent, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_error_result_adds_replan_tag(self, agent):
|
||||
with patch("backend.agent.coding_agent.dispatch_tool", return_value="ERROR: file not found"):
|
||||
self._set_pending(agent, "read_file", path="nonexistent.py")
|
||||
agent.approve()
|
||||
await agent.approve()
|
||||
last_msg = agent.messages[-1]["content"]
|
||||
assert "replan" in last_msg
|
||||
|
||||
def test_approve_returns_tool_name_in_result(self, agent, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
@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 = agent.approve()
|
||||
result = await agent.approve()
|
||||
assert result["tool"] == "list_files"
|
||||
assert result["is_done"] is False
|
||||
|
||||
@ -508,13 +371,13 @@ class TestReject:
|
||||
|
||||
def test_reject_without_pending_does_not_crash(self, agent):
|
||||
agent.pending_action = None
|
||||
agent.reject("no pending action") # should not raise
|
||||
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("*")) # no files created
|
||||
assert not list(tmp_path.glob("*"))
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
@ -526,47 +389,45 @@ class TestFullLoop:
|
||||
Skipped automatically if the API is not reachable.
|
||||
"""
|
||||
|
||||
MAX_STEPS = 15 # safety limit for the test loop
|
||||
MAX_STEPS = 15
|
||||
|
||||
def _run_until_done(self, agent) -> list:
|
||||
"""Drive the agent loop until done or MAX_STEPS reached."""
|
||||
async def _run_until_done(self, agent) -> list:
|
||||
steps = []
|
||||
for _ in range(self.MAX_STEPS):
|
||||
action = agent.propose_next_action()
|
||||
result = agent.approve()
|
||||
action = await agent.propose_next_action()
|
||||
result = await agent.approve()
|
||||
steps.append(result)
|
||||
if result["is_done"]:
|
||||
break
|
||||
return steps
|
||||
|
||||
def test_agent_completes_hello_world_task(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
agent = CodingAgent()
|
||||
try:
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_completes_hello_world_task(self, tmp_path):
|
||||
try:
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
agent = CodingAgent()
|
||||
agent.start_task(
|
||||
"Write a Python file called hello.py that prints 'Hello World'. "
|
||||
"Validate it and run it."
|
||||
)
|
||||
steps = self._run_until_done(agent)
|
||||
except Exception as e:
|
||||
pytest.skip(f"API not reachable: {e}")
|
||||
steps = await self._run_until_done(agent)
|
||||
assert agent.is_done, "Agent did not reach done state"
|
||||
tools_used = [s["tool"] for s in steps]
|
||||
assert "done" in tools_used
|
||||
except Exception as e:
|
||||
pytest.skip(f"API not reachable or environment incomplete: {e}")
|
||||
|
||||
assert agent.is_done, "Agent did not reach done state"
|
||||
tools_used = [s["tool"] for s in steps]
|
||||
assert "write_file" in tools_used
|
||||
assert "done" in tools_used
|
||||
|
||||
def test_agent_creates_file_on_disk(self, tmp_path):
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
agent = CodingAgent()
|
||||
try:
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_creates_file_on_disk(self, tmp_path):
|
||||
try:
|
||||
with patch("backend.agent.coding_agent.WORKSPACE", tmp_path):
|
||||
agent = CodingAgent()
|
||||
agent.start_task("Write a file called output.txt containing the text 'test passed'.")
|
||||
self._run_until_done(agent)
|
||||
except Exception as e:
|
||||
pytest.skip(f"API not reachable: {e}")
|
||||
|
||||
py_files = list(tmp_path.glob("*.txt")) + list(tmp_path.glob("*.py"))
|
||||
assert len(py_files) > 0, "Agent did not create any file"
|
||||
await self._run_until_done(agent)
|
||||
py_files = list(tmp_path.glob("*.txt")) + list(tmp_path.glob("*.py"))
|
||||
assert len(py_files) > 0, "Agent did not create any file"
|
||||
except Exception as e:
|
||||
pytest.skip(f"API not reachable or environment incomplete: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -76,8 +76,11 @@ class TestGetLogs:
|
||||
logger.log_error("second")
|
||||
assert len(logger.get_logs()) == 2
|
||||
|
||||
def test_get_logs_returns_internal_list(self, logger):
|
||||
assert logger.get_logs() is logger.logs
|
||||
def test_get_logs_returns_copy_not_reference(self, logger):
|
||||
logger.log("entry")
|
||||
logs = logger.get_logs()
|
||||
assert logs == logger.logs
|
||||
assert logs is not logger.logs
|
||||
|
||||
|
||||
# ── clear() ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -140,3 +140,95 @@ class TestInitStateIdempotency:
|
||||
fake_state.active_file = "/workspace/main.py"
|
||||
init_state()
|
||||
assert fake_state.active_file == "/workspace/main.py"
|
||||
|
||||
def test_second_call_does_not_overwrite_last_selected(self, fake_state):
|
||||
from frontend.state import init_state
|
||||
init_state()
|
||||
fake_state.last_selected = "src/app.py"
|
||||
init_state()
|
||||
assert fake_state.last_selected == "src/app.py"
|
||||
|
||||
def test_second_call_does_not_overwrite_selected_folder(self, fake_state):
|
||||
from frontend.state import init_state
|
||||
init_state()
|
||||
fake_state.selected_folder = "/workspace/src"
|
||||
init_state()
|
||||
assert fake_state.selected_folder == "/workspace/src"
|
||||
|
||||
def test_second_call_does_not_overwrite_selected_folder_rel(self, fake_state):
|
||||
from frontend.state import init_state
|
||||
init_state()
|
||||
fake_state.selected_folder_rel = "src"
|
||||
init_state()
|
||||
assert fake_state.selected_folder_rel == "src"
|
||||
|
||||
def test_second_call_does_not_overwrite_files_content(self, fake_state):
|
||||
from frontend.state import init_state
|
||||
init_state()
|
||||
fake_state.files_content = {"/workspace/main.py": "x = 1"}
|
||||
init_state()
|
||||
assert fake_state.files_content == {"/workspace/main.py": "x = 1"}
|
||||
|
||||
def test_second_call_does_not_overwrite_active_tab(self, fake_state):
|
||||
from frontend.state import init_state
|
||||
init_state()
|
||||
fake_state.active_tab = 2
|
||||
init_state()
|
||||
assert fake_state.active_tab == 2
|
||||
|
||||
def test_second_call_does_not_overwrite_is_editing(self, fake_state):
|
||||
from frontend.state import init_state
|
||||
init_state()
|
||||
fake_state.is_editing = True
|
||||
init_state()
|
||||
assert fake_state.is_editing is True
|
||||
|
||||
def test_second_call_does_not_overwrite_code_suggestions(self, fake_state):
|
||||
from frontend.state import init_state
|
||||
init_state()
|
||||
fake_state.code_suggestions = ["use f-strings"]
|
||||
init_state()
|
||||
assert fake_state.code_suggestions == ["use f-strings"]
|
||||
|
||||
def test_second_call_does_not_overwrite_code_execution_output(self, fake_state):
|
||||
from frontend.state import init_state
|
||||
init_state()
|
||||
fake_state.code_execution_output = "previous output"
|
||||
init_state()
|
||||
assert fake_state.code_execution_output == "previous output"
|
||||
|
||||
def test_second_call_does_not_overwrite_agent_mode(self, fake_state):
|
||||
from frontend.state import init_state
|
||||
init_state()
|
||||
fake_state.agent_mode = True
|
||||
init_state()
|
||||
assert fake_state.agent_mode is True
|
||||
|
||||
def test_second_call_does_not_overwrite_coding_agent(self, fake_state):
|
||||
from frontend.state import init_state
|
||||
init_state()
|
||||
sentinel = object()
|
||||
fake_state.coding_agent = sentinel
|
||||
init_state()
|
||||
assert fake_state.coding_agent is sentinel
|
||||
|
||||
def test_second_call_does_not_overwrite_agent_status(self, fake_state):
|
||||
from frontend.state import init_state
|
||||
init_state()
|
||||
fake_state.agent_status = "waiting_approval"
|
||||
init_state()
|
||||
assert fake_state.agent_status == "waiting_approval"
|
||||
|
||||
def test_second_call_does_not_overwrite_agent_log(self, fake_state):
|
||||
from frontend.state import init_state
|
||||
init_state()
|
||||
fake_state.agent_log = [{"step": 1, "tool": "write_file"}]
|
||||
init_state()
|
||||
assert len(fake_state.agent_log) == 1
|
||||
|
||||
def test_second_call_does_not_overwrite_agent_pending_action(self, fake_state):
|
||||
from frontend.state import init_state
|
||||
init_state()
|
||||
fake_state.agent_pending_action = {"tool": "write_file", "arguments": {}}
|
||||
init_state()
|
||||
assert fake_state.agent_pending_action is not None
|
||||
|
||||
@ -29,6 +29,9 @@ class TestSystemPrompterBasePrompt:
|
||||
def test_none_equals_no_argument(self):
|
||||
assert SystemPrompter.generate_prompt(file_context=None) == SystemPrompter.generate_prompt()
|
||||
|
||||
def test_empty_dict_behaves_like_none(self):
|
||||
assert SystemPrompter.generate_prompt(file_context={}) == SystemPrompter.generate_prompt()
|
||||
|
||||
|
||||
class TestSystemPrompterWithFileContext:
|
||||
"""Tests for generate_prompt() with file_context provided."""
|
||||
@ -86,3 +89,38 @@ class TestSystemPrompterTruncation:
|
||||
content = "x" * (MAX_FILE_CHARS + 1)
|
||||
prompt = SystemPrompter.generate_prompt(file_context={"name": "f.py", "content": content})
|
||||
assert "[truncated]" in prompt
|
||||
|
||||
|
||||
class TestSystemPrompterSpecialCharacters:
|
||||
"""Tests that XML special characters in file content are handled without breaking the prompt."""
|
||||
|
||||
def test_less_than_in_content_does_not_break_prompt(self):
|
||||
prompt = SystemPrompter.generate_prompt(
|
||||
file_context={"name": "f.py", "content": "if x < 10:"}
|
||||
)
|
||||
assert "if x < 10:" in prompt
|
||||
|
||||
def test_greater_than_in_content_does_not_break_prompt(self):
|
||||
prompt = SystemPrompter.generate_prompt(
|
||||
file_context={"name": "f.py", "content": "if x > 0:"}
|
||||
)
|
||||
assert "if x > 0:" in prompt
|
||||
|
||||
def test_ampersand_in_content_does_not_break_prompt(self):
|
||||
prompt = SystemPrompter.generate_prompt(
|
||||
file_context={"name": "f.py", "content": "# true & false"}
|
||||
)
|
||||
assert "true & false" in prompt
|
||||
|
||||
def test_xml_tags_in_content_are_preserved_literally(self):
|
||||
prompt = SystemPrompter.generate_prompt(
|
||||
file_context={"name": "template.html", "content": "<div>hello</div>"}
|
||||
)
|
||||
assert "<div>hello</div>" in prompt
|
||||
|
||||
def test_prompt_still_contains_file_xml_tag_with_special_content(self):
|
||||
prompt = SystemPrompter.generate_prompt(
|
||||
file_context={"name": "f.py", "content": "x < y and a > b"}
|
||||
)
|
||||
assert "<file" in prompt
|
||||
assert "<code>" in prompt
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user