Files
ss-tools/agent/tests/test_agent/test_agent_handler.py
busya b95df37cd5 refactor(agent): extract agent+shared into standalone packages with full GRACE semantic markup
- Move agent code from backend/src/agent/ to agent/src/ss_tools/agent/
- Extract shared stdlib-only utilities to shared/src/ss_tools/shared/
- Add #region/#endregion contracts to all ~140 functions (INV_1 compliance)
- Update docker files, entrypoint, build scripts for new package layout
- Backend now imports ss_tools.shared._llm_health (no gradio/langchain deps)
- Add specs for 036-039 feature plans
2026-07-07 15:18:24 +03:00

275 lines
10 KiB
Python

# #region TestAgentChat.Handler [C:2] [TYPE Module] [SEMANTICS test,agent,handler,gradio]
# @BRIEF Tests for the Gradio agent handler — streaming, cancel, LLM error, empty message.
# @RELATION BINDS_TO -> [AgentChat.GradioApp.Handler]
import os
from pathlib import Path
import sys
sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
import jwt
# Set AUTH_SECRET_KEY and LLM_API_KEY for tests (match conftest)
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
AUTH_SECRET_KEY = os.environ["AUTH_SECRET_KEY"]
os.environ["OPENAI_API_KEY"] = "sk-test-key"
os.environ["LLM_API_KEY"] = "sk-test-key"
os.environ["LLM_MODEL"] = "gpt-4o"
os.environ["LLM_BASE_URL"] = "https://api.openai.com/v1"
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture(autouse=True)
def mock_save_conversation():
with patch("ss_tools.agent.app.save_conversation", new_callable=AsyncMock):
yield
def _make_test_jwt(user_id: str = "test-user") -> str:
return jwt.encode({"sub": user_id}, AUTH_SECRET_KEY, algorithm="HS256")
def _empty_agent_state():
state = MagicMock(spec_set=["next"])
state.next = ()
return state
# #region TestAgentChat.Handler.EmptyMessage [C:2] [TYPE Function] [SEMANTICS test,handler,empty]
# @BRIEF Empty message returns immediately without calling LangGraph.
# @TEST_EDGE empty_text, empty_with_files_none
@pytest.mark.anyio
async def test_handler_empty_message_returns_immediately():
"""An empty message should return immediately without calling the graph."""
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
token = _make_test_jwt()
mock_request.headers = {"authorization": f"Bearer {token}"}
mock_request.client.host = "127.0.0.1"
# Patch create_agent to avoid OpenAI init
with patch("ss_tools.agent.langgraph_setup.create_agent"):
# Empty message
message = {"text": "", "files": None}
results = []
async for chunk in agent_handler(message, history, mock_request, None, None):
results.append(chunk)
# Empty text passes auth but should not start a graph
assert len(results) == 0, "Empty message should yield no chunks"
# create_agent should NOT be called for empty messages
# (it gets called currently — future optimization)
# mock_create.assert_not_called() # TODO: optimize to skip graph for empty msg
# #endregion TestAgentChat.Handler.EmptyMessage
# #region TestAgentChat.Handler.AuthGraceful [C:2] [TYPE Function] [SEMANTICS test,handler,auth]
# @BRIEF Missing or invalid JWT does NOT reject — Gradio handler forwards to graph (auth at tool layer).
# @TEST_EDGE missing_auth, invalid_token
# @RATIONALE Per design, @gradio/client does not forward Authorization headers, so the Gradio handler
# does NOT enforce JWT. Missing/invalid JWT falls back to anonymous context.
# Tool-level auth is enforced via SERVICE_JWT + X-User-JWT dual identity pattern.
@pytest.mark.anyio
async def test_handler_missing_auth_continues_gracefully():
"""Missing authorization header does NOT yield UNAUTHORIZED — handler continues."""
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
mock_request.headers = {}
mock_request.client.host = "127.0.0.1"
message = {"text": "hello", "files": None}
# Patch create_agent to prevent LLM call — handler should proceed without JWT
with patch("ss_tools.agent.app.create_agent") as mock_create:
mock_graph = AsyncMock()
async def _empty_stream(*_args, **_kwargs):
"""Empty async generator — yields nothing."""
return
yield # make it a generator
mock_graph.astream_events = _empty_stream
mock_graph.aget_state = AsyncMock(return_value=_empty_agent_state())
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, None, None):
results.append(chunk)
# No UNAUTHORIZED error — handler proceeds with empty user_jwt
for r in results:
import json
parsed = json.loads(r) if isinstance(r, str) else r
meta = parsed.get("metadata", {})
assert meta.get("code") != "UNAUTHORIZED", "Handler should not reject missing auth — JWT optional at Gradio layer"
@pytest.mark.anyio
async def test_handler_invalid_jwt_continues_gracefully():
"""Invalid JWT does NOT yield UNAUTHORIZED — handler continues with fallback context."""
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
mock_request.headers = {"authorization": "Bearer invalid-token"}
mock_request.client.host = "127.0.0.1"
message = {"text": "hello", "files": None}
with patch("ss_tools.agent.app.create_agent") as mock_create:
mock_graph = AsyncMock()
async def _empty_stream(*_args, **_kwargs):
return
yield
mock_graph.astream_events = _empty_stream
mock_graph.aget_state = AsyncMock(return_value=_empty_agent_state())
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, None, None):
results.append(chunk)
for r in results:
import json
parsed = json.loads(r) if isinstance(r, str) else r
meta = parsed.get("metadata", {})
assert meta.get("code") != "UNAUTHORIZED", "Handler should not reject invalid JWT — invalid token ignored at Gradio layer"
# #endregion TestAgentChat.Handler.AuthError
# #region TestAgentChat.Handler.Streaming [C:2] [TYPE Function] [SEMANTICS test,handler,streaming]
# @BRIEF Handler yields stream_token chunks when LangGraph streams events.
@pytest.mark.anyio
async def test_handler_yields_stream_tokens():
"""Handler yields stream_token metadata when graph emits token events."""
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
token = _make_test_jwt()
mock_request.headers = {"authorization": f"Bearer {token}"}
mock_request.client.host = "127.0.0.1"
message = {"text": "hello", "files": None}
# Patch create_agent to return a mock that streams events
with patch("ss_tools.agent.app.create_agent") as mock_create:
mock_graph = AsyncMock()
async def _mock_stream(*_args, **_kwargs):
yield {"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="Hello")}}
yield {"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content=" world")}}
mock_graph.astream_events = _mock_stream
mock_graph.aget_state = AsyncMock(return_value=_empty_agent_state())
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, "test-conv", None):
results.append(chunk)
assert len(results) > 0, "Should yield at least one chunk"
import json
stream_tokens = [r for r in results if json.loads(r)["metadata"]["type"] == "stream_token"]
assert len(stream_tokens) > 0, "Should yield stream_token metadata"
# #endregion TestAgentChat.Handler.Streaming
# #region TestAgentChat.Handler.ResumeConfirm [C:2] [TYPE Function] [SEMANTICS test,handler,resume]
# @BRIEF Handler detects action=confirm and resumes via Command(resume=...).
@pytest.mark.anyio
async def test_handler_resume_confirm():
"""When action='confirm', handler resumes via Command(resume=...)."""
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
token = _make_test_jwt()
mock_request.headers = {"authorization": f"Bearer {token}"}
mock_request.client.host = "127.0.0.1"
message = {"text": "confirm", "files": None}
# Patch create_agent in _confirmation because handle_resume (now in _confirmation.py)
# imports create_agent directly from langgraph_setup
with patch("ss_tools.agent._confirmation.create_agent") as mock_create:
mock_graph = MagicMock()
async def _empty_stream(*_args, **_kwargs):
return
yield
mock_graph.astream_events = _empty_stream
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, "test-conv", "confirm"):
results.append(chunk)
# Should yield confirm_resolved metadata
assert len(results) == 1
import json
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
assert parsed["metadata"]["type"] == "confirm_resolved"
assert parsed["metadata"]["result"] == "confirmed"
assert mock_create.call_args.kwargs["interrupt_before"] == []
# #endregion TestAgentChat.Handler.ResumeConfirm
# #region TestAgentChat.Handler.ResumeDeny [C:2] [TYPE Function] [SEMANTICS test,handler,deny]
@pytest.mark.anyio
async def test_handler_resume_deny():
"""When action='deny', handler yields confirm_resolved with denied."""
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
token = _make_test_jwt()
mock_request.headers = {"authorization": f"Bearer {token}"}
mock_request.client.host = "127.0.0.1"
message = {"text": "deny", "files": None}
with patch("ss_tools.agent._confirmation.create_agent") as mock_create:
mock_graph = MagicMock()
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, "test-conv", "deny"):
results.append(chunk)
assert len(results) == 1
import json
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
assert parsed["metadata"]["type"] == "confirm_resolved"
assert parsed["metadata"]["result"] == "denied"
# #endregion TestAgentChat.Handler.ResumeDeny
# #endregion TestAgentChat.Handler