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
307 lines
13 KiB
Python
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
|