From 131c7cdfa4527323ea13d8936d4d084a0bba89ba Mon Sep 17 00:00:00 2001 From: busya Date: Tue, 30 Jun 2026 15:21:15 +0300 Subject: [PATCH] chore: remaining pre-existing changes (storage, stream processor, tests, run.sh) --- backend/src/models/storage.py | 1 + backend/tests/test_agent/test_app.py | 115 ++++++++++++++++ .../tests/test_agent/test_langchain_tools.py | 26 ++++ .../lib/components/layout/TopNavbar.svelte | 5 + .../AgentChat.StreamProcessor.svelte.ts | 12 ++ frontend/src/lib/models/AgentChatTypes.ts | 25 +++- .../models/__tests__/AgentChatModel.test.ts | 130 +++++++++++++++++- frontend/src/lib/ui/LanguageSwitcher.svelte | 8 +- run.sh | 2 + 9 files changed, 319 insertions(+), 5 deletions(-) diff --git a/backend/src/models/storage.py b/backend/src/models/storage.py index 522cedb9..59191782 100644 --- a/backend/src/models/storage.py +++ b/backend/src/models/storage.py @@ -12,6 +12,7 @@ from pydantic import BaseModel, Field class FileCategory(str, Enum): BACKUP = "backups" REPOSITORY = "repositories" + CHAT_UPLOADS = "chat_uploads" # #endregion FileCategory # #region StorageConfig [C:1] [TYPE Class] diff --git a/backend/tests/test_agent/test_app.py b/backend/tests/test_agent/test_app.py index 97e89d85..b0524786 100644 --- a/backend/tests/test_agent/test_app.py +++ b/backend/tests/test_agent/test_app.py @@ -8,6 +8,7 @@ import sys sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) import json +import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -77,6 +78,120 @@ class TestExtractUserId: # #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_fast_confirmation_tool_detects_no_arg_read_intent(self): + from src.agent.app import _fast_confirmation_tool + + assert _fast_confirmation_tool("Какие есть окружения?") == "list_environments" + assert _fast_confirmation_tool("Покажи maintenance") == "list_maintenance_events" + assert _fast_confirmation_tool("Запусти миграцию") is None + + def test_extracts_tool_call_and_read_contract(self): + from src.agent.app 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.app import _confirmation_metadata + + state = MagicMock() + state.values = {"messages": []} + state.next = ("tools",) + + meta = _confirmation_metadata("conv-2", state, "Покажи окружения") + + assert meta["tool_name"] == "list_environments" + assert meta["risk"] == "read" + assert meta["risk_level"] == "safe" + assert meta["prompt"] == "Разрешить чтение данных?" + + def test_write_contract_for_guarded_tool(self): + from src.agent.app 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"] == "Подтвердить изменение данных?" + + def test_unknown_contract_does_not_expose_graph_node_as_tool(self): + from src.agent.app 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"] == "unknown" + assert meta["risk_level"] == "unknown" + assert meta["prompt"] == "Подтвердите действие" + + def test_fast_resume_deny_closes_without_langgraph(self): + from src.agent.app import _handle_resume, _pending_confirmations + + _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.app import _handle_resume, _pending_confirmations + + 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.app._find_tool", return_value=tool): + 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 == ["confirm_resolved", "tool_start", "tool_end", "stream_token"] + assert json.loads(chunks[-1])["content"] == '[{"id":"ss-dev"}]' +# #endregion test_confirmation_metadata + + # #region test_agent_handler [C:2] [TYPE Function] # @BRIEF Test agent_handler for various scenarios. class TestAgentHandler: diff --git a/backend/tests/test_agent/test_langchain_tools.py b/backend/tests/test_agent/test_langchain_tools.py index 567e84a8..2e2f5158 100644 --- a/backend/tests/test_agent/test_langchain_tools.py +++ b/backend/tests/test_agent/test_langchain_tools.py @@ -267,6 +267,32 @@ async def test_list_environments_calls_correct_url(): args, kwargs = call_args url = args[0] if args else kwargs.get("url", "") assert "api/settings/environments" in url + + +@pytest.mark.anyio +async def test_list_environments_redacts_sensitive_fields(): + """list_environments must not expose backend secrets to chat output.""" + from src.agent.context import set_service_jwt, set_user_jwt + from src.agent.tools import list_environments + + set_user_jwt("jwt") + set_service_jwt("svc-jwt") + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_client.return_value.__aenter__.return_value = mock_instance + mock_instance.get.return_value.status_code = 200 + mock_instance.get.return_value.text = ( + '[{"id":"prod","password":"secret-pass","api_key":"secret-key",' + '"nested":{"token":"secret-token"},"name":"ss-prod"}]' + ) + + result = await list_environments.ainvoke({}) + + assert "secret-pass" not in result + assert "secret-key" not in result + assert "secret-token" not in result + assert result.count("[redacted]") == 3 # #endregion TestAgentChat.Tools.ListEnvironments diff --git a/frontend/src/lib/components/layout/TopNavbar.svelte b/frontend/src/lib/components/layout/TopNavbar.svelte index 2dbff155..063d081a 100644 --- a/frontend/src/lib/components/layout/TopNavbar.svelte +++ b/frontend/src/lib/components/layout/TopNavbar.svelte @@ -414,6 +414,9 @@ class="global-search-container relative flex-1 max-w-xl mx-4 hidden md:block" > 0}