Files
hermes-agent/tests/agent/test_vision_tool_messages.py
T
teknium1 d10bb2ab6f test: make tests/ mirror the source tree; drop issue numbers from filenames
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.

Parallel directories for one source package, folded into the mirror:
  tests/acp        -> tests/acp_adapter   (its __init__/conftest move with it)
  tests/cli        -> tests/hermes_cli    (prompt_toolkit fixture merged into
                                           hermes_cli/conftest.py)
  tests/run_agent  -> tests/agent         (backoff fixture becomes
                                           agent/conftest.py)
  tests/relay      -> tests/gateway/relay
  tests/state      -> tests/hermes_state

246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.

Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.

Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).

Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
2026-09-13 09:18:02 -07:00

154 lines
5.7 KiB
Python

"""Tests for proactive vision-tool-message downgrade (issue #41072).
When a provider supports vision in user messages but rejects list-type
tool message content (e.g. Xiaomi MiMo's 400 "text is not set"),
``_tool_result_content_for_active_model`` should proactively downgrade
to a text summary instead of waiting for a reactive 400 recovery.
The fix adds ``supports_vision_tool_messages`` to ``ProviderProfile``
and checks it in ``_tool_result_content_for_active_model``.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_agent(provider="openrouter", model="gpt-4o"):
"""Create a minimal AIAgent mock with provider/model attributes."""
from run_agent import AIAgent
agent = MagicMock(spec=AIAgent)
agent.provider = provider
agent.model = model
agent._no_list_tool_content_models = set()
def _real_content_has_image_parts(content):
if not isinstance(content, list):
return False
for part in content:
if isinstance(part, dict) and part.get("type") in {"image_url", "input_image"}:
return True
return False
agent._content_has_image_parts = _real_content_has_image_parts
agent._model_supports_vision = lambda: AIAgent._model_supports_vision(agent)
agent._provider_supports_vision_tool_messages = lambda: AIAgent._provider_supports_vision_tool_messages(agent)
agent._tool_result_content_for_active_model = (
lambda name, result: AIAgent._tool_result_content_for_active_model(agent, name, result)
)
return agent
def _multimodal_result(text="screenshot", image_url="data:image/png;base64,AAAA"):
return {
"_multimodal": True,
"content": [
{"type": "text", "text": text},
{"type": "image_url", "image_url": {"url": image_url}},
],
"text_summary": text,
}
# ---------------------------------------------------------------------------
# _provider_supports_vision_tool_messages
# ---------------------------------------------------------------------------
class TestProviderSupportsVisionToolMessages:
def test_xiaomi_returns_false(self):
agent = _make_agent("xiaomi", "mimo-v2.5")
assert agent._provider_supports_vision_tool_messages() is False
# ---------------------------------------------------------------------------
# _tool_result_content_for_active_model — proactive downgrade
# ---------------------------------------------------------------------------
class TestToolResultContentProactiveDowngrade:
def test_xiaomi_downgrades_to_text_summary(self):
"""Xiaomi: vision=True but supports_vision_tool_messages=False → text."""
agent = _make_agent("xiaomi", "mimo-v2.5")
result = _multimodal_result(text="screenshot captured")
with patch.object(agent, "_model_supports_vision", return_value=True):
content = agent._tool_result_content_for_active_model("browser_screenshot", result)
assert isinstance(content, str)
assert "screenshot captured" in content
def test_xiaomi_non_multimodal_passes_through(self):
"""Non-multimodal results should pass through unchanged."""
agent = _make_agent("xiaomi", "mimo-v2.5")
result = "plain text result"
content = agent._tool_result_content_for_active_model("some_tool", result)
assert content == "plain text result"
def test_openrouter_vision_keeps_list_content(self):
"""OpenRouter with vision: list content preserved."""
agent = _make_agent("openrouter", "gpt-4o")
result = _multimodal_result()
with patch.object(agent, "_model_supports_vision", return_value=True):
content = agent._tool_result_content_for_active_model("browser_screenshot", result)
assert isinstance(content, list)
assert any(p.get("type") == "image_url" for p in content if isinstance(p, dict))
def test_reactive_cache_still_works(self):
"""In-session cache (_no_list_tool_content_models) still triggers."""
agent = _make_agent("openrouter", "some-model")
agent._no_list_tool_content_models = {("openrouter", "some-model")}
result = _multimodal_result(text="cached downgrade")
with patch.object(agent, "_model_supports_vision", return_value=True):
content = agent._tool_result_content_for_active_model("browser_screenshot", result)
assert isinstance(content, str)
assert "cached downgrade" in content
# ---------------------------------------------------------------------------
# ProviderProfile.supports_vision_tool_messages field
# ---------------------------------------------------------------------------
class TestProviderProfileField:
def test_default_is_true(self):
from providers.base import ProviderProfile
# ProviderProfile uses __init__ with defaults; check via a minimal instance
# by reading the class-level default from a dataclass-like field
import dataclasses
if dataclasses.is_dataclass(ProviderProfile):
fields = {f.name: f.default for f in dataclasses.fields(ProviderProfile)}
assert fields.get("supports_vision_tool_messages", True) is True
else:
# Class-level attribute default
assert getattr(ProviderProfile, "supports_vision_tool_messages", True) is True
def test_xiaomi_profile_has_false(self):
from providers import get_provider_profile
profile = get_provider_profile("xiaomi")
assert profile is not None
assert profile.supports_vision_tool_messages is False