This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/tests/timmy/test_research_triage.py
2026-03-23 15:34:13 +00:00

354 lines
13 KiB
Python

"""Tests for research triage — action item extraction and Gitea issue filing."""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from timmy.research_triage import (
ActionItem,
_parse_llm_response,
_validate_action_item,
create_gitea_issue,
extract_action_items,
triage_research_report,
)
# ---------------------------------------------------------------------------
# ActionItem
# ---------------------------------------------------------------------------
SAMPLE_REPORT = """
## Research: MCP Abstraction Layer
### Finding 1: FastMCP overhead is negligible
FastMCP averages 26.45ms per tool call. Total overhead <3% of budget.
### Finding 2: Agno tool calling is broken
Agno issues #2231, #2625 document persistent breakage with Ollama.
Fix: Use Ollama's `format` parameter with Pydantic JSON schemas.
### Recommendation
Implement three-tier router for structured output.
"""
SAMPLE_LLM_RESPONSE = json.dumps(
[
{
"title": "[Router] Implement three-tier structured output router",
"body": (
"**What:** Build a three-tier router that uses Ollama's "
"`format` parameter for structured output.\n"
"**Why:** Agno's native tool calling is broken (#2231, #2625). "
"Pydantic JSON schemas with `format` bypass the issue.\n"
"**Suggested approach:** Add format parameter support to "
"CascadeRouter.\n"
"**Acceptance criteria:** Tool calls return valid JSON matching "
"the Pydantic schema."
),
"labels": ["actionable", "feature", "kimi-ready"],
"priority": "high",
"source_urls": ["https://github.com/agno-agi/agno/issues/2231"],
},
]
)
class TestActionItem:
def test_to_issue_body_basic(self):
item = ActionItem(title="Test", body="Test body")
body = item.to_issue_body()
assert "Test body" in body
assert "Auto-triaged" in body
def test_to_issue_body_with_source_issue(self):
item = ActionItem(title="Test", body="Test body")
body = item.to_issue_body(source_issue=946)
assert "#946" in body
assert "Origin" in body
def test_to_issue_body_with_source_urls(self):
item = ActionItem(
title="Test",
body="Body",
source_urls=["https://example.com/finding"],
)
body = item.to_issue_body()
assert "https://example.com/finding" in body
assert "Source Evidence" in body
# ---------------------------------------------------------------------------
# _parse_llm_response
# ---------------------------------------------------------------------------
class TestParseLlmResponse:
def test_plain_json(self):
items = _parse_llm_response('[{"title": "foo"}]')
assert len(items) == 1
assert items[0]["title"] == "foo"
def test_fenced_json(self):
raw = '```json\n[{"title": "bar"}]\n```'
items = _parse_llm_response(raw)
assert len(items) == 1
assert items[0]["title"] == "bar"
def test_empty_array(self):
assert _parse_llm_response("[]") == []
def test_non_array_returns_empty(self):
assert _parse_llm_response('{"title": "not an array"}') == []
def test_invalid_json_raises(self):
with pytest.raises(json.JSONDecodeError):
_parse_llm_response("not json at all")
# ---------------------------------------------------------------------------
# _validate_action_item
# ---------------------------------------------------------------------------
class TestValidateActionItem:
def test_valid_item(self):
raw = {
"title": "[Area] A specific clear title",
"body": "Detailed body with enough content to be useful.",
"labels": ["actionable", "bug"],
"priority": "high",
}
item = _validate_action_item(raw)
assert item is not None
assert item.title == "[Area] A specific clear title"
assert item.priority == "high"
assert "actionable" in item.labels
def test_short_title_rejected(self):
raw = {"title": "Short", "body": "Detailed body with enough content here."}
assert _validate_action_item(raw) is None
def test_short_body_rejected(self):
raw = {"title": "A perfectly fine title here", "body": "Too short"}
assert _validate_action_item(raw) is None
def test_missing_title_rejected(self):
raw = {"body": "Detailed body with enough content to be useful."}
assert _validate_action_item(raw) is None
def test_non_dict_rejected(self):
assert _validate_action_item("not a dict") is None
def test_actionable_label_auto_added(self):
raw = {
"title": "A perfectly fine title here",
"body": "Detailed body with enough content to be useful.",
"labels": ["bug"],
}
item = _validate_action_item(raw)
assert item is not None
assert "actionable" in item.labels
def test_labels_as_csv_string(self):
raw = {
"title": "A perfectly fine title here",
"body": "Detailed body with enough content to be useful.",
"labels": "bug, feature",
}
item = _validate_action_item(raw)
assert item is not None
assert "bug" in item.labels
assert "feature" in item.labels
def test_invalid_priority_defaults_medium(self):
raw = {
"title": "A perfectly fine title here",
"body": "Detailed body with enough content to be useful.",
"priority": "urgent",
}
item = _validate_action_item(raw)
assert item is not None
assert item.priority == "medium"
# ---------------------------------------------------------------------------
# extract_action_items
# ---------------------------------------------------------------------------
class TestExtractActionItems:
@pytest.mark.asyncio
async def test_extracts_items_from_report(self):
mock_llm = AsyncMock(return_value=SAMPLE_LLM_RESPONSE)
items = await extract_action_items(SAMPLE_REPORT, llm_caller=mock_llm)
assert len(items) == 1
assert "three-tier" in items[0].title.lower()
assert items[0].priority == "high"
mock_llm.assert_called_once()
@pytest.mark.asyncio
async def test_empty_report_returns_empty(self):
items = await extract_action_items("")
assert items == []
@pytest.mark.asyncio
async def test_llm_failure_returns_empty(self):
mock_llm = AsyncMock(side_effect=RuntimeError("LLM down"))
items = await extract_action_items(SAMPLE_REPORT, llm_caller=mock_llm)
assert items == []
@pytest.mark.asyncio
async def test_llm_returns_empty_string(self):
mock_llm = AsyncMock(return_value="")
items = await extract_action_items(SAMPLE_REPORT, llm_caller=mock_llm)
assert items == []
@pytest.mark.asyncio
async def test_llm_returns_invalid_json(self):
mock_llm = AsyncMock(return_value="not valid json")
items = await extract_action_items(SAMPLE_REPORT, llm_caller=mock_llm)
assert items == []
@pytest.mark.asyncio
async def test_caps_at_five_items(self):
many_items = [
{
"title": f"[Area] Action item number {i} is specific",
"body": f"Detailed body for action item {i} with enough words.",
"labels": ["actionable"],
"priority": "medium",
}
for i in range(10)
]
mock_llm = AsyncMock(return_value=json.dumps(many_items))
items = await extract_action_items(SAMPLE_REPORT, llm_caller=mock_llm)
assert len(items) <= 5
# ---------------------------------------------------------------------------
# create_gitea_issue
# ---------------------------------------------------------------------------
class TestCreateGiteaIssue:
@pytest.mark.asyncio
async def test_creates_issue_via_api(self):
item = ActionItem(
title="[Test] Create a test issue",
body="This is a test issue body with details.",
labels=["actionable"],
)
issue_resp = MagicMock()
issue_resp.status_code = 201
issue_resp.json.return_value = {"number": 42, "title": item.title}
mock_client = AsyncMock()
mock_client.post.return_value = issue_resp
with (
patch("timmy.research_triage.settings") as mock_settings,
patch(
"timmy.research_triage._resolve_label_ids", new_callable=AsyncMock, return_value=[1]
),
patch("timmy.research_triage.httpx.AsyncClient") as mock_cls,
):
mock_settings.gitea_enabled = True
mock_settings.gitea_token = "test-token"
mock_settings.gitea_repo = "owner/repo"
mock_settings.gitea_url = "http://localhost:3000"
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
result = await create_gitea_issue(item, source_issue=946)
assert result is not None
assert result["number"] == 42
@pytest.mark.asyncio
async def test_returns_none_when_disabled(self):
item = ActionItem(title="[Test] Disabled test", body="Body content here.")
with patch("timmy.research_triage.settings") as mock_settings:
mock_settings.gitea_enabled = False
mock_settings.gitea_token = ""
result = await create_gitea_issue(item)
assert result is None
@pytest.mark.asyncio
async def test_handles_connection_error(self):
item = ActionItem(
title="[Test] Connection fail",
body="Body content for connection test.",
)
mock_client = AsyncMock()
mock_client.post.side_effect = httpx.ConnectError("refused")
with (
patch("timmy.research_triage.settings") as mock_settings,
patch(
"timmy.research_triage._resolve_label_ids", new_callable=AsyncMock, return_value=[]
),
patch("timmy.research_triage.httpx.AsyncClient") as mock_cls,
):
mock_settings.gitea_enabled = True
mock_settings.gitea_token = "test-token"
mock_settings.gitea_repo = "owner/repo"
mock_settings.gitea_url = "http://localhost:3000"
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
result = await create_gitea_issue(item)
assert result is None
# ---------------------------------------------------------------------------
# triage_research_report (integration)
# ---------------------------------------------------------------------------
class TestTriageResearchReport:
@pytest.mark.asyncio
async def test_dry_run_extracts_without_filing(self):
mock_llm = AsyncMock(return_value=SAMPLE_LLM_RESPONSE)
results = await triage_research_report(
SAMPLE_REPORT, source_issue=946, llm_caller=mock_llm, dry_run=True
)
assert len(results) == 1
assert results[0]["action_item"] is not None
assert results[0]["gitea_issue"] is None
@pytest.mark.asyncio
async def test_empty_report_returns_empty(self):
results = await triage_research_report("", llm_caller=AsyncMock(return_value="[]"))
assert results == []
@pytest.mark.asyncio
async def test_end_to_end_with_mock_gitea(self):
mock_llm = AsyncMock(return_value=SAMPLE_LLM_RESPONSE)
issue_resp = MagicMock()
issue_resp.status_code = 201
issue_resp.json.return_value = {"number": 99, "title": "test"}
mock_client = AsyncMock()
mock_client.post.return_value = issue_resp
with (
patch("timmy.research_triage.settings") as mock_settings,
patch(
"timmy.research_triage._resolve_label_ids", new_callable=AsyncMock, return_value=[]
),
patch("timmy.research_triage.httpx.AsyncClient") as mock_cls,
):
mock_settings.gitea_enabled = True
mock_settings.gitea_token = "test-token"
mock_settings.gitea_repo = "owner/repo"
mock_settings.gitea_url = "http://localhost:3000"
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
results = await triage_research_report(
SAMPLE_REPORT, source_issue=946, llm_caller=mock_llm
)
assert len(results) == 1
assert results[0]["gitea_issue"]["number"] == 99