#!/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/run_agent/repro_48013_image_shrink_brick.py Or as a pytest smoke test (skipped automatically when Pillow is absent): scripts/run_tests.sh tests/run_agent/repro_48013_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())