Files
pocketpaw/tests/test_status_api.py
Rohit Kushwaha 5adeae1f9c feat: agent status API, SSE stream, and CLI command (#586)
* docs: add agent status API & CLI design

Design for GET /api/v1/agent/status endpoint, SSE stream, and
pocketpaw status CLI command. Exposes real-time agent state
(idle/thinking/tool_running/streaming/error) for external integrations.

* docs: add agent status API implementation plan

9-task plan covering StatusTracker, REST endpoint, SSE stream,
CLI command, auth, tests, and lifecycle wiring.

* feat: add agent status API, SSE stream, and CLI command

Adds a public status endpoint for external integrations (stream decks,
LED indicators, desktop widgets) to monitor PocketPaw agent state.

- GET /api/v1/agent/status returns global state (idle/active/degraded)
  and per-session breakdown (thinking/tool_running/streaming/error)
- GET /api/v1/agent/status/stream pushes SSE events on state changes
- pocketpaw status CLI with --json and --watch flags
- Optional API key auth via POCKETPAW_STATUS_API_KEY / X-Status-Key
- StatusTracker subscribes to bus events, no internal coupling
- 22 tests covering state transitions, auth, and CLI formatting

* fix(tests): update router count to 24 and fix lint in test files

Update test_v1_routers_count assertion from 23 to 24 for the new
agent_status router. Reformat test files and fix UP038 lint error.

* fix: address review issues in agent status API

- Cache status API key to avoid Settings.load() on every request
- Add client disconnect detection in SSE stream loop
- Fix wait_for_change race condition with version-based tracking
- Wire response_model=AgentStatusResponse and fix schema alias mismatch
- Move lifecycle registration from module scope to startup_event
- Extract session title enrichment into dedicated method
- Update tests for new caching and version tracking

* fix: emit agent_start/end events, fix SSE stream spamming, add docs

- AgentLoop now emits agent_start before processing and agent_end on
  completion/error so StatusTracker actually tracks sessions
- Skip redundant thinking notifications when state is already thinking
- Deduplicate SSE stream using state fingerprints (ignores timing fields)
- Increase SSE debounce from 200ms to 1s to coalesce rapid tool events
- Add API docs for GET /agent/status and GET /agent/status/stream
2026-03-14 10:14:12 +05:30

148 lines
5.1 KiB
Python

"""Tests for agent status API endpoint auth and CLI formatting."""
from unittest.mock import MagicMock, patch
import pytest
from pocketpaw.status import StatusTracker
@pytest.fixture(autouse=True)
def _clear_status_key_cache():
"""Reset the cached status API key between tests."""
from pocketpaw.api.v1 import agent_status
if hasattr(agent_status._get_status_api_key, "_value"):
del agent_status._get_status_api_key._value
yield
if hasattr(agent_status._get_status_api_key, "_value"):
del agent_status._get_status_api_key._value
class TestAgentStatusAuth:
"""Test the status endpoint auth logic."""
def test_rejects_bad_key(self):
"""Verify auth check rejects wrong key."""
from fastapi import HTTPException
from pocketpaw.api.v1.agent_status import _check_status_key
mock_request = MagicMock()
mock_request.headers = {"x-status-key": "wrong"}
with patch("pocketpaw.api.v1.agent_status._get_status_api_key", return_value="correct-key"):
with pytest.raises(HTTPException) as exc_info:
_check_status_key(mock_request, None)
assert exc_info.value.status_code == 403
def test_allows_correct_key_via_header(self):
from pocketpaw.api.v1.agent_status import _check_status_key
mock_request = MagicMock()
mock_request.headers = {"x-status-key": "my-key"}
with patch("pocketpaw.api.v1.agent_status._get_status_api_key", return_value="my-key"):
_check_status_key(mock_request, None) # Should not raise
def test_allows_correct_key_via_query_param(self):
from pocketpaw.api.v1.agent_status import _check_status_key
mock_request = MagicMock()
mock_request.headers = {}
with patch("pocketpaw.api.v1.agent_status._get_status_api_key", return_value="my-key"):
_check_status_key(mock_request, "my-key") # Should not raise
def test_allows_when_no_key_configured(self):
from pocketpaw.api.v1.agent_status import _check_status_key
mock_request = MagicMock()
mock_request.headers = {}
with patch("pocketpaw.api.v1.agent_status._get_status_api_key", return_value=""):
_check_status_key(mock_request, None) # Should not raise
class TestSnapshotShape:
"""Verify snapshot structure matches API contract."""
def test_idle_snapshot_shape(self):
tracker = StatusTracker(max_concurrent=5)
snap = tracker.snapshot()
assert "global" in snap
assert "sessions" in snap
assert snap["global"]["state"] == "idle"
assert snap["global"]["max_concurrent"] == 5
assert snap["global"]["active_sessions"] == 0
assert isinstance(snap["global"]["uptime_seconds"], int)
assert isinstance(snap["sessions"], list)
async def test_active_snapshot_shape(self):
from pocketpaw.bus.events import SystemEvent
tracker = StatusTracker(max_concurrent=3)
await tracker._on_event(
SystemEvent(event_type="agent_start", data={"session_key": "websocket:abc123"})
)
await tracker._on_event(
SystemEvent(
event_type="tool_start",
data={"session_key": "websocket:abc123", "name": "bash"},
)
)
snap = tracker.snapshot()
assert snap["global"]["state"] == "active"
session = snap["sessions"][0]
assert session["session_key"] == "websocket:abc123"
assert session["session_id"] == "abc123"
assert session["channel"] == "websocket"
assert session["state"] == "tool_running"
assert session["tool_name"] == "bash"
assert isinstance(session["duration_seconds"], float)
class TestVersionTracking:
"""Test the version-based change detection."""
async def test_version_increments_on_state_change(self):
from pocketpaw.bus.events import SystemEvent
tracker = StatusTracker()
v0 = tracker.version
await tracker._on_event(SystemEvent(event_type="agent_start", data={"session_key": "ws:1"}))
assert tracker.version > v0
async def test_wait_for_change_returns_immediately_when_version_advanced(self):
from pocketpaw.bus.events import SystemEvent
tracker = StatusTracker()
v0 = tracker.version
await tracker._on_event(SystemEvent(event_type="agent_start", data={"session_key": "ws:1"}))
# Version already advanced, should return True immediately
result = await tracker.wait_for_change(since_version=v0, timeout=0.01)
assert result is True
class TestCLIFormat:
"""Test CLI formatting helpers."""
def test_format_duration_seconds(self):
from pocketpaw.cli.status import _format_duration
assert _format_duration(0) == "0s"
assert _format_duration(45) == "45s"
def test_format_duration_minutes(self):
from pocketpaw.cli.status import _format_duration
assert _format_duration(90) == "1m 30s"
assert _format_duration(120) == "2m 0s"
def test_format_duration_hours(self):
from pocketpaw.cli.status import _format_duration
assert _format_duration(3661) == "1h 1m 1s"