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:
2026-06-29 17:15:25 +03:00
parent ab3293ac0d
commit 576fff8cc6
115 changed files with 8948 additions and 1763 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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