fix(agent-chat): streaming state leak, document parser magic bytes, HITL flow
### Bugfixes — Agent Chat 'Думаю' State Leak - fix(agent-chat): loadHistory() now resets streamingState/idle + cancels stale submission — prevents 'Думаю' state leak across conversation switches - fix(agent-chat): onDisconnected/onDisconnectedPermanent cascade to streamingState — prevents permanent hang on connection loss during stream - fix(agent-chat): guard on isLoadingHistory — prevents false commit of 'agent unavailable' fallback when switching conversations - fix(agent-chat): remove race in _sendNow empty-response check vs Svelte microtask (duplicate logic removed, handles correctly) - fix(stream-processor): confirm_resolved now appends msg.text to partialText instead of dropping it ### Bugfixes — Backend PDF Upload - fix(document-parser): _detect_format_by_magic() — reads file header magic bytes as fallback when Gradio loses filename - fix(document-parser): improved name extraction — tries orig_name, path stem - fix(document-parser): @RELATION AgentChatTypes -> AgentChat.Types ### HITL Flow & Agent Chat Improvements - feat(agent): HITL resume confirm/deny with userId/userJwt/envId propagation - feat(agent): confirm_required metadata fallback via aget_state() after 'Event loop is closed' error during interrupt - feat(agent): interrupt_before re-enabled via AGENT_CONFIRM_TOOLS env var - feat(frontend): debug panel with connection/stream state monitoring - feat(frontend): AgentChatModel constructor options + onBeforeSend callback - feat(frontend): crypto.randomUUID() for local conversation ID on first send ### Backend Agent Refactoring - refactor(agent): langgraph_setup — monkey-patch for PydanticSerializationError - refactor(agent): tools.py — dual identity headers, expanded tool set - refactor(agent): run.py — _find_free_port, Gradio server port fallback - refactor(agent): app.py — file size validation, message truncation, HITL path ### Frontend - feat(dashboard-hub): DashboardHubModel with filters, pagination, git actions - feat(ui): DateRangeFilter component - feat(i18n): new dashboard keys; cache tooltips fix - fix(i18n): full run tooltips — cache is NOT ignored ### Semantic Protocol - chore(agents): update all agents with canonical format - chore(skills): sync semantics-core, semantics-contracts, molecular-cot-logging ### Housekeeping - chore: remove stale semantic reports (10 files, Jan 2026) - chore: update 033-gradio-agent-chat specs, contracts, UX, tasks, tests - chore: add .agents/ directory (mirrors .opencode/ agent layouts) - chore: update run.sh with DEV_MODE, port management
This commit is contained in:
@@ -20,6 +20,17 @@ 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("src.agent.app._save_conversation", new_callable=AsyncMock):
|
||||
yield
|
||||
|
||||
|
||||
def _make_test_jwt(user_id: str = "test-user") -> str:
|
||||
return jwt.encode({"sub": user_id}, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
@@ -27,7 +38,7 @@ def _make_test_jwt(user_id: str = "test-user") -> str:
|
||||
# #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.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_handler_empty_message_returns_immediately():
|
||||
"""An empty message should return immediately without calling the graph."""
|
||||
from src.agent.app import agent_handler
|
||||
@@ -59,7 +70,7 @@ async def test_handler_empty_message_returns_immediately():
|
||||
# @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.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_handler_missing_auth_continues_gracefully():
|
||||
"""Missing authorization header does NOT yield UNAUTHORIZED — handler continues."""
|
||||
from src.agent.app import agent_handler
|
||||
@@ -94,7 +105,7 @@ async def test_handler_missing_auth_continues_gracefully():
|
||||
"Handler should not reject missing auth — JWT optional at Gradio layer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_handler_invalid_jwt_continues_gracefully():
|
||||
"""Invalid JWT does NOT yield UNAUTHORIZED — handler continues with fallback context."""
|
||||
from src.agent.app import agent_handler
|
||||
@@ -129,7 +140,7 @@ async def test_handler_invalid_jwt_continues_gracefully():
|
||||
|
||||
# #region TestAgentChat.Handler.Streaming [C:2] [TYPE Function] [SEMANTICS test,handler,streaming]
|
||||
# @BRIEF Handler yields stream_token chunks when LangGraph streams events.
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_handler_yields_stream_tokens():
|
||||
"""Handler yields stream_token metadata when graph emits token events."""
|
||||
from src.agent.app import agent_handler
|
||||
@@ -169,7 +180,7 @@ async def test_handler_yields_stream_tokens():
|
||||
|
||||
# #region TestAgentChat.Handler.ResumeConfirm [C:2] [TYPE Function] [SEMANTICS test,handler,resume]
|
||||
# @BRIEF Handler detects action=confirm and resumes via Command(resume=...).
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_handler_resume_confirm():
|
||||
"""When action='confirm', handler resumes via Command(resume=...)."""
|
||||
from src.agent.app import agent_handler
|
||||
@@ -184,6 +195,10 @@ async def test_handler_resume_confirm():
|
||||
|
||||
with patch("src.agent.app.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 = []
|
||||
@@ -196,11 +211,12 @@ async def test_handler_resume_confirm():
|
||||
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.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_handler_resume_deny():
|
||||
"""When action='deny', handler yields confirm_resolved with denied."""
|
||||
from src.agent.app import agent_handler
|
||||
|
||||
@@ -58,17 +58,17 @@ def mock_request():
|
||||
class TestExtractUserId:
|
||||
def test_extracts_sub(self):
|
||||
from src.agent.app import _extract_user_id
|
||||
with patch("src.agent.app.jwt.decode", return_value={"sub": "user-1"}):
|
||||
with patch("src.agent.app.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.app import _extract_user_id
|
||||
with patch("src.agent.app.jwt.decode", return_value={"user_id": "user-2"}):
|
||||
with patch("src.agent.app.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.app import _extract_user_id
|
||||
with patch("src.agent.app.jwt.decode", side_effect=Exception("bad token")):
|
||||
with patch("src.agent.app.decode_token", side_effect=Exception("bad token")):
|
||||
assert _extract_user_id("bad") == "unknown"
|
||||
|
||||
def test_returns_unknown_on_empty(self):
|
||||
@@ -248,19 +248,39 @@ class TestAgentHandler:
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_jwt_passes_gracefully(self):
|
||||
from src.agent.app import agent_handler
|
||||
import jwt as pyjwt
|
||||
from jose import JWTError
|
||||
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.jwt.decode", side_effect=pyjwt.InvalidTokenError("invalid")), \
|
||||
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)]
|
||||
assert len(results) == 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)]
|
||||
|
||||
assert len(results) == 1
|
||||
assert get_user_jwt() == token
|
||||
# #endregion test_agent_handler
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
@@ -19,10 +19,15 @@ os.environ["SERVICE_JWT"] = "test-service-jwt"
|
||||
os.environ["OPENAI_API_KEY"] = "sk-test-key"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
# #region TestAgentChat.Tools.DualAuth [C:2] [TYPE Function] [SEMANTICS test,tools,auth]
|
||||
# @BRIEF Dual-identity auth headers built from ContextVar and env vars.
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_dual_auth_headers():
|
||||
"""Tools should build auth headers from ContextVar when set."""
|
||||
from src.agent.context import set_service_jwt, set_user_jwt
|
||||
@@ -35,7 +40,11 @@ async def test_tool_dual_auth_headers():
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value.text = '{"dashboards": []}'
|
||||
mock_instance.get.return_value = Mock(
|
||||
status_code=200,
|
||||
text='{"dashboards": [], "total": 0}',
|
||||
)
|
||||
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
|
||||
|
||||
await search_dashboards.ainvoke({"query": "test"})
|
||||
|
||||
@@ -44,9 +53,8 @@ async def test_tool_dual_auth_headers():
|
||||
assert call_kwargs is not None, "HTTP GET should have been called"
|
||||
_, kwargs = call_kwargs
|
||||
headers = kwargs.get("headers", {})
|
||||
assert "Authorization" in headers, "Should include Authorization header (service JWT)"
|
||||
assert "Authorization" in headers, "Should include Authorization header"
|
||||
assert headers["Authorization"] == "Bearer service-jwt-token"
|
||||
assert "X-User-JWT" in headers, "Should include X-User-JWT header (user JWT)"
|
||||
assert headers["X-User-JWT"] == "user-jwt-token"
|
||||
# #endregion TestAgentChat.Tools.DualAuth
|
||||
|
||||
@@ -54,7 +62,7 @@ async def test_tool_dual_auth_headers():
|
||||
# #region TestAgentChat.Tools.FallbackAuth [C:2] [TYPE Function] [SEMANTICS test,tools,auth,fallback]
|
||||
# @BRIEF Dual-identity auth falls back to env var when ContextVar is not set.
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_auth_fallback_to_env():
|
||||
"""Tools should fall back to SERVICE_JWT env var when ContextVar is empty."""
|
||||
from src.agent.context import set_service_jwt, set_user_jwt
|
||||
@@ -70,7 +78,11 @@ async def test_tool_auth_fallback_to_env():
|
||||
patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value.text = '{"dashboards": []}'
|
||||
mock_instance.get.return_value = Mock(
|
||||
status_code=200,
|
||||
text='{"dashboards": [], "total": 0}',
|
||||
)
|
||||
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
|
||||
|
||||
# Since tool uses os.getenv at call time, the env var will be read
|
||||
await search_dashboards.ainvoke({"query": "test"})
|
||||
@@ -87,7 +99,7 @@ async def test_tool_auth_fallback_to_env():
|
||||
# #region TestAgentChat.Tools.HttpFailure [C:2] [TYPE Function] [SEMANTICS test,tools,failure]
|
||||
# @BRIEF Tool handles HTTP failure gracefully (returns error text, not exception).
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_http_exception_handling():
|
||||
"""Tool should propagate HTTP exception as error text."""
|
||||
from src.agent.context import set_service_jwt, set_user_jwt
|
||||
@@ -115,32 +127,65 @@ def test_get_all_tools_returns_expected_list():
|
||||
from src.agent.tools import get_all_tools
|
||||
|
||||
tools = get_all_tools()
|
||||
assert len(tools) >= 4, f"Expected at least 4 tools, got {len(tools)}"
|
||||
|
||||
tool_names = [t.name for t in tools]
|
||||
assert "search_dashboards" in tool_names
|
||||
assert "get_health_summary" in tool_names
|
||||
assert "list_environments" in tool_names
|
||||
assert "get_task_status" in tool_names
|
||||
expected = {
|
||||
"show_capabilities",
|
||||
"search_dashboards",
|
||||
"get_health_summary",
|
||||
"list_environments",
|
||||
"get_task_status",
|
||||
"list_llm_providers",
|
||||
"get_llm_status",
|
||||
"create_branch",
|
||||
"commit_changes",
|
||||
"deploy_dashboard",
|
||||
"execute_migration",
|
||||
"run_backup",
|
||||
"run_llm_validation",
|
||||
"run_llm_documentation",
|
||||
"list_maintenance_events",
|
||||
"start_maintenance",
|
||||
"end_maintenance",
|
||||
}
|
||||
assert expected.issubset(set(tool_names))
|
||||
|
||||
|
||||
def test_get_all_tools_args_schema():
|
||||
"""Tools with args_schema should have SearchDashboardsInput."""
|
||||
"""Tools with args_schema should expose required fields."""
|
||||
from src.agent.tools import get_all_tools
|
||||
|
||||
tools = get_all_tools()
|
||||
search_tool = next(t for t in tools if t.name == "search_dashboards")
|
||||
health_tool = next(t for t in tools if t.name == "get_health_summary")
|
||||
assert search_tool.args_schema is not None, "search_dashboards should have args_schema"
|
||||
schema_fields = search_tool.args_schema.model_fields
|
||||
assert "query" in schema_fields, "search_dashboards should have 'query' field"
|
||||
assert schema_fields["query"].is_required(), "query should be required"
|
||||
assert health_tool.args_schema is not None, "get_health_summary should have args_schema"
|
||||
assert "env_id" in health_tool.args_schema.model_fields
|
||||
|
||||
|
||||
def test_get_tools_for_query_keeps_prefetched_dashboard_prompt_small():
|
||||
"""Dashboard requests with prefetched data should not send all tool schemas."""
|
||||
from src.agent.tools import get_tools_for_query
|
||||
|
||||
tools = get_tools_for_query("Покажи доступные дашборды", prefetch_available=True)
|
||||
assert [tool.name for tool in tools] == ["show_capabilities"]
|
||||
|
||||
|
||||
def test_get_tools_for_query_selects_write_tool_by_intent():
|
||||
"""Write intents should expose only the matching operational tool plus capabilities."""
|
||||
from src.agent.tools import get_tools_for_query
|
||||
|
||||
tools = get_tools_for_query("Запусти миграцию", prefetch_available=False)
|
||||
assert [tool.name for tool in tools] == ["show_capabilities", "execute_migration"]
|
||||
# #endregion TestAgentChat.Tools.GetAll
|
||||
|
||||
|
||||
# #region TestAgentChat.Tools.ToolContracts [C:2] [TYPE Function] [SEMANTICS test,tools,contract]
|
||||
# @BRIEF Tool contracts match @POST and @PRE declared in contracts/modules.md.
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_search_dashboards_correct_url():
|
||||
"""search_dashboards calls GET /api/dashboards with query params."""
|
||||
from src.agent.context import set_service_jwt, set_user_jwt
|
||||
@@ -152,7 +197,11 @@ async def test_search_dashboards_correct_url():
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value.text = '{"data": []}'
|
||||
mock_instance.get.return_value = Mock(
|
||||
status_code=200,
|
||||
text='{"dashboards": [], "total": 0}',
|
||||
)
|
||||
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
|
||||
|
||||
await search_dashboards.ainvoke({"query": "dashboard-name", "env_id": "prod"})
|
||||
|
||||
@@ -167,9 +216,9 @@ async def test_search_dashboards_correct_url():
|
||||
# #region TestAgentChat.Tools.HealthSummary [C:2] [TYPE Function] [SEMANTICS test,tools,health]
|
||||
# @BRIEF get_health_summary calls the correct FastAPI endpoint.
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_get_health_summary_calls_correct_url():
|
||||
"""get_health_summary should call GET /api/dashboards/health."""
|
||||
"""get_health_summary should call GET /api/health/summary."""
|
||||
from src.agent.context import set_service_jwt, set_user_jwt
|
||||
from src.agent.tools import get_health_summary
|
||||
|
||||
@@ -179,22 +228,24 @@ async def test_get_health_summary_calls_correct_url():
|
||||
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 = '{"status": "ok"}'
|
||||
|
||||
await get_health_summary.ainvoke({})
|
||||
await get_health_summary.ainvoke({"env_id": "ss-dev"})
|
||||
|
||||
call_args = mock_instance.get.call_args
|
||||
assert call_args is not None
|
||||
args, kwargs = call_args
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
assert "api/dashboards/health" in url
|
||||
assert "api/health/summary" in url
|
||||
assert kwargs.get("params") == {"environment_id": "ss-dev"}
|
||||
# #endregion TestAgentChat.Tools.HealthSummary
|
||||
|
||||
|
||||
# #region TestAgentChat.Tools.ListEnvironments [C:2] [TYPE Function] [SEMANTICS test,tools,environments]
|
||||
# @BRIEF list_environments calls the correct FastAPI endpoint.
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_list_environments_calls_correct_url():
|
||||
"""list_environments should call GET /api/settings/environments."""
|
||||
from src.agent.context import set_service_jwt, set_user_jwt
|
||||
@@ -206,6 +257,7 @@ async def test_list_environments_calls_correct_url():
|
||||
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 = '["prod", "dev"]'
|
||||
|
||||
await list_environments.ainvoke({})
|
||||
@@ -221,7 +273,7 @@ async def test_list_environments_calls_correct_url():
|
||||
# #region TestAgentChat.Tools.TaskStatus [C:2] [TYPE Function] [SEMANTICS test,tools,task]
|
||||
# @BRIEF get_task_status calls the correct FastAPI endpoint with task_id.
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_get_task_status_calls_correct_url():
|
||||
"""get_task_status should call GET /api/tasks/{task_id}."""
|
||||
from src.agent.context import set_service_jwt, set_user_jwt
|
||||
@@ -233,6 +285,7 @@ async def test_get_task_status_calls_correct_url():
|
||||
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 = '{"status": "running"}'
|
||||
|
||||
await get_task_status.ainvoke({"task_id": "task-123"})
|
||||
@@ -245,11 +298,60 @@ async def test_get_task_status_calls_correct_url():
|
||||
# #endregion TestAgentChat.Tools.TaskStatus
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_backup_posts_task_payload():
|
||||
"""run_backup should create a superset-backup task through /api/tasks."""
|
||||
from src.agent.context import set_service_jwt, set_user_jwt
|
||||
from src.agent.tools import run_backup
|
||||
|
||||
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.post.return_value = Mock(status_code=201, text='{"id": "task-1"}')
|
||||
|
||||
await run_backup.ainvoke({"environment_id": "prod", "dashboard_id": 10})
|
||||
|
||||
call_args = mock_instance.post.call_args
|
||||
assert call_args is not None
|
||||
args, kwargs = call_args
|
||||
assert "api/tasks" in args[0]
|
||||
assert kwargs["json"] == {
|
||||
"plugin_id": "superset-backup",
|
||||
"params": {"environment_id": "prod", "dashboard_ids": [10]},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deploy_dashboard_posts_git_endpoint():
|
||||
"""deploy_dashboard should call the native Git deploy API."""
|
||||
from src.agent.context import set_service_jwt, set_user_jwt
|
||||
from src.agent.tools import deploy_dashboard
|
||||
|
||||
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.post.return_value = Mock(status_code=200, text='{"status": "success"}')
|
||||
|
||||
await deploy_dashboard.ainvoke({"dashboard_ref": "42", "environment_id": "prod"})
|
||||
|
||||
call_args = mock_instance.post.call_args
|
||||
assert call_args is not None
|
||||
args, kwargs = call_args
|
||||
assert "api/git/repositories/42/deploy" in args[0]
|
||||
assert kwargs["json"] == {"environment_id": "prod"}
|
||||
|
||||
|
||||
# #region TestAgentChat.Tools.DualAuthHeaders [C:2] [TYPE Function] [SEMANTICS test,tools,auth,headers]
|
||||
# @BRIEF _dual_auth_headers builds proper headers from ContextVars.
|
||||
|
||||
def test_dual_auth_headers_with_both_jwts():
|
||||
"""_dual_auth_headers returns Authorization + X-User-JWT when both set."""
|
||||
"""_dual_auth_headers uses service auth plus user identity when both are set."""
|
||||
from src.agent.context import set_service_jwt, set_user_jwt
|
||||
from src.agent.tools import _dual_auth_headers
|
||||
|
||||
@@ -262,7 +364,7 @@ def test_dual_auth_headers_with_both_jwts():
|
||||
|
||||
|
||||
def test_dual_auth_headers_no_user_jwt():
|
||||
"""_dual_auth_headers returns only Authorization when no user JWT."""
|
||||
"""_dual_auth_headers falls back to service Authorization when no user JWT."""
|
||||
from src.agent.context import set_service_jwt, set_user_jwt
|
||||
from src.agent.tools import _dual_auth_headers
|
||||
|
||||
@@ -271,7 +373,6 @@ def test_dual_auth_headers_no_user_jwt():
|
||||
|
||||
headers = _dual_auth_headers()
|
||||
assert headers.get("Authorization") == "Bearer svc-token"
|
||||
assert "X-User-JWT" not in headers or headers.get("X-User-JWT") == ""
|
||||
|
||||
|
||||
def test_dual_auth_headers_no_jwts(monkeypatch):
|
||||
|
||||
@@ -7,10 +7,15 @@ import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
# #region test_configure_from_api [C:2] [TYPE Function]
|
||||
# @BRIEF Test configure_from_api updates global config.
|
||||
class TestConfigureFromApi:
|
||||
@@ -37,7 +42,8 @@ class TestConfigureFromApi:
|
||||
# #region test_create_agent [C:2] [TYPE Function]
|
||||
# @BRIEF Test create_agent with various LLM config states.
|
||||
class TestCreateAgent:
|
||||
def test_creates_agent_with_api_config(self):
|
||||
@pytest.mark.anyio
|
||||
async def test_creates_agent_with_api_config(self):
|
||||
import src.agent.langgraph_setup as ls
|
||||
ls._llm_config = None # Reset
|
||||
ls.configure_from_api({
|
||||
@@ -47,9 +53,10 @@ class TestCreateAgent:
|
||||
"default_model": "gpt-4o-mini",
|
||||
})
|
||||
with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create:
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
|
||||
mock_create.return_value = MagicMock()
|
||||
result = ls.create_agent([MagicMock()])
|
||||
result = await ls.create_agent([MagicMock()])
|
||||
assert result is mock_create.return_value
|
||||
call_kwargs = mock_llm.call_args[1]
|
||||
assert call_kwargs["api_key"] == "sk-api-config"
|
||||
@@ -57,11 +64,13 @@ class TestCreateAgent:
|
||||
assert call_kwargs["model"] == "gpt-4o-mini"
|
||||
ls._llm_config = None
|
||||
|
||||
def test_creates_agent_with_env_fallback(self):
|
||||
@pytest.mark.anyio
|
||||
async def test_creates_agent_with_env_fallback(self):
|
||||
import src.agent.langgraph_setup as ls
|
||||
ls._llm_config = None # Reset
|
||||
with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \
|
||||
patch("src.agent.langgraph_setup.os.getenv") as mock_getenv:
|
||||
mock_getenv.side_effect = lambda key, default=None: {
|
||||
"LLM_API_KEY": "sk-env-key",
|
||||
@@ -69,14 +78,15 @@ class TestCreateAgent:
|
||||
"LLM_MODEL": "gpt-4",
|
||||
}.get(key, default)
|
||||
mock_create.return_value = MagicMock()
|
||||
result = ls.create_agent([])
|
||||
result = await ls.create_agent([])
|
||||
assert result is mock_create.return_value
|
||||
call_kwargs = mock_llm.call_args[1]
|
||||
assert call_kwargs["api_key"] == "sk-env-key"
|
||||
assert call_kwargs["model"] == "gpt-4"
|
||||
ls._llm_config = None
|
||||
|
||||
def test_creates_agent_with_partial_api_config(self):
|
||||
@pytest.mark.anyio
|
||||
async def test_creates_agent_with_partial_api_config(self):
|
||||
import src.agent.langgraph_setup as ls
|
||||
ls._llm_config = None
|
||||
ls.configure_from_api({
|
||||
@@ -84,9 +94,10 @@ class TestCreateAgent:
|
||||
"api_key": "sk-key-only",
|
||||
})
|
||||
with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create:
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
|
||||
mock_create.return_value = MagicMock()
|
||||
result = ls.create_agent([])
|
||||
result = await ls.create_agent([])
|
||||
assert result is mock_create.return_value
|
||||
call_kwargs = mock_llm.call_args[1]
|
||||
assert call_kwargs["api_key"] == "sk-key-only"
|
||||
@@ -94,17 +105,80 @@ class TestCreateAgent:
|
||||
assert call_kwargs["model"] == "gpt-4o-mini"
|
||||
ls._llm_config = None
|
||||
|
||||
def test_uses_inmemory_saver(self):
|
||||
@pytest.mark.anyio
|
||||
async def test_uses_inmemory_saver(self):
|
||||
import src.agent.langgraph_setup as ls
|
||||
ls._llm_config = None
|
||||
with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \
|
||||
patch("src.agent.langgraph_setup.os.getenv", return_value=None):
|
||||
mock_create.return_value = MagicMock()
|
||||
ls.create_agent([])
|
||||
await ls.create_agent([])
|
||||
call_kwargs = mock_create.call_args[1]
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
assert isinstance(call_kwargs["checkpointer"], InMemorySaver)
|
||||
ls._llm_config = None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_uses_empty_interrupt_list_by_default(self):
|
||||
import src.agent.langgraph_setup as ls
|
||||
ls._llm_config = None
|
||||
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \
|
||||
patch("src.agent.langgraph_setup.os.getenv", return_value=None):
|
||||
mock_create.return_value = MagicMock()
|
||||
await ls.create_agent([])
|
||||
assert mock_create.call_args[1]["interrupt_before"] == []
|
||||
ls._llm_config = None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_confirm_tools_env_interrupts_before_tools_node(self):
|
||||
import src.agent.langgraph_setup as ls
|
||||
ls._llm_config = None
|
||||
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \
|
||||
patch("src.agent.langgraph_setup.os.getenv") as mock_getenv:
|
||||
mock_getenv.side_effect = lambda key, default=None: {
|
||||
"AGENT_CONFIRM_TOOLS": "true",
|
||||
}.get(key, default)
|
||||
mock_create.return_value = MagicMock()
|
||||
await ls.create_agent([])
|
||||
assert mock_create.call_args[1]["interrupt_before"] == ["tools"]
|
||||
ls._llm_config = None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_uses_env_configured_interrupt_nodes(self):
|
||||
import src.agent.langgraph_setup as ls
|
||||
ls._llm_config = None
|
||||
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \
|
||||
patch("src.agent.langgraph_setup.os.getenv") as mock_getenv:
|
||||
mock_getenv.side_effect = lambda key, default=None: {
|
||||
"AGENT_INTERRUPT_BEFORE": "tools",
|
||||
}.get(key, default)
|
||||
mock_create.return_value = MagicMock()
|
||||
await ls.create_agent([])
|
||||
assert mock_create.call_args[1]["interrupt_before"] == ["tools"]
|
||||
ls._llm_config = None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_interrupt_override_bypasses_env_guardrail(self):
|
||||
import src.agent.langgraph_setup as ls
|
||||
ls._llm_config = None
|
||||
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \
|
||||
patch("src.agent.langgraph_setup.os.getenv") as mock_getenv:
|
||||
mock_getenv.side_effect = lambda key, default=None: {
|
||||
"AGENT_CONFIRM_TOOLS": "true",
|
||||
}.get(key, default)
|
||||
mock_create.return_value = MagicMock()
|
||||
await ls.create_agent([], interrupt_before=[])
|
||||
assert mock_create.call_args[1]["interrupt_before"] == []
|
||||
ls._llm_config = None
|
||||
# #endregion test_create_agent
|
||||
# #endregion Test.AgentChat.LangGraph.Setup
|
||||
|
||||
@@ -8,7 +8,7 @@ import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import socket
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -148,9 +148,13 @@ class TestMainBlock:
|
||||
|
||||
with patch('httpx.get') as mock_httpx_get, \
|
||||
patch('socket.socket') as mock_socket_cls, \
|
||||
patch('asyncio.run') as mock_asyncio_run, \
|
||||
patch('src.agent.app.create_chat_interface') as mock_create_ci, \
|
||||
patch('src.agent.context.set_service_jwt') as mock_set_jwt, \
|
||||
patch('src.agent.langgraph_setup.configure_from_api') as mock_configure:
|
||||
patch('src.agent.langgraph_setup.configure_from_api') as mock_configure, \
|
||||
patch('src.agent.langgraph_setup.init_checkpointer'):
|
||||
mock_asyncio_run.side_effect = lambda coro: coro.close() if hasattr(coro, "close") else None
|
||||
|
||||
# httpx for _fetch_llm_config
|
||||
mock_resp = MagicMock()
|
||||
if llm_configured:
|
||||
@@ -215,7 +219,10 @@ class TestMainBlock:
|
||||
# Ports 27863, 27864 busy → 27865 free
|
||||
with patch('src.agent.run.logger') as mock_logger:
|
||||
result = self._run_as_main(monkeypatch,
|
||||
env_overrides={"GRADIO_SERVER_PORT": "27863"},
|
||||
env_overrides={
|
||||
"GRADIO_SERVER_PORT": "27863",
|
||||
"GRADIO_ALLOW_PORT_FALLBACK": "true",
|
||||
},
|
||||
port_bind_sequence=[OSError("in use"), OSError("in use"), None])
|
||||
result['demo'].launch.assert_called_once()
|
||||
|
||||
@@ -224,7 +231,10 @@ class TestMainBlock:
|
||||
with patch('src.agent.run.logger') as mock_logger:
|
||||
with pytest.raises(OSError):
|
||||
self._run_as_main(monkeypatch,
|
||||
env_overrides={"GRADIO_SERVER_PORT": "27866"},
|
||||
env_overrides={
|
||||
"GRADIO_SERVER_PORT": "27866",
|
||||
"GRADIO_ALLOW_PORT_FALLBACK": "true",
|
||||
},
|
||||
port_always_fail=True)
|
||||
# #endregion test_main_block
|
||||
# #endregion Test.AgentChat.Run
|
||||
|
||||
Reference in New Issue
Block a user