[claude] Add unit tests for agent_health.py (#1195) (#1203)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled

This commit was merged in pull request #1203.
This commit is contained in:
2026-03-23 22:02:44 +00:00
parent f86e2e103d
commit 3d40523947

View File

@@ -2,10 +2,15 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from timmy.vassal.agent_health import AgentHealthReport, AgentStatus
pytestmark = pytest.mark.unit
# ---------------------------------------------------------------------------
# AgentStatus
# ---------------------------------------------------------------------------
@@ -35,6 +40,25 @@ def test_agent_status_stuck():
assert s.needs_reassignment is True
def test_agent_status_checked_at_is_iso_string():
s = AgentStatus(agent="claude")
# Should be parseable as an ISO datetime
dt = datetime.fromisoformat(s.checked_at)
assert dt.tzinfo is not None
def test_agent_status_multiple_stuck_issues():
s = AgentStatus(agent="kimi", stuck_issue_numbers=[1, 2, 3])
assert s.is_stuck is True
assert s.needs_reassignment is True
def test_agent_status_active_but_not_stuck():
s = AgentStatus(agent="claude", active_issue_numbers=[5], is_idle=False)
assert s.is_stuck is False
assert s.needs_reassignment is False
# ---------------------------------------------------------------------------
# AgentHealthReport
# ---------------------------------------------------------------------------
@@ -47,11 +71,24 @@ def test_report_any_stuck():
assert report.any_stuck is True
def test_report_not_any_stuck():
report = AgentHealthReport(
agents=[AgentStatus(agent="claude"), AgentStatus(agent="kimi")]
)
assert report.any_stuck is False
def test_report_all_idle():
report = AgentHealthReport(agents=[AgentStatus(agent="claude"), AgentStatus(agent="kimi")])
assert report.all_idle is True
def test_report_not_all_idle():
claude = AgentStatus(agent="claude", active_issue_numbers=[1], is_idle=False)
report = AgentHealthReport(agents=[claude, AgentStatus(agent="kimi")])
assert report.all_idle is False
def test_report_for_agent_found():
kimi = AgentStatus(agent="kimi", active_issue_numbers=[42])
report = AgentHealthReport(agents=[AgentStatus(agent="claude"), kimi])
@@ -64,6 +101,233 @@ def test_report_for_agent_not_found():
assert report.for_agent("timmy") is None
def test_report_generated_at_is_iso_string():
report = AgentHealthReport()
dt = datetime.fromisoformat(report.generated_at)
assert dt.tzinfo is not None
def test_report_empty_agents():
report = AgentHealthReport(agents=[])
assert report.any_stuck is False
assert report.all_idle is True
# ---------------------------------------------------------------------------
# _issue_created_time
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_issue_created_time_valid():
from timmy.vassal.agent_health import _issue_created_time
issue = {"created_at": "2024-01-15T10:30:00Z"}
result = await _issue_created_time(issue)
assert result is not None
assert result.year == 2024
assert result.month == 1
assert result.day == 15
@pytest.mark.asyncio
async def test_issue_created_time_missing_key():
from timmy.vassal.agent_health import _issue_created_time
result = await _issue_created_time({})
assert result is None
@pytest.mark.asyncio
async def test_issue_created_time_invalid_format():
from timmy.vassal.agent_health import _issue_created_time
result = await _issue_created_time({"created_at": "not-a-date"})
assert result is None
@pytest.mark.asyncio
async def test_issue_created_time_with_timezone():
from timmy.vassal.agent_health import _issue_created_time
issue = {"created_at": "2024-06-01T12:00:00+00:00"}
result = await _issue_created_time(issue)
assert result is not None
assert result.tzinfo is not None
# ---------------------------------------------------------------------------
# _fetch_labeled_issues — mocked HTTP client
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fetch_labeled_issues_success():
from timmy.vassal.agent_health import _fetch_labeled_issues
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = [
{"number": 1, "title": "Fix bug"},
{"number": 2, "title": "Add feature", "pull_request": {"url": "..."}},
]
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
result = await _fetch_labeled_issues(
mock_client, "http://gitea/api/v1", {}, "owner/repo", "claude-ready"
)
# Only non-PR issues returned
assert len(result) == 1
assert result[0]["number"] == 1
@pytest.mark.asyncio
async def test_fetch_labeled_issues_http_error():
from timmy.vassal.agent_health import _fetch_labeled_issues
mock_resp = MagicMock()
mock_resp.status_code = 401
mock_resp.json.return_value = []
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
result = await _fetch_labeled_issues(
mock_client, "http://gitea/api/v1", {}, "owner/repo", "claude-ready"
)
assert result == []
@pytest.mark.asyncio
async def test_fetch_labeled_issues_exception():
from timmy.vassal.agent_health import _fetch_labeled_issues
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=ConnectionError("network down"))
result = await _fetch_labeled_issues(
mock_client, "http://gitea/api/v1", {}, "owner/repo", "claude-ready"
)
assert result == []
@pytest.mark.asyncio
async def test_fetch_labeled_issues_filters_pull_requests():
from timmy.vassal.agent_health import _fetch_labeled_issues
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = [
{"number": 10, "title": "Issue"},
{"number": 11, "title": "PR", "pull_request": {"url": "http://gitea/pulls/11"}},
{"number": 12, "title": "Another Issue"},
]
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
result = await _fetch_labeled_issues(
mock_client, "http://gitea/api/v1", {}, "owner/repo", "claude-ready"
)
# Issues with truthy pull_request field are excluded
assert len(result) == 2
assert all(i["number"] in (10, 12) for i in result)
# ---------------------------------------------------------------------------
# _last_comment_time — mocked HTTP client
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_last_comment_time_with_comments():
from timmy.vassal.agent_health import _last_comment_time
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = [
{"updated_at": "2024-03-10T14:00:00Z", "created_at": "2024-03-10T13:00:00Z"}
]
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
result = await _last_comment_time(
mock_client, "http://gitea/api/v1", {}, "owner/repo", 42
)
assert result is not None
assert result.year == 2024
assert result.month == 3
@pytest.mark.asyncio
async def test_last_comment_time_uses_created_at_fallback():
from timmy.vassal.agent_health import _last_comment_time
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = [
{"created_at": "2024-03-10T13:00:00Z"} # no updated_at
]
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
result = await _last_comment_time(
mock_client, "http://gitea/api/v1", {}, "owner/repo", 42
)
assert result is not None
@pytest.mark.asyncio
async def test_last_comment_time_no_comments():
from timmy.vassal.agent_health import _last_comment_time
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = []
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
result = await _last_comment_time(
mock_client, "http://gitea/api/v1", {}, "owner/repo", 99
)
assert result is None
@pytest.mark.asyncio
async def test_last_comment_time_http_error():
from timmy.vassal.agent_health import _last_comment_time
mock_resp = MagicMock()
mock_resp.status_code = 404
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
result = await _last_comment_time(
mock_client, "http://gitea/api/v1", {}, "owner/repo", 99
)
assert result is None
@pytest.mark.asyncio
async def test_last_comment_time_exception():
from timmy.vassal.agent_health import _last_comment_time
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=TimeoutError("timed out"))
result = await _last_comment_time(
mock_client, "http://gitea/api/v1", {}, "owner/repo", 7
)
assert result is None
# ---------------------------------------------------------------------------
# check_agent_health — no Gitea in unit tests
# ---------------------------------------------------------------------------
@@ -90,6 +354,140 @@ async def test_check_agent_health_no_token():
assert status.agent == "claude"
@pytest.mark.asyncio
async def test_check_agent_health_detects_stuck_issue(monkeypatch):
"""Issues with last activity before the cutoff are flagged as stuck."""
import timmy.vassal.agent_health as ah
old_time = (datetime.now(UTC) - timedelta(minutes=200)).isoformat()
async def _fake_fetch(client, base_url, headers, repo, label):
return [{"number": 55, "created_at": old_time}]
async def _fake_last_comment(client, base_url, headers, repo, issue_number):
return datetime.now(UTC) - timedelta(minutes=200)
monkeypatch.setattr(ah, "_fetch_labeled_issues", _fake_fetch)
monkeypatch.setattr(ah, "_last_comment_time", _fake_last_comment)
mock_settings = MagicMock()
mock_settings.gitea_enabled = True
mock_settings.gitea_token = "fake-token"
mock_settings.gitea_url = "http://gitea"
mock_settings.gitea_repo = "owner/repo"
import httpx
with patch("config.settings", mock_settings):
status = await ah.check_agent_health("claude", stuck_threshold_minutes=120)
assert 55 in status.active_issue_numbers
assert 55 in status.stuck_issue_numbers
assert status.is_stuck is True
@pytest.mark.asyncio
async def test_check_agent_health_active_not_stuck(monkeypatch):
"""Recent activity means issue is active but not stuck."""
import timmy.vassal.agent_health as ah
recent_time = (datetime.now(UTC) - timedelta(minutes=5)).isoformat()
async def _fake_fetch(client, base_url, headers, repo, label):
return [{"number": 77, "created_at": recent_time}]
async def _fake_last_comment(client, base_url, headers, repo, issue_number):
return datetime.now(UTC) - timedelta(minutes=5)
monkeypatch.setattr(ah, "_fetch_labeled_issues", _fake_fetch)
monkeypatch.setattr(ah, "_last_comment_time", _fake_last_comment)
mock_settings = MagicMock()
mock_settings.gitea_enabled = True
mock_settings.gitea_token = "fake-token"
mock_settings.gitea_url = "http://gitea"
mock_settings.gitea_repo = "owner/repo"
with patch("config.settings", mock_settings):
status = await ah.check_agent_health("claude", stuck_threshold_minutes=120)
assert 77 in status.active_issue_numbers
assert 77 not in status.stuck_issue_numbers
assert status.is_idle is False
@pytest.mark.asyncio
async def test_check_agent_health_uses_issue_created_when_no_comments(monkeypatch):
"""Falls back to issue created_at when no comment time is available."""
import timmy.vassal.agent_health as ah
old_time = (datetime.now(UTC) - timedelta(minutes=300)).isoformat()
async def _fake_fetch(client, base_url, headers, repo, label):
return [{"number": 99, "created_at": old_time}]
async def _fake_last_comment(client, base_url, headers, repo, issue_number):
return None # No comments
monkeypatch.setattr(ah, "_fetch_labeled_issues", _fake_fetch)
monkeypatch.setattr(ah, "_last_comment_time", _fake_last_comment)
mock_settings = MagicMock()
mock_settings.gitea_enabled = True
mock_settings.gitea_token = "fake-token"
mock_settings.gitea_url = "http://gitea"
mock_settings.gitea_repo = "owner/repo"
with patch("config.settings", mock_settings):
status = await ah.check_agent_health("kimi", stuck_threshold_minutes=120)
assert 99 in status.stuck_issue_numbers
@pytest.mark.asyncio
async def test_check_agent_health_gitea_disabled(monkeypatch):
"""When gitea_enabled=False, returns idle status without querying."""
import timmy.vassal.agent_health as ah
mock_settings = MagicMock()
mock_settings.gitea_enabled = False
mock_settings.gitea_token = "fake-token"
with patch("config.settings", mock_settings):
status = await ah.check_agent_health("claude")
assert status.is_idle is True
assert status.active_issue_numbers == []
@pytest.mark.asyncio
async def test_check_agent_health_fetch_exception(monkeypatch):
"""HTTP exception during check is handled gracefully."""
import timmy.vassal.agent_health as ah
async def _bad_fetch(client, base_url, headers, repo, label):
raise RuntimeError("connection refused")
monkeypatch.setattr(ah, "_fetch_labeled_issues", _bad_fetch)
mock_settings = MagicMock()
mock_settings.gitea_enabled = True
mock_settings.gitea_token = "fake-token"
mock_settings.gitea_url = "http://gitea"
mock_settings.gitea_repo = "owner/repo"
with patch("config.settings", mock_settings):
status = await ah.check_agent_health("claude")
assert isinstance(status, AgentStatus)
assert status.is_idle is True
# ---------------------------------------------------------------------------
# get_full_health_report
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_full_health_report_returns_both_agents():
from timmy.vassal.agent_health import get_full_health_report
@@ -98,3 +496,122 @@ async def test_get_full_health_report_returns_both_agents():
agent_names = {a.agent for a in report.agents}
assert "claude" in agent_names
assert "kimi" in agent_names
@pytest.mark.asyncio
async def test_get_full_health_report_structure():
from timmy.vassal.agent_health import get_full_health_report
report = await get_full_health_report()
assert isinstance(report, AgentHealthReport)
assert len(report.agents) == 2
# ---------------------------------------------------------------------------
# nudge_stuck_agent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_nudge_stuck_agent_no_token():
"""Returns False gracefully when Gitea is not configured."""
from timmy.vassal.agent_health import nudge_stuck_agent
result = await nudge_stuck_agent("claude", 123)
assert result is False
@pytest.mark.asyncio
async def test_nudge_stuck_agent_success(monkeypatch):
"""Returns True when comment is posted successfully."""
import timmy.vassal.agent_health as ah
mock_resp = MagicMock()
mock_resp.status_code = 201
mock_client_instance = AsyncMock()
mock_client_instance.post = AsyncMock(return_value=mock_resp)
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance)
mock_client_instance.__aexit__ = AsyncMock(return_value=False)
mock_settings = MagicMock()
mock_settings.gitea_enabled = True
mock_settings.gitea_token = "fake-token"
mock_settings.gitea_url = "http://gitea"
mock_settings.gitea_repo = "owner/repo"
with (
patch("config.settings", mock_settings),
patch("httpx.AsyncClient", return_value=mock_client_instance),
):
result = await ah.nudge_stuck_agent("claude", 55)
assert result is True
@pytest.mark.asyncio
async def test_nudge_stuck_agent_http_failure(monkeypatch):
"""Returns False when API returns non-2xx status."""
import timmy.vassal.agent_health as ah
mock_resp = MagicMock()
mock_resp.status_code = 500
mock_client_instance = AsyncMock()
mock_client_instance.post = AsyncMock(return_value=mock_resp)
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance)
mock_client_instance.__aexit__ = AsyncMock(return_value=False)
mock_settings = MagicMock()
mock_settings.gitea_enabled = True
mock_settings.gitea_token = "fake-token"
mock_settings.gitea_url = "http://gitea"
mock_settings.gitea_repo = "owner/repo"
with (
patch("config.settings", mock_settings),
patch("httpx.AsyncClient", return_value=mock_client_instance),
):
result = await ah.nudge_stuck_agent("kimi", 77)
assert result is False
@pytest.mark.asyncio
async def test_nudge_stuck_agent_gitea_disabled(monkeypatch):
"""Returns False when gitea_enabled=False."""
import timmy.vassal.agent_health as ah
mock_settings = MagicMock()
mock_settings.gitea_enabled = False
mock_settings.gitea_token = "fake-token"
with patch("config.settings", mock_settings):
result = await ah.nudge_stuck_agent("claude", 42)
assert result is False
@pytest.mark.asyncio
async def test_nudge_stuck_agent_exception(monkeypatch):
"""Returns False on network exception."""
import timmy.vassal.agent_health as ah
mock_client_instance = AsyncMock()
mock_client_instance.post = AsyncMock(side_effect=ConnectionError("refused"))
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance)
mock_client_instance.__aexit__ = AsyncMock(return_value=False)
mock_settings = MagicMock()
mock_settings.gitea_enabled = True
mock_settings.gitea_token = "fake-token"
mock_settings.gitea_url = "http://gitea"
mock_settings.gitea_repo = "owner/repo"
with (
patch("config.settings", mock_settings),
patch("httpx.AsyncClient", return_value=mock_client_instance),
):
result = await ah.nudge_stuck_agent("claude", 10)
assert result is False