feat(036): artifacts.py storage + tracker tests + bugfixes

This commit is contained in:
2026-07-28 11:47:54 +03:00
parent 8e395752f9
commit 7095497995
4 changed files with 323 additions and 2 deletions

View File

@@ -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 ──

View File

@@ -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)

View 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