Files
ss-tools/agent/tests/test_agent/test_app.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

691 lines
30 KiB
Python

# #region Test.AgentChat.GradioApp [C:3] [TYPE Module] [SEMANTICS test,agent,gradio,chat]
# @BRIEF Tests for agent/app.py — agent_handler, handle_resume, extract_user_id, save_conversation, create_chat_interface, health.
# @RELATION BINDS_TO -> [AgentChat.GradioApp]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import asyncio
import json
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
def _make_async_iter(items):
"""Create an async iterator from a list."""
class AsyncIter:
def __init__(self, items):
self._iter = iter(items)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self._iter)
except StopIteration:
raise StopAsyncIteration from None
return AsyncIter(items)
def _skip_pipeline(results: list[str]) -> list[str]:
"""Filter out pipeline_result events emitted before the agent stream loop."""
return [r for r in results if json.loads(r).get("metadata", {}).get("type") != "pipeline_result"]
def _make_agent_mock(stream_events=None, raise_on_call=False):
"""Create a properly mocked agent for async iteration."""
agent = MagicMock()
if raise_on_call:
agent.astream_events = MagicMock(side_effect=stream_events)
elif stream_events is not None:
agent.astream_events = MagicMock(return_value=_make_async_iter(stream_events))
else:
agent.astream_events = MagicMock(return_value=_make_async_iter([]))
# aget_state must be awaitable — handler calls `await agent.aget_state(config)` after stream loop
state_mock = MagicMock(spec_set=["next"])
state_mock.next = ()
agent.aget_state = AsyncMock(return_value=state_mock)
return agent
@pytest.fixture(autouse=True)
def clear_locks():
"""Clear _user_locks before each test."""
from ss_tools.agent.app import _user_locks
_user_locks.clear()
@pytest.fixture
def mock_request():
req = MagicMock()
req.headers = {"authorization": ""}
return req
# #region test_extract_user_id [C:2] [TYPE Function]
# @BRIEF Test extract_user_id for various JWT payloads.
class TestExtractUserId:
def test_extracts_sub(self):
from ss_tools.agent._persistence import extract_user_id
with patch("ss_tools.agent._jwt_decoder.decode_token", return_value={"sub": "user-1"}):
assert extract_user_id("fake-jwt") == "user-1"
def test_extracts_user_id_fallback(self):
from ss_tools.agent._persistence import extract_user_id
with patch("ss_tools.agent._jwt_decoder.decode_token", return_value={"user_id": "user-2"}):
assert extract_user_id("fake-jwt") == "user-2"
def test_returns_unknown_on_exception(self):
from ss_tools.agent._persistence import extract_user_id
with patch("ss_tools.agent._jwt_decoder.decode_token", side_effect=Exception("bad token")):
assert extract_user_id("bad") == "unknown"
def test_returns_unknown_on_empty(self):
from ss_tools.agent._persistence import extract_user_id
assert extract_user_id("") == "unknown"
# #endregion test_extract_user_id
# #region test_confirmation_metadata [C:2] [TYPE Function]
# @BRIEF Test backend HITL confirmation contract exposed to the frontend.
class TestConfirmationMetadata:
def test_extracts_tool_call_and_read_contract(self):
from ss_tools.agent._confirmation import confirmation_metadata
msg = MagicMock()
msg.tool_calls = [{"name": "list_environments", "args": {"env_id": "prod"}}]
state = MagicMock()
state.values = {"messages": [msg]}
state.next = ("tools",)
meta = confirmation_metadata("conv-1", state, "Какие есть окружения?")
assert meta["type"] == "confirm_required"
assert meta["thread_id"] == "conv-1"
assert meta["prompt"] == "Разрешить чтение данных?"
assert meta["tool_name"] == "list_environments"
assert meta["tool_args"] == {"env_id": "prod"}
assert meta["risk"] == "read"
assert meta["risk_level"] == "safe"
assert meta["requires_confirmation"] is True
assert meta["intent"]["operation"] == "list_environments"
def test_infers_tool_from_text_when_state_only_has_graph_node(self):
from ss_tools.agent._confirmation import confirmation_metadata
state = MagicMock()
state.values = {"messages": []}
state.next = ("tools",)
meta = confirmation_metadata("conv-2", state, "Покажи окружения")
# extract_tool_call_from_state no longer infers from text — LLM handles intent.
# State has no tool_calls, and "tools" node is in _GRAPH_NODE_NAMES → filtered.
assert meta["tool_name"] == "unknown_action"
assert meta["risk"] == "read"
assert meta["risk_level"] == "safe"
def test_write_contract_for_guarded_tool(self):
from ss_tools.agent._confirmation import confirmation_metadata
msg = MagicMock()
msg.tool_calls = [
{
"name": "start_maintenance",
"args": {"environment_id": "prod", "tables": ["sales"]},
}
]
state = MagicMock()
state.values = {"messages": [msg]}
state.next = ("tools",)
meta = confirmation_metadata("conv-3", state, "Запусти maintenance")
assert meta["tool_name"] == "start_maintenance"
assert meta["risk"] == "write"
assert meta["risk_level"] == "guarded"
assert meta["prompt"] == "⚠️ Изменение данных в PRODUCTION! Подтвердите действие."
# v2 fields — permission check runs against user_role in state; test defaults to "viewer"
# start_maintenance requires "admin", so permission_granted will be False here
assert meta["dangerous"] is False
assert meta["env_context"] == "prod"
assert meta["permission_granted"] is False
assert meta["required_role"] == "admin"
def test_unknown_contract_does_not_expose_graph_node_as_tool(self):
from ss_tools.agent._confirmation import confirmation_metadata
state = MagicMock()
state.values = {"messages": []}
state.next = ("tools",)
meta = confirmation_metadata("conv-4", state, "Непонятное действие")
assert meta["tool_name"] == "unknown_action"
assert meta["risk"] == "read"
assert meta["risk_level"] == "safe"
def test_fast_resume_deny_closes_without_langgraph(self):
from ss_tools.agent._confirmation import _pending_confirmations, handle_resume
_pending_confirmations["conv-fast-deny"] = {
"tool_name": "list_environments",
"tool_args": {},
}
async def collect():
return [chunk async for chunk in handle_resume("conv-fast-deny", "deny")]
chunks = asyncio.run(collect())
data = json.loads(chunks[0])
assert data["metadata"] == {"type": "confirm_resolved", "result": "denied"}
def test_fast_resume_confirm_executes_tool_directly(self):
from ss_tools.agent._confirmation import _pending_confirmations, handle_resume
tool = MagicMock()
tool.ainvoke = AsyncMock(return_value='[{"id":"ss-dev"}]')
_pending_confirmations["conv-fast-confirm"] = {
"tool_name": "list_environments",
"tool_args": {},
}
async def collect():
with patch("ss_tools.agent._confirmation.find_tool", return_value=tool), patch("ss_tools.agent._confirmation._format_tool_output_via_llm") as mock_format:
# Make the mock format helper yield nothing — don't need LLM in unit test
async def _empty_format(*_args, **_kwargs):
return
yield # pragma: no cover — async generator requires at least one yield
mock_format.side_effect = _empty_format
return [chunk async for chunk in handle_resume("conv-fast-confirm", "confirm")]
chunks = asyncio.run(collect())
metadata_types = [json.loads(chunk)["metadata"]["type"] for chunk in chunks]
assert metadata_types[:3] == ["confirm_resolved", "tool_start", "tool_end"]
# #endregion test_confirmation_metadata
# #region test_agent_handler [C:2] [TYPE Function]
# @BRIEF Test agent_handler for various scenarios.
class TestAgentHandler:
@pytest.mark.asyncio
async def test_handles_concurrent_send(self, mock_request):
from ss_tools.agent.app import _user_locks, agent_handler
# Handler resolves user_id as "admin" when no user_id_str or JWT is provided
_user_locks["admin"] = True
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["code"] == "CONCURRENT_SEND"
@pytest.mark.asyncio
async def test_handles_file_too_large(self, mock_request):
from ss_tools.agent.app import MAX_FILE_SIZE_BYTES, agent_handler
message = {"text": "analyze", "files": ["fake_path"]}
with patch("os.path.exists", return_value=True), patch("os.path.getsize", return_value=MAX_FILE_SIZE_BYTES + 1):
results = [r async for r in agent_handler(message, [], mock_request, None, None)]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["code"] == "FILE_TOO_LARGE"
@pytest.mark.asyncio
async def test_handles_hitl_confirm(self, mock_request):
from ss_tools.agent.app import agent_handler
# handle_resume is imported into app.py from _confirmation
with patch("ss_tools.agent.app.handle_resume") as mock_resume:
mock_resume.return_value = _make_async_iter([json.dumps({"content": "confirmed", "metadata": {"type": "confirm_resolved"}})])
with patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("confirm", [], mock_request, "conv-1", "confirm")]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["type"] == "confirm_resolved"
@pytest.mark.asyncio
async def test_handles_hitl_deny(self, mock_request):
from ss_tools.agent.app import agent_handler
with patch("ss_tools.agent.app.handle_resume") as mock_resume:
mock_resume.return_value = _make_async_iter([json.dumps({"content": "denied", "metadata": {"type": "confirm_resolved", "result": "denied"}})])
with patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("deny", [], mock_request, "conv-1", "deny")]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["result"] == "denied"
@pytest.mark.asyncio
async def test_normal_send_yields_tokens(self, mock_request):
from ss_tools.agent.app import agent_handler
mock_chunk = MagicMock()
mock_chunk.content = "Hello"
mock_event_stream = [
{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}},
{"event": "on_chain_end", "interrupt": {}},
]
agent = _make_agent_mock(mock_event_stream)
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) > 0
token_data = json.loads(filtered[0])
assert token_data["metadata"]["type"] == "stream_token"
@pytest.mark.asyncio
async def test_normal_send_hides_runtime_context_from_saved_history(self, mock_request):
from ss_tools.agent.app import agent_handler
mock_chunk = MagicMock()
mock_chunk.content = "ok"
agent = _make_agent_mock(
[
{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}},
]
)
save_mock = AsyncMock()
with (
patch("ss_tools.agent.app.create_agent", return_value=agent),
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
patch("ss_tools.agent.app.prefetch_dashboards", AsyncMock(return_value="Available dashboards\n- USA Births Names (id: 15)")),
patch("ss_tools.agent.app.generate_llm_title", AsyncMock()),
patch("ss_tools.agent.app.save_conversation", save_mock),
):
results = [
r
async for r in agent_handler(
"Запусти обслуживание дашборда USA на 15 минут",
[],
mock_request,
"conv-runtime",
None,
None,
None,
"ss-dev",
)
]
# pipeline_result + stream_token = 2 events
assert len(results) == 2
messages_arg = agent.astream_events.call_args.args[0]["messages"]
llm_text = messages_arg[0].content
assert "[RUNTIME CONTEXT]" in llm_text
assert "Current datetime:" in llm_text
assert "Available dashboards" in llm_text
save_mock.assert_called_once()
assert save_mock.call_args.args[1] == "Запусти обслуживание дашборда USA на 15 минут"
@pytest.mark.asyncio
async def test_tool_events_yielded(self, mock_request):
from ss_tools.agent.app import agent_handler
mock_event_stream = [
{"event": "on_tool_start", "name": "test_tool", "data": {"input": {}}},
{"event": "on_tool_end", "name": "test_tool", "data": {"output": "done"}},
]
agent = _make_agent_mock(mock_event_stream)
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()), patch("ss_tools.agent.app.log_tool_event", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 2
assert json.loads(filtered[0])["metadata"]["type"] == "tool_start"
assert json.loads(filtered[1])["metadata"]["type"] == "tool_end"
@pytest.mark.asyncio
async def test_tool_error_event(self, mock_request):
from ss_tools.agent.app import agent_handler
mock_event_stream = [
{"event": "on_tool_error", "name": "test_tool", "data": {"error": "something failed"}},
]
agent = _make_agent_mock(mock_event_stream)
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()), patch("ss_tools.agent.app.log_tool_event", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
assert json.loads(filtered[0])["metadata"]["type"] == "tool_error"
@pytest.mark.asyncio
async def test_output_parser_exception_retry(self, mock_request):
from langchain_core.exceptions import OutputParserException
from ss_tools.agent.app import agent_handler
mock_stream_after = _make_async_iter([{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="retried")}}])
call_count = [0]
def mock_astream(*_args, **_kwargs):
call_count[0] += 1
if call_count[0] == 1:
raise OutputParserException("bad output")
return mock_stream_after
agent = MagicMock()
agent.astream_events = mock_astream
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) > 0
@pytest.mark.asyncio
async def test_output_parser_exception_final_failure(self, mock_request):
from langchain_core.exceptions import OutputParserException
from ss_tools.agent.app import agent_handler
call_count = [0]
def mock_astream(*_args, **_kwargs):
call_count[0] += 1
raise OutputParserException(f"bad {call_count[0]}")
agent = MagicMock()
agent.astream_events = mock_astream
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
data = json.loads(filtered[0])
assert data["metadata"]["code"] == "LLM_MALFORMED_OUTPUT"
@pytest.mark.asyncio
async def test_non_llm_error_saves_conversation(self, mock_request):
from ss_tools.agent.app import agent_handler
def mock_astream(*_args, **_kwargs):
raise RuntimeError("API connection error")
agent = MagicMock()
agent.astream_events = mock_astream
save_mock = AsyncMock()
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", save_mock):
# Handler catches RuntimeError, yields a pipeline result first, then error chunk
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
data = json.loads(filtered[0])
assert data["metadata"]["code"] == "PROCESSING_ERROR"
save_mock.assert_called_once()
@pytest.mark.asyncio
async def test_rate_limit_error_yields_code(self, mock_request):
"""RateLimitError (429) yields LLM_RATE_LIMITED, not guardrails card."""
from openai import RateLimitError
from ss_tools.agent.app import agent_handler
def mock_astream(*_args, **_kwargs):
raise RateLimitError(
"429 quota exceeded",
response=MagicMock(status_code=429),
body={"error": {"message": "quota exceeded"}},
)
agent = MagicMock()
agent.astream_events = mock_astream
save_mock = AsyncMock()
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", save_mock):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
data = json.loads(filtered[0])
assert data["metadata"]["code"] == "LLM_RATE_LIMITED"
assert "Превышен лимит" in data["content"]
# save_conversation should be called even on error
save_mock.assert_called_once()
@pytest.mark.asyncio
async def test_generic_exception_with_llm_keyword_yields_error_not_guardrails(self, mock_request):
"""Generic exception containing 'quota' keyword yields error, not confirmation."""
from ss_tools.agent.app import agent_handler
def mock_astream(*_args, **_kwargs):
raise RuntimeError("All codex accounts reached configured quota threshold")
agent = MagicMock()
agent.astream_events = mock_astream
# Even though aget_state may return a truthy 'next', the error
# contains LLM keywords and should skip the HITL recovery path.
agent.aget_state = AsyncMock(return_value=MagicMock(next=("tools",)))
save_mock = AsyncMock()
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", save_mock):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
data = json.loads(filtered[0])
# Must be PROCESSING_ERROR (not confirm_required)
assert data["metadata"]["code"] == "PROCESSING_ERROR"
save_mock.assert_called_once()
@pytest.mark.asyncio
async def test_invalid_jwt_passes_gracefully(self):
from jose import JWTError
from ss_tools.agent.app import agent_handler
req = MagicMock()
req.headers = {"authorization": "Bearer invalid.jwt"}
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}]
agent = _make_agent_mock(mock_event_stream)
with patch("ss_tools.agent.app.decode_token", side_effect=JWTError("invalid")), patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hi", [], req, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
@pytest.mark.asyncio
async def test_valid_user_jwt_with_audience_is_kept_for_tool_auth(self, mock_request):
from ss_tools.agent.app import agent_handler
from ss_tools.agent.context import get_user_jwt
# Create a test JWT using the same jose library the agent uses
from datetime import datetime, timedelta, timezone
from jose import jwt as jose_jwt
import os
secret = os.getenv("AUTH_SECRET_KEY", "test-secret-key-for-unit-tests")
token = jose_jwt.encode(
{"sub": "admin", "scopes": ["Admin"], "exp": datetime.now(timezone.utc) + timedelta(hours=1)},
secret,
algorithm="HS256",
)
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}]
agent = _make_agent_mock(mock_event_stream)
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hi", [], mock_request, None, None, None, token)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
assert get_user_jwt() == token
# #endregion test_agent_handler
# #region test_handle_resume [C:2] [TYPE Function]
# @BRIEF Test handle_resume LangGraph checkpoint resume (no pending confirmation).
class TestHandleResume:
@pytest.mark.asyncio
async def test_confirm_checkpoint_resume(self):
from ss_tools.agent._confirmation import handle_resume
mock_agent = MagicMock()
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
results = [r async for r in handle_resume("conv-1", "confirm")]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["result"] == "confirmed"
@pytest.mark.asyncio
async def test_deny_checkpoint_resume(self):
from ss_tools.agent._confirmation import handle_resume
mock_agent = MagicMock()
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
results = [r async for r in handle_resume("conv-1", "deny")]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["result"] == "denied"
# #endregion test_handle_resume
# #region test_save_conversation [C:2] [TYPE Function]
# @BRIEF Test save_conversation with various scenarios.
class TestSaveConversation:
@pytest.mark.asyncio
async def test_save_success(self):
from ss_tools.agent._persistence import save_conversation
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.post = AsyncMock()
await save_conversation("conv-1", "test message", "user-1")
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
@pytest.mark.asyncio
async def test_save_with_service_jwt(self):
from ss_tools.agent._persistence import save_conversation
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client, patch("ss_tools.agent._persistence.os.getenv", return_value="service-token"):
mock_client.return_value.__aenter__.return_value.post = AsyncMock()
await save_conversation("conv-1", "hello", "admin")
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
@pytest.mark.asyncio
async def test_save_failure_logged(self):
from ss_tools.agent._persistence import save_conversation
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.post.side_effect = Exception("network err")
# Should not raise
await save_conversation("conv-1", "msg", "u1")
@pytest.mark.asyncio
async def test_save_empty_title(self):
from ss_tools.agent._persistence import save_conversation
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client:
client_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = client_instance
await save_conversation("conv-1", " ", "user-1")
call_kwargs = client_instance.post.call_args[1]
# clean_title(" ") returns "Новый диалог" (Russian for "New conversation")
assert call_kwargs["json"]["title"] == "Новый диалог"
# #endregion test_save_conversation
# #region test_create_chat_interface [C:2] [TYPE Function]
# @BRIEF Test create_chat_interface returns a gr.ChatInterface.
class TestCreateChatInterface:
def test_returns_chat_interface(self):
from ss_tools.agent.app import create_chat_interface
with patch("ss_tools.agent.app.gr.ChatInterface") as mock_ci:
result = create_chat_interface()
assert result is mock_ci.return_value
# #endregion test_create_chat_interface
# #region test_health [C:2] [TYPE Function]
# @BRIEF Test health endpoint returns status ok.
class TestHealth:
@pytest.mark.asyncio
async def test_health_returns_ok(self):
from ss_tools.agent.app import health
result = await health()
assert result["status"] == "ok"
# #endregion test_health
# #region test_file_upload_parsing [C:2] [TYPE Function]
# @BRIEF Test file upload branch — parse_upload called for valid small files.
class TestFileUploadParsing:
@pytest.mark.asyncio
async def test_handler_parses_uploaded_file(self, mock_request, tmp_path):
"""Valid small file triggers parse_upload and continues to stream."""
from ss_tools.agent.app import agent_handler
test_file = tmp_path / "test.txt"
test_file.write_text("upload content for analysis")
message = {"text": "analyze", "files": [str(test_file)]}
with (
patch("ss_tools.agent.app.create_agent") as mock_create,
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
patch("ss_tools.agent.app.log_tool_event", AsyncMock()),
patch("ss_tools.agent.app._persist_chat_file", return_value="chat_uploads/test.txt"),
):
agent = _make_agent_mock([{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="result")}}])
mock_create.return_value = agent
results = [r async for r in agent_handler(message, [], mock_request, None, None)]
# Now: file_uploaded, pipeline_result, stream_token (3 events)
assert len(results) >= 3
# First result is file upload metadata, third (after pipeline) is stream token
file_data = json.loads(results[0])
assert file_data["metadata"]["type"] == "file_uploaded"
token_data = json.loads(results[2])
assert token_data["metadata"]["type"] == "stream_token"
# #endregion test_file_upload_parsing
# #region test_app_main_block [C:2] [TYPE Function]
# @BRIEF Test if __name__ == '__main__' block in app.py.
class TestAppMainBlock:
def test_app_main_block(self):
"""if __name__ == '__main__' creates ChatInterface and launches."""
import importlib.util
from pathlib import Path
app_path = Path(__file__).parent.parent.parent / "src" / "ss_tools" / "agent" / "app.py"
spec = importlib.util.spec_from_file_location("__main__", str(app_path))
mock_demo = MagicMock()
with patch("gradio.ChatInterface") as mock_ci:
mock_ci.return_value = mock_demo
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
mock_demo.launch.assert_called_once()
# #endregion test_app_main_block
# #endregion Test.AgentChat.GradioApp