mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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).
164 lines
6 KiB
Python
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}"
|
|
|
|
|
|
|
|
|
|
|