# #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 src.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 src.agent._persistence import extract_user_id with patch("src.core.auth.jwt.decode_token", return_value={"sub": "user-1"}): assert extract_user_id("fake-jwt") == "user-1" def test_extracts_user_id_fallback(self): from src.agent._persistence import extract_user_id with patch("src.core.auth.jwt.decode_token", return_value={"user_id": "user-2"}): assert extract_user_id("fake-jwt") == "user-2" def test_returns_unknown_on_exception(self): from src.agent._persistence import extract_user_id with patch("src.core.auth.jwt.decode_token", side_effect=Exception("bad token")): assert extract_user_id("bad") == "unknown" def test_returns_unknown_on_empty(self): from src.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 src.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 src.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 src.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 src.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 src.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 src.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("src.agent._confirmation.find_tool", return_value=tool), \ patch("src.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 src.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 src.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 src.agent.app import agent_handler # handle_resume is imported into app.py from _confirmation with patch("src.agent.app.handle_resume") as mock_resume: mock_resume.return_value = _make_async_iter([ json.dumps({"content": "confirmed", "metadata": {"type": "confirm_resolved"}}) ]) with patch("src.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 src.agent.app import agent_handler with patch("src.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("src.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 src.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("src.agent.app.create_agent", return_value=agent), \ patch("src.agent.app.get_all_tools", return_value=[]), \ patch("src.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 src.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("src.agent.app.create_agent", return_value=agent), \ patch("src.agent.app.get_all_tools", return_value=[]), \ patch("src.agent.app.prefetch_dashboards", AsyncMock(return_value="Available dashboards\n- USA Births Names (id: 15)")), \ patch("src.agent.app.generate_llm_title", AsyncMock()), \ patch("src.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 src.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("src.agent.app.create_agent", return_value=agent), \ patch("src.agent.app.get_all_tools", return_value=[]), \ patch("src.agent.app.save_conversation", AsyncMock()), \ patch("src.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 src.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("src.agent.app.create_agent", return_value=agent), \ patch("src.agent.app.get_all_tools", return_value=[]), \ patch("src.agent.app.save_conversation", AsyncMock()), \ patch("src.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 src.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("src.agent.app.create_agent", return_value=agent), \ patch("src.agent.app.get_all_tools", return_value=[]), \ patch("src.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 src.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("src.agent.app.create_agent", return_value=agent), \ patch("src.agent.app.get_all_tools", return_value=[]), \ patch("src.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 src.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("src.agent.app.create_agent", return_value=agent), \ patch("src.agent.app.get_all_tools", return_value=[]), \ patch("src.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 src.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("src.agent.app.create_agent", return_value=agent), \ patch("src.agent.app.get_all_tools", return_value=[]), \ patch("src.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 src.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("src.agent.app.create_agent", return_value=agent), \ patch("src.agent.app.get_all_tools", return_value=[]), \ patch("src.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 src.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("src.agent.app.decode_token", side_effect=JWTError("invalid")), \ patch("src.agent.app.create_agent", return_value=agent), \ patch("src.agent.app.get_all_tools", return_value=[]), \ patch("src.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 src.agent.app import agent_handler from src.agent.context import get_user_jwt from src.core.auth.jwt import create_access_token token = create_access_token({"sub": "admin", "scopes": ["Admin"]}) mock_event_stream = [ {"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}} ] agent = _make_agent_mock(mock_event_stream) with patch("src.agent.app.create_agent", return_value=agent), \ patch("src.agent.app.get_all_tools", return_value=[]), \ patch("src.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 src.agent._confirmation import handle_resume mock_agent = MagicMock() with patch("src.agent._confirmation.create_agent", return_value=mock_agent), \ patch("src.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 src.agent._confirmation import handle_resume mock_agent = MagicMock() with patch("src.agent._confirmation.create_agent", return_value=mock_agent), \ patch("src.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 src.agent._persistence import save_conversation with patch("src.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 src.agent._persistence import save_conversation with patch("src.agent._persistence.httpx.AsyncClient") as mock_client, \ patch("src.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 src.agent._persistence import save_conversation with patch("src.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 src.agent._persistence import save_conversation with patch("src.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 src.agent.app import create_chat_interface with patch("src.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 src.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 src.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('src.agent.app.create_agent') as mock_create, \ patch('src.agent.app.get_all_tools', return_value=[]), \ patch('src.agent.app.save_conversation', AsyncMock()), \ patch('src.agent.app.log_tool_event', AsyncMock()), \ patch('src.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" / "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