Skip to content

Commit 2ccf787

Browse files
committed
✨ Feat: Add pytest and agent tests
- Introduces a comprehensive test suite for the SimpleAgent. - Adds new test files in the tests/ directory. - Includes mock backends and tools for isolated testing. - Implements tests for direct responses, tool execution, and edge cases. - Updates Makefile to include a 'test' target. - Adds pytest and pytest-cov to dev dependencies. - Modifies pyproject.toml and requirements.txt to include testing dependencies.
1 parent 3130495 commit 2ccf787

File tree

7 files changed

+132
-4
lines changed

7 files changed

+132
-4
lines changed

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ DEFAULT_PYTHON := $(if $(wildcard $(VENV_BIN)/python),$(VENV_BIN)/python,)
99

1010
PYTHON ?= $(if $(DEFAULT_PYTHON),$(DEFAULT_PYTHON),$(UVX) python)
1111
RUFF ?= $(UVX) ruff
12+
PYTEST ?= pytest -vv --cov=simple_agent --cov-report=term-missing
1213

13-
.PHONY: venv run lint tools install clean
14+
.PHONY: venv run lint tools install clean test
1415

1516
venv:
1617
$(VENV_PYTHON) -m venv $(VENV)
@@ -25,6 +26,9 @@ run:
2526
tools:
2627
$(PYTHON) main.py --list-tools
2728

29+
test:
30+
$(PYTEST)
31+
2832
lint:
2933
$(RUFF) check simple_agent main.py
3034

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ dependencies = [
1515
[project.optional-dependencies]
1616
dev = [
1717
"ruff>=0.6",
18+
"pytest>=8.3",
19+
"pytest-cov>=5.0",
1820
]
1921

2022
[build-system]

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ python-dotenv>=1.0
22
requests>=2.32
33
psutil>=5.9
44
beautifulsoup4>=4.12
5+
pytest>=8.3
6+
pytest-cov>=7.0.0

simple_agent/config.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,10 @@
66
from dataclasses import dataclass
77
from functools import lru_cache
88
from typing import Literal
9-
109
from dotenv import load_dotenv
1110

1211
load_dotenv()
1312

14-
1513
BackendName = Literal["chatgpt", "gemini"]
1614

1715

@@ -46,7 +44,7 @@ def from_env(cls) -> "Settings":
4644
"AGENT_SYSTEM_PROMPT",
4745
"You are a concise assistant. Use tools only when strictly necessary.",
4846
)
49-
or "You are a concise assistant. Use tools only when strictly necessary.",
47+
or "You are a concise assistant. Use tools only when strictly necessary.",
5048
openai_api_key=cls._get_env("OPENAI_API_KEY"),
5149
openai_model=cls._get_env("OPENAI_MODEL", "gpt-4o-mini"),
5250
gemini_api_key=cls._get_env("GEMINI_API_KEY"),

tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Ensure the project package is importable when running tests."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
from pathlib import Path
7+
8+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
9+
if str(PROJECT_ROOT) not in sys.path:
10+
sys.path.insert(0, str(PROJECT_ROOT))

tests/test_agent.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Tests for the SimpleAgent orchestration logic."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Iterable, List
6+
7+
import pytest
8+
9+
from simple_agent.agent import SimpleAgent, _truncate
10+
from simple_agent.backends.base import LLMBackend, Message
11+
from simple_agent.tools.base import SimpleTool, Tool
12+
13+
14+
class DummyBackend(LLMBackend):
15+
"""Backend that returns predefined responses for each call."""
16+
17+
def __init__(self, responses: Iterable[str]) -> None:
18+
self._responses = list(responses)
19+
self.calls: List[List[Message]] = []
20+
21+
def generate(self, messages: List[Message]) -> str:
22+
if not self._responses:
23+
raise AssertionError("DummyBackend has no more responses queued.")
24+
# Capture a shallow copy so tests can inspect the conversation.
25+
self.calls.append([msg.copy() for msg in messages])
26+
return self._responses.pop(0)
27+
28+
29+
class RecordingTool(SimpleTool):
30+
"""Tool that records the inputs it receives."""
31+
32+
def __init__(self) -> None:
33+
super().__init__(name="echo", description="Echo the provided input.")
34+
self.invocations: list[str] = []
35+
36+
def run(self, query: str) -> str:
37+
self.invocations.append(query)
38+
return f"tool ran with: {query}"
39+
40+
41+
def _make_agent(responses: Iterable[str], tools: Iterable[Tool] = ()) -> tuple[SimpleAgent, DummyBackend]:
42+
backend = DummyBackend(responses)
43+
agent = SimpleAgent(backend=backend, tools=list(tools), system_prompt="Be helpful.")
44+
return agent, backend
45+
46+
47+
def test_agent_returns_direct_response_without_tool_use() -> None:
48+
agent, backend = _make_agent([" final answer "])
49+
50+
result = agent.run("Question?")
51+
52+
assert result == "final answer"
53+
assert len(backend.calls) == 1
54+
assert backend.calls[0][0]["role"] == "system"
55+
56+
57+
def test_agent_executes_requested_tool_and_returns_model_reply() -> None:
58+
tool = RecordingTool()
59+
agent, backend = _make_agent(
60+
responses=[
61+
'{"tool":"echo","input":"calculate pi"}',
62+
"Result is 3.14",
63+
],
64+
tools=[tool],
65+
)
66+
67+
result = agent.run("What is pi?", max_turns=2)
68+
69+
assert result == "Result is 3.14"
70+
assert tool.invocations == ["calculate pi"]
71+
assert len(backend.calls) == 2
72+
# Ensure the second backend call contains the tool output in the history.
73+
assert backend.calls[1][-1]["content"].startswith("[Tool:echo] tool ran with: calculate pi")
74+
75+
76+
@pytest.mark.parametrize(
77+
"text,expected",
78+
[
79+
('{"tool":"echo","input":"test"}', {"tool": "echo", "input": "test"}),
80+
("```json\n{\"tool\": \"echo\"}\n```", {"tool": "echo"}),
81+
("Some text", None),
82+
],
83+
)
84+
def test_maybe_extract_tool_request_variants(text: str, expected: dict | None) -> None:
85+
assert SimpleAgent._maybe_extract_tool_request(text) == expected # type: ignore[arg-type]
86+
87+
88+
def test_truncate_adds_ellipsis_when_text_is_long() -> None:
89+
text = "abc" * 200
90+
truncated = _truncate(text, limit=10)
91+
assert truncated.endswith("…")
92+
assert len(truncated) == 11

tests/test_python_tool.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Tests for the Python sandbox helper functions."""
2+
3+
from __future__ import annotations
4+
5+
from simple_agent.tools.python_tool import _find_disallowed_imports
6+
7+
8+
def test_find_disallowed_imports_blocks_unknown_modules() -> None:
9+
code = "import math\nimport secrets\nfrom collections import Counter"
10+
blocked = _find_disallowed_imports(code, {"math", "collections"})
11+
12+
assert blocked == {"secrets"}
13+
14+
15+
def test_find_disallowed_imports_marks_relative_imports() -> None:
16+
code = "from . import helpers"
17+
18+
blocked = _find_disallowed_imports(code, {"json"})
19+
20+
assert "<relative>" in blocked

0 commit comments

Comments
 (0)