Files
aiturk-hermes-ide/tests/tools/test_vision_native_fast_path.py
T

460 lines
18 KiB
Python

"""Tests for the native-vision fast path inside vision_analyze.
When the active main model supports native vision AND the provider supports
image content inside tool-result messages, ``_handle_vision_analyze`` skips
the auxiliary LLM and returns a multimodal envelope so the main model sees
the pixels directly on its next turn.
"""
from __future__ import annotations
import asyncio
import base64
import json
from io import BytesIO
from unittest.mock import patch
from tools.vision_tools import (
_build_native_vision_tool_result,
_handle_vision_analyze,
_supports_media_in_tool_results,
_vision_analyze_native,
)
# Minimal valid 1x1 PNG bytes.
_TINY_PNG = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
# PNG-shaped but undecodable; resolver/native fast path must reject it.
_CORRUPT_PNG = base64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAAFElEQVR4nGP8z8Dwn4EIwESJ5gAAVQ4CH1evYJQAAAAASUVORK5CYII="
)
def _animated_gif_bytes(colors, *, size=(4, 4)):
from PIL import Image
frames = [Image.new("RGB", size, color) for color in colors]
encoded = BytesIO()
frames[0].save(
encoded,
format="GIF",
save_all=True,
append_images=frames[1:],
duration=100,
loop=0,
)
return encoded.getvalue()
def _track_validated_frame_loads(monkeypatch):
from PIL import ImageSequence
loaded_frames = []
real_iterator = ImageSequence.Iterator
class TrackedFrame:
def __init__(self, frame, frame_number):
self.frame = frame
self.frame_number = frame_number
self.width = frame.width
self.height = frame.height
def load(self):
loaded_frames.append(self.frame_number)
return self.frame.load()
def tracking_iterator(image):
for frame_number, frame in enumerate(real_iterator(image), start=1):
yield TrackedFrame(frame, frame_number)
monkeypatch.setattr(ImageSequence, "Iterator", tracking_iterator)
return loaded_frames
# ─── _supports_media_in_tool_results ─────────────────────────────────────────
class TestSupportsMediaInToolResults:
def test_anthropic_native_yes(self):
assert _supports_media_in_tool_results("anthropic", "claude-opus-4-6") is True
def test_openrouter_yes(self):
assert _supports_media_in_tool_results("openrouter", "anthropic/claude-opus-4.6") is True
def test_empty_provider_no(self):
assert _supports_media_in_tool_results("", "anything") is False
assert _supports_media_in_tool_results(None, "anything") is False # type: ignore[arg-type]
def test_profile_tool_message_veto_overrides_supports_vision(self):
"""supports_vision_tool_messages=False is a hard veto even when the
profile declares supports_vision=True (xiaomi/MiMo 400s on list-type
tool-result content, #89981)."""
assert _supports_media_in_tool_results("xiaomi", "mimo-v2.5") is False
def test_profile_veto_applies_even_when_vision_capable_lookup_agrees(self):
"""A capability source marking the model vision-capable must not
re-open the native fast path for a provider that rejects it."""
from tools.vision_tools import _should_use_native_vision_fast_path
from agent.auxiliary_client import set_runtime_main, clear_runtime_main
from agent import image_routing
set_runtime_main("xiaomi", "mimo-v2.5")
try:
with patch.object(
image_routing, "decide_image_input_mode", return_value="native"
), patch.object(
image_routing, "_lookup_supports_vision", return_value=True
):
assert _should_use_native_vision_fast_path() is False
finally:
clear_runtime_main()
# ─── _build_native_vision_tool_result ────────────────────────────────────────
class TestBuildNativeVisionToolResult:
def test_envelope_shape(self):
env = _build_native_vision_tool_result(
image_url="/tmp/foo.png",
question="what does it say?",
image_data_url="data:image/png;base64,XYZ",
image_size_bytes=1024,
)
assert env["_multimodal"] is True
assert isinstance(env["content"], list)
assert len(env["content"]) == 2
assert env["content"][0]["type"] == "text"
assert env["content"][1]["type"] == "image_url"
assert env["content"][1]["image_url"]["url"] == "data:image/png;base64,XYZ"
assert "what does it say?" in env["content"][0]["text"]
assert "Image attached natively" in env["text_summary"]
def test_no_question_omits_question_section(self):
env = _build_native_vision_tool_result(
image_url="/tmp/foo.png",
question="",
image_data_url="data:image/png;base64,XYZ",
image_size_bytes=512,
)
text = env["content"][0]["text"]
assert "Question:" not in text
assert "Image loaded" in text
# ─── _vision_analyze_native ──────────────────────────────────────────────────
class TestVisionAnalyzeNative:
def test_local_file_returns_multimodal_envelope(self, tmp_path):
img = tmp_path / "test.png"
img.write_bytes(_TINY_PNG)
result = asyncio.get_event_loop().run_until_complete(
_vision_analyze_native(str(img), "what is this?")
)
assert isinstance(result, dict)
assert result.get("_multimodal") is True
parts = result["content"]
assert any(p.get("type") == "image_url" for p in parts)
assert any(p.get("type") == "text" for p in parts)
url = next(p["image_url"]["url"] for p in parts if p.get("type") == "image_url")
assert url.startswith("data:image/")
def test_truncated_supported_image_is_rejected_before_embedding(self, tmp_path):
"""A valid header must not let partially downloaded bytes poison history."""
pytest = __import__("pytest")
Image = pytest.importorskip(
"PIL.Image", reason="Pillow is required for full raster decode validation"
)
truncated = tmp_path / "truncated.png"
truncated.write_bytes(_TINY_PNG[:-22])
# This is the production failure shape: header parsing succeeds, but a
# complete decode fails after the partial download is read.
with Image.open(truncated) as image:
assert image.format == "PNG"
with pytest.raises(OSError, match="truncated"):
image.load()
result = asyncio.get_event_loop().run_until_complete(
_vision_analyze_native(str(truncated), "describe")
)
assert isinstance(result, str), "corrupt image must not return a multimodal envelope"
payload = json.loads(result)
assert payload["success"] is False
# Two stacked gates can catch this: the resolver-boundary verify()
# (salvaged #53307) reports "not a recognized image"; the full-decode
# gate (salvaged #76896) reports a decode failure. Either rejection
# keeps the truncated bytes out of history.
err = payload["error"].lower()
assert "decode" in err or "not a recognized image" in err
def test_truncated_animated_gif_frame_is_rejected_before_embedding(
self, tmp_path, monkeypatch
):
"""Every frame must decode before an animated raster enters history."""
pytest = __import__("pytest")
pytest.importorskip(
"PIL.Image", reason="Pillow is required for full raster decode validation"
)
from PIL import Image, ImageSequence
truncated = tmp_path / "truncated.gif"
truncated.write_bytes(_animated_gif_bytes(["red", "blue"])[:-3])
monkeypatch.setattr(
"tools.vision_tools._VISION_MAX_VALIDATED_FRAME_COUNT", 2
)
monkeypatch.setattr(
"tools.vision_tools._VISION_MAX_VALIDATED_AGGREGATE_PIXELS", 32
)
with Image.open(truncated) as image:
assert image.format == "GIF"
assert getattr(image, "n_frames", 1) == 2
with pytest.raises(OSError, match="truncated"):
for frame in ImageSequence.Iterator(image):
frame.load()
result = asyncio.get_event_loop().run_until_complete(
_vision_analyze_native(str(truncated), "describe")
)
assert isinstance(result, str), "corrupt animation must not return a multimodal envelope"
payload = json.loads(result)
assert payload["success"] is False
assert "decode" in payload["error"].lower()
def test_animation_over_frame_validation_limit_is_rejected(
self, tmp_path, monkeypatch
):
"""A small animation is rejected before an excess frame is decoded."""
pytest = __import__("pytest")
pytest.importorskip(
"PIL.Image", reason="Pillow is required for full raster decode validation"
)
animation = tmp_path / "too-many-frames.gif"
animation.write_bytes(_animated_gif_bytes(["red", "green", "blue"]))
monkeypatch.setattr(
"tools.vision_tools._VISION_MAX_VALIDATED_FRAME_COUNT", 2
)
loaded_frames = _track_validated_frame_loads(monkeypatch)
result = asyncio.get_event_loop().run_until_complete(
_vision_analyze_native(str(animation), "describe")
)
assert isinstance(result, str)
payload = json.loads(result)
assert payload["success"] is False
assert "frame 3" in payload["error"].lower()
assert "maximum 2" in payload["error"].lower()
assert loaded_frames == [1, 2]
def test_animation_over_aggregate_pixel_validation_limit_is_rejected(
self, tmp_path, monkeypatch
):
"""Aggregate decoded pixels are bounded independently of file size."""
pytest = __import__("pytest")
pytest.importorskip(
"PIL.Image", reason="Pillow is required for full raster decode validation"
)
animation = tmp_path / "too-many-pixels.gif"
animation.write_bytes(_animated_gif_bytes(["red", "blue"]))
monkeypatch.setattr(
"tools.vision_tools._VISION_MAX_VALIDATED_AGGREGATE_PIXELS", 31
)
loaded_frames = _track_validated_frame_loads(monkeypatch)
result = asyncio.get_event_loop().run_until_complete(
_vision_analyze_native(str(animation), "describe")
)
assert isinstance(result, str)
payload = json.loads(result)
assert payload["success"] is False
assert "aggregate decoded pixel" in payload["error"].lower()
assert "32" in payload["error"]
assert "maximum 31" in payload["error"].lower()
assert loaded_frames == [1]
def test_file_url_scheme_resolves(self, tmp_path):
img = tmp_path / "t.png"
img.write_bytes(_TINY_PNG)
result = asyncio.get_event_loop().run_until_complete(
_vision_analyze_native(f"file://{img}", "?")
)
assert isinstance(result, dict)
assert result.get("_multimodal") is True
def test_corrupt_png_rejected_before_native_embed(self, tmp_path):
"""Header-only PNG bytes must not enter conversation history."""
img = tmp_path / "bad.png"
img.write_bytes(_CORRUPT_PNG)
result = asyncio.get_event_loop().run_until_complete(
_vision_analyze_native(str(img), "what is this?")
)
assert isinstance(result, str)
parsed = json.loads(result)
assert parsed.get("success") is False
assert "multimodal" not in parsed
assert "recognized image" in parsed.get("error", "")
def test_oversized_image_resized_under_embed_cap(self, tmp_path):
"""Regression for the wedged-session incident (May 2026).
A vision tool-result image is baked into conversation history and
re-sent on every subsequent turn. The native fast path must
proactively resize down to the history-reuse embed cap BEFORE
embedding, not just at the 20 MB hard ceiling. Skips if Pillow
isn't available (resize is a no-op).
"""
pytest = __import__("pytest")
try:
from PIL import Image
except ImportError:
pytest.skip("Pillow not installed — proactive resize is a no-op")
from tools.vision_tools import _EMBED_TARGET_BYTES
# Noisy PNG that base64-encodes to well over 5 MB (won't compress much).
big = tmp_path / "big.png"
Image.effect_noise((2600, 2600), 80).convert("RGB").save(big, format="PNG")
assert big.stat().st_size * 4 // 3 > 5 * 1024 * 1024, "test image not big enough"
result = asyncio.get_event_loop().run_until_complete(
_vision_analyze_native(str(big), "describe")
)
assert isinstance(result, dict) and result.get("_multimodal") is True
url = next(
p["image_url"]["url"]
for p in result["content"]
if p.get("type") == "image_url"
)
assert len(url) <= _EMBED_TARGET_BYTES, (
f"embedded image {len(url) / 1024:.0f} KB exceeds embed cap "
f"{_EMBED_TARGET_BYTES / 1024:.0f} KB — would bloat every later turn"
)
def test_embed_caps_are_sized_for_history_reuse(self):
"""Native embeds ride every later turn, so caps must stay well below
the Anthropic 5 MB / 8000px reject limits (#92699)."""
from tools.vision_tools import _EMBED_MAX_DIMENSION, _EMBED_TARGET_BYTES
assert _EMBED_TARGET_BYTES <= 512 * 1024
assert _EMBED_MAX_DIMENSION <= 2048
# ─── _handle_vision_analyze fast-path gating ─────────────────────────────────
class TestHandleVisionAnalyzeFastPath:
"""Verify the dispatcher chooses fast-path vs aux-LLM correctly."""
def test_vision_capable_main_model_uses_fast_path(self, tmp_path, monkeypatch):
"""Main model supports native vision → fast path returns multimodal."""
img = tmp_path / "x.png"
img.write_bytes(_TINY_PNG)
# Set runtime override so the handler thinks we're on opus@openrouter
from agent.auxiliary_client import set_runtime_main, clear_runtime_main
set_runtime_main("openrouter", "anthropic/claude-opus-4.6")
try:
# 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()
assert isinstance(result, dict), \
f"Expected multimodal envelope, got {type(result).__name__}: {str(result)[:200]}"
assert result.get("_multimodal") is True
def test_fast_path_disabled_for_unsupported_provider(self, tmp_path, monkeypatch):
"""Even with vision-capable model, unknown provider → fall through."""
img = tmp_path / "x.png"
img.write_bytes(_TINY_PNG)
async def _aux_sentinel(*args, **kwargs):
return '{"sentinel": "aux-path"}'
from agent.auxiliary_client import set_runtime_main, clear_runtime_main
set_runtime_main("brand-new-provider", "anthropic/claude-opus-4.6")
try:
with patch("tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel):
coro = _handle_vision_analyze({"image_url": str(img), "question": "?"})
result = asyncio.get_event_loop().run_until_complete(coro)
finally:
clear_runtime_main()
assert not (isinstance(result, dict) and result.get("_multimodal") is True), \
"Fast path fired for unknown provider; should have fallen through"
def test_supports_vision_override_bypasses_provider_allowlist(self, tmp_path):
"""supports_vision=true enables the fast path on an unlisted provider."""
img = tmp_path / "x.png"
img.write_bytes(_TINY_PNG)
async def _aux_sentinel(*args, **kwargs):
return '{"sentinel": "aux-path"}'
from agent.auxiliary_client import set_runtime_main, clear_runtime_main
set_runtime_main("brand-new-provider", "llava-v1.6")
try:
with patch(
"hermes_cli.config.load_config",
return_value={"model": {"supports_vision": True}},
), patch(
"tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel,
) as mock_aux:
coro = _handle_vision_analyze({"image_url": str(img), "question": "?"})
result = asyncio.get_event_loop().run_until_complete(coro)
finally:
clear_runtime_main()
assert isinstance(result, dict) and result.get("_multimodal") is True
mock_aux.assert_not_called()
def test_text_mode_wins_over_supports_vision_override(self, tmp_path):
"""Explicit text routing blocks the fast path even with supports_vision."""
img = tmp_path / "x.png"
img.write_bytes(_TINY_PNG)
async def _aux_sentinel(*args, **kwargs):
return '{"sentinel": "aux-path"}'
from agent.auxiliary_client import set_runtime_main, clear_runtime_main
set_runtime_main("brand-new-provider", "llava-v1.6")
try:
with patch(
"hermes_cli.config.load_config",
return_value={
"agent": {"image_input_mode": "text"},
"model": {"supports_vision": True},
},
), patch(
"tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel,
) as mock_aux:
coro = _handle_vision_analyze({"image_url": str(img), "question": "?"})
result = asyncio.get_event_loop().run_until_complete(coro)
finally:
clear_runtime_main()
assert isinstance(result, str)
assert json.loads(result) == {"sentinel": "aux-path"}
mock_aux.assert_called_once()