This commit was merged in pull request #1203.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user