Files
hermes-agent/tests/agent/repro_image_shrink_brick.py
T
teknium1 d10bb2ab6f test: make tests/ mirror the source tree; drop issue numbers from filenames
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.

Parallel directories for one source package, folded into the mirror:
  tests/acp        -> tests/acp_adapter   (its __init__/conftest move with it)
  tests/cli        -> tests/hermes_cli    (prompt_toolkit fixture merged into
                                           hermes_cli/conftest.py)
  tests/run_agent  -> tests/agent         (backoff fixture becomes
                                           agent/conftest.py)
  tests/relay      -> tests/gateway/relay
  tests/state      -> tests/hermes_state

246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.

Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.

Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).

Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
2026-09-13 09:18:02 -07:00

180 lines
6.8 KiB
Python

#!/usr/bin/env python3
"""Runnable proof for issue #48013 — image-dimension 400 session brick.
Before the fix, ``agent.conversation_compression.try_shrink_image_parts_in_messages``
silently discarded a *pixel-correct* downscale whenever the re-encoded PNG was
larger in bytes than the original (the common case for downscaled Retina
screenshots). The image was left at its original oversized dimensions, the
provider re-rejected it on retry, and the session wedged forever on the
Anthropic many-image 2000px path.
This script reproduces the exact scenario with REAL Pillow (no mocks): it
synthesizes screenshot-like PNGs at the dimensions from the issue's table —
images that are small in bytes (under the 4 MB budget) but over the 2000px
per-side cap — and runs the real recovery helper. It asserts every image is
brought under the cap and that the helper reports success.
Run directly to see a human-readable report:
python tests/agent/repro_image_shrink_brick.py
Or as a pytest smoke test (skipped automatically when Pillow is absent):
scripts/run_tests.sh tests/agent/repro_image_shrink_brick.py
"""
from __future__ import annotations
import base64
import io
import sys
from pathlib import Path
import pytest
# Make the repo root importable when run as a plain script.
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
PIL = pytest.importorskip("PIL", reason="Pillow required for the real-resize proof")
from PIL import Image, ImageDraw # noqa: E402
from agent.conversation_compression import ( # noqa: E402
try_shrink_image_parts_in_messages,
)
# The many-image per-side cap Anthropic reported in the wild (issue #48013).
MANY_IMAGE_CAP = 2000
BYTE_BUDGET = 4 * 1024 * 1024
# Dimensions straight from the issue's per-image table. The "REJECTED" rows
# are the ones that bricked: tall/large screenshots whose downscale re-encodes
# to MORE PNG bytes than the original.
CASES = [
(2344, 778), # wide — shrank even before the fix
(2374, 1144), # wide — shrank even before the fix
(2097, 1476), # REJECTED before fix
(2247, 1544), # REJECTED before fix
(2263, 1644), # REJECTED before fix
]
def _make_screenshot_png(width: int, height: int) -> bytes:
"""A screenshot-like PNG: mostly flat UI regions so it compresses small.
Flat regions keep the byte size well under the 4 MB budget, forcing the
DIMENSION path (not the byte path) — exactly the code that bricked. The
downscale of such an image re-encodes to a comparable-or-larger PNG, which
is what the old byte gate wrongly rejected.
"""
img = Image.new("RGB", (width, height), (245, 245, 247))
draw = ImageDraw.Draw(img)
for y in range(0, height, 40):
shade = 255 - (y // 40) % 6 * 4
draw.rectangle([20, y + 5, width - 20, y + 30], fill=(shade, 250, 250))
for x in range(0, width, 160):
draw.rectangle([x, 0, x + 2, height], fill=(220, 220, 225))
draw.text((40, 40), "Some UI text " * 30, fill=(20, 20, 20))
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=False)
return buf.getvalue()
def _data_url(raw: bytes) -> str:
return "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
def _decode_dims(data_url: str) -> tuple[int, int]:
payload = data_url.partition(",")[2]
with Image.open(io.BytesIO(base64.b64decode(payload))) as img:
return img.size
def run_proof(verbose: bool = False) -> list[dict]:
"""Run the recovery against every case; return per-case results."""
results: list[dict] = []
for width, height in CASES:
raw = _make_screenshot_png(width, height)
url = _data_url(raw)
# Sanity: this case must be UNDER the byte budget and OVER the pixel cap,
# i.e. it exercises the dimension path that bricked.
under_byte_budget = len(url) <= BYTE_BUDGET
over_pixel_cap = max(width, height) > MANY_IMAGE_CAP
msgs = [{
"role": "user",
"content": [{"type": "image_url", "image_url": {"url": url}}],
}]
changed = try_shrink_image_parts_in_messages(
msgs, max_dimension=MANY_IMAGE_CAP,
)
out_url = msgs[0]["content"][0]["image_url"]["url"]
out_dims = _decode_dims(out_url)
result = {
"orig": (width, height),
"orig_bytes": len(raw),
"under_byte_budget": under_byte_budget,
"over_pixel_cap": over_pixel_cap,
"changed": changed,
"result_dims": out_dims,
"under_cap_after": max(out_dims) <= MANY_IMAGE_CAP,
}
results.append(result)
if verbose:
status = "OK" if result["under_cap_after"] else "BRICK"
print(
f" {width}x{height} ({len(raw)//1024:>3} KB)"
f" -> changed={changed!s:>5}"
f" result={out_dims[0]}x{out_dims[1]}"
f" [{status}]"
)
return results
def test_issue_48013_dimension_shrink_does_not_brick():
"""Every dimension-oversized screenshot must be brought under the cap."""
results = run_proof()
assert results, "no cases ran"
for r in results:
# Precondition: we really are on the dimension path.
assert r["under_byte_budget"], (
f"{r['orig']} must be under the byte budget to exercise the bug"
)
assert r["over_pixel_cap"], f"{r['orig']} must exceed the pixel cap"
# The fix: image lands under the cap and the helper reports success.
assert r["under_cap_after"], (
f"BRICK: {r['orig']} left at {r['result_dims']} "
f"(> {MANY_IMAGE_CAP}px) — the shrink recovery discarded a "
f"pixel-correct downscale (#48013)"
)
assert r["changed"] is True, (
f"{r['orig']} shrank but helper reported no progress — caller "
f"would surface the original error and burn the one-shot retry"
)
def main() -> int:
print("Issue #48013 proof — image-dimension shrink must not brick sessions")
print(f"(many-image per-side cap = {MANY_IMAGE_CAP}px, byte budget = "
f"{BYTE_BUDGET // (1024 * 1024)} MB)\n")
results = run_proof(verbose=True)
bricked = [r for r in results if not r["under_cap_after"]]
no_progress = [r for r in results if r["under_cap_after"] and not r["changed"]]
print()
if bricked:
print(f"FAIL: {len(bricked)} image(s) still over the pixel cap (BRICK).")
return 1
if no_progress:
print(f"FAIL: {len(no_progress)} image(s) shrank but helper reported "
f"no progress (would burn the retry).")
return 1
print(f"PASS: all {len(results)} dimension-oversized screenshots brought "
f"under {MANY_IMAGE_CAP}px and reported as progress.")
return 0
if __name__ == "__main__":
raise SystemExit(main())