Files
hermes-agent/tests/agent/test_turn_retry_state.py
T
Sora-bluesky d61411d131 fix(agent): narrow #69078 image-corrupt recovery to the classifier route
Review (Sol xhigh) on the prior commit found a P1: the generic strip-
and-retry fallback ("any non-retryable 400 with image parts present
strips and retries") was too blunt. It couldn't tell an actual
image-corruption 400 apart from an unrelated one — bad tool schema,
unsupported parameter, billing, content policy — that merely happened
to carry image parts in the request. Any of those would silently erase
vision history and retry the still-invalid request, degrading sessions
that were never bricked in the first place. That's worse than the bug
it was meant to fix.

Revert the generic fallback (agent/conversation_loop.py). Keep only
the classifier-routed path: FailoverReason.image_corrupt +
_IMAGE_CORRUPT_PATTERNS, checked before _IMAGE_TOO_LARGE_PATTERNS
because shrinking corrupt bytes can't repair them. Corrupt-image
wordings still route to strip-and-retry; everything else falls through
to normal (non-retryable) handling as before. Add xAI's second wire
wording for the same corruption class ("base64 string of provided
image cannot be decoded", returned on unaligned truncation vs "Invalid
PNG image." on aligned truncation) and a compound-message test pinning
that image_corrupt wins when a body matches both pattern lists.

Drop TurnRetryState.stripped_images_this_turn. It's unnecessary now
that only one branch is left: the branch already only retries when
_strip_images_from_messages reports it removed something, and that
helper strips every image part from the request in one pass — so a
second corrupt-image hit on the retried (now text-only) request has
nothing left to strip and falls through on its own. No separate
one-shot flag needed.

Add a run_conversation integration test at the sequenced-provider
layer: corrupt 400 on attempt 1, strip, retry succeeds on attempt 2,
with explicit before/after assertions on the outgoing image_url part.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: paultaki <paultaki@users.noreply.github.com>
2026-08-28 03:46:24 -07:00

92 lines
3.2 KiB
Python

"""Unit tests for TurnRetryState (god-file Phase 1b).
The dataclass holds the inner-retry-loop's one-shot recovery guards + restart
signals. These tests pin its shape and default semantics — the behavioral
guarantee for the loop itself is the existing recovery-branch tests in
tests/run_agent/ which now exercise these fields via `_retry.<flag>`.
"""
from __future__ import annotations
from dataclasses import fields
from agent.turn_retry_state import TurnRetryState
EXPECTED_FIELDS = {
"codex_auth_retry_attempted",
"anthropic_auth_retry_attempted",
"nous_auth_retry_attempted",
"nous_paid_entitlement_refresh_attempted",
"copilot_auth_retry_attempted",
"copilot_stale_cred_retry_attempted",
"vertex_auth_retry_attempted",
"thinking_sig_retry_attempted",
"invalid_encrypted_content_retry_attempted",
"native_compaction_reject_retry_attempted",
"image_shrink_retry_attempted",
"multimodal_tool_content_retry_attempted",
"oauth_1m_beta_retry_attempted",
"llama_cpp_grammar_retry_attempted",
"primary_recovery_attempted",
"has_retried_429",
"auth_failover_attempted",
"restart_with_compressed_messages",
"restart_with_length_continuation",
"restart_with_rebuilt_messages",
"restart_with_redirected_messages",
}
def test_field_set_matches_contract():
names = {f.name for f in fields(TurnRetryState)}
assert names == EXPECTED_FIELDS, (
f"unexpected drift: missing={EXPECTED_FIELDS - names} extra={names - EXPECTED_FIELDS}"
)
def test_guards_are_independently_mutable():
s = TurnRetryState()
s.codex_auth_retry_attempted = True
s.restart_with_compressed_messages = True
assert s.codex_auth_retry_attempted is True
assert s.restart_with_compressed_messages is True
# untouched guards stay False
assert s.has_retried_429 is False
assert s.anthropic_auth_retry_attempted is False
def test_copilot_provider_check_accepts_alias_spellings():
"""`/model` and profile configs can leave `github-copilot` / `github` as
the provider spelling; the recovery gates must not silently skip them."""
from agent.conversation_loop import _is_copilot_provider
from run_agent import AIAgent
class _Agent:
# Reuse the real single-owner check unbound; only provider/_base_url
# state is faked.
_is_copilot_provider = AIAgent._is_copilot_provider
_is_copilot_url = AIAgent._is_copilot_url
def __init__(self, provider, base_url=""):
self.provider = provider
self._base_url_lower = base_url.lower()
assert _is_copilot_provider(_Agent("copilot"))
assert _is_copilot_provider(_Agent("github-copilot"))
assert _is_copilot_provider(_Agent("GitHub-Copilot"))
assert _is_copilot_provider(_Agent("github"))
# URL fallback: unnormalized provider but a Copilot base URL.
assert _is_copilot_provider(_Agent("custom", "https://api.githubcopilot.com"))
assert not _is_copilot_provider(_Agent("openrouter", "https://openrouter.ai/api/v1"))
class _NoMethod:
provider = "github-copilot"
# Fallback path when the agent object lacks the method entirely.
assert _is_copilot_provider(_NoMethod())