diff --git a/python/lean_pool/review.py b/python/lean_pool/review.py index 082cc2f883..814a57d614 100644 --- a/python/lean_pool/review.py +++ b/python/lean_pool/review.py @@ -16,6 +16,15 @@ comment shows which tier was actually used and prices the request at that tier's rate. +Very large PRs (machine-generated certificates or case data) can exceed +the model's input-token limit — PR #278's 3.1M-character diff was +rejected at 1.56M tokens. Rather than crashing and leaving the PR +unreviewed, the diff is fitted to a token budget first: the largest +per-file patches are elided (keeping every file's path, line counts, and +opening lines) until the estimate fits, and both the model prompt and +the posted comment state that the review covered a reduced diff. See +:func:`fit_diff_to_budget`. + Per-token prices live in the ``PRICING_PER_M`` table below — the OpenAI API does not return cost in its responses, so we maintain a small lookup keyed on ``(model, tier)``. Update it when bumping ``DEFAULT_MODEL`` or @@ -41,11 +50,12 @@ import os import subprocess import sys +from dataclasses import dataclass from pathlib import Path from textwrap import dedent from typing import Any -from openai import APIStatusError, OpenAI, RateLimitError +from openai import APIStatusError, BadRequestError, OpenAI, RateLimitError REPO_ROOT = Path(__file__).resolve().parents[2] PROJECT_RULES_PATH = REPO_ROOT / ".github" / "REVIEW_RULES.md" @@ -55,6 +65,23 @@ REQUEST_TIMEOUT_SECONDS = 6000.0 LLM_REVIEW_MARKER = "" +# Input-token guardrail. OpenAI rejected PR #278's review at a configured +# limit of 922k input tokens; its 3,112,190-character diff measured ~2.0 +# characters per token (machine-generated arithmetic tokenizes far denser +# than the ~4 chars/token of ordinary code). Budget below the observed +# limit and estimate with that worst measured density; anything denser +# still is caught by the shrink-and-retry loop in :func:`request_review`. +MAX_INPUT_TOKENS = 800_000 +CHARS_PER_TOKEN_ESTIMATE = 2.0 +# Initial fit plus this many budget halvings before giving up. +REVIEW_FIT_ATTEMPTS = 3 +# Patch lines kept for an elided file — enough for an added Lean file's +# module docstring and imports. If even the elided diff overflows, refit +# with the minimum head before hard-truncating the tail. +ELIDED_FILE_HEAD_LINES = 40 +MINIMUM_ELIDED_FILE_HEAD_LINES = 8 +ELISION_MARKER = "[elided by lean-pool llm-review:" + # USD per 1M tokens, keyed by (model, tier) -> (input_per_M, output_per_M). # Source: https://developers.openai.com/api/docs/pricing — update when # bumping DEFAULT_MODEL or when OpenAI changes pricing. @@ -300,26 +327,172 @@ def classify_pr(files: list[tuple[str, str]]) -> str: return "project" -def request_review( - model: str, rules: str, diff: str, system_prompt: str -) -> tuple[dict, Any, str]: - """Ask the model to apply ``rules`` to ``diff`` under ``system_prompt``. +def estimate_tokens(text: str) -> int: + """Conservatively estimate how many model tokens ``text`` costs. + + Uses the densest ratio measured in practice (PR #278's generated + interval-arithmetic diff: ~2.0 characters per token), so ordinary + code overestimates — which only errs toward eliding more, never + toward an API rejection. + """ + return int(len(text) / CHARS_PER_TOKEN_ESTIMATE) + 1 + + +@dataclass(frozen=True) +class DiffTruncation: + """How an oversized diff was reduced to fit the review token budget. + + Attributes: + total_files: Per-file chunks in the original diff. + elided_files: Files whose patch bodies were replaced with markers. + original_chars: Character count of the untruncated diff. + final_chars: Character count of the diff actually sent. + hard_truncated: True when even full elision overflowed and the + tail of the diff was cut outright. + """ + + total_files: int + elided_files: int + original_chars: int + final_chars: int + hard_truncated: bool = False + + +def split_diff_into_files(diff: str) -> list[str]: + """Split a unified diff into per-file chunks. + + Each chunk starts at a ``diff --git`` header. Content lines inside a + patch body always carry a prefix (``+``, ``-``, space, ``@@``), so a + line starting with ``diff --git `` is reliably a file boundary. + """ + chunk_lines: list[list[str]] = [] + current: list[str] = [] + for line in diff.splitlines(): + if line.startswith("diff --git ") and current: + chunk_lines.append(current) + current = [line] + else: + current.append(line) + if current: + chunk_lines.append(current) + return ["\n".join(lines) for lines in chunk_lines] + + +def _elide_file_chunk(chunk: str, head_lines: int) -> str: + """Replace the bulk of one file's patch with a marker, keeping its head. + + Keeps the file headers (everything up to the first ``@@`` hunk) plus + the first ``head_lines`` patch lines — for an added Lean file that is + the module docstring and imports — and appends a marker recording how + many patch lines were dropped. Returns the chunk unchanged when it is + too small for elision to save anything. + """ + lines = chunk.split("\n") + body_start = next((i for i, line in enumerate(lines) if line.startswith("@@")), 0) + kept = body_start + head_lines + if len(lines) <= kept + 1: + return chunk + marker = ( + f"{ELISION_MARKER} {len(lines) - kept:,} of {len(lines) - body_start:,} " + "patch lines omitted — diff exceeded the review size budget]" + ) + return "\n".join([*lines[:kept], marker]) + - Tries the ``flex`` service tier first; if OpenAI returns 429 Resource - Unavailable, retries with ``service_tier="auto"`` (standard tier). +def fit_diff_to_budget( + diff: str, + budget_tokens: int, + head_lines: int = ELIDED_FILE_HEAD_LINES, +) -> tuple[str, DiffTruncation | None]: + """Reduce ``diff`` until it fits ``budget_tokens`` estimated tokens. + + A diff that already fits is returned unchanged. Otherwise the bodies + of the largest per-file patches are elided first — machine-generated + certificate/data files are what blows PRs past the limit — keeping + every file's path, line counts, and opening lines. If eliding every + file is still not enough, the fit is redone with the minimum head + size, and as a last resort the tail of the diff is cut outright. Returns: - ``(payload, usage, tier)`` where ``payload`` is the parsed JSON - review, ``usage`` is the OpenAI ``CompletionUsage`` object (or - ``None``), and ``tier`` is ``"flex"`` or ``"standard"``. + ``(fitted_diff, truncation)`` where ``truncation`` is ``None`` + when the diff was left untouched. """ - client = OpenAI(timeout=REQUEST_TIMEOUT_SECONDS) - user_content = f"## Review rules\n\n{rules}\n\n## PR diff\n\n```diff\n{diff}\n```" - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_content}, - ] + budget_chars = int(budget_tokens * CHARS_PER_TOKEN_ESTIMATE) + if len(diff) <= budget_chars: + return diff, None + chunks = split_diff_into_files(diff) + total_chars = sum(len(chunk) + 1 for chunk in chunks) + largest_first = sorted( + range(len(chunks)), key=lambda index: len(chunks[index]), reverse=True + ) + elided = 0 + for index in largest_first: + if total_chars <= budget_chars: + break + replacement = _elide_file_chunk(chunks[index], head_lines) + if len(replacement) >= len(chunks[index]): + continue + total_chars -= len(chunks[index]) - len(replacement) + chunks[index] = replacement + elided += 1 + fitted = "\n".join(chunks) + "\n" + hard_truncated = False + if len(fitted) > budget_chars: + if head_lines > MINIMUM_ELIDED_FILE_HEAD_LINES: + return fit_diff_to_budget( + diff, budget_tokens, head_lines=MINIMUM_ELIDED_FILE_HEAD_LINES + ) + fitted = ( + fitted[:budget_chars] + + f"\n{ELISION_MARKER} remainder of the diff omitted — review " + "size budget exhausted]\n" + ) + hard_truncated = True + return fitted, DiffTruncation( + total_files=len(chunks), + elided_files=elided, + original_chars=len(diff), + final_chars=len(fitted), + hard_truncated=hard_truncated, + ) + + +def build_user_content(rules: str, diff: str, truncation: DiffTruncation | None) -> str: + """Assemble the user message from rules, diff, and any size notice.""" + sections = [f"## Review rules\n\n{rules}"] + if truncation is not None: + notice = ( + "This PR's diff was too large to send in full " + f"({truncation.original_chars:,} characters). The bodies of the " + f"{truncation.elided_files} largest of its {truncation.total_files} " + f"file patches were replaced with `{ELISION_MARKER} ...]` markers; " + "every file's diff header, patch line counts, and opening lines " + "are retained. Machine-generated bulk (proof certificates, " + "generated case data) is the usual cause of this size. Review " + "from the visible content plus the elided files' paths, sizes, " + "and heads — an elision marker is not missing work by the author " + "— and state in your summary that the review is based on a " + "partial diff." + ) + if truncation.hard_truncated: + notice += ( + " Even the elided diff overflowed, so its tail was cut " + "outright after the character budget." + ) + sections.append(f"## Diff size notice\n\n{notice}") + sections.append(f"## PR diff\n\n```diff\n{diff}\n```") + return "\n\n".join(sections) + +def _completion_with_tier_fallback( + client: OpenAI, model: str, messages: list[dict[str, str]] +) -> tuple[Any, str]: + """Create a completion on the ``flex`` tier, falling back to standard. + + Flex is either out of capacity (429 RateLimitError) or unsupported for + this model (e.g. 500 InternalServerError on gpt-5.5); either way retry + with ``service_tier="auto"``. Any other error propagates. + """ try: response = client.chat.completions.create( model=model, @@ -327,11 +500,8 @@ def request_review( response_format={"type": "json_object"}, service_tier="flex", ) - tier = "flex" + return response, "flex" except (RateLimitError, APIStatusError) as e: - # Flex either out of capacity (429 RateLimitError) or unsupported - # for this model (e.g. 500 InternalServerError on gpt-5.5). - # Either way, fall back to standard. if isinstance(e, RateLimitError) or (500 <= e.status_code < 600): response = client.chat.completions.create( model=model, @@ -339,12 +509,77 @@ def request_review( response_format={"type": "json_object"}, service_tier="auto", ) - tier = "standard" - else: - raise + return response, "standard" + raise - content = response.choices[0].message.content or "{}" - return json.loads(content), response.usage, tier + +def _is_token_overflow(error: Exception) -> bool: + """Whether ``error`` is the API rejecting the input as too many tokens. + + Matches e.g. "Input tokens exceed the configured limit of 922000 + tokens" (PR #278) as well as the classic "maximum context length" + phrasing, without relying on a stable machine-readable error code. + """ + if getattr(error, "status_code", None) != 400: + return False + message = str(error).lower() + return "token" in message and ( + "exceed" in message or "context length" in message or "too long" in message + ) + + +def request_review( + model: str, rules: str, diff: str, system_prompt: str +) -> tuple[dict, Any, str, DiffTruncation | None]: + """Ask the model to apply ``rules`` to ``diff`` under ``system_prompt``. + + The diff is first fitted to :data:`MAX_INPUT_TOKENS` estimated input + tokens (:func:`fit_diff_to_budget`). Token estimation is approximate, + so if the API still rejects the input as too large, the diff budget is + halved and the request refitted and retried instead of failing the + review. Tries the ``flex`` service tier first; if OpenAI returns 429 + Resource Unavailable, retries with ``service_tier="auto"`` (standard). + + Returns: + ``(payload, usage, tier, truncation)`` where ``payload`` is the + parsed JSON review, ``usage`` is the OpenAI ``CompletionUsage`` + object (or ``None``), ``tier`` is ``"flex"`` or ``"standard"``, + and ``truncation`` describes any diff elision (``None`` when the + full diff was reviewed). + """ + client = OpenAI(timeout=REQUEST_TIMEOUT_SECONDS) + scaffold_tokens = estimate_tokens(rules) + estimate_tokens(system_prompt) + diff_budget_tokens = MAX_INPUT_TOKENS - scaffold_tokens + for attempt in range(REVIEW_FIT_ATTEMPTS): + fitted_diff, truncation = fit_diff_to_budget(diff, diff_budget_tokens) + if truncation is not None: + print( + f"Diff over review budget: elided {truncation.elided_files} of " + f"{truncation.total_files} files " + f"({truncation.original_chars:,} -> {truncation.final_chars:,} " + "chars).", + file=sys.stderr, + ) + user_content = build_user_content(rules, fitted_diff, truncation) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content}, + ] + try: + response, tier = _completion_with_tier_fallback(client, model, messages) + except BadRequestError as error: + if not _is_token_overflow(error) or attempt == REVIEW_FIT_ATTEMPTS - 1: + raise + diff_budget_tokens //= 2 + print( + "Model rejected the input as too large; refitting the diff " + f"to ~{diff_budget_tokens:,} estimated tokens and retrying.", + file=sys.stderr, + ) + continue + content = response.choices[0].message.content or "{}" + return json.loads(content), response.usage, tier, truncation + raise RuntimeError("unreachable: review fit loop exited without a result") def render_usage(usage: Any, model: str, tier: str) -> str: @@ -442,12 +677,14 @@ def render_comment( tier: str, reviewed_head_sha: str, kind: str = "project", + truncation: DiffTruncation | None = None, ) -> str: """Render the model's payload as a Markdown PR comment body. ``kind`` is ``"project"`` (fit/significance review) or ``"refactor"`` (tech-debt review); it selects the header, the assessment table, and - the rules doc linked in the footer. + the rules doc linked in the footer. When ``truncation`` is set, the + comment states up front that the model reviewed a reduced diff. """ summary = (payload.get("summary") or "").strip() verdict = (payload.get("verdict") or "").strip() @@ -463,6 +700,19 @@ def render_comment( if reviewed_head_sha: lines.extend([f"**Reviewed head:** `{reviewed_head_sha}`", ""]) + if truncation is not None: + notice = ( + "> ⚠️ **Partial review — diff exceeded the size budget.** The " + f"bodies of the {truncation.elided_files} largest of " + f"{truncation.total_files} file patches were elided before review " + f"({truncation.original_chars:,} → {truncation.final_chars:,} " + "characters); every file's path, line counts, and opening lines " + "were still shown to the model." + ) + if truncation.hard_truncated: + notice += " Even the elided diff overflowed, so its tail was cut outright." + lines.extend([notice, ""]) + if verdict: icon = VERDICT_ICON.get(verdict, "•") lines.extend([f"**Verdict:** {icon} `{verdict}`", ""]) @@ -581,7 +831,7 @@ def main() -> int: os.environ.get("REVIEW_HEAD_SHA") or fetch_head_sha(pr_number, repo_full_name).strip() ) - payload, usage, tier = request_review( + payload, usage, tier, truncation = request_review( model=model, rules=rules, diff=diff, system_prompt=system_prompt ) comment = render_comment( @@ -591,6 +841,7 @@ def main() -> int: tier=tier, reviewed_head_sha=reviewed_head_sha, kind=kind, + truncation=truncation, ) post_comment(pr_number, comment, repo_full_name=repo_full_name) return 0 diff --git a/python/tests/aggregator/test_triage.py b/python/tests/aggregator/test_triage.py index 64defd158c..e0f1387dc1 100644 --- a/python/tests/aggregator/test_triage.py +++ b/python/tests/aggregator/test_triage.py @@ -3,23 +3,14 @@ from __future__ import annotations import json -import sys -import types from pathlib import Path import yaml -# `openai` ships only in the `review` dependency group, which the test -# environment does not install. Stub it before importing the triage module -# (which imports it at module load), exactly as the review tests do. -openai_stub = types.ModuleType("openai") -openai_stub.APIStatusError = Exception -openai_stub.OpenAI = object -openai_stub.RateLimitError = Exception -sys.modules.setdefault("openai", openai_stub) - -from lean_pool.aggregator.discovery import _github_queries # noqa: E402 -from lean_pool.aggregator.triage import ( # noqa: E402 +# `openai` is stubbed by tests/conftest.py before this module loads; the +# triage module imports it at module load. +from lean_pool.aggregator.discovery import _github_queries +from lean_pool.aggregator.triage import ( repository_context, triage_discovered_file, ) diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 0000000000..4c4d540be1 --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,42 @@ +"""Shared fixtures: a canonical stub of the optional ``openai`` package. + +``openai`` ships only in the ``review`` dependency group, which the test +environment does not install, yet ``lean_pool.review`` and +``lean_pool.aggregator.triage`` import it at module load. Register one +stub — with the real package's exception hierarchy, including the +``status_code`` attributes the code under test inspects — before any +test module imports those modules. Individual test files must not +register competing stubs: whichever loaded first would win, starving +the others of attributes it lacks. +""" + +from __future__ import annotations + +import sys +import types + + +class _APIStatusError(Exception): + """Stub mirroring ``openai.APIStatusError``'s ``status_code``.""" + + status_code = 500 + + +class _RateLimitError(_APIStatusError): + """Stub mirroring ``openai.RateLimitError`` (HTTP 429).""" + + status_code = 429 + + +class _BadRequestError(_APIStatusError): + """Stub mirroring ``openai.BadRequestError`` (HTTP 400).""" + + status_code = 400 + + +openai_stub = types.ModuleType("openai") +openai_stub.APIStatusError = _APIStatusError +openai_stub.BadRequestError = _BadRequestError +openai_stub.OpenAI = object +openai_stub.RateLimitError = _RateLimitError +sys.modules.setdefault("openai", openai_stub) diff --git a/python/tests/test_review.py b/python/tests/test_review.py index 7d3b99037c..70c22abc2c 100644 --- a/python/tests/test_review.py +++ b/python/tests/test_review.py @@ -1,15 +1,25 @@ -"""Tests for LLM review comment rendering.""" +"""Tests for LLM review comment rendering and oversized-diff truncation.""" from __future__ import annotations -import sys import types -openai_stub = types.ModuleType("openai") -openai_stub.APIStatusError = Exception -openai_stub.OpenAI = object -openai_stub.RateLimitError = Exception -sys.modules.setdefault("openai", openai_stub) +# The stub registered by tests/conftest.py; its exception classes carry +# the status_code attributes the review module's error handling inspects. +import openai + + +def _file_patch(path: str, body_lines: list[str]) -> str: + """Build one added-file chunk of a unified diff.""" + return "\n".join( + [ + f"diff --git a/{path} b/{path}", + "--- /dev/null", + f"+++ b/{path}", + f"@@ -0,0 +1,{len(body_lines)} @@", + *[f"+{line}" for line in body_lines], + ] + ) def test_render_comment_includes_marker_and_reviewed_head() -> None: @@ -43,6 +53,8 @@ def test_render_comment_includes_marker_and_reviewed_head() -> None: # A new-project review links the project rules, not the refactor rules. assert "REVIEW_RULES.md" in body assert "REFACTOR_REVIEW_RULES.md" not in body + # An untruncated review must not carry the partial-review banner. + assert "Partial review" not in body def test_classify_pr_pure_golf_is_refactor() -> None: @@ -121,3 +133,201 @@ def test_render_comment_refactor_mode() -> None: # Project-only assessment fields must not leak into a refactor review. assert "| Fit |" not in body assert "| Level |" not in body + + +def test_fit_diff_under_budget_is_untouched() -> None: + """A diff inside the token budget is sent verbatim, with no truncation.""" + from lean_pool.review import fit_diff_to_budget + + diff = _file_patch("LeanPool/Foo/A.lean", ["import Mathlib", "-- tiny"]) + "\n" + fitted, truncation = fit_diff_to_budget(diff, budget_tokens=10_000) + + assert fitted == diff + assert truncation is None + + +def test_fit_diff_elides_largest_files_first() -> None: + """Oversized diffs lose the biggest file bodies but keep paths and heads.""" + from lean_pool.review import ( + CHARS_PER_TOKEN_ESTIMATE, + ELISION_MARKER, + fit_diff_to_budget, + ) + + small = _file_patch( + "LeanPool/Tiny/Small.lean", [f"KEEP_SMALL line {i}" for i in range(5)] + ) + huge = _file_patch( + "LeanPool/Big/Certificate.lean", + [f"BULK_CERT interval case {i} padding 12345/67890" for i in range(400)], + ) + medium = _file_patch( + "LeanPool/Mid/Medium.lean", + [f"KEEP_MEDIUM padding padding {i}" for i in range(50)], + ) + diff = "\n".join([small, huge, medium]) + "\n" + + budget_tokens = 4_000 + fitted, truncation = fit_diff_to_budget(diff, budget_tokens=budget_tokens) + + assert truncation is not None + assert truncation.total_files == 3 + assert truncation.elided_files == 1 + assert not truncation.hard_truncated + assert truncation.original_chars == len(diff) + assert truncation.final_chars == len(fitted) + assert len(fitted) <= int(budget_tokens * CHARS_PER_TOKEN_ESTIMATE) + # The elided file keeps its path, head lines, and an elision marker + # recording the dropped line counts. + assert "diff --git a/LeanPool/Big/Certificate.lean" in fitted + assert "+BULK_CERT interval case 0 " in fitted + assert "case 399" not in fitted + assert ELISION_MARKER in fitted + assert "361 of 401 patch lines omitted" in fitted + # Small files survive in full, in their original order. + assert "+KEEP_SMALL line 4" in fitted + assert "+KEEP_MEDIUM padding padding 49" in fitted + assert ( + fitted.index("Small.lean") + < fitted.index("Certificate.lean") + < fitted.index("Medium.lean") + ) + + +def test_fit_diff_hard_truncates_as_last_resort() -> None: + """When even fully elided files overflow, the diff tail is cut outright.""" + from lean_pool.review import ELISION_MARKER, fit_diff_to_budget + + diff = ( + "\n".join( + _file_patch( + f"LeanPool/Bulk/File{n}.lean", + [f"generated case {n}.{i}" for i in range(60)], + ) + for n in range(40) + ) + + "\n" + ) + fitted, truncation = fit_diff_to_budget(diff, budget_tokens=500) + + assert truncation is not None + assert truncation.hard_truncated + assert len(fitted) < 1_500 + assert ELISION_MARKER in fitted + assert "remainder of the diff omitted" in fitted + + +def test_request_review_refits_after_token_overflow(monkeypatch) -> None: + """An "input tokens exceed" API rejection refits the diff, not the run.""" + from lean_pool import review + + calls: list[str] = [] + payload_json = '{"summary": "ok", "verdict": "approve", "findings": []}' + + class _FakeCompletions: + """Rejects the first request as oversized, accepts the second.""" + + def create(self, **kwargs): + """Record the user message and answer like the OpenAI client.""" + assert kwargs["service_tier"] == "flex" + calls.append(kwargs["messages"][1]["content"]) + if len(calls) == 1: + raise openai.BadRequestError( + "Input tokens exceed the configured limit of 922000 " + "tokens. Your messages resulted in 1556823 tokens." + ) + message = types.SimpleNamespace(content=payload_json) + return types.SimpleNamespace( + choices=[types.SimpleNamespace(message=message)], usage=None + ) + + class _FakeClient: + """Stub OpenAI client exposing chat.completions.create.""" + + def __init__(self) -> None: + """Wire up the fake chat.completions endpoint.""" + self.chat = types.SimpleNamespace(completions=_FakeCompletions()) + + monkeypatch.setattr(review, "OpenAI", lambda timeout: _FakeClient()) + monkeypatch.setattr(review, "MAX_INPUT_TOKENS", 3_000) + + big = _file_patch( + "LeanPool/Big/Generated.lean", + [f"machine generated bulk case line {i}".ljust(40, ".") for i in range(100)], + ) + small = _file_patch("LeanPool/Tiny/Card.lean", ["KEEP_ME"] * 10) + diff = "\n".join([big, small]) + "\n" + + payload, usage, tier, truncation = review.request_review( + model="gpt-5.5", rules="R" * 200, diff=diff, system_prompt="Review." + ) + + assert payload == {"summary": "ok", "verdict": "approve", "findings": []} + assert usage is None + assert tier == "flex" + # First attempt sent the full diff; the retry elided the big file. + assert len(calls) == 2 + assert review.ELISION_MARKER not in calls[0] + assert "case line 99" in calls[0] + assert review.ELISION_MARKER in calls[1] + assert len(calls[1]) < len(calls[0]) + assert "+KEEP_ME" in calls[1] + assert truncation is not None + assert truncation.elided_files == 1 + + +def test_request_review_reraises_unrelated_bad_request(monkeypatch) -> None: + """A 400 that is not a token overflow still fails the review loudly.""" + import pytest + + from lean_pool import review + + class _FakeCompletions: + """Always rejects the request with a non-overflow 400.""" + + def create(self, **kwargs): + """Raise a schema-style 400 regardless of input.""" + raise openai.BadRequestError("Invalid value for response_format.") + + class _FakeClient: + """Stub OpenAI client exposing chat.completions.create.""" + + def __init__(self) -> None: + """Wire up the fake chat.completions endpoint.""" + self.chat = types.SimpleNamespace(completions=_FakeCompletions()) + + monkeypatch.setattr(review, "OpenAI", lambda timeout: _FakeClient()) + + with pytest.raises(openai.BadRequestError): + review.request_review( + model="gpt-5.5", + rules="rules", + diff="diff --git a/x b/x\n", + system_prompt="s", + ) + + +def test_render_comment_notes_truncation() -> None: + """A truncated review states the elision clearly, keeping the contract.""" + from lean_pool.review import LLM_REVIEW_MARKER, DiffTruncation, render_comment + + body = render_comment( + {"summary": "Reviewed a reduced diff.", "verdict": "needs_discussion"}, + model="gpt-5.5", + usage=None, + tier="flex", + reviewed_head_sha="abc123", + truncation=DiffTruncation( + total_files=317, + elided_files=250, + original_chars=3_112_190, + final_chars=1_500_000, + ), + ) + + # The sticky marker and head-SHA keying are untouched by the notice. + assert body.startswith(LLM_REVIEW_MARKER) + assert "**Reviewed head:** `abc123`" in body + assert "Partial review" in body + assert "250" in body and "317" in body + assert "3,112,190" in body