chore: merge main
This commit is contained in:
@@ -480,6 +480,39 @@ class TestClassifyApiError:
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
|
||||
# ── Message-only usage limit disambiguation (no status code) ──
|
||||
|
||||
def test_message_usage_limit_transient_is_rate_limit(self):
|
||||
"""'usage limit' + 'try again' with no status code → rate_limit, not billing."""
|
||||
e = Exception("usage limit exceeded, try again in 5 minutes")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
assert result.retryable is True
|
||||
assert result.should_rotate_credential is True
|
||||
assert result.should_fallback is True
|
||||
|
||||
def test_message_usage_limit_no_retry_signal_is_billing(self):
|
||||
"""'usage limit' with no transient signal and no status code → billing."""
|
||||
e = Exception("usage limit reached")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.billing
|
||||
assert result.retryable is False
|
||||
assert result.should_rotate_credential is True
|
||||
|
||||
def test_message_quota_with_reset_window_is_rate_limit(self):
|
||||
"""'quota' + 'resets at' with no status code → rate_limit."""
|
||||
e = Exception("quota exceeded, resets at midnight UTC")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
assert result.retryable is True
|
||||
|
||||
def test_message_limit_exceeded_with_wait_is_rate_limit(self):
|
||||
"""'limit exceeded' + 'wait' with no status code → rate_limit."""
|
||||
e = Exception("key limit exceeded, please wait before retrying")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
assert result.retryable is True
|
||||
|
||||
# ── Unknown / fallback ──
|
||||
|
||||
def test_generic_exception_is_unknown(self):
|
||||
@@ -507,6 +540,38 @@ class TestClassifyApiError:
|
||||
assert result.reason == FailoverReason.format_error
|
||||
assert result.retryable is False
|
||||
|
||||
def test_400_flat_body_descriptive_not_context_overflow(self):
|
||||
"""Responses API flat body with descriptive error + large session → format error.
|
||||
|
||||
The Codex Responses API returns errors in flat body format:
|
||||
{"message": "...", "type": "..."} without an "error" wrapper.
|
||||
A descriptive 400 must NOT be misclassified as context overflow
|
||||
just because the session is large.
|
||||
"""
|
||||
e = MockAPIError(
|
||||
"Invalid 'input[index].name': string does not match pattern.",
|
||||
status_code=400,
|
||||
body={"message": "Invalid 'input[index].name': string does not match pattern.",
|
||||
"type": "invalid_request_error"},
|
||||
)
|
||||
result = classify_api_error(e, approx_tokens=200000, context_length=400000, num_messages=500)
|
||||
assert result.reason == FailoverReason.format_error
|
||||
assert result.retryable is False
|
||||
|
||||
def test_400_flat_body_generic_large_session_still_context_overflow(self):
|
||||
"""Flat body with generic 'Error' message + large session → context overflow.
|
||||
|
||||
Regression: the flat-body fallback must not break the existing heuristic
|
||||
for genuinely generic errors from providers that use flat bodies.
|
||||
"""
|
||||
e = MockAPIError(
|
||||
"Error",
|
||||
status_code=400,
|
||||
body={"message": "Error"},
|
||||
)
|
||||
result = classify_api_error(e, approx_tokens=100000, context_length=200000)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
|
||||
# ── Peer closed + large session ──
|
||||
|
||||
def test_peer_closed_large_session(self):
|
||||
|
||||
@@ -6,6 +6,17 @@ from unittest.mock import patch
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _assert_chrome_debug_cmd(cmd, expected_chrome, expected_port):
|
||||
"""Verify the auto-launch command has all required flags."""
|
||||
assert cmd[0] == expected_chrome
|
||||
assert f"--remote-debugging-port={expected_port}" in cmd
|
||||
assert "--no-first-run" in cmd
|
||||
assert "--no-default-browser-check" in cmd
|
||||
user_data_args = [a for a in cmd if a.startswith("--user-data-dir=")]
|
||||
assert len(user_data_args) == 1, "Expected exactly one --user-data-dir flag"
|
||||
assert "chrome-debug" in user_data_args[0]
|
||||
|
||||
|
||||
class TestChromeDebugLaunch:
|
||||
def test_windows_launch_uses_browser_found_on_path(self):
|
||||
captured = {}
|
||||
@@ -20,7 +31,7 @@ class TestChromeDebugLaunch:
|
||||
patch("subprocess.Popen", side_effect=fake_popen):
|
||||
assert HermesCLI._try_launch_chrome_debug(9333, "Windows") is True
|
||||
|
||||
assert captured["cmd"] == [r"C:\Chrome\chrome.exe", "--remote-debugging-port=9333"]
|
||||
_assert_chrome_debug_cmd(captured["cmd"], r"C:\Chrome\chrome.exe", 9333)
|
||||
assert captured["kwargs"]["start_new_session"] is True
|
||||
|
||||
def test_windows_launch_falls_back_to_common_install_dirs(self, monkeypatch):
|
||||
@@ -43,4 +54,4 @@ class TestChromeDebugLaunch:
|
||||
patch("subprocess.Popen", side_effect=fake_popen):
|
||||
assert HermesCLI._try_launch_chrome_debug(9222, "Windows") is True
|
||||
|
||||
assert captured["cmd"] == [installed, "--remote-debugging-port=9222"]
|
||||
_assert_chrome_debug_cmd(captured["cmd"], installed, 9222)
|
||||
|
||||
@@ -147,6 +147,20 @@ class TestEscapedSpaces:
|
||||
assert result["path"] == tmp_image_with_spaces
|
||||
assert result["remainder"] == "what is this?"
|
||||
|
||||
def test_tilde_prefixed_path(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
img = home / "storage" / "shared" / "Pictures" / "cat.png"
|
||||
img.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
|
||||
result = _detect_file_drop("~/storage/shared/Pictures/cat.png what is this?")
|
||||
|
||||
assert result is not None
|
||||
assert result["path"] == img
|
||||
assert result["is_image"] is True
|
||||
assert result["remainder"] == "what is this?"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: edge cases
|
||||
|
||||
109
tests/cli/test_cli_image_command.py
Normal file
109
tests/cli/test_cli_image_command.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from cli import (
|
||||
HermesCLI,
|
||||
_collect_query_images,
|
||||
_format_image_attachment_badges,
|
||||
_termux_example_image_path,
|
||||
)
|
||||
|
||||
|
||||
def _make_cli():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj._attached_images = []
|
||||
return cli_obj
|
||||
|
||||
|
||||
def _make_image(path: Path) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
return path
|
||||
|
||||
|
||||
class TestImageCommand:
|
||||
def test_handle_image_command_attaches_local_image(self, tmp_path):
|
||||
img = _make_image(tmp_path / "photo.png")
|
||||
cli_obj = _make_cli()
|
||||
|
||||
with patch("cli._cprint"):
|
||||
cli_obj._handle_image_command(f"/image {img}")
|
||||
|
||||
assert cli_obj._attached_images == [img]
|
||||
|
||||
def test_handle_image_command_supports_quoted_path_with_spaces(self, tmp_path):
|
||||
img = _make_image(tmp_path / "my photo.png")
|
||||
cli_obj = _make_cli()
|
||||
|
||||
with patch("cli._cprint"):
|
||||
cli_obj._handle_image_command(f'/image "{img}"')
|
||||
|
||||
assert cli_obj._attached_images == [img]
|
||||
|
||||
def test_handle_image_command_rejects_non_image_file(self, tmp_path):
|
||||
file_path = tmp_path / "notes.txt"
|
||||
file_path.write_text("hello\n", encoding="utf-8")
|
||||
cli_obj = _make_cli()
|
||||
|
||||
with patch("cli._cprint") as mock_print:
|
||||
cli_obj._handle_image_command(f"/image {file_path}")
|
||||
|
||||
assert cli_obj._attached_images == []
|
||||
rendered = " ".join(str(arg) for call in mock_print.call_args_list for arg in call.args)
|
||||
assert "Not a supported image file" in rendered
|
||||
|
||||
|
||||
class TestCollectQueryImages:
|
||||
def test_collect_query_images_accepts_explicit_image_arg(self, tmp_path):
|
||||
img = _make_image(tmp_path / "diagram.png")
|
||||
|
||||
message, images = _collect_query_images("describe this", str(img))
|
||||
|
||||
assert message == "describe this"
|
||||
assert images == [img]
|
||||
|
||||
def test_collect_query_images_extracts_leading_path(self, tmp_path):
|
||||
img = _make_image(tmp_path / "camera.png")
|
||||
|
||||
message, images = _collect_query_images(f"{img} what do you see?")
|
||||
|
||||
assert message == "what do you see?"
|
||||
assert images == [img]
|
||||
|
||||
def test_collect_query_images_supports_tilde_paths(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
img = _make_image(home / "storage" / "shared" / "Pictures" / "cat.png")
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
|
||||
message, images = _collect_query_images("describe this", "~/storage/shared/Pictures/cat.png")
|
||||
|
||||
assert message == "describe this"
|
||||
assert images == [img]
|
||||
|
||||
|
||||
class TestTermuxImageHints:
|
||||
def test_termux_example_image_path_prefers_real_shared_storage_root(self, monkeypatch):
|
||||
existing = {"/sdcard", "/storage/emulated/0"}
|
||||
monkeypatch.setattr("cli.os.path.isdir", lambda path: path in existing)
|
||||
|
||||
hint = _termux_example_image_path()
|
||||
|
||||
assert hint == "/sdcard/Pictures/cat.png"
|
||||
|
||||
|
||||
class TestImageBadgeFormatting:
|
||||
def test_compact_badges_use_filename_on_narrow_terminals(self, tmp_path):
|
||||
img = _make_image(tmp_path / "Screenshot 2026-04-09 at 11.22.33 AM.png")
|
||||
|
||||
badges = _format_image_attachment_badges([img], image_counter=1, width=40)
|
||||
|
||||
assert badges.startswith("[📎 ")
|
||||
assert "Image #1" not in badges
|
||||
|
||||
def test_compact_badges_summarize_multiple_images(self, tmp_path):
|
||||
img1 = _make_image(tmp_path / "one.png")
|
||||
img2 = _make_image(tmp_path / "two.png")
|
||||
|
||||
badges = _format_image_attachment_badges([img1, img2], image_counter=2, width=45)
|
||||
|
||||
assert badges == "[📎 2 images attached]"
|
||||
@@ -49,6 +49,25 @@ class TestCliSkinPromptIntegration:
|
||||
set_active_skin("ares")
|
||||
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ❯ ")]
|
||||
|
||||
def test_narrow_terminals_compact_voice_prompt_fragments(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._voice_mode = True
|
||||
|
||||
with patch.object(HermesCLI, "_get_tui_terminal_width", return_value=50):
|
||||
assert cli._get_tui_prompt_fragments() == [("class:voice-prompt", "🎤 ")]
|
||||
|
||||
def test_narrow_terminals_compact_voice_recording_prompt_fragments(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._voice_recording = True
|
||||
cli._voice_recorder = SimpleNamespace(current_rms=3000)
|
||||
|
||||
with patch.object(HermesCLI, "_get_tui_terminal_width", return_value=50):
|
||||
frags = cli._get_tui_prompt_fragments()
|
||||
|
||||
assert frags[0][0] == "class:voice-recording"
|
||||
assert frags[0][1].startswith("●")
|
||||
assert "❯" not in frags[0][1]
|
||||
|
||||
def test_icon_only_skin_symbol_still_visible_in_special_states(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._secret_state = {"response_queue": object()}
|
||||
|
||||
@@ -41,6 +41,7 @@ def _attach_agent(
|
||||
session_completion_tokens=completion_tokens,
|
||||
session_total_tokens=total_tokens,
|
||||
session_api_calls=api_calls,
|
||||
get_rate_limit_state=lambda: None,
|
||||
context_compressor=SimpleNamespace(
|
||||
last_prompt_tokens=context_tokens,
|
||||
context_length=context_length,
|
||||
@@ -205,6 +206,59 @@ class TestCLIStatusBar:
|
||||
assert "⚕" in text
|
||||
assert "claude-sonnet-4-20250514" in text
|
||||
|
||||
def test_minimal_tui_chrome_threshold(self):
|
||||
cli_obj = _make_cli()
|
||||
|
||||
assert cli_obj._use_minimal_tui_chrome(width=63) is True
|
||||
assert cli_obj._use_minimal_tui_chrome(width=64) is False
|
||||
|
||||
def test_bottom_input_rule_hides_on_narrow_terminals(self):
|
||||
cli_obj = _make_cli()
|
||||
|
||||
assert cli_obj._tui_input_rule_height("top", width=50) == 1
|
||||
assert cli_obj._tui_input_rule_height("bottom", width=50) == 0
|
||||
assert cli_obj._tui_input_rule_height("bottom", width=90) == 1
|
||||
|
||||
def test_agent_spacer_reclaimed_on_narrow_terminals(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._agent_running = True
|
||||
|
||||
assert cli_obj._agent_spacer_height(width=50) == 0
|
||||
assert cli_obj._agent_spacer_height(width=90) == 1
|
||||
cli_obj._agent_running = False
|
||||
assert cli_obj._agent_spacer_height(width=90) == 0
|
||||
|
||||
def test_spinner_line_hidden_on_narrow_terminals(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._spinner_text = "thinking"
|
||||
|
||||
assert cli_obj._spinner_widget_height(width=50) == 0
|
||||
assert cli_obj._spinner_widget_height(width=90) == 1
|
||||
cli_obj._spinner_text = ""
|
||||
assert cli_obj._spinner_widget_height(width=90) == 0
|
||||
|
||||
def test_voice_status_bar_compacts_on_narrow_terminals(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._voice_mode = True
|
||||
cli_obj._voice_recording = False
|
||||
cli_obj._voice_processing = False
|
||||
cli_obj._voice_tts = True
|
||||
cli_obj._voice_continuous = True
|
||||
|
||||
fragments = cli_obj._get_voice_status_fragments(width=50)
|
||||
|
||||
assert fragments == [("class:voice-status", " 🎤 Ctrl+B ")]
|
||||
|
||||
def test_voice_recording_status_bar_compacts_on_narrow_terminals(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._voice_mode = True
|
||||
cli_obj._voice_recording = True
|
||||
cli_obj._voice_processing = False
|
||||
|
||||
fragments = cli_obj._get_voice_status_fragments(width=50)
|
||||
|
||||
assert fragments == [("class:voice-status-recording", " ● REC ")]
|
||||
|
||||
|
||||
class TestCLIUsageReport:
|
||||
def test_show_usage_includes_estimated_cost(self, capsys):
|
||||
|
||||
@@ -38,6 +38,8 @@ def _isolate_hermes_home(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False)
|
||||
monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
# Avoid making real calls during tests if this key is set in the env files
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
@@ -56,7 +56,7 @@ class FakeTree:
|
||||
|
||||
|
||||
class FakeBot:
|
||||
def __init__(self, *, intents):
|
||||
def __init__(self, *, intents, proxy=None):
|
||||
self.intents = intents
|
||||
self.user = SimpleNamespace(id=999, name="Hermes")
|
||||
self._events = {}
|
||||
@@ -95,7 +95,7 @@ async def test_connect_only_requests_members_intent_when_needed(monkeypatch, all
|
||||
|
||||
created = {}
|
||||
|
||||
def fake_bot_factory(*, command_prefix, intents):
|
||||
def fake_bot_factory(*, command_prefix, intents, proxy=None):
|
||||
created["bot"] = FakeBot(intents=intents)
|
||||
return created["bot"]
|
||||
|
||||
@@ -124,7 +124,7 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
discord_platform.commands,
|
||||
"Bot",
|
||||
lambda **kwargs: FakeBot(intents=kwargs["intents"]),
|
||||
lambda **kwargs: FakeBot(intents=kwargs["intents"], proxy=kwargs.get("proxy")),
|
||||
)
|
||||
|
||||
async def fake_wait_for(awaitable, timeout):
|
||||
|
||||
@@ -38,10 +38,11 @@ def _make_timeout_error() -> httpx.TimeoutException:
|
||||
# cache_image_from_url (base.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("tools.url_safety.is_safe_url", return_value=True)
|
||||
class TestCacheImageFromUrl:
|
||||
"""Tests for gateway.platforms.base.cache_image_from_url"""
|
||||
|
||||
def test_success_on_first_attempt(self, tmp_path, monkeypatch):
|
||||
def test_success_on_first_attempt(self, _mock_safe, tmp_path, monkeypatch):
|
||||
"""A clean 200 response caches the image and returns a path."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
@@ -65,7 +66,7 @@ class TestCacheImageFromUrl:
|
||||
assert path.endswith(".jpg")
|
||||
mock_client.get.assert_called_once()
|
||||
|
||||
def test_retries_on_timeout_then_succeeds(self, tmp_path, monkeypatch):
|
||||
def test_retries_on_timeout_then_succeeds(self, _mock_safe, tmp_path, monkeypatch):
|
||||
"""A timeout on the first attempt is retried; second attempt succeeds."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
@@ -95,7 +96,7 @@ class TestCacheImageFromUrl:
|
||||
assert mock_client.get.call_count == 2
|
||||
mock_sleep.assert_called_once()
|
||||
|
||||
def test_retries_on_429_then_succeeds(self, tmp_path, monkeypatch):
|
||||
def test_retries_on_429_then_succeeds(self, _mock_safe, tmp_path, monkeypatch):
|
||||
"""A 429 response on the first attempt is retried; second attempt succeeds."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
@@ -122,7 +123,7 @@ class TestCacheImageFromUrl:
|
||||
assert path.endswith(".jpg")
|
||||
assert mock_client.get.call_count == 2
|
||||
|
||||
def test_raises_after_max_retries_exhausted(self, tmp_path, monkeypatch):
|
||||
def test_raises_after_max_retries_exhausted(self, _mock_safe, tmp_path, monkeypatch):
|
||||
"""Timeout on every attempt raises after all retries are consumed."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
@@ -145,7 +146,7 @@ class TestCacheImageFromUrl:
|
||||
# 3 total calls: initial + 2 retries
|
||||
assert mock_client.get.call_count == 3
|
||||
|
||||
def test_non_retryable_4xx_raises_immediately(self, tmp_path, monkeypatch):
|
||||
def test_non_retryable_4xx_raises_immediately(self, _mock_safe, tmp_path, monkeypatch):
|
||||
"""A 404 (non-retryable) is raised immediately without any retry."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
@@ -175,10 +176,11 @@ class TestCacheImageFromUrl:
|
||||
# cache_audio_from_url (base.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("tools.url_safety.is_safe_url", return_value=True)
|
||||
class TestCacheAudioFromUrl:
|
||||
"""Tests for gateway.platforms.base.cache_audio_from_url"""
|
||||
|
||||
def test_success_on_first_attempt(self, tmp_path, monkeypatch):
|
||||
def test_success_on_first_attempt(self, _mock_safe, tmp_path, monkeypatch):
|
||||
"""A clean 200 response caches the audio and returns a path."""
|
||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||
|
||||
@@ -202,7 +204,7 @@ class TestCacheAudioFromUrl:
|
||||
assert path.endswith(".ogg")
|
||||
mock_client.get.assert_called_once()
|
||||
|
||||
def test_retries_on_timeout_then_succeeds(self, tmp_path, monkeypatch):
|
||||
def test_retries_on_timeout_then_succeeds(self, _mock_safe, tmp_path, monkeypatch):
|
||||
"""A timeout on the first attempt is retried; second attempt succeeds."""
|
||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||
|
||||
@@ -232,7 +234,7 @@ class TestCacheAudioFromUrl:
|
||||
assert mock_client.get.call_count == 2
|
||||
mock_sleep.assert_called_once()
|
||||
|
||||
def test_retries_on_429_then_succeeds(self, tmp_path, monkeypatch):
|
||||
def test_retries_on_429_then_succeeds(self, _mock_safe, tmp_path, monkeypatch):
|
||||
"""A 429 response on the first attempt is retried; second attempt succeeds."""
|
||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||
|
||||
@@ -259,7 +261,7 @@ class TestCacheAudioFromUrl:
|
||||
assert path.endswith(".ogg")
|
||||
assert mock_client.get.call_count == 2
|
||||
|
||||
def test_retries_on_500_then_succeeds(self, tmp_path, monkeypatch):
|
||||
def test_retries_on_500_then_succeeds(self, _mock_safe, tmp_path, monkeypatch):
|
||||
"""A 500 response on the first attempt is retried; second attempt succeeds."""
|
||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||
|
||||
@@ -286,7 +288,7 @@ class TestCacheAudioFromUrl:
|
||||
assert path.endswith(".ogg")
|
||||
assert mock_client.get.call_count == 2
|
||||
|
||||
def test_raises_after_max_retries_exhausted(self, tmp_path, monkeypatch):
|
||||
def test_raises_after_max_retries_exhausted(self, _mock_safe, tmp_path, monkeypatch):
|
||||
"""Timeout on every attempt raises after all retries are consumed."""
|
||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||
|
||||
@@ -309,7 +311,7 @@ class TestCacheAudioFromUrl:
|
||||
# 3 total calls: initial + 2 retries
|
||||
assert mock_client.get.call_count == 3
|
||||
|
||||
def test_non_retryable_4xx_raises_immediately(self, tmp_path, monkeypatch):
|
||||
def test_non_retryable_4xx_raises_immediately(self, _mock_safe, tmp_path, monkeypatch):
|
||||
"""A 404 (non-retryable) is raised immediately without any retry."""
|
||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||
|
||||
|
||||
@@ -619,6 +619,18 @@ class TestFormatMessage:
|
||||
result = adapter.format_message("[click here](https://example.com)")
|
||||
assert result == "<https://example.com|click here>"
|
||||
|
||||
def test_link_conversion_strips_markdown_angle_brackets(self, adapter):
|
||||
result = adapter.format_message("[click here](<https://example.com>)")
|
||||
assert result == "<https://example.com|click here>"
|
||||
|
||||
def test_escapes_control_characters(self, adapter):
|
||||
result = adapter.format_message("AT&T < 5 > 3")
|
||||
assert result == "AT&T < 5 > 3"
|
||||
|
||||
def test_preserves_existing_slack_entities(self, adapter):
|
||||
text = "Hey <@U123>, see <https://example.com|example> and <!here>"
|
||||
assert adapter.format_message(text) == text
|
||||
|
||||
def test_strikethrough(self, adapter):
|
||||
assert adapter.format_message("~~deleted~~") == "~deleted~"
|
||||
|
||||
@@ -643,6 +655,325 @@ class TestFormatMessage:
|
||||
def test_none_passthrough(self, adapter):
|
||||
assert adapter.format_message(None) is None
|
||||
|
||||
def test_blockquote_preserved(self, adapter):
|
||||
"""Single-line blockquote > marker is preserved."""
|
||||
assert adapter.format_message("> quoted text") == "> quoted text"
|
||||
|
||||
def test_multiline_blockquote(self, adapter):
|
||||
"""Multi-line blockquote preserves > on each line."""
|
||||
text = "> line one\n> line two"
|
||||
assert adapter.format_message(text) == "> line one\n> line two"
|
||||
|
||||
def test_blockquote_with_formatting(self, adapter):
|
||||
"""Blockquote containing bold text."""
|
||||
assert adapter.format_message("> **bold quote**") == "> *bold quote*"
|
||||
|
||||
def test_nested_blockquote(self, adapter):
|
||||
"""Multiple > characters for nested quotes."""
|
||||
assert adapter.format_message(">> deeply quoted") == ">> deeply quoted"
|
||||
|
||||
def test_blockquote_mixed_with_plain(self, adapter):
|
||||
"""Blockquote lines interleaved with plain text."""
|
||||
text = "normal\n> quoted\nnormal again"
|
||||
result = adapter.format_message(text)
|
||||
assert "> quoted" in result
|
||||
assert "normal" in result
|
||||
|
||||
def test_non_prefix_gt_still_escaped(self, adapter):
|
||||
"""Greater-than in mid-line is still escaped."""
|
||||
assert adapter.format_message("5 > 3") == "5 > 3"
|
||||
|
||||
def test_blockquote_with_code(self, adapter):
|
||||
"""Blockquote containing inline code."""
|
||||
result = adapter.format_message("> use `fmt.Println`")
|
||||
assert result.startswith(">")
|
||||
assert "`fmt.Println`" in result
|
||||
|
||||
def test_bold_italic_combined(self, adapter):
|
||||
"""Triple-star ***text*** converts to Slack bold+italic *_text_*."""
|
||||
assert adapter.format_message("***hello***") == "*_hello_*"
|
||||
|
||||
def test_bold_italic_with_surrounding_text(self, adapter):
|
||||
"""Bold+italic in a sentence."""
|
||||
result = adapter.format_message("This is ***important*** stuff")
|
||||
assert "*_important_*" in result
|
||||
|
||||
def test_bold_italic_does_not_break_plain_bold(self, adapter):
|
||||
"""**bold** still works after adding ***bold italic*** support."""
|
||||
assert adapter.format_message("**bold**") == "*bold*"
|
||||
|
||||
def test_bold_italic_does_not_break_plain_italic(self, adapter):
|
||||
"""*italic* still works after adding ***bold italic*** support."""
|
||||
assert adapter.format_message("*italic*") == "_italic_"
|
||||
|
||||
def test_bold_italic_mixed_with_bold(self, adapter):
|
||||
"""Both ***bold italic*** and **bold** in the same message."""
|
||||
result = adapter.format_message("***important*** and **bold**")
|
||||
assert "*_important_*" in result
|
||||
assert "*bold*" in result
|
||||
|
||||
def test_pre_escaped_ampersand_not_double_escaped(self, adapter):
|
||||
"""Already-escaped & must not become &amp;."""
|
||||
assert adapter.format_message("&") == "&"
|
||||
|
||||
def test_pre_escaped_lt_not_double_escaped(self, adapter):
|
||||
"""Already-escaped < must not become &lt;."""
|
||||
assert adapter.format_message("<") == "<"
|
||||
|
||||
def test_pre_escaped_gt_not_double_escaped(self, adapter):
|
||||
"""Already-escaped > in plain text must not become &gt;."""
|
||||
assert adapter.format_message("5 > 3") == "5 > 3"
|
||||
|
||||
def test_mixed_raw_and_escaped_entities(self, adapter):
|
||||
"""Raw & and pre-escaped & coexist correctly."""
|
||||
result = adapter.format_message("AT&T and & entity")
|
||||
assert result == "AT&T and & entity"
|
||||
|
||||
def test_link_with_parentheses_in_url(self, adapter):
|
||||
"""Wikipedia-style URL with balanced parens is not truncated."""
|
||||
result = adapter.format_message("[Foo](https://en.wikipedia.org/wiki/Foo_(bar))")
|
||||
assert result == "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>"
|
||||
|
||||
def test_link_with_multiple_paren_pairs(self, adapter):
|
||||
"""URL with multiple balanced paren pairs."""
|
||||
result = adapter.format_message("[text](https://example.com/a_(b)_c_(d))")
|
||||
assert result == "<https://example.com/a_(b)_c_(d)|text>"
|
||||
|
||||
def test_link_without_parens_still_works(self, adapter):
|
||||
"""Normal URL without parens is unaffected by regex change."""
|
||||
result = adapter.format_message("[click](https://example.com/path?q=1)")
|
||||
assert result == "<https://example.com/path?q=1|click>"
|
||||
|
||||
def test_link_with_angle_brackets_and_parens(self, adapter):
|
||||
"""Angle-bracket URL with parens (CommonMark syntax)."""
|
||||
result = adapter.format_message("[Foo](<https://en.wikipedia.org/wiki/Foo_(bar)>)")
|
||||
assert result == "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>"
|
||||
|
||||
def test_escaping_is_idempotent(self, adapter):
|
||||
"""Formatting already-formatted text produces the same result."""
|
||||
original = "AT&T < 5 > 3"
|
||||
once = adapter.format_message(original)
|
||||
twice = adapter.format_message(once)
|
||||
assert once == twice
|
||||
|
||||
# --- Entity preservation (spec-compliance) ---
|
||||
|
||||
def test_channel_mention_preserved(self, adapter):
|
||||
"""<!channel> special mention passes through unchanged."""
|
||||
assert adapter.format_message("Attention <!channel>") == "Attention <!channel>"
|
||||
|
||||
def test_everyone_mention_preserved(self, adapter):
|
||||
"""<!everyone> special mention passes through unchanged."""
|
||||
assert adapter.format_message("Hey <!everyone>") == "Hey <!everyone>"
|
||||
|
||||
def test_subteam_mention_preserved(self, adapter):
|
||||
"""<!subteam^ID> user group mention passes through unchanged."""
|
||||
assert adapter.format_message("Paging <!subteam^S12345>") == "Paging <!subteam^S12345>"
|
||||
|
||||
def test_date_formatting_preserved(self, adapter):
|
||||
"""<!date^...> formatting token passes through unchanged."""
|
||||
text = "Posted <!date^1392734382^{date_pretty}|Feb 18, 2014>"
|
||||
assert adapter.format_message(text) == text
|
||||
|
||||
def test_channel_link_preserved(self, adapter):
|
||||
"""<#CHANNEL_ID> channel link passes through unchanged."""
|
||||
assert adapter.format_message("Join <#C12345>") == "Join <#C12345>"
|
||||
|
||||
# --- Additional edge cases ---
|
||||
|
||||
def test_message_only_code_block(self, adapter):
|
||||
"""Entire message is a fenced code block — no conversion."""
|
||||
code = "```python\nx = 1\n```"
|
||||
assert adapter.format_message(code) == code
|
||||
|
||||
def test_multiline_mixed_formatting(self, adapter):
|
||||
"""Multi-line message with headers, bold, links, code, and blockquotes."""
|
||||
text = "## Title\n**bold** and [link](https://x.com)\n> quote\n`code`"
|
||||
result = adapter.format_message(text)
|
||||
assert result.startswith("*Title*")
|
||||
assert "*bold*" in result
|
||||
assert "<https://x.com|link>" in result
|
||||
assert "> quote" in result
|
||||
assert "`code`" in result
|
||||
|
||||
def test_markdown_unordered_list_with_asterisk(self, adapter):
|
||||
"""Asterisk list items must not trigger italic conversion."""
|
||||
text = "* item one\n* item two"
|
||||
result = adapter.format_message(text)
|
||||
assert "item one" in result
|
||||
assert "item two" in result
|
||||
|
||||
def test_nested_bold_in_link(self, adapter):
|
||||
"""Bold inside link label — label is stashed before bold pass."""
|
||||
result = adapter.format_message("[**bold**](https://example.com)")
|
||||
assert "https://example.com" in result
|
||||
assert "bold" in result
|
||||
|
||||
def test_url_with_query_string_and_ampersand(self, adapter):
|
||||
"""Ampersand in URL query string must not be escaped."""
|
||||
result = adapter.format_message("[link](https://x.com?a=1&b=2)")
|
||||
assert result == "<https://x.com?a=1&b=2|link>"
|
||||
|
||||
def test_emoji_shortcodes_passthrough(self, adapter):
|
||||
"""Emoji shortcodes like :smile: pass through unchanged."""
|
||||
assert adapter.format_message(":smile: hello :wave:") == ":smile: hello :wave:"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestEditMessage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEditMessage:
|
||||
"""Verify that edit_message() applies mrkdwn formatting before sending."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_formats_bold(self, adapter):
|
||||
"""edit_message converts **bold** to Slack *bold*."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
await adapter.edit_message("C123", "1234.5678", "**hello world**")
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs["text"] == "*hello world*"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_formats_links(self, adapter):
|
||||
"""edit_message converts markdown links to Slack format."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
await adapter.edit_message("C123", "1234.5678", "[click](https://example.com)")
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs["text"] == "<https://example.com|click>"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_preserves_blockquotes(self, adapter):
|
||||
"""edit_message preserves blockquote > markers."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
await adapter.edit_message("C123", "1234.5678", "> quoted text")
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs["text"] == "> quoted text"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_escapes_control_chars(self, adapter):
|
||||
"""edit_message escapes & < > in plain text."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
await adapter.edit_message("C123", "1234.5678", "AT&T < 5 > 3")
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs["text"] == "AT&T < 5 > 3"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestEditMessageStreamingPipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEditMessageStreamingPipeline:
|
||||
"""E2E: verify that sequential streaming edits all go through format_message.
|
||||
|
||||
Simulates the GatewayStreamConsumer pattern where edit_message is called
|
||||
repeatedly with progressively longer accumulated text. Every call must
|
||||
produce properly formatted mrkdwn in the chat_update payload.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_formats_streaming_updates(self, adapter):
|
||||
"""Simulates streaming: multiple edits, each should be formatted."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
|
||||
# First streaming update — bold
|
||||
result1 = await adapter.edit_message("C123", "ts1", "**Processing**...")
|
||||
assert result1.success is True
|
||||
kwargs1 = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs1["text"] == "*Processing*..."
|
||||
|
||||
# Second streaming update — bold + link
|
||||
result2 = await adapter.edit_message(
|
||||
"C123", "ts1", "**Done!** See [results](https://example.com)"
|
||||
)
|
||||
assert result2.success is True
|
||||
kwargs2 = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs2["text"] == "*Done!* See <https://example.com|results>"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_formats_code_and_bold(self, adapter):
|
||||
"""Streaming update with code block and bold — code must be preserved."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
|
||||
content = "**Result:**\n```python\nprint('hello')\n```"
|
||||
result = await adapter.edit_message("C123", "ts1", content)
|
||||
assert result.success is True
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs["text"].startswith("*Result:*")
|
||||
assert "```python\nprint('hello')\n```" in kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_formats_blockquote_in_stream(self, adapter):
|
||||
"""Streaming update with blockquote — '>' marker must survive."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
|
||||
content = "> **Important:** do this\nnormal line"
|
||||
result = await adapter.edit_message("C123", "ts1", content)
|
||||
assert result.success is True
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs["text"].startswith("> *Important:*")
|
||||
assert "normal line" in kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_formats_progressive_accumulation(self, adapter):
|
||||
"""Simulate real streaming: text grows with each edit, all formatted."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
|
||||
updates = [
|
||||
("**Step 1**", "*Step 1*"),
|
||||
("**Step 1**\n**Step 2**", "*Step 1*\n*Step 2*"),
|
||||
(
|
||||
"**Step 1**\n**Step 2**\nSee [docs](https://docs.example.com)",
|
||||
"*Step 1*\n*Step 2*\nSee <https://docs.example.com|docs>",
|
||||
),
|
||||
]
|
||||
|
||||
for raw, expected in updates:
|
||||
result = await adapter.edit_message("C123", "ts1", raw)
|
||||
assert result.success is True
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs["text"] == expected, f"Failed for input: {raw!r}"
|
||||
|
||||
# Total edit count should match number of updates
|
||||
assert adapter._app.client.chat_update.call_count == len(updates)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_formats_bold_italic(self, adapter):
|
||||
"""Bold+italic ***text*** is formatted as *_text_* in edited messages."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
await adapter.edit_message("C123", "ts1", "***important*** update")
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert "*_important_*" in kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_does_not_double_escape(self, adapter):
|
||||
"""Pre-escaped entities in edited messages must not get double-escaped."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
await adapter.edit_message("C123", "ts1", "5 > 3 and & entity")
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert "&gt;" not in kwargs["text"]
|
||||
assert "&amp;" not in kwargs["text"]
|
||||
assert ">" in kwargs["text"]
|
||||
assert "&" in kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_formats_url_with_parens(self, adapter):
|
||||
"""Wikipedia-style URL with parens survives edit pipeline."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
await adapter.edit_message("C123", "ts1", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))")
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_not_connected(self, adapter):
|
||||
"""edit_message returns failure when adapter is not connected."""
|
||||
adapter._app = None
|
||||
result = await adapter.edit_message("C123", "ts1", "**hello**")
|
||||
assert result.success is False
|
||||
assert "Not connected" in result.error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestReactions
|
||||
@@ -1085,6 +1416,48 @@ class TestMessageSplitting:
|
||||
await adapter.send("C123", "hello world")
|
||||
assert adapter._app.client.chat_postMessage.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_preserves_blockquote_formatting(self, adapter):
|
||||
"""Blockquote '>' markers must survive format → chunk → send pipeline."""
|
||||
adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
|
||||
await adapter.send("C123", "> quoted text\nnormal text")
|
||||
kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
|
||||
sent_text = kwargs["text"]
|
||||
assert sent_text.startswith("> quoted text")
|
||||
assert "normal text" in sent_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_formats_bold_italic(self, adapter):
|
||||
"""Bold+italic ***text*** is formatted as *_text_* in sent messages."""
|
||||
adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
|
||||
await adapter.send("C123", "***important*** update")
|
||||
kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
|
||||
assert "*_important_*" in kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_explicitly_enables_mrkdwn(self, adapter):
|
||||
adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
|
||||
await adapter.send("C123", "**hello**")
|
||||
kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
|
||||
assert kwargs.get("mrkdwn") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_does_not_double_escape_entities(self, adapter):
|
||||
"""Pre-escaped & in sent messages must not become &amp;."""
|
||||
adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
|
||||
await adapter.send("C123", "Use & for ampersand")
|
||||
kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
|
||||
assert "&amp;" not in kwargs["text"]
|
||||
assert "&" in kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_formats_url_with_parens(self, adapter):
|
||||
"""Wikipedia-style URL with parens survives send pipeline."""
|
||||
adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
|
||||
await adapter.send("C123", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))")
|
||||
kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
|
||||
assert "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in kwargs["text"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestReplyBroadcast
|
||||
|
||||
312
tests/gateway/test_slack_mention.py
Normal file
312
tests/gateway/test_slack_mention.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Tests for Slack mention gating (require_mention / free_response_channels).
|
||||
|
||||
Follows the same pattern as test_whatsapp_group_gating.py.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock slack-bolt if not installed (same as test_slack.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_slack_mock():
|
||||
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
|
||||
return
|
||||
|
||||
slack_bolt = MagicMock()
|
||||
slack_bolt.async_app.AsyncApp = MagicMock
|
||||
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
|
||||
|
||||
slack_sdk = MagicMock()
|
||||
slack_sdk.web.async_client.AsyncWebClient = MagicMock
|
||||
|
||||
for name, mod in [
|
||||
("slack_bolt", slack_bolt),
|
||||
("slack_bolt.async_app", slack_bolt.async_app),
|
||||
("slack_bolt.adapter", slack_bolt.adapter),
|
||||
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
|
||||
("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
|
||||
("slack_sdk", slack_sdk),
|
||||
("slack_sdk.web", slack_sdk.web),
|
||||
("slack_sdk.web.async_client", slack_sdk.web.async_client),
|
||||
]:
|
||||
sys.modules.setdefault(name, mod)
|
||||
|
||||
|
||||
_ensure_slack_mock()
|
||||
|
||||
import gateway.platforms.slack as _slack_mod
|
||||
_slack_mod.SLACK_AVAILABLE = True
|
||||
|
||||
from gateway.platforms.slack import SlackAdapter # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BOT_USER_ID = "U_BOT_123"
|
||||
CHANNEL_ID = "C0AQWDLHY9M"
|
||||
OTHER_CHANNEL_ID = "C9999999999"
|
||||
|
||||
|
||||
def _make_adapter(require_mention=None, free_response_channels=None):
|
||||
extra = {}
|
||||
if require_mention is not None:
|
||||
extra["require_mention"] = require_mention
|
||||
if free_response_channels is not None:
|
||||
extra["free_response_channels"] = free_response_channels
|
||||
|
||||
adapter = object.__new__(SlackAdapter)
|
||||
adapter.platform = Platform.SLACK
|
||||
adapter.config = PlatformConfig(enabled=True, extra=extra)
|
||||
adapter._bot_user_id = BOT_USER_ID
|
||||
adapter._team_bot_user_ids = {}
|
||||
return adapter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _slack_require_mention
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_require_mention_defaults_to_true(monkeypatch):
|
||||
monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False)
|
||||
adapter = _make_adapter()
|
||||
assert adapter._slack_require_mention() is True
|
||||
|
||||
|
||||
def test_require_mention_false():
|
||||
adapter = _make_adapter(require_mention=False)
|
||||
assert adapter._slack_require_mention() is False
|
||||
|
||||
|
||||
def test_require_mention_true():
|
||||
adapter = _make_adapter(require_mention=True)
|
||||
assert adapter._slack_require_mention() is True
|
||||
|
||||
|
||||
def test_require_mention_string_true():
|
||||
adapter = _make_adapter(require_mention="true")
|
||||
assert adapter._slack_require_mention() is True
|
||||
|
||||
|
||||
def test_require_mention_string_false():
|
||||
adapter = _make_adapter(require_mention="false")
|
||||
assert adapter._slack_require_mention() is False
|
||||
|
||||
|
||||
def test_require_mention_string_no():
|
||||
adapter = _make_adapter(require_mention="no")
|
||||
assert adapter._slack_require_mention() is False
|
||||
|
||||
|
||||
def test_require_mention_string_yes():
|
||||
adapter = _make_adapter(require_mention="yes")
|
||||
assert adapter._slack_require_mention() is True
|
||||
|
||||
|
||||
def test_require_mention_empty_string_stays_true():
|
||||
"""Empty/malformed strings keep gating ON (explicit-false parser)."""
|
||||
adapter = _make_adapter(require_mention="")
|
||||
assert adapter._slack_require_mention() is True
|
||||
|
||||
|
||||
def test_require_mention_malformed_string_stays_true():
|
||||
"""Unrecognised values keep gating ON (fail-closed)."""
|
||||
adapter = _make_adapter(require_mention="maybe")
|
||||
assert adapter._slack_require_mention() is True
|
||||
|
||||
|
||||
def test_require_mention_env_var_fallback(monkeypatch):
|
||||
monkeypatch.setenv("SLACK_REQUIRE_MENTION", "false")
|
||||
adapter = _make_adapter() # no config value -> falls back to env
|
||||
assert adapter._slack_require_mention() is False
|
||||
|
||||
|
||||
def test_require_mention_env_var_default_true(monkeypatch):
|
||||
monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False)
|
||||
adapter = _make_adapter()
|
||||
assert adapter._slack_require_mention() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _slack_free_response_channels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_free_response_channels_default_empty(monkeypatch):
|
||||
monkeypatch.delenv("SLACK_FREE_RESPONSE_CHANNELS", raising=False)
|
||||
adapter = _make_adapter()
|
||||
assert adapter._slack_free_response_channels() == set()
|
||||
|
||||
|
||||
def test_free_response_channels_list():
|
||||
adapter = _make_adapter(free_response_channels=[CHANNEL_ID, OTHER_CHANNEL_ID])
|
||||
result = adapter._slack_free_response_channels()
|
||||
assert CHANNEL_ID in result
|
||||
assert OTHER_CHANNEL_ID in result
|
||||
|
||||
|
||||
def test_free_response_channels_csv_string():
|
||||
adapter = _make_adapter(free_response_channels=f"{CHANNEL_ID}, {OTHER_CHANNEL_ID}")
|
||||
result = adapter._slack_free_response_channels()
|
||||
assert CHANNEL_ID in result
|
||||
assert OTHER_CHANNEL_ID in result
|
||||
|
||||
|
||||
def test_free_response_channels_empty_string():
|
||||
adapter = _make_adapter(free_response_channels="")
|
||||
assert adapter._slack_free_response_channels() == set()
|
||||
|
||||
|
||||
def test_free_response_channels_env_var_fallback(monkeypatch):
|
||||
monkeypatch.setenv("SLACK_FREE_RESPONSE_CHANNELS", f"{CHANNEL_ID},{OTHER_CHANNEL_ID}")
|
||||
adapter = _make_adapter() # no config value → falls back to env
|
||||
result = adapter._slack_free_response_channels()
|
||||
assert CHANNEL_ID in result
|
||||
assert OTHER_CHANNEL_ID in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: mention gating integration (simulating _handle_slack_message logic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _would_process(adapter, *, is_dm=False, channel_id=CHANNEL_ID,
|
||||
text="hello", mentioned=False, thread_reply=False,
|
||||
active_session=False):
|
||||
"""Simulate the mention gating logic from _handle_slack_message.
|
||||
|
||||
Returns True if the message would be processed, False if it would be
|
||||
skipped (returned early).
|
||||
"""
|
||||
bot_uid = adapter._team_bot_user_ids.get("T1", adapter._bot_user_id)
|
||||
if mentioned:
|
||||
text = f"<@{bot_uid}> {text}"
|
||||
is_mentioned = bot_uid and f"<@{bot_uid}>" in text
|
||||
|
||||
if not is_dm:
|
||||
if channel_id in adapter._slack_free_response_channels():
|
||||
return True
|
||||
elif not adapter._slack_require_mention():
|
||||
return True
|
||||
elif not is_mentioned:
|
||||
if thread_reply and active_session:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def test_default_require_mention_channel_without_mention_ignored():
|
||||
adapter = _make_adapter() # default: require_mention=True
|
||||
assert _would_process(adapter, text="hello everyone") is False
|
||||
|
||||
|
||||
def test_require_mention_false_channel_without_mention_processed():
|
||||
adapter = _make_adapter(require_mention=False)
|
||||
assert _would_process(adapter, text="hello everyone") is True
|
||||
|
||||
|
||||
def test_channel_in_free_response_processed_without_mention():
|
||||
adapter = _make_adapter(
|
||||
require_mention=True,
|
||||
free_response_channels=[CHANNEL_ID],
|
||||
)
|
||||
assert _would_process(adapter, channel_id=CHANNEL_ID, text="hello") is True
|
||||
|
||||
|
||||
def test_other_channel_not_in_free_response_still_gated():
|
||||
adapter = _make_adapter(
|
||||
require_mention=True,
|
||||
free_response_channels=[CHANNEL_ID],
|
||||
)
|
||||
assert _would_process(adapter, channel_id=OTHER_CHANNEL_ID, text="hello") is False
|
||||
|
||||
|
||||
def test_dm_always_processed_regardless_of_setting():
|
||||
adapter = _make_adapter(require_mention=True)
|
||||
assert _would_process(adapter, is_dm=True, text="hello") is True
|
||||
|
||||
|
||||
def test_mentioned_message_always_processed():
|
||||
adapter = _make_adapter(require_mention=True)
|
||||
assert _would_process(adapter, mentioned=True, text="what's up") is True
|
||||
|
||||
|
||||
def test_thread_reply_with_active_session_processed():
|
||||
adapter = _make_adapter(require_mention=True)
|
||||
assert _would_process(
|
||||
adapter, text="followup",
|
||||
thread_reply=True, active_session=True,
|
||||
) is True
|
||||
|
||||
|
||||
def test_thread_reply_without_active_session_ignored():
|
||||
adapter = _make_adapter(require_mention=True)
|
||||
assert _would_process(
|
||||
adapter, text="followup",
|
||||
thread_reply=True, active_session=False,
|
||||
) is False
|
||||
|
||||
|
||||
def test_bot_uid_none_processes_channel_message():
|
||||
"""When bot_uid is None (before auth_test), channel messages pass through.
|
||||
|
||||
This preserves the old behavior: the gating block is skipped entirely
|
||||
when bot_uid is falsy, so messages are not silently dropped during
|
||||
startup or for new workspaces.
|
||||
"""
|
||||
adapter = _make_adapter(require_mention=True)
|
||||
adapter._bot_user_id = None
|
||||
adapter._team_bot_user_ids = {}
|
||||
|
||||
# With bot_uid=None, the `if not is_dm and bot_uid:` condition is False,
|
||||
# so the gating block is skipped — message passes through.
|
||||
bot_uid = adapter._team_bot_user_ids.get("T1", adapter._bot_user_id)
|
||||
assert bot_uid is None
|
||||
|
||||
# Simulate: gating block not entered when bot_uid is falsy
|
||||
is_dm = False
|
||||
if not is_dm and bot_uid:
|
||||
result = False # would enter gating
|
||||
else:
|
||||
result = True # gating skipped, message processed
|
||||
assert result is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: config bridging
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_config_bridges_slack_free_response_channels(monkeypatch, tmp_path):
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
"slack:\n"
|
||||
" require_mention: false\n"
|
||||
" free_response_channels:\n"
|
||||
" - C0AQWDLHY9M\n"
|
||||
" - C9999999999\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("SLACK_FREE_RESPONSE_CHANNELS", raising=False)
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config is not None
|
||||
slack_extra = config.platforms[Platform.SLACK].extra
|
||||
assert slack_extra.get("require_mention") is False
|
||||
assert slack_extra.get("free_response_channels") == ["C0AQWDLHY9M", "C9999999999"]
|
||||
# Verify env vars were set by config bridging
|
||||
import os as _os
|
||||
assert _os.environ["SLACK_REQUIRE_MENTION"] == "false"
|
||||
assert _os.environ["SLACK_FREE_RESPONSE_CHANNELS"] == "C0AQWDLHY9M,C9999999999"
|
||||
@@ -4,7 +4,7 @@ import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -355,7 +355,8 @@ class TestMediaUpload:
|
||||
assert calls[3][1]["chunk_index"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_remote_bytes_rejects_large_content_length(self):
|
||||
@patch("tools.url_safety.is_safe_url", return_value=True)
|
||||
async def test_download_remote_bytes_rejects_large_content_length(self, _mock_safe):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
class FakeResponse:
|
||||
|
||||
@@ -628,14 +628,21 @@ class TestHasAnyProviderConfigured:
|
||||
def test_claude_code_creds_ignored_on_fresh_install(self, monkeypatch, tmp_path):
|
||||
"""Claude Code credentials should NOT skip the wizard when Hermes is unconfigured."""
|
||||
from hermes_cli import config as config_module
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||
# Clear all provider env vars so earlier checks don't short-circuit
|
||||
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"):
|
||||
_all_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"}
|
||||
for pconfig in PROVIDER_REGISTRY.values():
|
||||
if pconfig.auth_type == "api_key":
|
||||
_all_vars.update(pconfig.api_key_env_vars)
|
||||
for var in _all_vars:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
# Prevent gh-cli / copilot auth fallback from leaking in
|
||||
monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda _pid: {})
|
||||
# Simulate valid Claude Code credentials
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.read_claude_code_credentials",
|
||||
@@ -710,6 +717,7 @@ class TestHasAnyProviderConfigured:
|
||||
"""config.yaml model dict with empty default and no creds stays false."""
|
||||
import yaml
|
||||
from hermes_cli import config as config_module
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_file = hermes_home / "config.yaml"
|
||||
@@ -719,9 +727,15 @@ class TestHasAnyProviderConfigured:
|
||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"):
|
||||
_all_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"}
|
||||
for pconfig in PROVIDER_REGISTRY.values():
|
||||
if pconfig.auth_type == "api_key":
|
||||
_all_vars.update(pconfig.api_key_env_vars)
|
||||
for var in _all_vars:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
# Prevent gh-cli / copilot auth fallback from leaking in
|
||||
monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda _pid: {})
|
||||
from hermes_cli.main import _has_any_provider_configured
|
||||
assert _has_any_provider_configured() is False
|
||||
|
||||
@@ -941,9 +955,10 @@ class TestHuggingFaceModels:
|
||||
"""Every HF model should have a context length entry."""
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS
|
||||
lower_keys = {k.lower() for k in DEFAULT_CONTEXT_LENGTHS}
|
||||
hf_models = _PROVIDER_MODELS["huggingface"]
|
||||
for model in hf_models:
|
||||
assert model in DEFAULT_CONTEXT_LENGTHS, (
|
||||
assert model.lower() in lower_keys, (
|
||||
f"HF model {model!r} missing from DEFAULT_CONTEXT_LENGTHS"
|
||||
)
|
||||
|
||||
|
||||
@@ -49,6 +49,30 @@ def test_chat_subcommand_accepts_skills_flag(monkeypatch):
|
||||
}
|
||||
|
||||
|
||||
def test_chat_subcommand_accepts_image_flag(monkeypatch):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_cmd_chat(args):
|
||||
captured["query"] = args.query
|
||||
captured["image"] = args.image
|
||||
|
||||
monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["hermes", "chat", "-q", "hello", "--image", "~/storage/shared/Pictures/cat.png"],
|
||||
)
|
||||
|
||||
main_mod.main()
|
||||
|
||||
assert captured == {
|
||||
"query": "hello",
|
||||
"image": "~/storage/shared/Pictures/cat.png",
|
||||
}
|
||||
|
||||
|
||||
def test_continue_worktree_and_skills_flags_work_together(monkeypatch):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
|
||||
@@ -68,6 +68,17 @@ class TestCommandRegistry:
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
assert cmd.category in valid_categories, f"{cmd.name} has invalid category '{cmd.category}'"
|
||||
|
||||
def test_reasoning_subcommands_are_in_logical_order(self):
|
||||
reasoning = next(cmd for cmd in COMMAND_REGISTRY if cmd.name == "reasoning")
|
||||
assert reasoning.subcommands[:6] == (
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
)
|
||||
|
||||
def test_cli_only_and_gateway_only_are_mutually_exclusive(self):
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
assert not (cmd.cli_only and cmd.gateway_only), \
|
||||
@@ -428,8 +439,8 @@ class TestSlashCommandCompleter:
|
||||
class TestSubcommands:
|
||||
def test_explicit_subcommands_extracted(self):
|
||||
"""Commands with explicit subcommands on CommandDef are extracted."""
|
||||
assert "/prompt" in SUBCOMMANDS
|
||||
assert "clear" in SUBCOMMANDS["/prompt"]
|
||||
assert "/skills" in SUBCOMMANDS
|
||||
assert "install" in SUBCOMMANDS["/skills"]
|
||||
|
||||
def test_reasoning_has_subcommands(self):
|
||||
assert "/reasoning" in SUBCOMMANDS
|
||||
|
||||
@@ -14,6 +14,23 @@ from hermes_cli import doctor as doctor_mod
|
||||
from hermes_cli.doctor import _has_provider_env_config
|
||||
|
||||
|
||||
class TestDoctorPlatformHints:
|
||||
def test_termux_package_hint(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
assert doctor._is_termux() is True
|
||||
assert doctor._python_install_cmd() == "python -m pip install"
|
||||
assert doctor._system_package_install_cmd("ripgrep") == "pkg install ripgrep"
|
||||
|
||||
def test_non_termux_package_hint_defaults_to_apt(self, monkeypatch):
|
||||
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
||||
monkeypatch.setenv("PREFIX", "/usr")
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
assert doctor._is_termux() is False
|
||||
assert doctor._python_install_cmd() == "uv pip install"
|
||||
assert doctor._system_package_install_cmd("ripgrep") == "sudo apt install ripgrep"
|
||||
|
||||
|
||||
class TestProviderEnvDetection:
|
||||
def test_detects_openai_api_key(self):
|
||||
content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=***"
|
||||
@@ -206,3 +223,72 @@ class TestDoctorMemoryProviderSection:
|
||||
out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="mem0")
|
||||
assert "Memory Provider" in out
|
||||
assert "Built-in memory active" not in out
|
||||
|
||||
|
||||
def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkeypatch, tmp_path):
|
||||
helper = TestDoctorMemoryProviderSection()
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
|
||||
real_which = doctor_mod.shutil.which
|
||||
|
||||
def fake_which(cmd):
|
||||
if cmd in {"docker", "node", "npm"}:
|
||||
return None
|
||||
return real_which(cmd)
|
||||
|
||||
monkeypatch.setattr(doctor_mod.shutil, "which", fake_which)
|
||||
|
||||
out = helper._run_doctor_and_capture(monkeypatch, tmp_path, provider="")
|
||||
|
||||
assert "Docker backend is not available inside Termux" in out
|
||||
assert "Node.js not found (browser tools are optional in the tested Termux path)" in out
|
||||
assert "Install Node.js on Termux with: pkg install nodejs" in out
|
||||
assert "Termux browser setup:" in out
|
||||
assert "1) pkg install nodejs" in out
|
||||
assert "2) npm install -g agent-browser" in out
|
||||
assert "3) agent-browser install" in out
|
||||
assert "docker not found (optional)" not in out
|
||||
|
||||
|
||||
def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(monkeypatch, tmp_path):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
|
||||
project = tmp_path / "project"
|
||||
project.mkdir(exist_ok=True)
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
||||
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
|
||||
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
||||
monkeypatch.setattr(doctor_mod.shutil, "which", lambda cmd: "/data/data/com.termux/files/usr/bin/node" if cmd in {"node", "npm"} else None)
|
||||
|
||||
fake_model_tools = types.SimpleNamespace(
|
||||
check_tool_availability=lambda *a, **kw: (["terminal"], [{"name": "browser", "env_vars": [], "tools": ["browser_navigate"]}]),
|
||||
TOOLSET_REQUIREMENTS={
|
||||
"terminal": {"name": "terminal"},
|
||||
"browser": {"name": "browser"},
|
||||
},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import io, contextlib
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
doctor_mod.run_doctor(Namespace(fix=False))
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "✓ browser" not in out
|
||||
assert "browser" in out
|
||||
assert "system dependency not met" in out
|
||||
assert "agent-browser is not installed (expected in the tested Termux path)" in out
|
||||
assert "npm install -g agent-browser && agent-browser install" in out
|
||||
|
||||
@@ -10,6 +10,7 @@ import hermes_cli.gateway as gateway
|
||||
class TestSystemdLingerStatus:
|
||||
def test_reports_enabled(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setenv("USER", "alice")
|
||||
monkeypatch.setattr(
|
||||
gateway.subprocess,
|
||||
@@ -22,6 +23,7 @@ class TestSystemdLingerStatus:
|
||||
|
||||
def test_reports_disabled(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setenv("USER", "alice")
|
||||
monkeypatch.setattr(
|
||||
gateway.subprocess,
|
||||
@@ -32,6 +34,11 @@ class TestSystemdLingerStatus:
|
||||
|
||||
assert gateway.get_systemd_linger_status() == (False, "")
|
||||
|
||||
def test_reports_termux_as_not_supported(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: True)
|
||||
|
||||
assert gateway.get_systemd_linger_status() == (None, "not supported in Termux")
|
||||
|
||||
|
||||
def test_systemd_status_warns_when_linger_disabled(monkeypatch, tmp_path, capsys):
|
||||
unit_path = tmp_path / "hermes-gateway.service"
|
||||
|
||||
@@ -8,6 +8,7 @@ import hermes_cli.gateway as gateway
|
||||
class TestEnsureLingerEnabled:
|
||||
def test_linger_already_enabled_via_file(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
|
||||
monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: True))
|
||||
|
||||
@@ -22,6 +23,7 @@ class TestEnsureLingerEnabled:
|
||||
|
||||
def test_status_enabled_skips_enable(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
|
||||
monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False))
|
||||
monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (True, ""))
|
||||
@@ -37,6 +39,7 @@ class TestEnsureLingerEnabled:
|
||||
|
||||
def test_loginctl_success_enables_linger(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
|
||||
monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False))
|
||||
monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (False, ""))
|
||||
@@ -59,6 +62,7 @@ class TestEnsureLingerEnabled:
|
||||
|
||||
def test_missing_loginctl_shows_manual_guidance(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
|
||||
monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False))
|
||||
monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (None, "loginctl not found"))
|
||||
@@ -76,6 +80,7 @@ class TestEnsureLingerEnabled:
|
||||
|
||||
def test_loginctl_failure_shows_manual_guidance(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
|
||||
monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False))
|
||||
monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (False, ""))
|
||||
|
||||
@@ -109,7 +109,8 @@ class TestGatewayStopCleanup:
|
||||
unit_path = tmp_path / "hermes-gateway.service"
|
||||
unit_path.write_text("unit\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path)
|
||||
|
||||
@@ -134,7 +135,8 @@ class TestGatewayStopCleanup:
|
||||
unit_path = tmp_path / "hermes-gateway.service"
|
||||
unit_path.write_text("unit\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path)
|
||||
|
||||
@@ -256,7 +258,8 @@ class TestGatewayServiceDetection:
|
||||
user_unit = SimpleNamespace(exists=lambda: True)
|
||||
system_unit = SimpleNamespace(exists=lambda: True)
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
@@ -278,7 +281,8 @@ class TestGatewayServiceDetection:
|
||||
|
||||
class TestGatewaySystemServiceRouting:
|
||||
def test_gateway_install_passes_system_flags(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
|
||||
calls = []
|
||||
@@ -294,11 +298,30 @@ class TestGatewaySystemServiceRouting:
|
||||
|
||||
assert calls == [(True, True, "alice")]
|
||||
|
||||
def test_gateway_install_reports_termux_manual_mode(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
|
||||
try:
|
||||
gateway_cli.gateway_command(
|
||||
SimpleNamespace(gateway_command="install", force=False, system=False, run_as_user=None)
|
||||
)
|
||||
except SystemExit as exc:
|
||||
assert exc.code == 1
|
||||
else:
|
||||
raise AssertionError("Expected gateway_command to exit on unsupported Termux service install")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "not supported on Termux" in out
|
||||
assert "Run manually: hermes gateway" in out
|
||||
|
||||
def test_gateway_status_prefers_system_service_when_only_system_unit_exists(self, monkeypatch):
|
||||
user_unit = SimpleNamespace(exists=lambda: False)
|
||||
system_unit = SimpleNamespace(exists=lambda: True)
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
@@ -313,6 +336,20 @@ class TestGatewaySystemServiceRouting:
|
||||
|
||||
assert calls == [(False, False)]
|
||||
|
||||
def test_gateway_status_on_termux_shows_manual_guidance(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "find_gateway_pids", lambda exclude_pids=None: [])
|
||||
monkeypatch.setattr(gateway_cli, "_runtime_health_lines", lambda: [])
|
||||
|
||||
gateway_cli.gateway_command(SimpleNamespace(gateway_command="status", deep=False, system=False))
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Gateway is not running" in out
|
||||
assert "nohup hermes gateway" in out
|
||||
assert "install as user service" not in out
|
||||
|
||||
def test_gateway_restart_does_not_fallback_to_foreground_when_launchd_restart_fails(self, tmp_path, monkeypatch):
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
plist_path.write_text("plist\n", encoding="utf-8")
|
||||
@@ -513,12 +550,22 @@ class TestGeneratedUnitUsesDetectedVenv:
|
||||
class TestGeneratedUnitIncludesLocalBin:
|
||||
"""~/.local/bin must be in PATH so uvx/pipx tools are discoverable."""
|
||||
|
||||
def test_user_unit_includes_local_bin_in_path(self):
|
||||
def test_user_unit_includes_local_bin_in_path(self, monkeypatch):
|
||||
home = Path.home()
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_build_user_local_paths",
|
||||
lambda home_path, existing: [str(home / ".local" / "bin")],
|
||||
)
|
||||
unit = gateway_cli.generate_systemd_unit(system=False)
|
||||
home = str(Path.home())
|
||||
assert f"{home}/.local/bin" in unit
|
||||
|
||||
def test_system_unit_includes_local_bin_in_path(self):
|
||||
def test_system_unit_includes_local_bin_in_path(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_build_user_local_paths",
|
||||
lambda home_path, existing: [str(home_path / ".local" / "bin")],
|
||||
)
|
||||
unit = gateway_cli.generate_systemd_unit(system=True)
|
||||
# System unit uses the resolved home dir from _system_service_identity
|
||||
assert "/.local/bin" in unit
|
||||
|
||||
34
tests/hermes_cli/test_reasoning_effort_menu.py
Normal file
34
tests/hermes_cli/test_reasoning_effort_menu.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
from hermes_cli.main import _prompt_reasoning_effort_selection
|
||||
|
||||
|
||||
class _FakeTerminalMenu:
|
||||
last_choices = None
|
||||
|
||||
def __init__(self, choices, **kwargs):
|
||||
_FakeTerminalMenu.last_choices = choices
|
||||
self._cursor_index = kwargs.get("cursor_index")
|
||||
|
||||
def show(self):
|
||||
return self._cursor_index
|
||||
|
||||
|
||||
def test_reasoning_menu_orders_minimal_before_low(monkeypatch):
|
||||
fake_module = types.SimpleNamespace(TerminalMenu=_FakeTerminalMenu)
|
||||
monkeypatch.setitem(sys.modules, "simple_term_menu", fake_module)
|
||||
|
||||
selected = _prompt_reasoning_effort_selection(
|
||||
["low", "minimal", "medium", "high"],
|
||||
current_effort="medium",
|
||||
)
|
||||
|
||||
assert selected == "medium"
|
||||
assert _FakeTerminalMenu.last_choices[:4] == [
|
||||
" minimal",
|
||||
" low",
|
||||
" medium ← currently in use",
|
||||
" high",
|
||||
]
|
||||
21
tests/hermes_cli/test_setup_hermes_script.py
Normal file
21
tests/hermes_cli/test_setup_hermes_script.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SETUP_SCRIPT = REPO_ROOT / "setup-hermes.sh"
|
||||
|
||||
|
||||
def test_setup_hermes_script_is_valid_shell():
|
||||
result = subprocess.run(["bash", "-n", str(SETUP_SCRIPT)], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_setup_hermes_script_has_termux_path():
|
||||
content = SETUP_SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
assert "is_termux()" in content
|
||||
assert ".[termux]" in content
|
||||
assert "constraints-termux.txt" in content
|
||||
assert "$PREFIX/bin" in content
|
||||
assert "Skipping tinker-atropos on Termux" in content
|
||||
@@ -44,7 +44,7 @@ class TestOfferOpenclawMigration:
|
||||
assert setup_mod._offer_openclaw_migration(tmp_path / ".hermes") is False
|
||||
|
||||
def test_runs_migration_when_user_accepts(self, tmp_path):
|
||||
"""Should dynamically load the script and run the Migrator."""
|
||||
"""Should run dry-run preview first, then execute after confirmation."""
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
openclaw_dir.mkdir()
|
||||
|
||||
@@ -60,6 +60,7 @@ class TestOfferOpenclawMigration:
|
||||
fake_migrator = MagicMock()
|
||||
fake_migrator.migrate.return_value = {
|
||||
"summary": {"migrated": 3, "skipped": 1, "conflict": 0, "error": 0},
|
||||
"items": [{"kind": "config", "status": "migrated", "destination": "/tmp/x"}],
|
||||
"output_dir": str(hermes_home / "migration"),
|
||||
}
|
||||
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
||||
@@ -70,6 +71,7 @@ class TestOfferOpenclawMigration:
|
||||
with (
|
||||
patch("hermes_cli.setup.Path.home", return_value=tmp_path),
|
||||
patch.object(setup_mod, "_OPENCLAW_SCRIPT", script),
|
||||
# Both prompts answered Yes: preview offer + proceed confirmation
|
||||
patch.object(setup_mod, "prompt_yes_no", return_value=True),
|
||||
patch.object(setup_mod, "get_config_path", return_value=config_path),
|
||||
patch("importlib.util.spec_from_file_location") as mock_spec_fn,
|
||||
@@ -91,13 +93,75 @@ class TestOfferOpenclawMigration:
|
||||
fake_mod.resolve_selected_options.assert_called_once_with(
|
||||
None, None, preset="full"
|
||||
)
|
||||
fake_mod.Migrator.assert_called_once()
|
||||
call_kwargs = fake_mod.Migrator.call_args[1]
|
||||
assert call_kwargs["execute"] is True
|
||||
assert call_kwargs["overwrite"] is True
|
||||
assert call_kwargs["migrate_secrets"] is True
|
||||
assert call_kwargs["preset_name"] == "full"
|
||||
fake_migrator.migrate.assert_called_once()
|
||||
# Migrator called twice: once for dry-run preview, once for execution
|
||||
assert fake_mod.Migrator.call_count == 2
|
||||
|
||||
# First call: dry-run preview (execute=False, overwrite=True to show all)
|
||||
preview_kwargs = fake_mod.Migrator.call_args_list[0][1]
|
||||
assert preview_kwargs["execute"] is False
|
||||
assert preview_kwargs["overwrite"] is True
|
||||
assert preview_kwargs["migrate_secrets"] is True
|
||||
assert preview_kwargs["preset_name"] == "full"
|
||||
|
||||
# Second call: actual execution (execute=True, overwrite=False to preserve)
|
||||
exec_kwargs = fake_mod.Migrator.call_args_list[1][1]
|
||||
assert exec_kwargs["execute"] is True
|
||||
assert exec_kwargs["overwrite"] is False
|
||||
assert exec_kwargs["migrate_secrets"] is True
|
||||
assert exec_kwargs["preset_name"] == "full"
|
||||
|
||||
# migrate() called twice (once per Migrator instance)
|
||||
assert fake_migrator.migrate.call_count == 2
|
||||
|
||||
def test_user_declines_after_preview(self, tmp_path):
|
||||
"""Should return False when user sees preview but declines to proceed."""
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
openclaw_dir.mkdir()
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text("agent:\n max_turns: 90\n")
|
||||
|
||||
fake_mod = ModuleType("openclaw_to_hermes")
|
||||
fake_mod.resolve_selected_options = MagicMock(return_value={"soul", "memory"})
|
||||
fake_migrator = MagicMock()
|
||||
fake_migrator.migrate.return_value = {
|
||||
"summary": {"migrated": 3, "skipped": 0, "conflict": 0, "error": 0},
|
||||
"items": [{"kind": "config", "status": "migrated", "destination": "/tmp/x"}],
|
||||
}
|
||||
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
||||
|
||||
script = tmp_path / "openclaw_to_hermes.py"
|
||||
script.write_text("# placeholder")
|
||||
|
||||
# First prompt (preview): Yes, Second prompt (proceed): No
|
||||
prompt_responses = iter([True, False])
|
||||
|
||||
with (
|
||||
patch("hermes_cli.setup.Path.home", return_value=tmp_path),
|
||||
patch.object(setup_mod, "_OPENCLAW_SCRIPT", script),
|
||||
patch.object(setup_mod, "prompt_yes_no", side_effect=prompt_responses),
|
||||
patch.object(setup_mod, "get_config_path", return_value=config_path),
|
||||
patch("importlib.util.spec_from_file_location") as mock_spec_fn,
|
||||
):
|
||||
mock_spec = MagicMock()
|
||||
mock_spec.loader = MagicMock()
|
||||
mock_spec_fn.return_value = mock_spec
|
||||
|
||||
def exec_module(mod):
|
||||
mod.resolve_selected_options = fake_mod.resolve_selected_options
|
||||
mod.Migrator = fake_mod.Migrator
|
||||
|
||||
mock_spec.loader.exec_module = exec_module
|
||||
|
||||
result = setup_mod._offer_openclaw_migration(hermes_home)
|
||||
|
||||
assert result is False
|
||||
# Only dry-run Migrator was created, not the execute one
|
||||
assert fake_mod.Migrator.call_count == 1
|
||||
preview_kwargs = fake_mod.Migrator.call_args[1]
|
||||
assert preview_kwargs["execute"] is False
|
||||
|
||||
def test_handles_migration_error_gracefully(self, tmp_path):
|
||||
"""Should catch exceptions and return False."""
|
||||
|
||||
@@ -12,3 +12,33 @@ def test_show_status_includes_tavily_key(monkeypatch, capsys, tmp_path):
|
||||
output = capsys.readouterr().out
|
||||
assert "Tavily" in output
|
||||
assert "tvly...cdef" in output
|
||||
|
||||
|
||||
def test_show_status_termux_gateway_section_skips_systemctl(monkeypatch, capsys, tmp_path):
|
||||
from hermes_cli import status as status_mod
|
||||
import hermes_cli.auth as auth_mod
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr(status_mod, "get_env_path", lambda: tmp_path / ".env", raising=False)
|
||||
monkeypatch.setattr(status_mod, "get_hermes_home", lambda: tmp_path, raising=False)
|
||||
monkeypatch.setattr(status_mod, "load_config", lambda: {"model": "gpt-5.4"}, raising=False)
|
||||
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "openai-codex", raising=False)
|
||||
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "openai-codex", raising=False)
|
||||
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "OpenAI Codex", raising=False)
|
||||
monkeypatch.setattr(auth_mod, "get_nous_auth_status", lambda: {}, raising=False)
|
||||
monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False)
|
||||
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False)
|
||||
|
||||
def _unexpected_systemctl(*args, **kwargs):
|
||||
raise AssertionError("systemctl should not be called in the Termux status view")
|
||||
|
||||
monkeypatch.setattr(status_mod.subprocess, "run", _unexpected_systemctl)
|
||||
|
||||
status_mod.show_status(SimpleNamespace(all=False, deep=False))
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Manager: Termux / manual process" in output
|
||||
assert "Start with: hermes gateway" in output
|
||||
assert "systemd (user)" not in output
|
||||
|
||||
@@ -354,6 +354,14 @@ def test_first_install_nous_auto_configures_managed_defaults(monkeypatch):
|
||||
lambda *args, **kwargs: {"web", "image_gen", "tts", "browser"},
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.tools_config.save_config", lambda config: None)
|
||||
# Prevent leaked platform tokens (e.g. DISCORD_BOT_TOKEN from gateway.run
|
||||
# import) from adding extra platforms. The loop in tools_command runs
|
||||
# apply_nous_managed_defaults per platform; a second iteration sees values
|
||||
# set by the first as "explicit" and skips them.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_enabled_platforms",
|
||||
lambda: ["cli"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.nous_subscription.get_nous_auth_status",
|
||||
lambda: {"logged_in": True},
|
||||
|
||||
@@ -368,6 +368,8 @@ class TestCmdUpdateLaunchdRestart:
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "is_macos", lambda: False,
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
commit_count="3",
|
||||
@@ -426,7 +428,8 @@ class TestCmdUpdateSystemService:
|
||||
):
|
||||
"""When user systemd is inactive but a system service exists, restart via system scope."""
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
commit_count="3",
|
||||
@@ -455,7 +458,8 @@ class TestCmdUpdateSystemService:
|
||||
):
|
||||
"""When system service restart fails, show the failure message."""
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
commit_count="3",
|
||||
@@ -477,7 +481,8 @@ class TestCmdUpdateSystemService:
|
||||
):
|
||||
"""When both user and system services are active, both are restarted."""
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
commit_count="3",
|
||||
@@ -560,7 +565,8 @@ class TestServicePidExclusion:
|
||||
):
|
||||
"""After systemd restart, the sweep must exclude the service PID."""
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
|
||||
SERVICE_PID = 55000
|
||||
|
||||
@@ -639,7 +645,8 @@ class TestGetServicePids:
|
||||
"""Unit tests for _get_service_pids()."""
|
||||
|
||||
def test_returns_systemd_main_pid(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
@@ -688,7 +695,8 @@ class TestGetServicePids:
|
||||
|
||||
def test_excludes_zero_pid(self, monkeypatch):
|
||||
"""systemd returns MainPID=0 for stopped services; skip those."""
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
|
||||
@@ -5,6 +5,7 @@ pieces. The OpenAI client and tool loading are mocked so no network calls
|
||||
are made.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -1061,6 +1062,77 @@ class TestExecuteToolCalls:
|
||||
assert len(messages[0]["content"]) < 150_000
|
||||
assert ("Truncated" in messages[0]["content"] or "<persisted-output>" in messages[0]["content"])
|
||||
|
||||
def test_quiet_tool_output_suppressed_when_progress_callback_present(self, agent):
|
||||
tc = _mock_tool_call(name="web_search", arguments='{"q":"test"}', call_id="c1")
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc])
|
||||
messages = []
|
||||
agent.tool_progress_callback = lambda *args, **kwargs: None
|
||||
|
||||
with patch("run_agent.handle_function_call", return_value="search result"), \
|
||||
patch.object(agent, "_safe_print") as mock_print:
|
||||
agent._execute_tool_calls(mock_msg, messages, "task-1")
|
||||
|
||||
mock_print.assert_not_called()
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["role"] == "tool"
|
||||
|
||||
def test_quiet_tool_output_prints_without_progress_callback(self, agent):
|
||||
tc = _mock_tool_call(name="web_search", arguments='{"q":"test"}', call_id="c1")
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc])
|
||||
messages = []
|
||||
agent.tool_progress_callback = None
|
||||
|
||||
with patch("run_agent.handle_function_call", return_value="search result"), \
|
||||
patch.object(agent, "_safe_print") as mock_print:
|
||||
agent._execute_tool_calls(mock_msg, messages, "task-1")
|
||||
|
||||
mock_print.assert_called_once()
|
||||
assert "search" in str(mock_print.call_args.args[0]).lower()
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["role"] == "tool"
|
||||
|
||||
def test_vprint_suppressed_in_parseable_quiet_mode(self, agent):
|
||||
agent.suppress_status_output = True
|
||||
|
||||
with patch.object(agent, "_safe_print") as mock_print:
|
||||
agent._vprint("status line", force=True)
|
||||
agent._vprint("normal line")
|
||||
|
||||
mock_print.assert_not_called()
|
||||
|
||||
def test_run_conversation_suppresses_retry_noise_in_parseable_quiet_mode(self, agent):
|
||||
class _RateLimitError(Exception):
|
||||
status_code = 429
|
||||
|
||||
def __str__(self):
|
||||
return "Error code: 429 - Rate limit exceeded."
|
||||
|
||||
responses = [_RateLimitError(), _mock_response(content="Recovered")]
|
||||
|
||||
def _fake_api_call(api_kwargs):
|
||||
result = responses.pop(0)
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
|
||||
agent.suppress_status_output = True
|
||||
agent._interruptible_api_call = _fake_api_call
|
||||
agent._persist_session = lambda *args, **kwargs: None
|
||||
agent._save_trajectory = lambda *args, **kwargs: None
|
||||
agent._save_session_log = lambda *args, **kwargs: None
|
||||
|
||||
captured = io.StringIO()
|
||||
agent._print_fn = lambda *args, **kw: print(*args, file=captured, **kw)
|
||||
|
||||
with patch("run_agent.time.sleep", return_value=None):
|
||||
result = agent.run_conversation("hello")
|
||||
|
||||
assert result["completed"] is True
|
||||
assert result["final_response"] == "Recovered"
|
||||
output = captured.getvalue()
|
||||
assert "API call failed" not in output
|
||||
assert "Rate limit reached" not in output
|
||||
|
||||
|
||||
class TestConcurrentToolExecution:
|
||||
"""Tests for _execute_tool_calls_concurrent and dispatch logic."""
|
||||
|
||||
@@ -211,14 +211,15 @@ class TestExchangeAuthCode:
|
||||
assert setup_module.PENDING_AUTH_PATH.exists()
|
||||
assert not setup_module.TOKEN_PATH.exists()
|
||||
|
||||
def test_refuses_to_overwrite_existing_token_with_narrower_scopes(self, setup_module, capsys):
|
||||
def test_accepts_narrower_scopes_with_warning(self, setup_module, capsys):
|
||||
"""Partial scopes are accepted with a warning (gws migration: v2.0)."""
|
||||
setup_module.PENDING_AUTH_PATH.write_text(
|
||||
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
|
||||
)
|
||||
setup_module.TOKEN_PATH.write_text(json.dumps({"token": "existing-token", "scopes": setup_module.SCOPES}))
|
||||
setup_module.TOKEN_PATH.write_text(json.dumps({"token": "***", "scopes": setup_module.SCOPES}))
|
||||
FakeFlow.credentials_payload = {
|
||||
"token": "narrow-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"token": "***",
|
||||
"refresh_token": "***",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
@@ -228,10 +229,12 @@ class TestExchangeAuthCode:
|
||||
],
|
||||
}
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
setup_module.exchange_auth_code("4/test-auth-code")
|
||||
setup_module.exchange_auth_code("4/test-auth-code")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "refusing to save incomplete google workspace token" in out.lower()
|
||||
assert json.loads(setup_module.TOKEN_PATH.read_text())["token"] == "existing-token"
|
||||
assert setup_module.PENDING_AUTH_PATH.exists()
|
||||
assert "warning" in out.lower()
|
||||
assert "missing" in out.lower()
|
||||
# Token is saved (partial scopes accepted)
|
||||
assert setup_module.TOKEN_PATH.exists()
|
||||
# Pending auth is cleaned up
|
||||
assert not setup_module.PENDING_AUTH_PATH.exists()
|
||||
|
||||
@@ -1,117 +1,175 @@
|
||||
"""Regression tests for Google Workspace API credential validation."""
|
||||
"""Tests for Google Workspace gws bridge and CLI wrapper."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SCRIPT_PATH = (
|
||||
BRIDGE_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "skills/productivity/google-workspace/scripts/gws_bridge.py"
|
||||
)
|
||||
API_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "skills/productivity/google-workspace/scripts/google_api.py"
|
||||
)
|
||||
|
||||
|
||||
class FakeAuthorizedCredentials:
|
||||
def __init__(self, *, valid=True, expired=False, refresh_token="refresh-token"):
|
||||
self.valid = valid
|
||||
self.expired = expired
|
||||
self.refresh_token = refresh_token
|
||||
self.refresh_calls = 0
|
||||
|
||||
def refresh(self, _request):
|
||||
self.refresh_calls += 1
|
||||
self.valid = True
|
||||
self.expired = False
|
||||
|
||||
def to_json(self):
|
||||
return json.dumps({
|
||||
"token": "refreshed-token",
|
||||
"refresh_token": self.refresh_token,
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"scopes": [
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/contacts.readonly",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/documents.readonly",
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
class FakeCredentialsFactory:
|
||||
creds = FakeAuthorizedCredentials()
|
||||
|
||||
@classmethod
|
||||
def from_authorized_user_file(cls, _path, _scopes):
|
||||
return cls.creds
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def google_api_module(monkeypatch, tmp_path):
|
||||
google_module = types.ModuleType("google")
|
||||
oauth2_module = types.ModuleType("google.oauth2")
|
||||
credentials_module = types.ModuleType("google.oauth2.credentials")
|
||||
credentials_module.Credentials = FakeCredentialsFactory
|
||||
auth_module = types.ModuleType("google.auth")
|
||||
transport_module = types.ModuleType("google.auth.transport")
|
||||
requests_module = types.ModuleType("google.auth.transport.requests")
|
||||
requests_module.Request = object
|
||||
def bridge_module(monkeypatch, tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
monkeypatch.setitem(sys.modules, "google", google_module)
|
||||
monkeypatch.setitem(sys.modules, "google.oauth2", oauth2_module)
|
||||
monkeypatch.setitem(sys.modules, "google.oauth2.credentials", credentials_module)
|
||||
monkeypatch.setitem(sys.modules, "google.auth", auth_module)
|
||||
monkeypatch.setitem(sys.modules, "google.auth.transport", transport_module)
|
||||
monkeypatch.setitem(sys.modules, "google.auth.transport.requests", requests_module)
|
||||
|
||||
spec = importlib.util.spec_from_file_location("google_workspace_api_test", SCRIPT_PATH)
|
||||
spec = importlib.util.spec_from_file_location("gws_bridge_test", BRIDGE_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
monkeypatch.setattr(module, "TOKEN_PATH", tmp_path / "google_token.json")
|
||||
return module
|
||||
|
||||
|
||||
def _write_token(path: Path, scopes):
|
||||
path.write_text(json.dumps({
|
||||
"token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
@pytest.fixture
|
||||
def api_module(monkeypatch, tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
spec = importlib.util.spec_from_file_location("gws_api_test", API_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _write_token(path: Path, *, token="ya29.test", expiry=None, **extra):
|
||||
data = {
|
||||
"token": token,
|
||||
"refresh_token": "1//refresh",
|
||||
"client_id": "123.apps.googleusercontent.com",
|
||||
"client_secret": "secret",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"scopes": scopes,
|
||||
}))
|
||||
**extra,
|
||||
}
|
||||
if expiry is not None:
|
||||
data["expiry"] = expiry
|
||||
path.write_text(json.dumps(data))
|
||||
|
||||
|
||||
def test_get_credentials_rejects_missing_scopes(google_api_module, capsys):
|
||||
FakeCredentialsFactory.creds = FakeAuthorizedCredentials(valid=True)
|
||||
_write_token(google_api_module.TOKEN_PATH, [
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
])
|
||||
def test_bridge_returns_valid_token(bridge_module, tmp_path):
|
||||
"""Non-expired token is returned without refresh."""
|
||||
future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
|
||||
token_path = bridge_module.get_token_path()
|
||||
_write_token(token_path, token="ya29.valid", expiry=future)
|
||||
|
||||
result = bridge_module.get_valid_token()
|
||||
assert result == "ya29.valid"
|
||||
|
||||
|
||||
def test_bridge_refreshes_expired_token(bridge_module, tmp_path):
|
||||
"""Expired token triggers a refresh via token_uri."""
|
||||
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||
token_path = bridge_module.get_token_path()
|
||||
_write_token(token_path, token="ya29.old", expiry=past)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps({
|
||||
"access_token": "ya29.refreshed",
|
||||
"expires_in": 3600,
|
||||
}).encode()
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||
result = bridge_module.get_valid_token()
|
||||
|
||||
assert result == "ya29.refreshed"
|
||||
# Verify persisted
|
||||
saved = json.loads(token_path.read_text())
|
||||
assert saved["token"] == "ya29.refreshed"
|
||||
|
||||
|
||||
def test_bridge_exits_on_missing_token(bridge_module):
|
||||
"""Missing token file causes exit with code 1."""
|
||||
with pytest.raises(SystemExit):
|
||||
google_api_module.get_credentials()
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert "missing google workspace scopes" in err.lower()
|
||||
assert "gmail.send" in err
|
||||
bridge_module.get_valid_token()
|
||||
|
||||
|
||||
def test_get_credentials_accepts_full_scope_token(google_api_module):
|
||||
FakeCredentialsFactory.creds = FakeAuthorizedCredentials(valid=True)
|
||||
_write_token(google_api_module.TOKEN_PATH, list(google_api_module.SCOPES))
|
||||
def test_bridge_main_injects_token_env(bridge_module, tmp_path):
|
||||
"""main() sets GOOGLE_WORKSPACE_CLI_TOKEN in subprocess env."""
|
||||
future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
|
||||
token_path = bridge_module.get_token_path()
|
||||
_write_token(token_path, token="ya29.injected", expiry=future)
|
||||
|
||||
creds = google_api_module.get_credentials()
|
||||
captured = {}
|
||||
|
||||
assert creds is FakeCredentialsFactory.creds
|
||||
def capture_run(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["env"] = kwargs.get("env", {})
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
with patch.object(sys, "argv", ["gws_bridge.py", "gmail", "+triage"]):
|
||||
with patch.object(subprocess, "run", side_effect=capture_run):
|
||||
with pytest.raises(SystemExit):
|
||||
bridge_module.main()
|
||||
|
||||
assert captured["env"]["GOOGLE_WORKSPACE_CLI_TOKEN"] == "ya29.injected"
|
||||
assert captured["cmd"] == ["gws", "gmail", "+triage"]
|
||||
|
||||
|
||||
def test_api_calendar_list_uses_agenda_by_default(api_module):
|
||||
"""calendar list without dates uses +agenda helper."""
|
||||
captured = {}
|
||||
|
||||
def capture_run(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
args = api_module.argparse.Namespace(
|
||||
start="", end="", max=25, calendar="primary", func=api_module.calendar_list,
|
||||
)
|
||||
|
||||
with patch.object(subprocess, "run", side_effect=capture_run):
|
||||
with pytest.raises(SystemExit):
|
||||
api_module.calendar_list(args)
|
||||
|
||||
gws_args = captured["cmd"][2:] # skip python + bridge path
|
||||
assert "calendar" in gws_args
|
||||
assert "+agenda" in gws_args
|
||||
assert "--days" in gws_args
|
||||
|
||||
|
||||
def test_api_calendar_list_respects_date_range(api_module):
|
||||
"""calendar list with --start/--end uses raw events list API."""
|
||||
captured = {}
|
||||
|
||||
def capture_run(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
args = api_module.argparse.Namespace(
|
||||
start="2026-04-01T00:00:00Z",
|
||||
end="2026-04-07T23:59:59Z",
|
||||
max=25,
|
||||
calendar="primary",
|
||||
func=api_module.calendar_list,
|
||||
)
|
||||
|
||||
with patch.object(subprocess, "run", side_effect=capture_run):
|
||||
with pytest.raises(SystemExit):
|
||||
api_module.calendar_list(args)
|
||||
|
||||
gws_args = captured["cmd"][2:]
|
||||
assert "events" in gws_args
|
||||
assert "list" in gws_args
|
||||
params_idx = gws_args.index("--params")
|
||||
params = json.loads(gws_args[params_idx + 1])
|
||||
assert params["timeMin"] == "2026-04-01T00:00:00Z"
|
||||
assert params["timeMax"] == "2026-04-07T23:59:59Z"
|
||||
|
||||
319
tests/test_ctx_halving_fix.py
Normal file
319
tests/test_ctx_halving_fix.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""Tests for the context-halving bugfix.
|
||||
|
||||
Background
|
||||
----------
|
||||
When the API returns "max_tokens too large given prompt" (input is fine,
|
||||
but input_tokens + requested max_tokens > context_window), the old code
|
||||
incorrectly halved context_length via get_next_probe_tier().
|
||||
|
||||
The fix introduces:
|
||||
* parse_available_output_tokens_from_error() — detects this specific
|
||||
error class and returns the available output token budget.
|
||||
* _ephemeral_max_output_tokens on AIAgent — a one-shot override that
|
||||
caps the output for one retry without touching context_length.
|
||||
|
||||
Naming note
|
||||
-----------
|
||||
max_tokens = OUTPUT token cap (a single response).
|
||||
context_length = TOTAL context window (input + output combined).
|
||||
These are different and the old code conflated them; the fix keeps them
|
||||
separate.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_available_output_tokens_from_error — unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseAvailableOutputTokens:
|
||||
"""Pure-function tests; no I/O required."""
|
||||
|
||||
def _parse(self, msg):
|
||||
from agent.model_metadata import parse_available_output_tokens_from_error
|
||||
return parse_available_output_tokens_from_error(msg)
|
||||
|
||||
# ── Should detect and extract ────────────────────────────────────────
|
||||
|
||||
def test_anthropic_canonical_format(self):
|
||||
"""Canonical Anthropic error: max_tokens: X > context_window: Y - input_tokens: Z = available_tokens: W"""
|
||||
msg = (
|
||||
"max_tokens: 32768 > context_window: 200000 "
|
||||
"- input_tokens: 190000 = available_tokens: 10000"
|
||||
)
|
||||
assert self._parse(msg) == 10000
|
||||
|
||||
def test_anthropic_format_large_numbers(self):
|
||||
msg = (
|
||||
"max_tokens: 128000 > context_window: 200000 "
|
||||
"- input_tokens: 180000 = available_tokens: 20000"
|
||||
)
|
||||
assert self._parse(msg) == 20000
|
||||
|
||||
def test_available_tokens_variant_spacing(self):
|
||||
"""Handles extra spaces around the colon."""
|
||||
msg = "max_tokens: 32768 > 200000 available_tokens : 5000"
|
||||
assert self._parse(msg) == 5000
|
||||
|
||||
def test_available_tokens_natural_language(self):
|
||||
"""'available tokens: N' wording (no underscore)."""
|
||||
msg = "max_tokens must be at most 10000 given your prompt (available tokens: 10000)"
|
||||
assert self._parse(msg) == 10000
|
||||
|
||||
def test_single_token_available(self):
|
||||
"""Edge case: only 1 token left."""
|
||||
msg = "max_tokens: 9999 > context_window: 10000 - input_tokens: 9999 = available_tokens: 1"
|
||||
assert self._parse(msg) == 1
|
||||
|
||||
# ── Should NOT detect (returns None) ─────────────────────────────────
|
||||
|
||||
def test_prompt_too_long_is_not_output_cap_error(self):
|
||||
"""'prompt is too long' errors must NOT be caught — they need context halving."""
|
||||
msg = "prompt is too long: 205000 tokens > 200000 maximum"
|
||||
assert self._parse(msg) is None
|
||||
|
||||
def test_generic_context_window_exceeded(self):
|
||||
"""Generic context window errors without available_tokens should not match."""
|
||||
msg = "context window exceeded: maximum is 32768 tokens"
|
||||
assert self._parse(msg) is None
|
||||
|
||||
def test_context_length_exceeded(self):
|
||||
msg = "context_length_exceeded: prompt has 131073 tokens, limit is 131072"
|
||||
assert self._parse(msg) is None
|
||||
|
||||
def test_no_max_tokens_keyword(self):
|
||||
"""Error not related to max_tokens at all."""
|
||||
msg = "invalid_api_key: the API key is invalid"
|
||||
assert self._parse(msg) is None
|
||||
|
||||
def test_empty_string(self):
|
||||
assert self._parse("") is None
|
||||
|
||||
def test_rate_limit_error(self):
|
||||
msg = "rate_limit_error: too many requests per minute"
|
||||
assert self._parse(msg) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_anthropic_kwargs — output cap clamping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildAnthropicKwargsClamping:
|
||||
"""The context_length clamp only fires when output ceiling > window.
|
||||
For standard Anthropic models (output ceiling < window) it must not fire.
|
||||
"""
|
||||
|
||||
def _build(self, model, max_tokens=None, context_length=None):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
return build_anthropic_kwargs(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=None,
|
||||
max_tokens=max_tokens,
|
||||
reasoning_config=None,
|
||||
context_length=context_length,
|
||||
)
|
||||
|
||||
def test_no_clamping_when_output_ceiling_fits_in_window(self):
|
||||
"""Opus 4.6 native output (128K) < context window (200K) — no clamping."""
|
||||
kwargs = self._build("claude-opus-4-6", context_length=200_000)
|
||||
assert kwargs["max_tokens"] == 128_000
|
||||
|
||||
def test_clamping_fires_for_tiny_custom_window(self):
|
||||
"""When context_length is 8K (local model), output cap is clamped to 7999."""
|
||||
kwargs = self._build("claude-opus-4-6", context_length=8_000)
|
||||
assert kwargs["max_tokens"] == 7_999
|
||||
|
||||
def test_explicit_max_tokens_respected_when_within_window(self):
|
||||
"""Explicit max_tokens smaller than window passes through unchanged."""
|
||||
kwargs = self._build("claude-opus-4-6", max_tokens=4096, context_length=200_000)
|
||||
assert kwargs["max_tokens"] == 4096
|
||||
|
||||
def test_explicit_max_tokens_clamped_when_exceeds_window(self):
|
||||
"""Explicit max_tokens larger than a small window is clamped."""
|
||||
kwargs = self._build("claude-opus-4-6", max_tokens=32_768, context_length=16_000)
|
||||
assert kwargs["max_tokens"] == 15_999
|
||||
|
||||
def test_no_context_length_uses_native_ceiling(self):
|
||||
"""Without context_length the native output ceiling is used directly."""
|
||||
kwargs = self._build("claude-sonnet-4-6")
|
||||
assert kwargs["max_tokens"] == 64_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ephemeral max_tokens mechanism — _build_api_kwargs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEphemeralMaxOutputTokens:
|
||||
"""_build_api_kwargs consumes _ephemeral_max_output_tokens exactly once
|
||||
and falls back to self.max_tokens on subsequent calls.
|
||||
"""
|
||||
|
||||
def _make_agent(self):
|
||||
"""Return a minimal AIAgent with api_mode='anthropic_messages' and
|
||||
a stubbed context_compressor, bypassing full __init__ cost."""
|
||||
from run_agent import AIAgent
|
||||
agent = object.__new__(AIAgent)
|
||||
# Minimal attributes used by _build_api_kwargs
|
||||
agent.api_mode = "anthropic_messages"
|
||||
agent.model = "claude-opus-4-6"
|
||||
agent.tools = []
|
||||
agent.max_tokens = None
|
||||
agent.reasoning_config = None
|
||||
agent._is_anthropic_oauth = False
|
||||
agent._ephemeral_max_output_tokens = None
|
||||
|
||||
compressor = MagicMock()
|
||||
compressor.context_length = 200_000
|
||||
agent.context_compressor = compressor
|
||||
|
||||
# Stub out the internal message-preparation helper
|
||||
agent._prepare_anthropic_messages_for_api = MagicMock(
|
||||
return_value=[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
agent._anthropic_preserve_dots = MagicMock(return_value=False)
|
||||
return agent
|
||||
|
||||
def test_ephemeral_override_is_used_on_first_call(self):
|
||||
"""When _ephemeral_max_output_tokens is set, it overrides self.max_tokens."""
|
||||
agent = self._make_agent()
|
||||
agent._ephemeral_max_output_tokens = 5_000
|
||||
|
||||
kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}])
|
||||
assert kwargs["max_tokens"] == 5_000
|
||||
|
||||
def test_ephemeral_override_is_consumed_after_one_call(self):
|
||||
"""After one call the ephemeral override is cleared to None."""
|
||||
agent = self._make_agent()
|
||||
agent._ephemeral_max_output_tokens = 5_000
|
||||
|
||||
agent._build_api_kwargs([{"role": "user", "content": "hi"}])
|
||||
assert agent._ephemeral_max_output_tokens is None
|
||||
|
||||
def test_subsequent_call_uses_self_max_tokens(self):
|
||||
"""A second _build_api_kwargs call uses the normal max_tokens path."""
|
||||
agent = self._make_agent()
|
||||
agent._ephemeral_max_output_tokens = 5_000
|
||||
agent.max_tokens = None # will resolve to native ceiling (128K for Opus 4.6)
|
||||
|
||||
agent._build_api_kwargs([{"role": "user", "content": "hi"}])
|
||||
# Second call — ephemeral is gone
|
||||
kwargs2 = agent._build_api_kwargs([{"role": "user", "content": "hi"}])
|
||||
assert kwargs2["max_tokens"] == 128_000 # Opus 4.6 native ceiling
|
||||
|
||||
def test_no_ephemeral_uses_self_max_tokens_directly(self):
|
||||
"""Without an ephemeral override, self.max_tokens is used normally."""
|
||||
agent = self._make_agent()
|
||||
agent.max_tokens = 8_192
|
||||
|
||||
kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}])
|
||||
assert kwargs["max_tokens"] == 8_192
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: error handler does NOT halve context_length for output-cap errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestContextNotHalvedOnOutputCapError:
|
||||
"""When the API returns 'max_tokens too large given prompt', the handler
|
||||
must set _ephemeral_max_output_tokens and NOT modify context_length.
|
||||
"""
|
||||
|
||||
def _make_agent_with_compressor(self, context_length=200_000):
|
||||
from run_agent import AIAgent
|
||||
from agent.context_compressor import ContextCompressor
|
||||
|
||||
agent = object.__new__(AIAgent)
|
||||
agent.api_mode = "anthropic_messages"
|
||||
agent.model = "claude-opus-4-6"
|
||||
agent.base_url = "https://api.anthropic.com"
|
||||
agent.tools = []
|
||||
agent.max_tokens = None
|
||||
agent.reasoning_config = None
|
||||
agent._is_anthropic_oauth = False
|
||||
agent._ephemeral_max_output_tokens = None
|
||||
agent.log_prefix = ""
|
||||
agent.quiet_mode = True
|
||||
agent.verbose_logging = False
|
||||
|
||||
compressor = MagicMock(spec=ContextCompressor)
|
||||
compressor.context_length = context_length
|
||||
compressor.threshold_percent = 0.75
|
||||
agent.context_compressor = compressor
|
||||
|
||||
agent._prepare_anthropic_messages_for_api = MagicMock(
|
||||
return_value=[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
agent._anthropic_preserve_dots = MagicMock(return_value=False)
|
||||
agent._vprint = MagicMock()
|
||||
return agent
|
||||
|
||||
def test_output_cap_error_sets_ephemeral_not_context_length(self):
|
||||
"""On 'max_tokens too large' error, _ephemeral_max_output_tokens is set
|
||||
and compressor.context_length is left unchanged."""
|
||||
from agent.model_metadata import parse_available_output_tokens_from_error
|
||||
from agent.model_metadata import get_next_probe_tier
|
||||
|
||||
error_msg = (
|
||||
"max_tokens: 128000 > context_window: 200000 "
|
||||
"- input_tokens: 180000 = available_tokens: 20000"
|
||||
)
|
||||
|
||||
# Simulate the handler logic from run_agent.py
|
||||
agent = self._make_agent_with_compressor(context_length=200_000)
|
||||
old_ctx = agent.context_compressor.context_length
|
||||
|
||||
available_out = parse_available_output_tokens_from_error(error_msg)
|
||||
assert available_out == 20_000, "parser must detect the error"
|
||||
|
||||
# The fix: set ephemeral, skip context_length modification
|
||||
agent._ephemeral_max_output_tokens = max(1, available_out - 64)
|
||||
|
||||
# context_length must be untouched
|
||||
assert agent.context_compressor.context_length == old_ctx
|
||||
assert agent._ephemeral_max_output_tokens == 19_936
|
||||
|
||||
def test_prompt_too_long_still_triggers_probe_tier(self):
|
||||
"""Genuine prompt-too-long errors must still use get_next_probe_tier."""
|
||||
from agent.model_metadata import parse_available_output_tokens_from_error
|
||||
from agent.model_metadata import get_next_probe_tier
|
||||
|
||||
error_msg = "prompt is too long: 205000 tokens > 200000 maximum"
|
||||
|
||||
available_out = parse_available_output_tokens_from_error(error_msg)
|
||||
assert available_out is None, "prompt-too-long must not be caught by output-cap parser"
|
||||
|
||||
# The old halving path is still used for this class of error
|
||||
new_ctx = get_next_probe_tier(200_000)
|
||||
assert new_ctx == 128_000
|
||||
|
||||
def test_output_cap_error_safety_margin(self):
|
||||
"""The ephemeral value includes a 64-token safety margin below available_out."""
|
||||
from agent.model_metadata import parse_available_output_tokens_from_error
|
||||
|
||||
error_msg = (
|
||||
"max_tokens: 32768 > context_window: 200000 "
|
||||
"- input_tokens: 190000 = available_tokens: 10000"
|
||||
)
|
||||
available_out = parse_available_output_tokens_from_error(error_msg)
|
||||
safe_out = max(1, available_out - 64)
|
||||
assert safe_out == 9_936
|
||||
|
||||
def test_safety_margin_never_goes_below_one(self):
|
||||
"""When available_out is very small, safe_out must be at least 1."""
|
||||
from agent.model_metadata import parse_available_output_tokens_from_error
|
||||
|
||||
error_msg = (
|
||||
"max_tokens: 10 > context_window: 200000 "
|
||||
"- input_tokens: 199990 = available_tokens: 1"
|
||||
)
|
||||
available_out = parse_available_output_tokens_from_error(error_msg)
|
||||
safe_out = max(1, available_out - 64)
|
||||
assert safe_out == 1
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -300,6 +301,59 @@ class TestAddRotatingHandler:
|
||||
logger.removeHandler(h)
|
||||
h.close()
|
||||
|
||||
def test_managed_mode_initial_open_sets_group_writable(self, tmp_path):
|
||||
log_path = tmp_path / "managed-open.log"
|
||||
logger = logging.getLogger("_test_rotating_managed_open")
|
||||
formatter = logging.Formatter("%(message)s")
|
||||
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
with patch("hermes_cli.config.is_managed", return_value=True):
|
||||
hermes_logging._add_rotating_handler(
|
||||
logger, log_path,
|
||||
level=logging.INFO, max_bytes=1024, backup_count=1,
|
||||
formatter=formatter,
|
||||
)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
assert log_path.exists()
|
||||
assert stat.S_IMODE(log_path.stat().st_mode) == 0o660
|
||||
|
||||
for h in list(logger.handlers):
|
||||
if isinstance(h, RotatingFileHandler):
|
||||
logger.removeHandler(h)
|
||||
h.close()
|
||||
|
||||
def test_managed_mode_rollover_sets_group_writable(self, tmp_path):
|
||||
log_path = tmp_path / "managed-rollover.log"
|
||||
logger = logging.getLogger("_test_rotating_managed_rollover")
|
||||
formatter = logging.Formatter("%(message)s")
|
||||
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
with patch("hermes_cli.config.is_managed", return_value=True):
|
||||
hermes_logging._add_rotating_handler(
|
||||
logger, log_path,
|
||||
level=logging.INFO, max_bytes=1, backup_count=1,
|
||||
formatter=formatter,
|
||||
)
|
||||
handler = next(
|
||||
h for h in logger.handlers if isinstance(h, RotatingFileHandler)
|
||||
)
|
||||
logger.info("a" * 256)
|
||||
handler.flush()
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
assert log_path.exists()
|
||||
assert stat.S_IMODE(log_path.stat().st_mode) == 0o660
|
||||
|
||||
for h in list(logger.handlers):
|
||||
if isinstance(h, RotatingFileHandler):
|
||||
logger.removeHandler(h)
|
||||
h.close()
|
||||
|
||||
|
||||
class TestReadLoggingConfig:
|
||||
"""_read_logging_config() reads from config.yaml."""
|
||||
|
||||
@@ -63,4 +63,4 @@ class TestCamofoxConfigDefaults:
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
# managed_persistence is auto-merged by _deep_merge, no version bump needed
|
||||
assert DEFAULT_CONFIG["_config_version"] == 12
|
||||
assert DEFAULT_CONFIG["_config_version"] == 13
|
||||
|
||||
@@ -13,6 +13,7 @@ from tools.browser_tool import (
|
||||
_find_agent_browser,
|
||||
_run_browser_command,
|
||||
_SANE_PATH,
|
||||
check_browser_requirements,
|
||||
)
|
||||
|
||||
|
||||
@@ -149,6 +150,31 @@ class TestFindAgentBrowser:
|
||||
_find_agent_browser()
|
||||
|
||||
|
||||
class TestBrowserRequirements:
|
||||
def test_termux_requires_real_agent_browser_install_not_npx_fallback(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.browser_tool._is_camofox_mode", lambda: False)
|
||||
monkeypatch.setattr("tools.browser_tool._get_cloud_provider", lambda: None)
|
||||
monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda: "npx agent-browser")
|
||||
|
||||
assert check_browser_requirements() is False
|
||||
|
||||
|
||||
class TestRunBrowserCommandTermuxFallback:
|
||||
def test_termux_local_mode_rejects_bare_npx_fallback(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda: "npx agent-browser")
|
||||
monkeypatch.setattr("tools.browser_tool._get_cloud_provider", lambda: None)
|
||||
|
||||
result = _run_browser_command("task-1", "navigate", ["https://example.com"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert "bare npx fallback" in result["error"]
|
||||
assert "agent-browser install" in result["error"]
|
||||
|
||||
|
||||
class TestRunBrowserCommandPathConstruction:
|
||||
"""Verify _run_browser_command() includes Homebrew node dirs in subprocess PATH."""
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ from tools.code_execution_tool import (
|
||||
build_execute_code_schema,
|
||||
EXECUTE_CODE_SCHEMA,
|
||||
_TOOL_DOC_LINES,
|
||||
_execute_remote,
|
||||
)
|
||||
|
||||
|
||||
@@ -115,6 +116,48 @@ class TestHermesToolsGeneration(unittest.TestCase):
|
||||
self.assertIn("def retry(", src)
|
||||
self.assertIn("import json, os, socket, shlex, time", src)
|
||||
|
||||
def test_file_transport_uses_tempfile_fallback_for_rpc_dir(self):
|
||||
src = generate_hermes_tools_module(["terminal"], transport="file")
|
||||
self.assertIn("import json, os, shlex, tempfile, time", src)
|
||||
self.assertIn("os.path.join(tempfile.gettempdir(), \"hermes_rpc\")", src)
|
||||
self.assertNotIn('os.environ.get("HERMES_RPC_DIR", "/tmp/hermes_rpc")', src)
|
||||
|
||||
|
||||
class TestExecuteCodeRemoteTempDir(unittest.TestCase):
|
||||
def test_execute_remote_uses_backend_temp_dir_for_sandbox(self):
|
||||
class FakeEnv:
|
||||
def __init__(self):
|
||||
self.commands = []
|
||||
|
||||
def get_temp_dir(self):
|
||||
return "/data/data/com.termux/files/usr/tmp"
|
||||
|
||||
def execute(self, command, cwd=None, timeout=None):
|
||||
self.commands.append((command, cwd, timeout))
|
||||
if "command -v python3" in command:
|
||||
return {"output": "OK\n"}
|
||||
if "python3 script.py" in command:
|
||||
return {"output": "hello\n", "returncode": 0}
|
||||
return {"output": ""}
|
||||
|
||||
env = FakeEnv()
|
||||
fake_thread = MagicMock()
|
||||
|
||||
with patch("tools.code_execution_tool._load_config", return_value={"timeout": 30, "max_tool_calls": 5}), \
|
||||
patch("tools.code_execution_tool._get_or_create_env", return_value=(env, "ssh")), \
|
||||
patch("tools.code_execution_tool._ship_file_to_remote"), \
|
||||
patch("tools.code_execution_tool.threading.Thread", return_value=fake_thread):
|
||||
result = json.loads(_execute_remote("print('hello')", "task-1", ["terminal"]))
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
mkdir_cmd = env.commands[1][0]
|
||||
run_cmd = next(cmd for cmd, _, _ in env.commands if "python3 script.py" in cmd)
|
||||
cleanup_cmd = env.commands[-1][0]
|
||||
self.assertIn("mkdir -p /data/data/com.termux/files/usr/tmp/hermes_exec_", mkdir_cmd)
|
||||
self.assertIn("HERMES_RPC_DIR=/data/data/com.termux/files/usr/tmp/hermes_exec_", run_cmd)
|
||||
self.assertIn("rm -rf /data/data/com.termux/files/usr/tmp/hermes_exec_", cleanup_cmd)
|
||||
self.assertNotIn("mkdir -p /tmp/hermes_exec_", mkdir_cmd)
|
||||
|
||||
|
||||
@unittest.skipIf(sys.platform == "win32", "UDS not available on Windows")
|
||||
class TestExecuteCode(unittest.TestCase):
|
||||
|
||||
@@ -258,28 +258,30 @@ def _make_execute_only_env(forward_env=None):
|
||||
|
||||
def test_init_env_args_uses_hermes_dotenv_for_allowlisted_env(monkeypatch):
|
||||
"""_build_init_env_args picks up forwarded env vars from .env file at init time."""
|
||||
env = _make_execute_only_env(["GITHUB_TOKEN"])
|
||||
# Use a var that is NOT in _HERMES_PROVIDER_ENV_BLOCKLIST (GITHUB_TOKEN
|
||||
# is in the copilot provider's api_key_env_vars and gets stripped).
|
||||
env = _make_execute_only_env(["DATABASE_URL"])
|
||||
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"GITHUB_TOKEN": "value_from_dotenv"})
|
||||
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"DATABASE_URL": "value_from_dotenv"})
|
||||
|
||||
args = env._build_init_env_args()
|
||||
args_str = " ".join(args)
|
||||
|
||||
assert "GITHUB_TOKEN=value_from_dotenv" in args_str
|
||||
assert "DATABASE_URL=value_from_dotenv" in args_str
|
||||
|
||||
|
||||
def test_init_env_args_prefers_shell_env_over_hermes_dotenv(monkeypatch):
|
||||
"""Shell env vars take priority over .env file values in init env args."""
|
||||
env = _make_execute_only_env(["GITHUB_TOKEN"])
|
||||
env = _make_execute_only_env(["DATABASE_URL"])
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "value_from_shell")
|
||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"GITHUB_TOKEN": "value_from_dotenv"})
|
||||
monkeypatch.setenv("DATABASE_URL", "value_from_shell")
|
||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"DATABASE_URL": "value_from_dotenv"})
|
||||
|
||||
args = env._build_init_env_args()
|
||||
args_str = " ".join(args)
|
||||
|
||||
assert "GITHUB_TOKEN=value_from_shell" in args_str
|
||||
assert "DATABASE_URL=value_from_shell" in args_str
|
||||
assert "value_from_dotenv" not in args_str
|
||||
|
||||
|
||||
|
||||
51
tests/tools/test_local_tempdir.py
Normal file
51
tests/tools/test_local_tempdir.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from tools.environments.local import LocalEnvironment
|
||||
|
||||
|
||||
class TestLocalTempDir:
|
||||
def test_uses_os_tmpdir_for_session_artifacts(self, monkeypatch):
|
||||
monkeypatch.setenv("TMPDIR", "/data/data/com.termux/files/usr/tmp")
|
||||
monkeypatch.delenv("TMP", raising=False)
|
||||
monkeypatch.delenv("TEMP", raising=False)
|
||||
|
||||
with patch.object(LocalEnvironment, "init_session", autospec=True, return_value=None):
|
||||
env = LocalEnvironment(cwd=".", timeout=10)
|
||||
|
||||
assert env.get_temp_dir() == "/data/data/com.termux/files/usr/tmp"
|
||||
assert env._snapshot_path == f"/data/data/com.termux/files/usr/tmp/hermes-snap-{env._session_id}.sh"
|
||||
assert env._cwd_file == f"/data/data/com.termux/files/usr/tmp/hermes-cwd-{env._session_id}.txt"
|
||||
|
||||
def test_prefers_backend_env_tmpdir_override(self, monkeypatch):
|
||||
monkeypatch.delenv("TMPDIR", raising=False)
|
||||
monkeypatch.delenv("TMP", raising=False)
|
||||
monkeypatch.delenv("TEMP", raising=False)
|
||||
|
||||
with patch.object(LocalEnvironment, "init_session", autospec=True, return_value=None):
|
||||
env = LocalEnvironment(
|
||||
cwd=".",
|
||||
timeout=10,
|
||||
env={"TMPDIR": "/data/data/com.termux/files/home/.cache/hermes-tmp/"},
|
||||
)
|
||||
|
||||
assert env.get_temp_dir() == "/data/data/com.termux/files/home/.cache/hermes-tmp"
|
||||
assert env._snapshot_path == (
|
||||
f"/data/data/com.termux/files/home/.cache/hermes-tmp/hermes-snap-{env._session_id}.sh"
|
||||
)
|
||||
assert env._cwd_file == (
|
||||
f"/data/data/com.termux/files/home/.cache/hermes-tmp/hermes-cwd-{env._session_id}.txt"
|
||||
)
|
||||
|
||||
def test_falls_back_to_tempfile_when_tmp_missing(self, monkeypatch):
|
||||
monkeypatch.delenv("TMPDIR", raising=False)
|
||||
monkeypatch.delenv("TMP", raising=False)
|
||||
monkeypatch.delenv("TEMP", raising=False)
|
||||
|
||||
with patch("tools.environments.local.os.path.isdir", return_value=False), \
|
||||
patch("tools.environments.local.os.access", return_value=False), \
|
||||
patch("tools.environments.local.tempfile.gettempdir", return_value="/cache/tmp"), \
|
||||
patch.object(LocalEnvironment, "init_session", autospec=True, return_value=None):
|
||||
env = LocalEnvironment(cwd=".", timeout=10)
|
||||
assert env.get_temp_dir() == "/cache/tmp"
|
||||
assert env._snapshot_path == f"/cache/tmp/hermes-snap-{env._session_id}.sh"
|
||||
assert env._cwd_file == f"/cache/tmp/hermes-cwd-{env._session_id}.txt"
|
||||
@@ -147,7 +147,7 @@ class TestBaseEnvCompatibility:
|
||||
"""Hermes wires parser selection through ServerManager.tool_parser."""
|
||||
import ast
|
||||
|
||||
base_env_path = Path(__file__).parent.parent / "environments" / "hermes_base_env.py"
|
||||
base_env_path = Path(__file__).parent.parent.parent / "environments" / "hermes_base_env.py"
|
||||
source = base_env_path.read_text()
|
||||
tree = ast.parse(source)
|
||||
|
||||
@@ -171,7 +171,7 @@ class TestBaseEnvCompatibility:
|
||||
|
||||
def test_hermes_base_env_uses_config_tool_call_parser(self):
|
||||
"""Verify hermes_base_env uses the config field rather than a local parser instance."""
|
||||
base_env_path = Path(__file__).parent.parent / "environments" / "hermes_base_env.py"
|
||||
base_env_path = Path(__file__).parent.parent.parent / "environments" / "hermes_base_env.py"
|
||||
source = base_env_path.read_text()
|
||||
|
||||
assert 'tool_call_parser: str = Field(' in source
|
||||
|
||||
@@ -135,6 +135,64 @@ class TestReadLog:
|
||||
assert "5 lines" in result["showing"]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Stdin helpers
|
||||
# =========================================================================
|
||||
|
||||
class TestStdinHelpers:
|
||||
def test_close_stdin_not_found(self, registry):
|
||||
result = registry.close_stdin("nonexistent")
|
||||
assert result["status"] == "not_found"
|
||||
|
||||
def test_close_stdin_pipe_mode(self, registry):
|
||||
proc = MagicMock()
|
||||
proc.stdin = MagicMock()
|
||||
s = _make_session()
|
||||
s.process = proc
|
||||
registry._running[s.id] = s
|
||||
|
||||
result = registry.close_stdin(s.id)
|
||||
|
||||
proc.stdin.close.assert_called_once()
|
||||
assert result["status"] == "ok"
|
||||
|
||||
def test_close_stdin_pty_mode(self, registry):
|
||||
pty = MagicMock()
|
||||
s = _make_session()
|
||||
s._pty = pty
|
||||
registry._running[s.id] = s
|
||||
|
||||
result = registry.close_stdin(s.id)
|
||||
|
||||
pty.sendeof.assert_called_once()
|
||||
assert result["status"] == "ok"
|
||||
|
||||
def test_close_stdin_allows_eof_driven_process_to_finish(self, registry, tmp_path):
|
||||
session = registry.spawn_local(
|
||||
'python3 -c "import sys; print(sys.stdin.read().strip())"',
|
||||
cwd=str(tmp_path),
|
||||
use_pty=False,
|
||||
)
|
||||
|
||||
try:
|
||||
time.sleep(0.5)
|
||||
assert registry.submit_stdin(session.id, "hello")["status"] == "ok"
|
||||
assert registry.close_stdin(session.id)["status"] == "ok"
|
||||
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
poll = registry.poll(session.id)
|
||||
if poll["status"] == "exited":
|
||||
assert poll["exit_code"] == 0
|
||||
assert "hello" in poll["output_preview"]
|
||||
return
|
||||
time.sleep(0.2)
|
||||
|
||||
pytest.fail("process did not exit after stdin was closed")
|
||||
finally:
|
||||
registry.kill_process(session.id)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# List sessions
|
||||
# =========================================================================
|
||||
@@ -282,6 +340,67 @@ class TestSpawnEnvSanitization:
|
||||
assert f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}TELEGRAM_BOT_TOKEN" not in env
|
||||
assert env["PYTHONUNBUFFERED"] == "1"
|
||||
|
||||
def test_spawn_via_env_uses_backend_temp_dir_for_artifacts(self, registry):
|
||||
class FakeEnv:
|
||||
def __init__(self):
|
||||
self.commands = []
|
||||
|
||||
def get_temp_dir(self):
|
||||
return "/data/data/com.termux/files/usr/tmp"
|
||||
|
||||
def execute(self, command, timeout=None):
|
||||
self.commands.append((command, timeout))
|
||||
return {"output": "4321\n"}
|
||||
|
||||
env = FakeEnv()
|
||||
fake_thread = MagicMock()
|
||||
|
||||
with patch("tools.process_registry.threading.Thread", return_value=fake_thread), \
|
||||
patch.object(registry, "_write_checkpoint"):
|
||||
session = registry.spawn_via_env(env, "echo hello")
|
||||
|
||||
bg_command = env.commands[0][0]
|
||||
assert session.pid == 4321
|
||||
assert "/data/data/com.termux/files/usr/tmp/hermes_bg_" in bg_command
|
||||
assert ".exit" in bg_command
|
||||
assert "rc=$?;" in bg_command
|
||||
assert " > /tmp/hermes_bg_" not in bg_command
|
||||
assert "cat /tmp/hermes_bg_" not in bg_command
|
||||
fake_thread.start.assert_called_once()
|
||||
|
||||
def test_env_poller_quotes_temp_paths_with_spaces(self, registry):
|
||||
session = _make_session(sid="proc_space")
|
||||
session.exited = False
|
||||
|
||||
class FakeEnv:
|
||||
def __init__(self):
|
||||
self.commands = []
|
||||
self._responses = iter([
|
||||
{"output": "hello\n"},
|
||||
{"output": "1\n"},
|
||||
{"output": "0\n"},
|
||||
])
|
||||
|
||||
def execute(self, command, timeout=None):
|
||||
self.commands.append((command, timeout))
|
||||
return next(self._responses)
|
||||
|
||||
env = FakeEnv()
|
||||
|
||||
with patch("tools.process_registry.time.sleep", return_value=None), \
|
||||
patch.object(registry, "_move_to_finished"):
|
||||
registry._env_poller_loop(
|
||||
session,
|
||||
env,
|
||||
"/path with spaces/hermes_bg.log",
|
||||
"/path with spaces/hermes_bg.pid",
|
||||
"/path with spaces/hermes_bg.exit",
|
||||
)
|
||||
|
||||
assert env.commands[0][0] == "cat '/path with spaces/hermes_bg.log' 2>/dev/null"
|
||||
assert env.commands[1][0] == "kill -0 \"$(cat '/path with spaces/hermes_bg.pid' 2>/dev/null)\" 2>/dev/null; echo $?"
|
||||
assert env.commands[2][0] == "cat '/path with spaces/hermes_bg.exit' 2>/dev/null"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Checkpoint
|
||||
|
||||
@@ -125,7 +125,9 @@ class TestSendMatrix:
|
||||
url = call_kwargs[0][0]
|
||||
assert url.startswith("https://matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/")
|
||||
assert call_kwargs[1]["headers"]["Authorization"] == "Bearer syt_tok"
|
||||
assert call_kwargs[1]["json"] == {"msgtype": "m.text", "body": "hello matrix"}
|
||||
payload = call_kwargs[1]["json"]
|
||||
assert payload["msgtype"] == "m.text"
|
||||
assert payload["body"] == "hello matrix"
|
||||
|
||||
def test_http_error(self):
|
||||
resp = _make_aiohttp_resp(403, text_data="Forbidden")
|
||||
|
||||
@@ -32,6 +32,30 @@ def _install_telegram_mock(monkeypatch, bot):
|
||||
monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod)
|
||||
|
||||
|
||||
def _ensure_slack_mock(monkeypatch):
|
||||
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
|
||||
return
|
||||
|
||||
slack_bolt = MagicMock()
|
||||
slack_bolt.async_app.AsyncApp = MagicMock
|
||||
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
|
||||
|
||||
slack_sdk = MagicMock()
|
||||
slack_sdk.web.async_client.AsyncWebClient = MagicMock
|
||||
|
||||
for name, mod in [
|
||||
("slack_bolt", slack_bolt),
|
||||
("slack_bolt.async_app", slack_bolt.async_app),
|
||||
("slack_bolt.adapter", slack_bolt.adapter),
|
||||
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
|
||||
("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
|
||||
("slack_sdk", slack_sdk),
|
||||
("slack_sdk.web", slack_sdk.web),
|
||||
("slack_sdk.web.async_client", slack_sdk.web.async_client),
|
||||
]:
|
||||
monkeypatch.setitem(sys.modules, name, mod)
|
||||
|
||||
|
||||
class TestSendMessageTool:
|
||||
def test_cron_duplicate_target_is_skipped_and_explained(self):
|
||||
home = SimpleNamespace(chat_id="-1001")
|
||||
@@ -426,7 +450,7 @@ class TestSendToPlatformChunking:
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.DISCORD,
|
||||
SimpleNamespace(enabled=True, token="tok", extra={}),
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"ch", long_msg,
|
||||
)
|
||||
)
|
||||
@@ -435,8 +459,115 @@ class TestSendToPlatformChunking:
|
||||
for call in send.await_args_list:
|
||||
assert len(call.args[2]) <= 2020 # each chunk fits the limit
|
||||
|
||||
def test_slack_messages_are_formatted_before_send(self, monkeypatch):
|
||||
_ensure_slack_mock(monkeypatch)
|
||||
|
||||
import gateway.platforms.slack as slack_mod
|
||||
|
||||
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
|
||||
with patch("tools.send_message_tool._send_slack", send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.SLACK,
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"C123",
|
||||
"**hello** from [Hermes](<https://example.com>)",
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
send.assert_awaited_once_with(
|
||||
"***",
|
||||
"C123",
|
||||
"*hello* from <https://example.com|Hermes>",
|
||||
)
|
||||
|
||||
def test_slack_bold_italic_formatted_before_send(self, monkeypatch):
|
||||
"""Bold+italic ***text*** survives tool-layer formatting."""
|
||||
_ensure_slack_mock(monkeypatch)
|
||||
import gateway.platforms.slack as slack_mod
|
||||
|
||||
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
with patch("tools.send_message_tool._send_slack", send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.SLACK,
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"C123",
|
||||
"***important*** update",
|
||||
)
|
||||
)
|
||||
assert result["success"] is True
|
||||
sent_text = send.await_args.args[2]
|
||||
assert "*_important_*" in sent_text
|
||||
|
||||
def test_slack_blockquote_formatted_before_send(self, monkeypatch):
|
||||
"""Blockquote '>' markers must survive formatting (not escaped to '>')."""
|
||||
_ensure_slack_mock(monkeypatch)
|
||||
import gateway.platforms.slack as slack_mod
|
||||
|
||||
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
with patch("tools.send_message_tool._send_slack", send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.SLACK,
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"C123",
|
||||
"> important quote\n\nnormal text & stuff",
|
||||
)
|
||||
)
|
||||
assert result["success"] is True
|
||||
sent_text = send.await_args.args[2]
|
||||
assert sent_text.startswith("> important quote")
|
||||
assert "&" in sent_text # & is escaped
|
||||
assert ">" not in sent_text.split("\n")[0] # > in blockquote is NOT escaped
|
||||
|
||||
def test_slack_pre_escaped_entities_not_double_escaped(self, monkeypatch):
|
||||
"""Pre-escaped HTML entities survive tool-layer formatting without double-escaping."""
|
||||
_ensure_slack_mock(monkeypatch)
|
||||
import gateway.platforms.slack as slack_mod
|
||||
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
with patch("tools.send_message_tool._send_slack", send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.SLACK,
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"C123",
|
||||
"AT&T <tag> test",
|
||||
)
|
||||
)
|
||||
assert result["success"] is True
|
||||
sent_text = send.await_args.args[2]
|
||||
assert "&amp;" not in sent_text
|
||||
assert "&lt;" not in sent_text
|
||||
assert "AT&T" in sent_text
|
||||
|
||||
def test_slack_url_with_parens_formatted_before_send(self, monkeypatch):
|
||||
"""Wikipedia-style URL with parens survives tool-layer formatting."""
|
||||
_ensure_slack_mock(monkeypatch)
|
||||
import gateway.platforms.slack as slack_mod
|
||||
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
with patch("tools.send_message_tool._send_slack", send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.SLACK,
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"C123",
|
||||
"See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))",
|
||||
)
|
||||
)
|
||||
assert result["success"] is True
|
||||
sent_text = send.await_args.args[2]
|
||||
assert "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in sent_text
|
||||
|
||||
def test_telegram_media_attaches_to_last_chunk(self):
|
||||
"""When chunked, media files are sent only with the last chunk."""
|
||||
|
||||
sent_calls = []
|
||||
|
||||
async def fake_send(token, chat_id, message, media_files=None, thread_id=None):
|
||||
|
||||
91
tests/tools/test_terminal_tool_pty_fallback.py
Normal file
91
tests/tools/test_terminal_tool_pty_fallback.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import tools.terminal_tool as terminal_tool_module
|
||||
from tools import process_registry as process_registry_module
|
||||
|
||||
|
||||
def _base_config(tmp_path):
|
||||
return {
|
||||
"env_type": "local",
|
||||
"docker_image": "",
|
||||
"singularity_image": "",
|
||||
"modal_image": "",
|
||||
"daytona_image": "",
|
||||
"cwd": str(tmp_path),
|
||||
"timeout": 30,
|
||||
}
|
||||
|
||||
|
||||
def test_command_requires_pipe_stdin_detects_gh_with_token():
|
||||
assert terminal_tool_module._command_requires_pipe_stdin(
|
||||
"gh auth login --hostname github.com --git-protocol https --with-token"
|
||||
) is True
|
||||
assert terminal_tool_module._command_requires_pipe_stdin(
|
||||
"gh auth login --web"
|
||||
) is False
|
||||
|
||||
|
||||
def test_terminal_background_disables_pty_for_gh_with_token(monkeypatch, tmp_path):
|
||||
config = _base_config(tmp_path)
|
||||
dummy_env = SimpleNamespace(env={})
|
||||
captured = {}
|
||||
|
||||
def fake_spawn_local(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(id="proc_test", pid=1234, notify_on_complete=False)
|
||||
|
||||
monkeypatch.setattr(terminal_tool_module, "_get_env_config", lambda: config)
|
||||
monkeypatch.setattr(terminal_tool_module, "_start_cleanup_thread", lambda: None)
|
||||
monkeypatch.setattr(terminal_tool_module, "_check_all_guards", lambda *_args, **_kwargs: {"approved": True})
|
||||
monkeypatch.setattr(process_registry_module.process_registry, "spawn_local", fake_spawn_local)
|
||||
monkeypatch.setitem(terminal_tool_module._active_environments, "default", dummy_env)
|
||||
monkeypatch.setitem(terminal_tool_module._last_activity, "default", 0.0)
|
||||
|
||||
try:
|
||||
result = json.loads(
|
||||
terminal_tool_module.terminal_tool(
|
||||
command="gh auth login --hostname github.com --git-protocol https --with-token",
|
||||
background=True,
|
||||
pty=True,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
terminal_tool_module._active_environments.pop("default", None)
|
||||
terminal_tool_module._last_activity.pop("default", None)
|
||||
|
||||
assert captured["use_pty"] is False
|
||||
assert result["session_id"] == "proc_test"
|
||||
assert "PTY disabled" in result["pty_note"]
|
||||
|
||||
|
||||
def test_terminal_background_keeps_pty_for_regular_interactive_commands(monkeypatch, tmp_path):
|
||||
config = _base_config(tmp_path)
|
||||
dummy_env = SimpleNamespace(env={})
|
||||
captured = {}
|
||||
|
||||
def fake_spawn_local(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(id="proc_test", pid=1234, notify_on_complete=False)
|
||||
|
||||
monkeypatch.setattr(terminal_tool_module, "_get_env_config", lambda: config)
|
||||
monkeypatch.setattr(terminal_tool_module, "_start_cleanup_thread", lambda: None)
|
||||
monkeypatch.setattr(terminal_tool_module, "_check_all_guards", lambda *_args, **_kwargs: {"approved": True})
|
||||
monkeypatch.setattr(process_registry_module.process_registry, "spawn_local", fake_spawn_local)
|
||||
monkeypatch.setitem(terminal_tool_module._active_environments, "default", dummy_env)
|
||||
monkeypatch.setitem(terminal_tool_module._last_activity, "default", 0.0)
|
||||
|
||||
try:
|
||||
result = json.loads(
|
||||
terminal_tool_module.terminal_tool(
|
||||
command="python3 -c \"print(input())\"",
|
||||
background=True,
|
||||
pty=True,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
terminal_tool_module._active_environments.pop("default", None)
|
||||
terminal_tool_module._last_activity.pop("default", None)
|
||||
|
||||
assert captured["use_pty"] is True
|
||||
assert "pty_note" not in result
|
||||
@@ -16,6 +16,7 @@ from tools.tool_result_storage import (
|
||||
STORAGE_DIR,
|
||||
_build_persisted_message,
|
||||
_heredoc_marker,
|
||||
_resolve_storage_dir,
|
||||
_write_to_sandbox,
|
||||
enforce_turn_budget,
|
||||
generate_preview,
|
||||
@@ -115,6 +116,24 @@ class TestWriteToSandbox:
|
||||
_write_to_sandbox("content", "/tmp/hermes-results/abc.txt", env)
|
||||
assert env.execute.call_args[1]["timeout"] == 30
|
||||
|
||||
def test_uses_parent_dir_of_remote_path(self):
|
||||
env = MagicMock()
|
||||
env.execute.return_value = {"output": "", "returncode": 0}
|
||||
remote_path = "/data/data/com.termux/files/usr/tmp/hermes-results/abc.txt"
|
||||
_write_to_sandbox("content", remote_path, env)
|
||||
cmd = env.execute.call_args[0][0]
|
||||
assert "mkdir -p /data/data/com.termux/files/usr/tmp/hermes-results" in cmd
|
||||
|
||||
|
||||
class TestResolveStorageDir:
|
||||
def test_defaults_to_storage_dir_without_env(self):
|
||||
assert _resolve_storage_dir(None) == STORAGE_DIR
|
||||
|
||||
def test_uses_env_temp_dir_when_available(self):
|
||||
env = MagicMock()
|
||||
env.get_temp_dir.return_value = "/data/data/com.termux/files/usr/tmp"
|
||||
assert _resolve_storage_dir(env) == "/data/data/com.termux/files/usr/tmp/hermes-results"
|
||||
|
||||
|
||||
# ── _build_persisted_message ──────────────────────────────────────────
|
||||
|
||||
@@ -341,6 +360,22 @@ class TestMaybePersistToolResult:
|
||||
)
|
||||
assert "DISTINCTIVE_START_MARKER" in result
|
||||
|
||||
def test_env_temp_dir_changes_persisted_path(self):
|
||||
env = MagicMock()
|
||||
env.execute.return_value = {"output": "", "returncode": 0}
|
||||
env.get_temp_dir.return_value = "/data/data/com.termux/files/usr/tmp"
|
||||
content = "x" * 60_000
|
||||
result = maybe_persist_tool_result(
|
||||
content=content,
|
||||
tool_name="terminal",
|
||||
tool_use_id="tc_termux",
|
||||
env=env,
|
||||
threshold=30_000,
|
||||
)
|
||||
assert "/data/data/com.termux/files/usr/tmp/hermes-results/tc_termux.txt" in result
|
||||
cmd = env.execute.call_args[0][0]
|
||||
assert "mkdir -p /data/data/com.termux/files/usr/tmp/hermes-results" in cmd
|
||||
|
||||
def test_threshold_zero_forces_persist(self):
|
||||
env = MagicMock()
|
||||
env.execute.return_value = {"output": "", "returncode": 0}
|
||||
|
||||
@@ -30,7 +30,10 @@ class TestValidateImageUrl:
|
||||
"""Tests for URL validation, including urlparse-based netloc check."""
|
||||
|
||||
def test_valid_https_url(self):
|
||||
assert _validate_image_url("https://example.com/image.jpg") is True
|
||||
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
]):
|
||||
assert _validate_image_url("https://example.com/image.jpg") is True
|
||||
|
||||
def test_valid_http_url(self):
|
||||
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||
@@ -56,10 +59,16 @@ class TestValidateImageUrl:
|
||||
assert _validate_image_url("http://localhost:8080/image.png") is False
|
||||
|
||||
def test_valid_url_with_port(self):
|
||||
assert _validate_image_url("http://example.com:8080/image.png") is True
|
||||
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
]):
|
||||
assert _validate_image_url("http://example.com:8080/image.png") is True
|
||||
|
||||
def test_valid_url_with_path_only(self):
|
||||
assert _validate_image_url("https://example.com/") is True
|
||||
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
]):
|
||||
assert _validate_image_url("https://example.com/") is True
|
||||
|
||||
def test_rejects_empty_string(self):
|
||||
assert _validate_image_url("") is False
|
||||
@@ -441,6 +450,11 @@ class TestVisionRequirements:
|
||||
(tmp_path / "auth.json").write_text(
|
||||
'{"active_provider":"openai-codex","providers":{"openai-codex":{"tokens":{"access_token":"codex-access-token","refresh_token":"codex-refresh-token"}}}}'
|
||||
)
|
||||
# config.yaml must reference the codex provider so vision auto-detect
|
||||
# falls back to the active provider via _read_main_provider().
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
'model:\n default: gpt-4o\n provider: openai-codex\n'
|
||||
)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
|
||||
@@ -183,12 +183,77 @@ class TestDetectAudioEnvironment:
|
||||
assert result["available"] is False
|
||||
assert any("PortAudio" in w for w in result["warnings"])
|
||||
|
||||
def test_termux_import_error_shows_termux_install_guidance(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
||||
monkeypatch.delenv("SSH_TTY", raising=False)
|
||||
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||
monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs")))
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: None)
|
||||
|
||||
from tools.voice_mode import detect_audio_environment
|
||||
result = detect_audio_environment()
|
||||
|
||||
assert result["available"] is False
|
||||
assert any("pkg install python-numpy portaudio" in w for w in result["warnings"])
|
||||
assert any("python -m pip install sounddevice" in w for w in result["warnings"])
|
||||
|
||||
def test_termux_api_package_without_android_app_blocks_voice(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
||||
monkeypatch.delenv("SSH_TTY", raising=False)
|
||||
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: False)
|
||||
monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs")))
|
||||
|
||||
from tools.voice_mode import detect_audio_environment
|
||||
result = detect_audio_environment()
|
||||
|
||||
assert result["available"] is False
|
||||
assert any("Termux:API Android app is not installed" in w for w in result["warnings"])
|
||||
|
||||
|
||||
def test_termux_api_microphone_allows_voice_without_sounddevice(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
||||
monkeypatch.delenv("SSH_TTY", raising=False)
|
||||
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||
monkeypatch.setattr("tools.voice_mode.shutil.which", lambda cmd: "/data/data/com.termux/files/usr/bin/termux-microphone-record" if cmd == "termux-microphone-record" else None)
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
|
||||
monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs")))
|
||||
|
||||
from tools.voice_mode import detect_audio_environment
|
||||
result = detect_audio_environment()
|
||||
|
||||
assert result["available"] is True
|
||||
assert any("Termux:API microphone recording available" in n for n in result.get("notices", []))
|
||||
assert result["warnings"] == []
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# check_voice_requirements
|
||||
# ============================================================================
|
||||
|
||||
class TestCheckVoiceRequirements:
|
||||
def test_termux_api_capture_counts_as_audio_available(self, monkeypatch):
|
||||
monkeypatch.setattr("tools.voice_mode._audio_available", lambda: False)
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
|
||||
monkeypatch.setattr("tools.voice_mode.detect_audio_environment", lambda: {"available": True, "warnings": [], "notices": ["Termux:API microphone recording available"]})
|
||||
monkeypatch.setattr("tools.transcription_tools._get_provider", lambda cfg: "openai")
|
||||
|
||||
from tools.voice_mode import check_voice_requirements
|
||||
result = check_voice_requirements()
|
||||
|
||||
assert result["available"] is True
|
||||
assert result["audio_available"] is True
|
||||
assert result["missing_packages"] == []
|
||||
assert "Termux:API microphone" in result["details"]
|
||||
|
||||
def test_all_requirements_met(self, monkeypatch):
|
||||
monkeypatch.setattr("tools.voice_mode._audio_available", lambda: True)
|
||||
monkeypatch.setattr("tools.voice_mode.detect_audio_environment",
|
||||
@@ -235,8 +300,85 @@ class TestCheckVoiceRequirements:
|
||||
# AudioRecorder
|
||||
# ============================================================================
|
||||
|
||||
class TestAudioRecorderStart:
|
||||
def test_start_raises_without_audio(self, monkeypatch):
|
||||
class TestCreateAudioRecorder:
|
||||
def test_termux_uses_termux_audio_recorder_when_api_present(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
|
||||
|
||||
from tools.voice_mode import create_audio_recorder, TermuxAudioRecorder
|
||||
recorder = create_audio_recorder()
|
||||
|
||||
assert isinstance(recorder, TermuxAudioRecorder)
|
||||
assert recorder.supports_silence_autostop is False
|
||||
|
||||
def test_termux_without_android_app_falls_back_to_audio_recorder(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: False)
|
||||
|
||||
from tools.voice_mode import create_audio_recorder, AudioRecorder
|
||||
recorder = create_audio_recorder()
|
||||
|
||||
assert isinstance(recorder, AudioRecorder)
|
||||
|
||||
|
||||
class TestTermuxAudioRecorder:
|
||||
def test_start_and_stop_use_termux_microphone_commands(self, monkeypatch, temp_voice_dir):
|
||||
command_calls = []
|
||||
output_path = Path(temp_voice_dir) / "recording_20260409_120000.aac"
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
command_calls.append(cmd)
|
||||
if cmd[1] == "-f":
|
||||
Path(cmd[2]).write_bytes(b"aac-bytes")
|
||||
return MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
|
||||
monkeypatch.setattr("tools.voice_mode.time.strftime", lambda fmt: "20260409_120000")
|
||||
monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run)
|
||||
|
||||
from tools.voice_mode import TermuxAudioRecorder
|
||||
recorder = TermuxAudioRecorder()
|
||||
recorder.start()
|
||||
recorder._start_time = time.monotonic() - 1.0
|
||||
result = recorder.stop()
|
||||
|
||||
assert result == str(output_path)
|
||||
assert command_calls[0][:2] == ["/data/data/com.termux/files/usr/bin/termux-microphone-record", "-f"]
|
||||
assert command_calls[1] == ["/data/data/com.termux/files/usr/bin/termux-microphone-record", "-q"]
|
||||
|
||||
def test_cancel_removes_partial_termux_recording(self, monkeypatch, temp_voice_dir):
|
||||
output_path = Path(temp_voice_dir) / "recording_20260409_120000.aac"
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd[1] == "-f":
|
||||
Path(cmd[2]).write_bytes(b"aac-bytes")
|
||||
return MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
|
||||
monkeypatch.setattr("tools.voice_mode.time.strftime", lambda fmt: "20260409_120000")
|
||||
monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run)
|
||||
|
||||
from tools.voice_mode import TermuxAudioRecorder
|
||||
recorder = TermuxAudioRecorder()
|
||||
recorder.start()
|
||||
recorder.cancel()
|
||||
|
||||
assert output_path.exists() is False
|
||||
assert recorder.is_recording is False
|
||||
|
||||
|
||||
class TestAudioRecorder:
|
||||
def test_start_raises_without_audio_libs(self, monkeypatch):
|
||||
def _fail_import():
|
||||
raise ImportError("no sounddevice")
|
||||
monkeypatch.setattr("tools.voice_mode._import_audio", _fail_import)
|
||||
|
||||
@@ -225,6 +225,7 @@ class TestWebCrawlTavily:
|
||||
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \
|
||||
patch("tools.web_tools.httpx.post", return_value=mock_response), \
|
||||
patch("tools.web_tools.check_website_access", return_value=None), \
|
||||
patch("tools.web_tools.is_safe_url", return_value=True), \
|
||||
patch("tools.interrupt.is_interrupted", return_value=False):
|
||||
from tools.web_tools import web_crawl_tool
|
||||
result = json.loads(asyncio.get_event_loop().run_until_complete(
|
||||
@@ -244,6 +245,7 @@ class TestWebCrawlTavily:
|
||||
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \
|
||||
patch("tools.web_tools.httpx.post", return_value=mock_response) as mock_post, \
|
||||
patch("tools.web_tools.check_website_access", return_value=None), \
|
||||
patch("tools.web_tools.is_safe_url", return_value=True), \
|
||||
patch("tools.interrupt.is_interrupted", return_value=False):
|
||||
from tools.web_tools import web_crawl_tool
|
||||
asyncio.get_event_loop().run_until_complete(
|
||||
|
||||
Reference in New Issue
Block a user