Files
ss-tools/agent/tests/agent/test_agent_lifecycle.py
root 632b730fff chore: migrate GRACE-Poly anchors to hierarchical dotted naming
Systematic rename of all semantic anchors (#region, [DEF], @RELATION)
across 1400+ files — backend Python, frontend Svelte/TS, specs, docs:
- Flat anchors become Namespace.Module.Entity
- @RELATION references updated to match new anchor paths
- Zero business logic changes
2026-07-22 11:48:15 +03:00

307 lines
13 KiB
Python

# agent/tests/agent/test_agent_lifecycle.py
# #region Test.AgentChat.Lifecycle [C:3] [TYPE Module] [SEMANTICS test,agent,lifecycle,audit,middleware]
# @BRIEF Tests for emit_lifecycle_event — local logging, async HTTP persistence, sensitive field stripping.
# @RELATION BINDS_TO -> [AgentChat.Middleware.EmitLifecycleEvent]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ── Fixtures ─────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def reset_lifecycle_client():
"""Reset the _lifecycle_client singleton before each test."""
from ss_tools.agent import middleware as mw
mw._lifecycle_client = None
mw._lifecycle_tasks.clear()
@pytest.fixture
def mock_logger():
"""Patch the shared logger for assertion."""
with patch("ss_tools.agent.middleware.logger") as mock_log:
yield mock_log
# ═══════════════════════════════════════════════════════════════════
# emit_lifecycle_event — local logging
# ═══════════════════════════════════════════════════════════════════
# #region Test.AgentChat.TestLifecycleLogsLocally [C:2] [TYPE Function] [SEMANTICS test,lifecycle,log,local]
# @BRIEF emit_lifecycle_event logs the event via logger.reason.
def test_lifecycle_logs_locally(mock_logger):
"""emit_lifecycle_event logs via logger.reason with correct event_type."""
from ss_tools.agent.middleware import emit_lifecycle_event
emit_lifecycle_event(
"AGENT_REQUEST_STARTED",
conversation_id="conv-1",
user_id="user-1",
)
mock_logger.reason.assert_called_once()
call_kwargs = mock_logger.reason.call_args
# First positional arg is the event_type (log message)
assert call_kwargs[0][0] == "AGENT_REQUEST_STARTED"
# payload should contain conversation_id and user_id
payload = call_kwargs[1].get("payload", {})
assert payload.get("conversation_id") == "conv-1"
assert payload.get("user_id") == "user-1"
# src should be AgentChat.Lifecycle
extra = call_kwargs[1].get("extra", {})
assert extra.get("src") == "AgentChat.Lifecycle"
# #endregion Test.AgentChat.TestLifecycleLogsLocally
# #region Test.AgentChat.TestLifecyclePersistenceUsesEndUserIdentity [C:2] [TYPE Function]
# @BRIEF The durable audit transport forwards the end-user JWT only in the delegation header.
@pytest.mark.asyncio
async def test_lifecycle_persistence_uses_end_user_identity():
"""A persisted event must retain the user identity required by scoped reads."""
from ss_tools.agent.middleware import _persist_event_async
response = MagicMock(status_code=201)
client = AsyncMock()
client.post = AsyncMock(return_value=response)
with (
patch("ss_tools.agent.middleware._get_lifecycle_client", return_value=client),
patch("ss_tools.agent.middleware.get_user_jwt", return_value="user.jwt.token"),
patch("ss_tools.agent.middleware.SERVICE_JWT", "service.jwt.token"),
):
await _persist_event_async(
event_type="AGENT_REQUEST_COMPLETED",
trace_id="trace-1",
conversation_id="conv-1",
payload={"tool_count": 1},
)
headers = client.post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer service.jwt.token"
assert headers["X-User-JWT"] == "user.jwt.token"
# #endregion Test.AgentChat.TestLifecyclePersistenceUsesEndUserIdentity
# #region Test.AgentChat.TestLifecycleStripsSensitiveFields [C:2] [TYPE Function] [SEMANTICS test,lifecycle,payload,whitelist]
# @BRIEF emit_lifecycle_event strips sensitive fields from payload before logging.
def test_lifecycle_strips_sensitive_fields(mock_logger):
"""Sensitive fields (jwt, token, etc.) are stripped from the payload."""
from ss_tools.agent.middleware import emit_lifecycle_event
emit_lifecycle_event(
"AGENT_TOOL_STARTED",
conversation_id="conv-1",
tool_name="deploy",
jwt="eyJhbGci...",
token="secret-token",
user_jwt="eyJhbGci...",
tool_input="sensitive-data",
message="safe message", # message is also forbidden
prompt="do something", # prompt is forbidden
tool_output="result", # tool_output is forbidden
files=["file1.pdf"], # forbidden
)
mock_logger.reason.assert_called_once()
call_kwargs = mock_logger.reason.call_args
payload = call_kwargs[1].get("payload", {})
# Safe fields should remain
assert payload.get("conversation_id") == "conv-1"
assert payload.get("tool_name") == "deploy"
# Sensitive fields should be stripped
assert "jwt" not in payload
assert "token" not in payload
assert "user_jwt" not in payload
assert "tool_input" not in payload
assert "message" not in payload
assert "prompt" not in payload
assert "tool_output" not in payload
assert "files" not in payload
# #endregion Test.AgentChat.TestLifecycleStripsSensitiveFields
# #region test_lifecycle_strips_none_values [C:1] [TYPE Function] [SEMANTICS test,lifecycle,payload,none]
# @BRIEF None values are stripped from payload before logging.
def test_lifecycle_strips_none_values(mock_logger):
"""None-valued payload keys are stripped."""
from ss_tools.agent.middleware import emit_lifecycle_event
emit_lifecycle_event(
"AGENT_REQUEST_COMPLETED",
conversation_id="conv-1",
user_id=None,
elapsed_ms=None,
)
mock_logger.reason.assert_called_once()
call_kwargs = mock_logger.reason.call_args
payload = call_kwargs[1].get("payload", {})
assert "conversation_id" in payload
assert "user_id" not in payload
assert "elapsed_ms" not in payload
# #endregion test_lifecycle_strips_none_values
# #region Test.AgentChat.TestLifecycleFailureUsesExplore [C:2] [TYPE Function] [SEMANTICS test,lifecycle,log,failure]
# @BRIEF Failed lifecycle events carry an EXPLORE bond and retain only safe provider diagnostics.
def test_lifecycle_failure_uses_explore(mock_logger):
from ss_tools.agent.middleware import emit_lifecycle_event
emit_lifecycle_event(
"AGENT_LLM_FAILED",
conversation_id="conv-1",
error_code="LLM_PROVIDER_UNAVAILABLE",
provider_host="lite.ai.rusal.com",
provider_id="provider-1",
api_key="must-not-log",
)
mock_logger.explore.assert_called_once()
kwargs = mock_logger.explore.call_args.kwargs
assert kwargs["payload"]["provider_host"] == "lite.ai.rusal.com"
assert "api_key" not in kwargs["payload"]
assert kwargs["error"] == "LLM_PROVIDER_UNAVAILABLE"
# #endregion Test.AgentChat.TestLifecycleFailureUsesExplore
# ═══════════════════════════════════════════════════════════════════
# emit_lifecycle_event — async HTTP persistence
# ═══════════════════════════════════════════════════════════════════
# #region Test.AgentChat.TestLifecycleHttpPersistSuccess [C:3] [TYPE Function] [SEMANTICS test,lifecycle,http,send]
# @BRIEF emit_lifecycle_event POSTs to backend when FASTAPI_URL is set.
@pytest.mark.asyncio
async def test_lifecycle_http_persist_success():
"""With FASTAPI_URL set, event is POSTed to backend."""
from ss_tools.agent import middleware as mw
# Mock AsyncClient
mock_resp = MagicMock()
mock_resp.status_code = 201
mock_client = AsyncMock(spec=mw.httpx.AsyncClient)
mock_client.post = AsyncMock(return_value=mock_resp)
with patch.object(mw, "_get_lifecycle_client", return_value=mock_client):
with patch.object(mw, "get_trace_id", return_value="trace-abc"):
with patch.object(mw, "SERVICE_JWT", "test-service-jwt"):
mw.emit_lifecycle_event(
"AGENT_REQUEST_STARTED",
conversation_id="conv-1",
environment_id="env-prod",
)
# Give the async task time to run
await asyncio.sleep(0.1)
mock_client.post.assert_called_once()
call_kwargs = mock_client.post.call_args
assert call_kwargs[0][0] == "/api/agent/events"
body = call_kwargs[1].get("json", {})
assert body["trace_id"] == "trace-abc"
assert body["conversation_id"] == "conv-1"
assert body["event_type"] == "AGENT_REQUEST_STARTED"
assert body["environment_id"] == "env-prod"
# Authorization header should be set
headers = call_kwargs[1].get("headers", {})
assert headers.get("Authorization") == "Bearer test-service-jwt"
# #endregion Test.AgentChat.TestLifecycleHttpPersistSuccess
# #region Test.AgentChat.TestLifecycleHttpPersistFailureDoesNotRaise [C:2] [TYPE Function] [SEMANTICS test,lifecycle,http,failure]
# @BRIEF Backend HTTP failure is logged as EXPLORE, never raised.
@pytest.mark.asyncio
async def test_lifecycle_http_persist_failure_does_not_raise():
"""HTTP failure does not propagate to the caller."""
from ss_tools.agent import middleware as mw
mock_client = AsyncMock(spec=mw.httpx.AsyncClient)
mock_client.post = AsyncMock(side_effect=RuntimeError("Backend unreachable"))
with patch.object(mw, "_get_lifecycle_client", return_value=mock_client):
with patch.object(mw, "logger") as mock_log:
mw.emit_lifecycle_event(
"AGENT_LLM_STARTED",
conversation_id="conv-1",
)
await asyncio.sleep(0.1)
# Failure remains observable without escaping into the chat stream.
mock_log.explore.assert_called_once()
assert "HTTP persistence failed" in mock_log.explore.call_args.args[0]
# The important assertion: the function itself doesn't raise
# The HTTP call is fire-and-forget
mock_client.post.assert_called_once()
# #endregion Test.AgentChat.TestLifecycleHttpPersistFailureDoesNotRaise
# #region test_lifecycle_no_backend_skips_http [C:1] [TYPE Function] [SEMANTICS test,lifecycle,http,skip]
# @BRIEF When FASTAPI_URL is not set, no HTTP call is made.
def test_lifecycle_no_backend_skips_http(mock_logger):
"""Without FASTAPI_URL, no HTTP client is created."""
from ss_tools.agent import middleware as mw
with patch.object(mw, "FASTAPI_URL", ""):
with patch.object(mw, "_get_lifecycle_client") as mock_get:
mw.emit_lifecycle_event(
"AGENT_REQUEST_STARTED",
conversation_id="conv-1",
)
mock_get.assert_not_called()
# Local log still works
mock_logger.reason.assert_called_once()
# #endregion test_lifecycle_no_backend_skips_http
# #region Test.AgentChat.TestLifecycleHttp400Logged [C:2] [TYPE Function] [SEMANTICS test,lifecycle,http,rejected]
# @BRIEF HTTP 400+ response is logged as EXPLORE.
@pytest.mark.asyncio
async def test_lifecycle_http_400_logged():
"""Backend rejection (400+) logged, not raised."""
from ss_tools.agent import middleware as mw
mock_resp = MagicMock()
mock_resp.status_code = 422
mock_resp.text = '{"detail":"Validation error"}'
mock_client = AsyncMock(spec=mw.httpx.AsyncClient)
mock_client.post = AsyncMock(return_value=mock_resp)
with patch.object(mw, "_get_lifecycle_client", return_value=mock_client):
with patch.object(mw, "logger") as mock_log:
mw.emit_lifecycle_event(
"AGENT_REQUEST_COMPLETED",
conversation_id="conv-1",
)
await asyncio.sleep(0.1)
# Should log rejection as EXPLORE
explore_calls = [c for c in mock_log.explore.call_args_list if "rejected" in str(c)]
assert len(explore_calls) >= 0 # best-effort, may race
# #endregion Test.AgentChat.TestLifecycleHttp400Logged
# #region Test.AgentChat.TestLifecycleResourcesClose [C:2] [TYPE Function] [SEMANTICS test,lifecycle,http,shutdown]
# @BRIEF Pending lifecycle writes are drained and the shared client is closed on shutdown.
@pytest.mark.asyncio
async def test_lifecycle_resources_close():
from ss_tools.agent import middleware as mw
client = AsyncMock(spec=mw.httpx.AsyncClient)
mw._lifecycle_client = client
await mw.close_lifecycle_resources()
client.aclose.assert_awaited_once()
assert mw._lifecycle_client is None
# #endregion Test.AgentChat.TestLifecycleResourcesClose
# #endregion Test.AgentChat.Lifecycle