diff --git a/tests/unit/test_vassal_agent_health.py b/tests/unit/test_vassal_agent_health.py index e2879705..1760708b 100644 --- a/tests/unit/test_vassal_agent_health.py +++ b/tests/unit/test_vassal_agent_health.py @@ -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