hermes-agent/tests/run_agent/test_image_rejection_fallback.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

164 lines
6 KiB
Python

"""Tests for the image-rejection fallback in run_agent.
When a server rejects image content (e.g. text-only endpoints), the agent
strips image parts from message history and retries text-only. These tests
verify that stripping preserves the role-alternation invariants providers
require, and that the phrase detector fires on the expected error bodies.
"""
from run_agent import _strip_images_from_messages
class TestStripImagesPreservesAlternation:
"""_strip_images_from_messages must not break message role alternation."""
def test_noop_when_no_images(self):
msgs = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
changed = _strip_images_from_messages(msgs)
assert changed is False
assert msgs == [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
def test_tool_message_with_all_images_replaced_not_deleted(self):
"""CRITICAL: tool messages must NEVER be deleted — their tool_call_id
pairs with an assistant tool_call and providers reject unmatched IDs.
"""
msgs = [
{"role": "user", "content": "take a screenshot"},
{
"role": "assistant",
"content": None,
"tool_calls": [{
"id": "call_abc",
"type": "function",
"function": {"name": "computer_use", "arguments": "{}"},
}],
},
{
"role": "tool",
"tool_call_id": "call_abc",
"content": [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}},
],
},
]
changed = _strip_images_from_messages(msgs)
assert changed is True
# Length preserved — tool message NOT deleted
assert len(msgs) == 3
# tool_call_id still present
assert msgs[2]["tool_call_id"] == "call_abc"
# Content replaced with text placeholder (now a string, not a list)
assert isinstance(msgs[2]["content"], str)
assert "image content removed" in msgs[2]["content"].lower()
def test_tool_message_with_mixed_content_keeps_text_parts(self):
msgs = [
{"role": "user", "content": "screenshot plz"},
{
"role": "assistant",
"content": None,
"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "x", "arguments": "{}"}}],
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": [
{"type": "text", "text": "Captured 1024x768"},
{"type": "image_url", "image_url": {"url": "data:..."}},
],
},
]
changed = _strip_images_from_messages(msgs)
assert changed is True
assert len(msgs) == 3
assert msgs[2]["content"] == [{"type": "text", "text": "Captured 1024x768"}]
assert msgs[2]["tool_call_id"] == "call_1"
def test_multiple_tool_messages_all_preserved(self):
"""Parallel tool calls: each tool_call_id must retain a paired message."""
msgs = [
{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "c1", "type": "function", "function": {"name": "x", "arguments": "{}"}},
{"id": "c2", "type": "function", "function": {"name": "x", "arguments": "{}"}},
],
},
{
"role": "tool",
"tool_call_id": "c1",
"content": [{"type": "image_url", "image_url": {}}],
},
{
"role": "tool",
"tool_call_id": "c2",
"content": [{"type": "image_url", "image_url": {}}],
},
]
changed = _strip_images_from_messages(msgs)
assert changed is True
tool_msgs = [m for m in msgs if m.get("role") == "tool"]
assert len(tool_msgs) == 2
assert {m["tool_call_id"] for m in tool_msgs} == {"c1", "c2"}
class TestImageRejectionPhraseIsolation:
"""The image-rejection phrase list must NOT false-match on other
image-related error categories (size-too-large, format errors, etc.)
so they route to the correct recovery handler (e.g. _try_shrink_image_parts).
"""
# Reproduces the phrase list used in run_agent.py's error-handler block.
_REJECTION_PHRASES = (
"only 'text' content type is supported",
"only text content type is supported",
"image_url is not supported",
"image content is not supported",
"multimodal is not supported",
"multimodal content is not supported",
"multimodal input is not supported",
"vision is not supported",
"vision input is not supported",
"does not support images",
"does not support image input",
"does not support multimodal",
"does not support vision",
"model does not support image",
"image_url'. expected",
"no endpoints found that support image input",
)
def _matches(self, body: str) -> bool:
low = body.lower()
return any(p in low for p in self._REJECTION_PHRASES)
def test_anthropic_image_too_large_does_not_trip(self):
# From agent/error_classifier.py _IMAGE_TOO_LARGE_PATTERNS —
# these must route to image_too_large / _try_shrink_image_parts_in_messages,
# NOT to our vision-unsupported fallback.
bodies = [
"messages.0.content.1.image.source.base64: image exceeds 5 MB maximum",
"image too large: 6291456 bytes > 5242880 limit",
"image_too_large",
"image size exceeds per-request limit",
]
for body in bodies:
assert self._matches(body) is False, f"false positive on: {body}"