d61411d131
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>
92 lines
3.2 KiB
Python
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())
|