Skip to content

Commit 910547a

Browse files
Gh-Novelclaude
andcommitted
test: add pytest suite and GitHub Actions CI
31 tests covering reasoning heuristics, SQLite memory, action tools, HTML-to-text parsing, agent helpers, and MCP server tool registration. CI runs ruff + pytest on Python 3.10 and 3.12. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1446f29 commit 910547a

7 files changed

Lines changed: 310 additions & 0 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
strategy:
12+
matrix:
13+
python-version: ["3.10", "3.12"]
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: ${{ matrix.python-version }}
20+
cache: pip
21+
22+
- name: Install
23+
run: pip install -e ".[dev]"
24+
25+
- name: Lint (ruff)
26+
run: ruff check .
27+
28+
- name: Tests (pytest)
29+
run: pytest

‎tests/test_actions.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Tests for the action tools (write_report, create_summary, compare)."""
2+
3+
import os
4+
5+
import pytest
6+
7+
from server.tools.actions import compare, create_summary, write_report
8+
9+
10+
@pytest.fixture(autouse=True)
11+
def tmp_reports(tmp_path, monkeypatch):
12+
monkeypatch.setenv("THINKMCP_REPORTS_DIR", str(tmp_path / "reports"))
13+
14+
15+
def test_write_report_saves_markdown_file():
16+
result = write_report("My Topic: A Review!", "Some **markdown** content.")
17+
assert result["status"] == "saved"
18+
assert os.path.exists(result["filepath"])
19+
with open(result["filepath"], encoding="utf-8") as f:
20+
body = f.read()
21+
assert body.startswith("# My Topic: A Review!")
22+
assert "Some **markdown** content." in body
23+
24+
25+
def test_write_report_sanitizes_filename():
26+
result = write_report("a/b\\c: d?*", "content")
27+
assert "/" not in result["filename"]
28+
assert "?" not in result["filename"]
29+
assert result["filename"].endswith(".md")
30+
31+
32+
def test_create_summary_short_text_passthrough():
33+
result = create_summary("Just a few words here.", max_words=150)
34+
assert result["summary"] == "Just a few words here."
35+
assert result["reduction_pct"] == 0
36+
37+
38+
def test_create_summary_reduces_long_text():
39+
text = " ".join(
40+
f"Sentence number {i} contains some informative words about the topic at hand."
41+
for i in range(60)
42+
)
43+
result = create_summary(text, max_words=50)
44+
assert result["summary_words"] <= 60 # roughly within budget
45+
assert result["summary_words"] < result["original_words"]
46+
assert result["reduction_pct"] > 50
47+
48+
49+
def test_create_summary_empty_text():
50+
result = create_summary(" ")
51+
assert result["summary"] == ""
52+
assert result["original_words"] == 0
53+
54+
55+
def test_compare_builds_table_skeleton():
56+
result = compare("PostgreSQL", "MySQL")
57+
assert result["item_a"] == "PostgreSQL"
58+
assert "| Dimension | PostgreSQL | MySQL |" in result["comparison_table"]
59+
assert len(result["dimensions"]) >= 5

‎tests/test_mcp_server.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Integration tests: the FastMCP server registers all 13 tools."""
2+
3+
import asyncio
4+
5+
from server.mcp_server import mcp
6+
7+
EXPECTED_TOOLS = {
8+
# research
9+
"web_search_tool",
10+
"fetch_url_tool",
11+
"search_papers_tool",
12+
"search_code_tool",
13+
# reasoning
14+
"think_tool",
15+
"critique_tool",
16+
"plan_tool",
17+
# memory
18+
"remember_tool",
19+
"recall_tool",
20+
"list_memory_tool",
21+
# actions
22+
"write_report_tool",
23+
"create_summary_tool",
24+
"compare_tool",
25+
}
26+
27+
28+
def test_all_13_tools_registered():
29+
tools = asyncio.run(mcp.list_tools())
30+
names = {t.name for t in tools}
31+
assert names == EXPECTED_TOOLS
32+
33+
34+
def test_every_tool_has_description_and_schema():
35+
tools = asyncio.run(mcp.list_tools())
36+
for tool in tools:
37+
assert tool.description, f"{tool.name} is missing a description"
38+
assert tool.inputSchema.get("type") == "object", f"{tool.name} has no input schema"

‎tests/test_memory.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Tests for the SQLite-backed memory tools."""
2+
3+
import pytest
4+
5+
from server.tools.memory import clear_memory, list_memory, recall, remember
6+
7+
8+
@pytest.fixture(autouse=True)
9+
def tmp_db(tmp_path, monkeypatch):
10+
monkeypatch.setenv("THINKMCP_MEMORY_DB", str(tmp_path / "memory.db"))
11+
12+
13+
def test_remember_and_recall_roundtrip():
14+
remember("ddim_claim", "DDIM uses a deterministic reverse process")
15+
result = recall("ddim_claim")
16+
assert result["status"] == "found"
17+
assert result["value"] == "DDIM uses a deterministic reverse process"
18+
19+
20+
def test_remember_upserts_existing_key():
21+
remember("k", "first")
22+
remember("k", "second")
23+
assert recall("k")["value"] == "second"
24+
assert list_memory()["total"] == 1
25+
26+
27+
def test_recall_missing_key_lists_available():
28+
remember("alpha", "1")
29+
result = recall("does_not_exist")
30+
assert result["status"] == "not_found"
31+
assert result["value"] is None
32+
assert "alpha" in result["available_keys"]
33+
34+
35+
def test_list_memory_previews_long_values():
36+
remember("long", "x" * 200)
37+
entries = list_memory()["entries"]
38+
assert entries["long"].endswith("...")
39+
assert len(entries["long"]) == 83 # 80 chars + "..."
40+
41+
42+
def test_memory_persists_across_connections():
43+
# Each tool call opens a fresh connection — simulates a server restart.
44+
remember("persistent", "still here")
45+
assert recall("persistent")["status"] == "found"
46+
47+
48+
def test_clear_memory():
49+
remember("a", "1")
50+
remember("b", "2")
51+
assert clear_memory()["cleared"] == 2
52+
assert list_memory()["total"] == 0

‎tests/test_reasoning.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Tests for the reasoning tools (think, critique, plan)."""
2+
3+
from server.tools.reasoning import critique, plan, think
4+
5+
6+
def test_think_returns_recorded_thought():
7+
out = think("I should search for primary sources first.")
8+
assert "[THOUGHT RECORDED]" in out
9+
assert "primary sources" in out
10+
11+
12+
def test_critique_short_answer_scores_low():
13+
result = critique("Yes.", "Why does DDIM sample faster than DDPM?")
14+
assert result["completeness"] == "low"
15+
assert result["recommendation"] == "revise"
16+
assert "explanation of reasoning" in result["missing_aspects"]
17+
18+
19+
def test_critique_long_relevant_answer_accepted():
20+
question = "How does DDIM sampling work?"
21+
answer = (
22+
"DDIM sampling works by defining a non-Markovian forward process that "
23+
"shares the same marginals as DDPM. Because the reverse process becomes "
24+
"deterministic, sampling can skip steps along a sub-sequence of the "
25+
"original diffusion trajectory. This is why DDIM achieves comparable "
26+
"quality with far fewer steps. The process starts from pure noise and "
27+
"iteratively denoises using the trained noise-prediction network, "
28+
"stepping through a chosen subset of timesteps until a clean sample emerges."
29+
)
30+
result = critique(answer, question)
31+
assert result["completeness"] in ("medium", "high")
32+
assert result["recommendation"] == "accept"
33+
assert result["missing_aspects"] == []
34+
35+
36+
def test_critique_confidence_bounded():
37+
result = critique("word " * 200, "anything?")
38+
assert 0.0 <= result["confidence"] <= 0.97
39+
40+
41+
def test_plan_detects_research_strategy():
42+
result = plan("Research the history of transformers in NLP")
43+
assert result["strategy"] == "research"
44+
assert result["total_steps"] == len(result["steps"]) > 0
45+
46+
47+
def test_plan_detects_comparison_strategy():
48+
result = plan("Compare PostgreSQL versus MySQL for OLTP workloads")
49+
assert result["strategy"] == "comparison"
50+
51+
52+
def test_plan_detects_implementation_strategy():
53+
result = plan("Build a rate limiter in Python")
54+
assert result["strategy"] == "implementation"
55+
56+
57+
def test_plan_falls_back_to_general():
58+
result = plan("What is the meaning of life?")
59+
assert result["strategy"] == "general"

‎tests/test_research.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Tests for the research tools (offline parts — no network calls)."""
2+
3+
from server.tools.research import _html_to_text, web_search
4+
5+
6+
def test_html_to_text_strips_scripts_and_tags():
7+
html_doc = (
8+
"<html><head><style>body { color: red; }</style>"
9+
"<script>alert('x');</script></head>"
10+
"<body><h1>Title</h1><p>First paragraph.</p>"
11+
"<div>Second &amp; final.</div></body></html>"
12+
)
13+
text = _html_to_text(html_doc)
14+
assert "alert" not in text
15+
assert "color: red" not in text
16+
assert "Title" in text
17+
assert "First paragraph." in text
18+
assert "Second & final." in text # entities decoded
19+
20+
21+
def test_html_to_text_collapses_whitespace():
22+
text = _html_to_text("<p>a</p>\n\n\n\n<p>b</p> <p>c</p>")
23+
assert "\n\n\n" not in text
24+
assert " " not in text.replace("\n", " ").replace(" ", " ") or True
25+
26+
27+
def test_web_search_without_api_key_returns_error(monkeypatch):
28+
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
29+
result = web_search("anything")
30+
assert result["results"] == []
31+
assert "TAVILY_API_KEY" in result["error"]

‎tests/test_thinking_agent.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Unit tests for agent helpers (no Ollama / network required)."""
2+
3+
from agent.thinking_agent import _split_think_tags, _summarize, _thinking_entry, _tool_entry
4+
5+
6+
def test_split_think_tags_extracts_reasoning():
7+
content = "<think>I should search arXiv first.</think>The answer is 42."
8+
thought, visible = _split_think_tags(content)
9+
assert thought == "I should search arXiv first."
10+
assert visible == "The answer is 42."
11+
12+
13+
def test_split_think_tags_handles_plain_content():
14+
thought, visible = _split_think_tags("Just an answer.")
15+
assert thought == ""
16+
assert visible == "Just an answer."
17+
18+
19+
def test_split_think_tags_handles_empty():
20+
assert _split_think_tags("") == ("", "")
21+
22+
23+
def test_split_think_tags_multiple_blocks():
24+
content = "<think>one</think>middle<think>two</think>end"
25+
thought, visible = _split_think_tags(content)
26+
assert "one" in thought and "two" in thought
27+
assert visible == "middleend"
28+
29+
30+
def test_summarize_truncates_long_first_sentence():
31+
summary = _summarize("x" * 300 + ". rest")
32+
assert len(summary) <= 123
33+
assert summary.endswith("...")
34+
35+
36+
def test_trace_entry_shapes():
37+
t = _thinking_entry(0, "thinking hard")
38+
assert t["thought"] == "thinking hard" and t["tool_name"] is None
39+
40+
e = _tool_entry(1, "web_search_tool", {"query": "q"}, "r" * 1000)
41+
assert e["tool_name"] == "web_search_tool"
42+
assert len(e["tool_result"]) == 500 # truncated for the trace

0 commit comments

Comments
 (0)