mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
commit
f81dba0da2
128 changed files with 8357 additions and 842 deletions
|
|
@ -1,8 +1,8 @@
|
|||
"""Persistence tests for the Camofox browser backend.
|
||||
|
||||
Tests that managed persistence uses stable identity while default mode
|
||||
uses random identity. The actual browser profile persistence is handled
|
||||
by the Camofox server (when CAMOFOX_PROFILE_DIR is set).
|
||||
uses random identity. Camofox automatically maps each userId to a
|
||||
dedicated persistent Firefox profile on the server side.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
|
|
|||
166
tests/tools/test_browser_cloud_fallback.py
Normal file
166
tests/tools/test_browser_cloud_fallback.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""Tests for cloud browser provider runtime fallback to local Chromium.
|
||||
|
||||
Covers the fallback logic in _get_session_info() when a cloud provider
|
||||
is configured but fails at runtime (issue #10883).
|
||||
"""
|
||||
import logging
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.browser_tool as browser_tool
|
||||
|
||||
|
||||
def _reset_session_state(monkeypatch):
|
||||
"""Clear caches so each test starts fresh."""
|
||||
monkeypatch.setattr(browser_tool, "_active_sessions", {})
|
||||
monkeypatch.setattr(browser_tool, "_cached_cloud_provider", None)
|
||||
monkeypatch.setattr(browser_tool, "_cloud_provider_resolved", False)
|
||||
monkeypatch.setattr(browser_tool, "_start_browser_cleanup_thread", lambda: None)
|
||||
monkeypatch.setattr(browser_tool, "_update_session_activity", lambda t: None)
|
||||
|
||||
|
||||
class TestCloudProviderRuntimeFallback:
|
||||
"""Tests for _get_session_info cloud → local fallback."""
|
||||
|
||||
def test_cloud_failure_falls_back_to_local(self, monkeypatch):
|
||||
"""When cloud provider.create_session raises, fall back to local."""
|
||||
_reset_session_state(monkeypatch)
|
||||
|
||||
provider = Mock()
|
||||
provider.create_session.side_effect = RuntimeError("401 Unauthorized")
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
|
||||
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
|
||||
|
||||
session = browser_tool._get_session_info("task-1")
|
||||
|
||||
assert session["fallback_from_cloud"] is True
|
||||
assert "401 Unauthorized" in session["fallback_reason"]
|
||||
assert session["fallback_provider"] == "Mock"
|
||||
assert session["features"]["local"] is True
|
||||
assert session["cdp_url"] is None
|
||||
|
||||
def test_cloud_success_no_fallback(self, monkeypatch):
|
||||
"""When cloud succeeds, no fallback markers are present."""
|
||||
_reset_session_state(monkeypatch)
|
||||
|
||||
provider = Mock()
|
||||
provider.create_session.return_value = {
|
||||
"session_name": "cloud-sess",
|
||||
"bb_session_id": "bb_123",
|
||||
"cdp_url": None,
|
||||
"features": {"browser_use": True},
|
||||
}
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
|
||||
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
|
||||
|
||||
session = browser_tool._get_session_info("task-2")
|
||||
|
||||
assert session["session_name"] == "cloud-sess"
|
||||
assert "fallback_from_cloud" not in session
|
||||
assert "fallback_reason" not in session
|
||||
|
||||
def test_cloud_and_local_both_fail(self, monkeypatch):
|
||||
"""When both cloud and local fail, raise RuntimeError with both contexts."""
|
||||
_reset_session_state(monkeypatch)
|
||||
|
||||
provider = Mock()
|
||||
provider.create_session.side_effect = RuntimeError("cloud boom")
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
|
||||
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
browser_tool, "_create_local_session",
|
||||
Mock(side_effect=OSError("no chromium")),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="cloud boom.*local.*no chromium"):
|
||||
browser_tool._get_session_info("task-3")
|
||||
|
||||
def test_no_provider_uses_local_directly(self, monkeypatch):
|
||||
"""When no cloud provider is configured, local mode is used with no fallback markers."""
|
||||
_reset_session_state(monkeypatch)
|
||||
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: None)
|
||||
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
|
||||
|
||||
session = browser_tool._get_session_info("task-4")
|
||||
|
||||
assert session["features"]["local"] is True
|
||||
assert "fallback_from_cloud" not in session
|
||||
|
||||
def test_cdp_override_bypasses_provider(self, monkeypatch):
|
||||
"""CDP override takes priority — cloud provider is never consulted."""
|
||||
_reset_session_state(monkeypatch)
|
||||
|
||||
provider = Mock()
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
|
||||
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: "ws://host:9222/devtools/browser/abc")
|
||||
|
||||
session = browser_tool._get_session_info("task-5")
|
||||
|
||||
provider.create_session.assert_not_called()
|
||||
assert session["cdp_url"] == "ws://host:9222/devtools/browser/abc"
|
||||
|
||||
def test_fallback_logs_warning_with_provider_name(self, monkeypatch, caplog):
|
||||
"""Fallback emits a warning log with the provider class name and error."""
|
||||
_reset_session_state(monkeypatch)
|
||||
|
||||
BrowserUseProviderFake = type("BrowserUseProvider", (), {
|
||||
"create_session": Mock(side_effect=ConnectionError("timeout")),
|
||||
})
|
||||
provider = BrowserUseProviderFake()
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
|
||||
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="tools.browser_tool"):
|
||||
session = browser_tool._get_session_info("task-6")
|
||||
|
||||
assert session["fallback_from_cloud"] is True
|
||||
assert any("BrowserUseProvider" in r.message and "timeout" in r.message
|
||||
for r in caplog.records)
|
||||
|
||||
def test_cloud_failure_does_not_poison_next_task(self, monkeypatch):
|
||||
"""A fallback for one task_id doesn't affect a new task_id when cloud recovers."""
|
||||
_reset_session_state(monkeypatch)
|
||||
|
||||
call_count = 0
|
||||
|
||||
def create_session_flaky(task_id):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise RuntimeError("transient failure")
|
||||
return {
|
||||
"session_name": "cloud-ok",
|
||||
"bb_session_id": "bb_999",
|
||||
"cdp_url": None,
|
||||
"features": {"browser_use": True},
|
||||
}
|
||||
|
||||
provider = Mock()
|
||||
provider.create_session.side_effect = create_session_flaky
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
|
||||
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
|
||||
|
||||
# First call fails → fallback
|
||||
s1 = browser_tool._get_session_info("task-a")
|
||||
assert s1["fallback_from_cloud"] is True
|
||||
|
||||
# Second call (different task) → cloud succeeds
|
||||
s2 = browser_tool._get_session_info("task-b")
|
||||
assert "fallback_from_cloud" not in s2
|
||||
assert s2["session_name"] == "cloud-ok"
|
||||
|
||||
def test_cloud_returns_invalid_session_triggers_fallback(self, monkeypatch):
|
||||
"""Cloud provider returning None or empty dict triggers fallback."""
|
||||
_reset_session_state(monkeypatch)
|
||||
|
||||
provider = Mock()
|
||||
provider.create_session.return_value = None
|
||||
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
|
||||
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
|
||||
|
||||
session = browser_tool._get_session_info("task-7")
|
||||
|
||||
assert session["fallback_from_cloud"] is True
|
||||
assert "invalid session" in session["fallback_reason"]
|
||||
|
|
@ -123,7 +123,7 @@ class TestSendMatrix:
|
|||
session.put.assert_called_once()
|
||||
call_kwargs = session.put.call_args
|
||||
url = call_kwargs[0][0]
|
||||
assert url.startswith("https://matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/")
|
||||
assert url.startswith("https://matrix.example.com/_matrix/client/v3/rooms/%21room%3Aexample.com/send/m.room.message/")
|
||||
assert call_kwargs[1]["headers"]["Authorization"] == "Bearer syt_tok"
|
||||
payload = call_kwargs[1]["json"]
|
||||
assert payload["msgtype"] == "m.text"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from gateway.config import Platform
|
|||
from tools.send_message_tool import (
|
||||
_parse_target_ref,
|
||||
_send_discord,
|
||||
_send_matrix_via_adapter,
|
||||
_send_telegram,
|
||||
_send_to_platform,
|
||||
send_message_tool,
|
||||
|
|
@ -576,7 +577,7 @@ class TestSendToPlatformChunking:
|
|||
|
||||
sent_calls = []
|
||||
|
||||
async def fake_send(token, chat_id, message, media_files=None, thread_id=None):
|
||||
async def fake_send(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False):
|
||||
sent_calls.append(media_files or [])
|
||||
return {"success": True, "platform": "telegram", "chat_id": chat_id, "message_id": str(len(sent_calls))}
|
||||
|
||||
|
|
@ -594,6 +595,103 @@ class TestSendToPlatformChunking:
|
|||
assert all(call == [] for call in sent_calls[:-1])
|
||||
assert sent_calls[-1] == media
|
||||
|
||||
def test_matrix_media_uses_native_adapter_helper(self):
|
||||
|
||||
doc_path = Path("/tmp/test-send-message-matrix.pdf")
|
||||
doc_path.write_bytes(b"%PDF-1.4 test")
|
||||
|
||||
try:
|
||||
helper = AsyncMock(return_value={"success": True, "platform": "matrix", "chat_id": "!room:example.com", "message_id": "$evt"})
|
||||
with patch("tools.send_message_tool._send_matrix_via_adapter", helper):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.MATRIX,
|
||||
SimpleNamespace(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.com"}),
|
||||
"!room:example.com",
|
||||
"here you go",
|
||||
media_files=[(str(doc_path), False)],
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
helper.assert_awaited_once()
|
||||
call = helper.await_args
|
||||
assert call.args[1] == "!room:example.com"
|
||||
assert call.args[2] == "here you go"
|
||||
assert call.kwargs["media_files"] == [(str(doc_path), False)]
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
def test_matrix_text_only_uses_lightweight_path(self):
|
||||
"""Text-only Matrix sends should NOT go through the heavy adapter path."""
|
||||
helper = AsyncMock()
|
||||
lightweight = AsyncMock(return_value={"success": True, "platform": "matrix", "chat_id": "!room:ex.com", "message_id": "$txt"})
|
||||
with patch("tools.send_message_tool._send_matrix_via_adapter", helper), \
|
||||
patch("tools.send_message_tool._send_matrix", lightweight):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.MATRIX,
|
||||
SimpleNamespace(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.com"}),
|
||||
"!room:ex.com",
|
||||
"just text, no files",
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
helper.assert_not_awaited()
|
||||
lightweight.assert_awaited_once()
|
||||
|
||||
def test_send_matrix_via_adapter_sends_document(self, tmp_path):
|
||||
file_path = tmp_path / "report.pdf"
|
||||
file_path.write_bytes(b"%PDF-1.4 test")
|
||||
|
||||
calls = []
|
||||
|
||||
class FakeAdapter:
|
||||
def __init__(self, _config):
|
||||
self.connected = False
|
||||
|
||||
async def connect(self):
|
||||
self.connected = True
|
||||
calls.append(("connect",))
|
||||
return True
|
||||
|
||||
async def send(self, chat_id, message, metadata=None):
|
||||
calls.append(("send", chat_id, message, metadata))
|
||||
return SimpleNamespace(success=True, message_id="$text")
|
||||
|
||||
async def send_document(self, chat_id, file_path, metadata=None):
|
||||
calls.append(("send_document", chat_id, file_path, metadata))
|
||||
return SimpleNamespace(success=True, message_id="$file")
|
||||
|
||||
async def disconnect(self):
|
||||
calls.append(("disconnect",))
|
||||
|
||||
fake_module = SimpleNamespace(MatrixAdapter=FakeAdapter)
|
||||
|
||||
with patch.dict(sys.modules, {"gateway.platforms.matrix": fake_module}):
|
||||
result = asyncio.run(
|
||||
_send_matrix_via_adapter(
|
||||
SimpleNamespace(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.com"}),
|
||||
"!room:example.com",
|
||||
"report attached",
|
||||
media_files=[(str(file_path), False)],
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"success": True,
|
||||
"platform": "matrix",
|
||||
"chat_id": "!room:example.com",
|
||||
"message_id": "$file",
|
||||
}
|
||||
assert calls == [
|
||||
("connect",),
|
||||
("send", "!room:example.com", "report attached", None),
|
||||
("send_document", "!room:example.com", str(file_path), None),
|
||||
("disconnect",),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML auto-detection in Telegram send
|
||||
|
|
@ -658,6 +756,17 @@ class TestSendTelegramHtmlDetection:
|
|||
kwargs = bot.send_message.await_args.kwargs
|
||||
assert kwargs["parse_mode"] == "MarkdownV2"
|
||||
|
||||
def test_disable_link_previews_sets_disable_web_page_preview(self, monkeypatch):
|
||||
bot = self._make_bot()
|
||||
_install_telegram_mock(monkeypatch, bot)
|
||||
|
||||
asyncio.run(
|
||||
_send_telegram("tok", "123", "https://example.com", disable_link_previews=True)
|
||||
)
|
||||
|
||||
kwargs = bot.send_message.await_args.kwargs
|
||||
assert kwargs["disable_web_page_preview"] is True
|
||||
|
||||
def test_html_with_code_and_pre_tags(self, monkeypatch):
|
||||
bot = self._make_bot()
|
||||
_install_telegram_mock(monkeypatch, bot)
|
||||
|
|
@ -707,6 +816,23 @@ class TestSendTelegramHtmlDetection:
|
|||
second_call = bot.send_message.await_args_list[1].kwargs
|
||||
assert second_call["parse_mode"] is None
|
||||
|
||||
def test_transient_bad_gateway_retries_text_send(self, monkeypatch):
|
||||
bot = self._make_bot()
|
||||
bot.send_message = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("502 Bad Gateway"),
|
||||
SimpleNamespace(message_id=2),
|
||||
]
|
||||
)
|
||||
_install_telegram_mock(monkeypatch, bot)
|
||||
|
||||
with patch("asyncio.sleep", new=AsyncMock()) as sleep_mock:
|
||||
result = asyncio.run(_send_telegram("tok", "123", "hello"))
|
||||
|
||||
assert result["success"] is True
|
||||
assert bot.send_message.await_count == 2
|
||||
sleep_mock.assert_awaited_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for Discord thread_id support
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue