Files
ss-tools/backend/tests/test_agent/test_run.py
busya 12678c637b 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
2026-06-29 17:15:25 +03:00

241 lines
11 KiB
Python

# #region Test.AgentChat.Run [C:3] [TYPE Module] [SEMANTICS test,agent,run,entrypoint]
# @BRIEF Tests for agent/run.py — _find_free_port and _fetch_llm_config.
# @RELATION BINDS_TO -> [AgentChat.Run]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import socket
from unittest.mock import MagicMock, patch
import pytest
# #region test_find_free_port [C:2] [TYPE Function]
# @BRIEF Test _find_free_port for port scanning behavior.
class TestFindFreePort:
def test_returns_free_port(self):
from src.agent.run import _find_free_port
with patch("socket.socket") as mock_socket:
mock_instance = MagicMock()
mock_socket.return_value.__enter__.return_value = mock_instance
result = _find_free_port(8000, 10)
assert result == 8000
mock_instance.bind.assert_called_once_with(("", 8000))
def test_skips_busy_ports(self):
from src.agent.run import _find_free_port
with patch("socket.socket") as mock_socket:
mock_instance = MagicMock()
mock_socket.return_value.__enter__.return_value = mock_instance
# Ports 8000-8002 busy, 8003 free
mock_instance.bind.side_effect = [
OSError("Address in use"), # 8000
OSError("Address in use"), # 8001
OSError("Address in use"), # 8002
None, # 8003 — success
]
result = _find_free_port(8000, 10)
assert result == 8003
assert mock_instance.bind.call_count == 4
def test_raises_when_all_busy(self):
from src.agent.run import _find_free_port
with patch("socket.socket") as mock_socket:
mock_instance = MagicMock()
mock_socket.return_value.__enter__.return_value = mock_instance
mock_instance.bind.side_effect = OSError("Address in use")
with pytest.raises(OSError, match="No free port found"):
_find_free_port(8000, 3)
assert mock_instance.bind.call_count == 3
# #endregion test_find_free_port
# #region test_fetch_llm_config [C:2] [TYPE Function]
# @BRIEF Test _fetch_llm_config with retry and fallback behavior.
class TestFetchLlmConfig:
def test_returns_config_on_success(self):
from src.agent.run import _fetch_llm_config
with patch("src.agent.run.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {"configured": True, "provider_type": "openai", "default_model": "gpt-4o"}
mock_get.return_value = mock_response
result = _fetch_llm_config()
assert result is not None
assert result["configured"] is True
def test_returns_none_when_not_configured(self):
from src.agent.run import _fetch_llm_config
with patch("src.agent.run.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {"configured": False, "reason": "no provider"}
mock_get.return_value = mock_response
result = _fetch_llm_config()
assert result is None
def test_retries_on_failure(self):
from src.agent.run import _fetch_llm_config
import time as time_module
with patch("src.agent.run.httpx.get") as mock_get, \
patch.object(time_module, "sleep") as mock_sleep:
mock_get.side_effect = Exception("Connection refused")
result = _fetch_llm_config()
assert result is None
assert mock_get.call_count == 6
def test_retries_then_returns_config(self):
from src.agent.run import _fetch_llm_config
import time as time_module
with patch("src.agent.run.httpx.get") as mock_get, \
patch.object(time_module, "sleep") as mock_sleep:
mock_get.side_effect = [
Exception("Timeout"), # Attempt 1
Exception("Timeout"), # Attempt 2
MagicMock(json=lambda: {"configured": True, "provider_type": "openai"}), # Attempt 3
]
result = _fetch_llm_config()
assert result is not None
assert result["configured"] is True
def test_returns_none_after_max_retries_with_http_error(self):
from src.agent.run import _fetch_llm_config
import time as time_module
with patch("src.agent.run.httpx.get") as mock_get, \
patch.object(time_module, "sleep") as mock_sleep:
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
mock_get.return_value = mock_response
result = _fetch_llm_config()
assert result is None
assert mock_get.call_count == 6
def test_uses_service_token_header(self):
from src.agent.run import _fetch_llm_config
with patch("src.agent.run.httpx.get") as mock_get, \
patch("src.agent.run.os.getenv", return_value="test-token"):
mock_response = MagicMock()
mock_response.json.return_value = {"configured": True}
mock_get.return_value = mock_response
result = _fetch_llm_config()
assert result is not None
# Verify Authorization header was sent
call_kwargs = mock_get.call_args[1]
assert call_kwargs["headers"].get("Authorization") == "Bearer test-token"
# #endregion test_fetch_llm_config
# #region test_main_block [C:2] [TYPE Function]
# @BRIEF Test if __name__ == '__main__' block — service JWT, LLM config, port fallback, OSError.
class TestMainBlock:
"""Test the if __name__ == '__main__' entry point block via importlib.util fresh module."""
def _run_as_main(self, monkeypatch, env_overrides=None, llm_configured=False,
port_bind_sequence=None, port_always_fail=False):
"""Execute run.py as __main__ with given mocking configuration."""
import importlib.util
from pathlib import Path
run_path = Path(__file__).parent.parent.parent / "src" / "agent" / "run.py"
spec = importlib.util.spec_from_file_location("__main__", str(run_path))
# Apply env overrides
env_overrides = env_overrides or {}
for k, v in env_overrides.items():
monkeypatch.setenv(k, v)
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.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:
mock_resp.json.return_value = {
"configured": True, "provider_type": "openai",
"default_model": "gpt-4o", "api_key": "sk-test",
}
else:
mock_resp.json.return_value = {"configured": False}
mock_httpx_get.return_value = mock_resp
# socket for _find_free_port
mock_sock = MagicMock()
mock_sock.__enter__.return_value = mock_sock
mock_socket_cls.return_value = mock_sock
if port_always_fail:
mock_sock.bind.side_effect = OSError("all ports busy")
elif port_bind_sequence is not None:
mock_sock.bind.side_effect = port_bind_sequence
else:
mock_sock.bind.side_effect = [None] # first bind succeeds
mock_demo = MagicMock()
mock_create_ci.return_value = mock_demo
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return {
'set_jwt': mock_set_jwt,
'configure': mock_configure,
'demo': mock_demo,
}
def test_main_block_basic(self, monkeypatch):
"""Main block with default env, no SERVICE_JWT, no LLM config."""
# Ensure SERVICE_JWT is NOT set (some tests leak it via os.environ)
monkeypatch.delenv("SERVICE_JWT", raising=False)
result = self._run_as_main(monkeypatch,
env_overrides={"GRADIO_SERVER_PORT": "27860"})
result['set_jwt'].assert_not_called()
result['configure'].assert_not_called()
result['demo'].launch.assert_called_once()
def test_main_block_with_service_jwt(self, monkeypatch):
"""Main block sets service JWT via ContextVar."""
result = self._run_as_main(monkeypatch, env_overrides={
"SERVICE_JWT": "test-service-token",
"GRADIO_SERVER_PORT": "27861",
})
result['set_jwt'].assert_called_once_with("test-service-token")
def test_main_block_with_llm_config(self, monkeypatch):
"""Main block calls configure_from_api when LLM config is active."""
result = self._run_as_main(monkeypatch,
env_overrides={"GRADIO_SERVER_PORT": "27862"},
llm_configured=True)
result['configure'].assert_called_once()
def test_main_block_port_fallback(self, monkeypatch):
"""Port in use triggers fallback warning (logged but continues)."""
# 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",
"GRADIO_ALLOW_PORT_FALLBACK": "true",
},
port_bind_sequence=[OSError("in use"), OSError("in use"), None])
result['demo'].launch.assert_called_once()
def test_main_block_port_oserror(self, monkeypatch):
"""OSError during port finding raises in main block."""
with patch('src.agent.run.logger') as mock_logger:
with pytest.raises(OSError):
self._run_as_main(monkeypatch,
env_overrides={
"GRADIO_SERVER_PORT": "27866",
"GRADIO_ALLOW_PORT_FALLBACK": "true",
},
port_always_fail=True)
# #endregion test_main_block
# #endregion Test.AgentChat.Run