feat(036): artifacts.py storage + tracker tests + bugfixes
This commit is contained in:
@@ -397,6 +397,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
_request_error_code: str | None = None
|
||||
_attempts_used: int = 0
|
||||
conv_id: str | None = None
|
||||
run_tracker = None # 036: Durable run tracker
|
||||
|
||||
try:
|
||||
# ── Resolve conversation ID and trace ID ──
|
||||
|
||||
@@ -132,12 +132,12 @@ def test_object_name_at_256_chars_passes():
|
||||
|
||||
# #region Test.Agent.TestInvalidContextVersionRaises [C:2] [TYPE Function]
|
||||
def test_invalid_context_version_raises():
|
||||
"""Only contextVersion=1 is currently supported."""
|
||||
"""Only contextVersion=1 and 2 are supported."""
|
||||
payload = {
|
||||
"objectType": "dashboard",
|
||||
"objectId": "42",
|
||||
"route": "/dashboards/42",
|
||||
"contextVersion": 2,
|
||||
"contextVersion": 3,
|
||||
}
|
||||
with pytest.raises(UIContextValidationError, match="contextVersion"):
|
||||
validate_uicontext(payload)
|
||||
|
||||
178
agent/tests/test_agent/test_agent_run_tracker.py
Normal file
178
agent/tests/test_agent/test_agent_run_tracker.py
Normal file
@@ -0,0 +1,178 @@
|
||||
# agent/tests/test_agent/test_agent_run_tracker.py
|
||||
# #region TestAgent.RunTracker [C:2] [TYPE Module] [SEMANTICS test,agent,run,tracker,036]
|
||||
# @BRIEF Tests for RunTracker — backend communication, error handling, idempotency.
|
||||
# @RELATION BINDS_TO -> [AgentChat.RunTracker]
|
||||
# @TEST_EDGE backend_loss -> no unpersisted emission.
|
||||
# @TEST_EDGE create_before_append -> error raised.
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
import httpx
|
||||
|
||||
from ss_tools.agent._run_tracker import RunTracker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tracker():
|
||||
return RunTracker(base_url="http://test-backend", service_jwt="test-jwt")
|
||||
|
||||
|
||||
class TestRunTrackerCreate:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_initializes_run(self, tracker):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = {
|
||||
"id": "run-1", "status": "RUNNING",
|
||||
"last_sequence": 1,
|
||||
"user_id": "user-1", "intent": "dashboard_scenario_build",
|
||||
"trigger": "manual", "dashboard_id": "42", "environment_id": "dev",
|
||||
"context_snapshot": {}, "current_stage": "context",
|
||||
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
|
||||
"stages": [], "drafts": [],
|
||||
}
|
||||
|
||||
with patch.object(httpx.AsyncClient, "post", return_value=mock_response):
|
||||
run_id = await tracker.create(
|
||||
context={"objectType": "dashboard", "objectId": "42", "envId": "dev",
|
||||
"route": "/dashboards/42", "contextVersion": 2,
|
||||
"intent": "build_dashboard_test_scenario"},
|
||||
)
|
||||
assert run_id == "run-1"
|
||||
assert tracker.run_id == "run-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_sets_sequence_from_response(self, tracker):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = {
|
||||
"id": "run-2", "status": "RUNNING", "last_sequence": 3,
|
||||
"user_id": "user-1", "intent": "dashboard_scenario_build",
|
||||
"trigger": "manual", "dashboard_id": "42", "environment_id": "dev",
|
||||
"context_snapshot": {}, "current_stage": "context",
|
||||
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
|
||||
"stages": [], "drafts": [],
|
||||
}
|
||||
|
||||
with patch.object(httpx.AsyncClient, "post", return_value=mock_response):
|
||||
await tracker.create(
|
||||
context={"objectType": "dashboard", "objectId": "42", "envId": "dev",
|
||||
"route": "/dashboards/42", "contextVersion": 2,
|
||||
"intent": "build_dashboard_test_scenario"},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_event_before_create_raises(self, tracker):
|
||||
with pytest.raises(RuntimeError, match="Must call create"):
|
||||
await tracker.append_event("progress", "inspect", "completed")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emit_progress_before_create_raises(self, tracker):
|
||||
with pytest.raises(RuntimeError, match="Must call create"):
|
||||
await tracker.emit_progress("inspect", "completed")
|
||||
|
||||
|
||||
class TestRunTrackerEvents:
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_event_increments_sequence(self, tracker):
|
||||
# Mock create
|
||||
mock_create = MagicMock()
|
||||
mock_create.status_code = 201
|
||||
mock_create.json.return_value = {
|
||||
"id": "run-1", "status": "RUNNING", "last_sequence": 1,
|
||||
"user_id": "user-1", "intent": "dashboard_scenario_build",
|
||||
"trigger": "manual", "dashboard_id": "42", "environment_id": "dev",
|
||||
"context_snapshot": {}, "current_stage": "context",
|
||||
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
|
||||
"stages": [], "drafts": [],
|
||||
}
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.status_code = 201
|
||||
mock_event.json.return_value = {
|
||||
"id": "evt-1", "run_id": "run-1", "sequence": 2,
|
||||
"event_type": "progress", "stage": "inspect", "status": "completed",
|
||||
"occurred_at": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
with patch.object(httpx.AsyncClient, "post") as mock_post:
|
||||
mock_post.side_effect = [mock_create, mock_event]
|
||||
await tracker.create(
|
||||
context={"objectType": "dashboard", "objectId": "42", "envId": "dev",
|
||||
"route": "/dashboards/42", "contextVersion": 2,
|
||||
"intent": "build_dashboard_test_scenario"},
|
||||
)
|
||||
result = await tracker.append_event("progress", "inspect", "completed")
|
||||
assert result["sequence"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emit_terminal_maps_status(self, tracker):
|
||||
mock_create = MagicMock()
|
||||
mock_create.status_code = 201
|
||||
mock_create.json.return_value = {
|
||||
"id": "run-1", "status": "RUNNING", "last_sequence": 1,
|
||||
"user_id": "user-1", "intent": "dashboard_scenario_build",
|
||||
"trigger": "manual", "dashboard_id": "42", "environment_id": "dev",
|
||||
"context_snapshot": {}, "current_stage": "context",
|
||||
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
|
||||
"stages": [], "drafts": [],
|
||||
}
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.status_code = 201
|
||||
mock_event.json.return_value = {"id": "evt-1", "sequence": 2}
|
||||
|
||||
with patch.object(httpx.AsyncClient, "post") as mock_post:
|
||||
mock_post.side_effect = [mock_create, mock_event]
|
||||
await tracker.create(
|
||||
context={"objectType": "dashboard", "objectId": "42", "envId": "dev",
|
||||
"route": "/dashboards/42", "contextVersion": 2,
|
||||
"intent": "build_dashboard_test_scenario"},
|
||||
)
|
||||
await tracker.emit_terminal("COMPLETED")
|
||||
# Verify the POST body was sent with mapped status
|
||||
call_args = mock_post.call_args_list[-1]
|
||||
sent_body = call_args[1].get("json", {})
|
||||
assert sent_body.get("status") == "completed"
|
||||
|
||||
|
||||
class TestRunTrackerErrorHandling:
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_error_propagates(self, tracker):
|
||||
mock_create = MagicMock()
|
||||
mock_create.status_code = 201
|
||||
mock_create.json.return_value = {
|
||||
"id": "run-1", "status": "RUNNING", "last_sequence": 1,
|
||||
"user_id": "user-1", "intent": "dashboard_scenario_build",
|
||||
"trigger": "manual", "dashboard_id": "42", "environment_id": "dev",
|
||||
"context_snapshot": {}, "current_stage": "context",
|
||||
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
|
||||
"stages": [], "drafts": [],
|
||||
}
|
||||
|
||||
mock_error = MagicMock()
|
||||
mock_error.status_code = 500
|
||||
mock_error.response = MagicMock()
|
||||
mock_error.response.text = "Internal error"
|
||||
mock_error.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Server error", request=MagicMock(), response=mock_error.response)
|
||||
|
||||
with patch.object(httpx.AsyncClient, "post") as mock_post:
|
||||
mock_post.side_effect = [mock_create, mock_error]
|
||||
await tracker.create(
|
||||
context={"objectType": "dashboard", "objectId": "42", "envId": "dev",
|
||||
"route": "/dashboards/42", "contextVersion": 2,
|
||||
"intent": "build_dashboard_test_scenario"},
|
||||
)
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await tracker.append_event("progress", "inspect", "completed")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_cleans_up(self, tracker):
|
||||
# Set a mocked client and verify close cleans up
|
||||
mock_client = MagicMock()
|
||||
mock_client.aclose = AsyncMock()
|
||||
tracker._client = mock_client
|
||||
await tracker.close()
|
||||
mock_client.aclose.assert_called_once()
|
||||
assert tracker._client is None
|
||||
# #endregion TestAgent.RunTracker
|
||||
Reference in New Issue
Block a user