From 6563b61320476dc16224645eea36dace01d80a9b Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Mon, 14 Sep 2026 02:15:47 -0700 Subject: [PATCH] feat(read): ranged file reads and a truncation marker that says how to continue (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit github_read_file and github_read_pr_file take start_line (1-based, default 1) and end_line (inclusive, 0 = to the end), sliced locally from the whole file the contents API returns (still --method GET with the ref as a query param). The 20000-char cap now cuts at a LINE boundary, never mid-line, and ends with one footer line naming the file's total line count and the next start_line: … [truncated: showed lines 1-487 of 870 (20000-char cap); continue with start_line=488] A range returned in full ends with "… [showed lines A-B of N; continue with start_line=B+1]" or "…; end of file]". A single line longer than the whole cap is the one mid-line cut and says so ("truncated mid-line: line A of N alone is L chars …"). The default read of a file that fits is byte-identical, as before. Bad ranges and a start_line past EOF are Error strings, checked before any gh call where possible. Tool descriptions state the cap, the marker, and to page with start_line/end_line instead of guessing or reading the base version. Release v0.8.0 (new tool parameters): manifest, pyproject, uv.lock project line. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LSRkzcPyaDrKSY7geFuGr3 --- PROTO.md | 15 +++ README.md | 6 +- protoagent.plugin.yaml | 2 +- pyproject.toml | 2 +- read_tools.py | 145 ++++++++++++++++++++-- tests/test_read_tools.py | 262 ++++++++++++++++++++++++++++++++++++++- uv.lock | 2 +- 7 files changed, 418 insertions(+), 16 deletions(-) diff --git a/PROTO.md b/PROTO.md index eb00258..83342ed 100644 --- a/PROTO.md +++ b/PROTO.md @@ -117,6 +117,21 @@ reviewer, their most recent — so bot COMMENTED reviews can't bury a human's CHANGES_REQUESTED; older gh falls back to the NEWEST `reviews`), `isDraft`; total output bounded at 12k chars (`github_issue_comments` too). +**File reads page by line (#31).** `github_read_file` and `github_read_pr_file` take +`start_line` (1-based, default 1) and `end_line` (inclusive; 0 = to the end), sliced +locally from the whole file the contents API returns (`--method GET`, the ref as a +query param — the range never reaches GitHub). The content is capped at 20000 chars +(`_MAX_FILE_CHARS`; the docstrings repeat the literal and a test pins they agree). The +DEFAULT read of a file that fits is returned byte-identical; anything else ends with ONE +footer line (`read_tools._render_file`), always naming the total line count: +`… [truncated: showed lines 1-540 of 870 (20000-char cap); continue with start_line=541]` +(cut at a LINE boundary, never mid-line; `, end_line=E` added when the caller's range is +still ahead), `… [showed lines A-B of N; continue with start_line=B+1]` / +`… [showed lines A-B of N; end of file]` (a range returned in full), and — the one +mid-line exception — `… [truncated mid-line: line A of N alone is L chars (20000-char +cap), showed its first 20000; continue with start_line=A+1]`. A bad range or a +`start_line` past EOF is an `Error: …` string, checked before any `gh` call where it can be. + **Write (gated on `github.write`)** — `github_create_issue` (**body-gated**: the SAME `missing_sections` gate the `/issue` command enforces — a thin body, or a `bug` without repro / a `feature` without a diff --git a/README.md b/README.md index 02f1192..aab9167 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,10 @@ mergeability, a checks summary with the failing check names, the reviews), `github_list_prs` (the PR board: draft / review decision / merge state per row), `github_get_issue`, `github_list_issues`, `github_issue_comments` (the thread on an issue or PR), `github_search_issues` (dedupe **before** filing), `github_get_commit_diff`, -`github_pr_diff`, `github_ci_runs`, `github_run_failure`, `github_read_file`, -`github_read_pr_file`, `github_repo_contents`, `github_path_exists`, and `github_status` +`github_pr_diff`, `github_ci_runs`, `github_run_failure`, `github_read_file` / +`github_read_pr_file` (20000 chars per call, cut at a line boundary with a +`continue with start_line=N` marker; page large files with `start_line`/`end_line`), +`github_repo_contents`, `github_path_exists`, and `github_status` (is `gh` installed / signed in, as whom, which default repo — the self-diagnosis probe the model calls when another tool errors). diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 051bdf9..db3f082 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -2,7 +2,7 @@ # Keep `version` in lockstep with pyproject.toml (tests/test_version.py asserts it). id: github name: GitHub (read/write tools) -version: 0.7.1 +version: 0.8.0 description: >- Read AND write GitHub tools over the `gh` CLI, with PER-AGENT write gating. The read tools (PRs — list + merge readiness, issues, comments, search-before-filing, diff --git a/pyproject.toml b/pyproject.toml index 08d144f..4963829 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "github-plugin" -version = "0.7.1" +version = "0.8.0" description = "Read/write GitHub tools for protoAgent over the gh CLI, with per-agent write gating." requires-python = ">=3.11" diff --git a/read_tools.py b/read_tools.py index 959797c..8e2d6eb 100644 --- a/read_tools.py +++ b/read_tools.py @@ -36,6 +36,111 @@ _MAX_REVIEWS = 10 _MAX_COMMENT_CHARS = 1000 +# github_read_file / github_read_pr_file: the per-call bound on the FILE CONTENT (the +# marker line and read_pr_file's header ride on top). The docstrings state it as a plain +# literal (an f-string docstring ships no description) — a test pins that they agree. +_MAX_FILE_CHARS = 20000 + + +def _bad_line_range(start_line: int, end_line: int) -> str | None: + """The error for an unusable 1-based inclusive line range, or None. Checked BEFORE any + `gh` call, so a bad range never spends a network round trip.""" + if start_line < 1: + return f"Error: start_line must be >= 1 (lines are 1-based); got {start_line}." + if end_line < 0: + return f"Error: end_line must be >= start_line, or 0 for 'to the end of the file'; got {end_line}." + if end_line and end_line < start_line: + return ( + f"Error: end_line ({end_line}) is before start_line ({start_line}) — " + "pass end_line >= start_line, or 0 to read to the end of the file." + ) + return None + + +def _split_lines(text: str) -> list[str]: + """Split on \\n ONLY, keeping each line's ending, so ``"".join(lines) == text``. + (``str.splitlines`` also breaks on \\r, \\f, \\u2028 … and would disagree with every + editor's and GitHub's line numbers.)""" + parts = text.split("\n") + lines = [part + "\n" for part in parts[:-1]] + if parts[-1]: + lines.append(parts[-1]) + return lines + + +def _render_file( + text: str, start_line: int = 1, end_line: int = 0, *, path: str = "", cap: int = _MAX_FILE_CHARS +) -> tuple[bool, str]: + """Slice a file's raw content to a line range and bound it — ``(ok, output)``. + + A whole-file read that fits the cap comes back UNCHANGED (byte-identical, no + marker). Anything else — a requested range, or a read over the cap — ends with ONE + marker line (issue #31), always naming the file's total line count: + + - ``… [truncated: showed lines A-B of N (20000-char cap); continue with start_line=B+1]`` + — the cut is at a LINE boundary, never mid-line, so no reader mistakes it for + broken code; the shown text (minus the newline before the marker) is within the + cap, the same rule as the unchanged whole-file read. ``, end_line=E`` is added when + the caller's own end_line is still ahead. + - ``… [truncated mid-line: line A of N alone is L chars (20000-char cap), showed its + first 20000; continue with start_line=A+1]`` — the one exception: a single line + longer than the whole cap (minified code) can't be shown whole. + - ``… [showed lines A-B of N; continue with start_line=B+1]`` or + ``… [showed lines A-B of N; end of file]`` — a range returned in full. + + ``ok`` is False (with an ``Error: …`` message) for a bad range or a start_line past + the end of the file. + """ + if err := _bad_line_range(start_line, end_line): + return False, err + ranged = start_line != 1 or end_line != 0 + if not ranged and len(text) <= cap: + return True, text + lines = _split_lines(text) + total = len(lines) + if start_line > total: + return ( + False, + f"Error: start_line={start_line} is past the end of {path or 'the file'} — it has {total} line(s).", + ) + last = min(end_line, total) if end_line else total # the last line the caller asked for + + def tail(shown_to: int) -> str: + if shown_to >= total: + return "end of file" + more = f"continue with start_line={shown_to + 1}" + if shown_to < last < total: # cut INSIDE a range that stops short of EOF: keep its end + more += f", end_line={last}" + return more + + shown: list[str] = [] + size = 0 + for line in lines[start_line - 1 : last]: + # A line's own newline doesn't count against the cap: it is the separator before + # the marker (or the end of the shown text), not content. + if size + len(line.removesuffix("\n")) > cap: + break + shown.append(line) + size += len(line) + if not shown: # the first requested line alone is over the cap — the only mid-line cut + first = lines[start_line - 1].removesuffix("\n") + body = first[:cap] + marker = ( + f"… [truncated mid-line: line {start_line} of {total} alone is {len(first)} chars " + f"({cap}-char cap), showed its first {cap}; {tail(start_line)}]" + ) + else: + body = "".join(shown) + shown_to = start_line + len(shown) - 1 + if shown_to < last: + marker = ( + f"… [truncated: showed lines {start_line}-{shown_to} of {total} ({cap}-char cap); {tail(shown_to)}]" + ) + else: + marker = f"… [showed lines {start_line}-{shown_to} of {total}; {tail(shown_to)}]" + return True, body + ("" if body.endswith("\n") else "\n") + marker + + # statusCheckRollup carries two shapes (verified against gh 2.92): a CheckRun # {name, status: COMPLETED|IN_PROGRESS|QUEUED|…, conclusion: SUCCESS|FAILURE|SKIPPED| # CANCELLED|NEUTRAL|TIMED_OUT|ACTION_REQUIRED|""} and a StatusContext {context, @@ -366,18 +471,29 @@ async def github_run_failure(run_id: int, repo: str = "", max_lines: int = 40) - # ── Repo-content readers — what lets an agent research ANY repo over `gh` without # registering an fs project per repo. ─────────────────────────────────────────── @tool - async def github_read_file(path: str, repo: str = "", ref: str = "") -> str: - """Read a single file's raw contents from a GitHub repo (capped at 20000 chars). + async def github_read_file(path: str, repo: str = "", ref: str = "", start_line: int = 1, end_line: int = 0) -> str: + """Read a file's raw contents from a GitHub repo — the whole file or a line range. + + Output is capped at 20000 chars per call. A longer read is cut at a LINE boundary + (never mid-line; the file is NOT broken there) and ends with a marker such as + ``… [truncated: showed lines 1-540 of 870 (20000-char cap); continue with start_line=541]``. + A ranged read ends with ``… [showed lines A-B of N; …]``. Page a large file with + ``start_line``/``end_line`` — don't guess at the rest or read another version instead. Args: repo: Repository as ``owner/name``. Omit to use the agent's configured default repo. path: Path to the file within the repo (e.g. ``docs/guide.md``). For a directory use ``github_repo_contents``; for a file as it is IN a PR use ``github_read_pr_file``. ref: Optional branch / tag / SHA (default: the repo's default branch). + start_line: First line to return, 1-based (default 1). + end_line: Last line to return, inclusive; 0 (default) = to the end of the file + (still subject to the 20000-char cap). """ repo = resolve_repo(repo, default_repo) or "" if err := bad_repo(repo): return err + if err := _bad_line_range(start_line, end_line): + return err # `--method GET` is load-bearing: `gh api` switches to POST as soon as a `-f` field is # added, and POST /contents/{path} is a 404 on GitHub — every ref-pinned read failed # (pr-reviewer-plugin#118). With GET, gh sends the fields as query params (?ref=…). @@ -394,12 +510,13 @@ async def github_read_file(path: str, repo: str = "", ref: str = "") -> str: rc, out, serr = await run_gh(args) if gh_err := check_gh_error(rc, serr, repo=repo): return gh_err - if len(out) > 20000: - out = out[:20000] + "\n… (truncated at 20000 chars)" - return out + # The contents API always returns the WHOLE file; the range is sliced here. + return _render_file(out, start_line, end_line, path=path)[1] @tool - async def github_read_pr_file(number: int, path: str, repo: str = "") -> str: + async def github_read_pr_file( + number: int, path: str, repo: str = "", start_line: int = 1, end_line: int = 0 + ) -> str: """Read a file as it exists IN a pull request — at the PR's head commit. Use this for every code-context read while reviewing a PR. Unlike @@ -407,14 +524,25 @@ async def github_read_pr_file(number: int, path: str, repo: str = "") -> str: server-side from the PR number, so the file you get is the PR's version, including anything the PR adds. + Output is capped at 20000 chars per call. A longer read is cut at a LINE boundary + (never mid-line; the file is NOT broken there) and ends with a marker such as + ``… [truncated: showed lines 1-540 of 870 (20000-char cap); continue with start_line=541]``. + A ranged read ends with ``… [showed lines A-B of N; …]``. Page a large file with + ``start_line``/``end_line`` — don't guess at the rest or read the base version instead. + Args: number: PR number. repo: Repository as ``owner/name``. Omit to use the agent's configured default repo. path: Path to the file within the repo (e.g. ``src/lib/queries.ts``). + start_line: First line to return, 1-based (default 1). + end_line: Last line to return, inclusive; 0 (default) = to the end of the file + (still subject to the 20000-char cap). """ repo = resolve_repo(repo, default_repo) or "" if err := bad_repo(repo): return err + if err := _bad_line_range(start_line, end_line): + return err # The ref is resolved HERE, from the PR — never supplied by the caller. A # model that omits (or mistypes) a ref on a plain read silently gets the # DEFAULT branch, i.e. the pre-PR file: it then "confirms" that symbols the @@ -441,8 +569,9 @@ async def github_read_pr_file(number: int, path: str, repo: str = "") -> str: # Fail LOUD, never fall back to the default branch: a silent fallback is # exactly the bug this tool exists to prevent. return f"Error reading {path} at {repo}#{number} head {head[:12]}: {gh_err}" - if len(out) > 20000: - out = out[:20000] + "\n… (truncated at 20000 chars)" + ok, out = _render_file(out, start_line, end_line, path=f"{path} at {repo}#{number} head {head[:12]}") + if not ok: # a start_line past EOF — the error alone, no content header over it + return out return f"{path} @ {repo}#{number} head {head[:12]}:\n\n{out}" @tool diff --git a/tests/test_read_tools.py b/tests/test_read_tools.py index 8596e93..bf111af 100644 --- a/tests/test_read_tools.py +++ b/tests/test_read_tools.py @@ -7,10 +7,12 @@ from __future__ import annotations import json +import random +import re from unittest.mock import AsyncMock, patch import pytest -from ghplugin.read_tools import get_read_tools +from ghplugin.read_tools import _MAX_FILE_CHARS, _render_file, _split_lines, get_read_tools def _read_file_tool(): @@ -71,10 +73,12 @@ async def test_gh_error_is_surfaced(): @pytest.mark.asyncio async def test_long_content_is_truncated(): + # One 25000-char line (no newline to cut at): the lone mid-line cut, and it says so. with patch("ghplugin.read_tools.run_gh", new=AsyncMock(return_value=(0, "x" * 25000, ""))): result = await _read_file_tool().ainvoke({"repo": "owner/name", "path": "big.txt"}) - assert result.endswith("… (truncated at 20000 chars)") - assert len(result) == 20000 + len("\n… (truncated at 20000 chars)") + assert result == "x" * 20000 + ( + "\n… [truncated mid-line: line 1 of 1 alone is 25000 chars (20000-char cap), showed its first 20000; end of file]" + ) @pytest.mark.asyncio @@ -835,6 +839,18 @@ def _tool(name: str): ("github_repo_contents", {"repo": "o/n", "path": "src", "ref": "main"}, [(0, "[]", "")]), # read_pr_file: resolve the head SHA, then read the file AT that SHA. ("github_read_pr_file", {"repo": "o/n", "number": 7, "path": "src/a.py"}, [(0, _REF, ""), (0, "body", "")]), + # #31: a ranged read is still the same GET with the same ref — the range is sliced + # locally from the whole file, never sent to GitHub. + ( + "github_read_file", + {"repo": "o/n", "path": "src/a.py", "ref": _REF, "start_line": 1, "end_line": 1}, + [(0, "body", "")], + ), + ( + "github_read_pr_file", + {"repo": "o/n", "number": 7, "path": "src/a.py", "start_line": 1, "end_line": 1}, + [(0, _REF, ""), (0, "body", "")], + ), ], ) async def test_ref_pinned_contents_reads_are_GETs(name, args, gh_replies): @@ -848,6 +864,7 @@ async def test_ref_pinned_contents_reads_are_GETs(name, args, gh_replies): # …and the ref still travels (as a query parameter on the GET). want = args.get("ref") or _REF assert f"ref={want}" in argv, argv + assert not any("start_line" in a or "end_line" in a for a in argv), argv @pytest.mark.asyncio @@ -857,3 +874,242 @@ async def test_read_pr_file_reads_at_the_resolved_head(): out = await _tool("github_read_pr_file").ainvoke({"repo": "o/n", "number": 7, "path": "src/a.py"}) assert out.startswith(f"src/a.py @ o/n#7 head {_REF[:12]}:") assert "print('hi')" in out + + +# ── Ranged reads + an honest truncation marker (#31) ───────────────────────────── +# On large files the review panel's lanes burned their turn budget working around a +# blind 20000-char cut (`find_removed_behavior` ran 974 s on protoAgent#3521 and never +# reported), and the structural lane posted "truncated mid-statement at line 539 → +# SyntaxError" as a blocker three rounds running on an intact file. The contract: the +# default whole-file read that fits is byte-identical; anything else is cut at a LINE +# boundary and ends with ONE marker line naming the total line count and how to go on. + +_W = 40 # every synthetic line is _W chars + "\n" = 41 → 487 whole lines fit in 20000 + + +def _numbered(n: int) -> str: + """n fixed-width lines ("L00001....."), no trailing newline (run_gh strips stdout).""" + return "\n".join(f"L{i:05d}".ljust(_W, ".") for i in range(1, n + 1)) + + +_BIG = _numbered(870) # the size of graph/snapshot_op.py in the issue +_BIG_LINES = _BIG.split("\n") +_PR_HEADER = f"big.py @ o/n#7 head {_REF[:12]}:\n\n" + + +def _lines(a: int, b: int) -> str: + return "\n".join(_BIG_LINES[a - 1 : b]) + + +async def _read(content: str, **kw) -> str: + with patch("ghplugin.read_tools.run_gh", new=AsyncMock(return_value=(0, content, ""))): + return await _tool("github_read_file").ainvoke({"repo": "o/n", "path": "big.py", **kw}) + + +async def _read_pr(content: str, **kw) -> str: + mock = AsyncMock(side_effect=[(0, _REF, ""), (0, content, "")]) + with patch("ghplugin.read_tools.run_gh", mock): + return await _tool("github_read_pr_file").ainvoke({"repo": "o/n", "number": 7, "path": "big.py", **kw}) + + +def test_the_synthetic_file_math_the_tests_lean_on(): + assert len(_BIG_LINES) == 870 and all(len(line) == _W for line in _BIG_LINES) + assert 487 * (_W + 1) - 1 <= _MAX_FILE_CHARS < 488 * (_W + 1) - 1 + assert len(_BIG) > _MAX_FILE_CHARS + + +@pytest.mark.parametrize( + "content", + ["", "x", "a\nb", "a\r\nb\r\n", "line\n\n\nend", "ünïcødé ✓\n", "y" * _MAX_FILE_CHARS, _numbered(487)], +) +async def test_a_whole_file_that_fits_is_byte_identical(content): + assert await _read(content) == content + assert await _read_pr(content) == _PR_HEADER + content + + +async def test_an_explicit_range_on_a_small_file_gets_the_marker(): + # Only the DEFAULT read is byte-identical; asking for a range always says what it got. + assert await _read("a\nb\nc", start_line=1, end_line=3) == "a\nb\nc\n… [showed lines 1-3 of 3; end of file]" + + +async def test_over_the_cap_is_cut_at_a_line_boundary_with_a_continue_marker(): + out = await _read(_BIG) + body, marker = out.rsplit("\n", 1) + assert marker == "… [truncated: showed lines 1-487 of 870 (20000-char cap); continue with start_line=488]" + assert body == _lines(1, 487) # whole lines only — line 487 intact, nothing of 488 + assert len(body) <= _MAX_FILE_CHARS + pr = await _read_pr(_BIG) + assert pr == _PR_HEADER + out + + +async def test_one_char_over_the_cap_is_truncated_and_exactly_at_it_is_not(): + at_cap = _numbered(487) + "\n" + "z" * 33 + assert len(at_cap) == _MAX_FILE_CHARS + assert await _read(at_cap) == at_cap + over = at_cap + "z" + assert await _read(over) == ( + _numbered(487) + "\n… [truncated: showed lines 1-487 of 488 (20000-char cap); continue with start_line=488]" + ) + + +async def test_following_the_marker_pages_through_the_whole_file(): + pages, start = [], 1 + for _ in range(10): + out = await _read(_BIG, start_line=start) + body, marker = out.rsplit("\n", 1) + pages.append(body) + m = re.search(r"continue with start_line=(\d+)\]$", marker) + if not m: + assert marker == "… [showed lines 488-870 of 870; end of file]", marker + break + start = int(m.group(1)) + assert "\n".join(pages) == _BIG + assert len(pages) == 2 + + +@pytest.mark.parametrize( + ("kw", "a", "b", "tail"), + [ + ({"start_line": 1, "end_line": 10}, 1, 10, "continue with start_line=11"), # first + ({"start_line": 100, "end_line": 200}, 100, 200, "continue with start_line=201"), # middle + ({"start_line": 861, "end_line": 870}, 861, 870, "end of file"), # last + ({"start_line": 860, "end_line": 5000}, 860, 870, "end of file"), # end_line past EOF clamps + ({"start_line": 541}, 541, 870, "end of file"), # end_line=0 → to the end + ({"start_line": 5, "end_line": 5}, 5, 5, "continue with start_line=6"), # one line + ], +) +async def test_a_range_that_fits_returns_exactly_those_lines(kw, a, b, tail): + out = await _read(_BIG, **kw) + assert out == _lines(a, b) + f"\n… [showed lines {a}-{b} of 870; {tail}]" + assert await _read_pr(_BIG, **kw) == _PR_HEADER + out + + +async def test_a_cut_inside_a_requested_range_keeps_its_end_line(): + out = await _read(_BIG, start_line=1, end_line=800) + assert out == _lines(1, 487) + ( + "\n… [truncated: showed lines 1-487 of 870 (20000-char cap); continue with start_line=488, end_line=800]" + ) + assert await _read(_BIG, start_line=488, end_line=800) == ( + _lines(488, 800) + "\n… [showed lines 488-800 of 870; continue with start_line=801]" + ) + # an end_line at/past EOF is "to the end" — nothing to carry forward + assert (await _read(_BIG, start_line=1, end_line=5000)).endswith("; continue with start_line=488]") + + +async def test_start_line_past_eof_is_a_clear_error(): + assert await _read(_BIG, start_line=871) == "Error: start_line=871 is past the end of big.py — it has 870 line(s)." + assert await _read_pr(_BIG, start_line=871, end_line=900) == ( + f"Error: start_line=871 is past the end of big.py at o/n#7 head {_REF[:12]} — it has 870 line(s)." + ) + assert ( + await _read("", start_line=1, end_line=5) == "Error: start_line=1 is past the end of big.py — it has 0 line(s)." + ) + + +@pytest.mark.parametrize( + ("kw", "msg"), + [ + ({"start_line": 0}, "Error: start_line must be >= 1 (lines are 1-based); got 0."), + ({"start_line": -5}, "Error: start_line must be >= 1 (lines are 1-based); got -5."), + ({"end_line": -1}, "Error: end_line must be >= start_line, or 0 for 'to the end of the file'; got -1."), + ( + {"start_line": 10, "end_line": 5}, + "Error: end_line (5) is before start_line (10) — pass end_line >= start_line, or 0 to read to the end of the file.", + ), + ], +) +@pytest.mark.parametrize("name", ["github_read_file", "github_read_pr_file"]) +async def test_a_bad_range_is_an_error_string_before_any_gh_call(name, kw, msg): + mock = AsyncMock(return_value=(0, _REF, "")) + args = {"repo": "o/n", "path": "big.py", **kw} + if name == "github_read_pr_file": + args["number"] = 7 + with patch("ghplugin.read_tools.run_gh", mock): + out = await _tool(name).ainvoke(args) + assert out == msg + mock.assert_not_called() + + +async def test_a_line_longer_than_the_cap_is_the_only_mid_line_cut_and_says_so(): + content = "m" * 25000 + "\nnext line\nlast" + assert await _read(content) == "m" * 20000 + ( + "\n… [truncated mid-line: line 1 of 3 alone is 25000 chars (20000-char cap), showed its first 20000; " + "continue with start_line=2]" + ) + # …and paging past it still works (no loop on the oversize line). + assert await _read(content, start_line=2) == "next line\nlast\n… [showed lines 2-3 of 3; end of file]" + + +@pytest.mark.parametrize("name", ["github_read_file", "github_read_pr_file"]) +def test_descriptions_state_the_cap_the_marker_and_how_to_page(name): + t = _tool(name) + desc = t.description + assert f"capped at {_MAX_FILE_CHARS} chars" in desc # the plain-literal docstring agrees with the constant + assert "LINE boundary" in desc and "continue with start_line=" in desc + assert "start_line" in desc and "end_line" in desc + assert t.args["start_line"].get("default") == 1 + assert t.args["end_line"].get("default") == 0 + + +# The rendered output, parsed back: `showed lines A-B of N` (optionally `truncated:` + +# the cap), then either the next start_line (+ the caller's end_line) or `end of file`. +_MARKER_RE = re.compile( + r"… \[(truncated: )?showed lines (\d+)-(\d+) of (\d+)(?: \((\d+)-char cap\))?; " + r"(?:continue with start_line=(\d+)(?:, end_line=(\d+))?|end of file)\]" +) +_MIDLINE_RE = re.compile( + r"… \[truncated mid-line: line (\d+) of (\d+) alone is (\d+) chars \((\d+)-char cap\), " + r"showed its first (\d+); (?:continue with start_line=(\d+)(?:, end_line=\d+)?|end of file)\]" +) + + +def test_render_invariants_over_random_files(): + """Random files (empty lines, CRLF, trailing newline or not, lines over the cap) × + random caps and ranges: the marker's numbers are always the truth about the output.""" + rng = random.Random(31) + for _ in range(3000): + lines = [] + for _ in range(rng.randint(0, 40)): + roll = rng.random() + n = 0 if roll < 0.15 else rng.randint(1, 30) if roll < 0.92 else rng.randint(60, 200) + lines.append("".join(rng.choice("ab (){}:") for _ in range(n))) + eol = rng.choice(["\n", "\r\n"]) + text = eol.join(lines) + (eol if rng.random() < 0.3 else "") + cap = rng.randint(20, 150) + all_lines = _split_lines(text) + assert "".join(all_lines) == text + total = len(all_lines) + start = rng.randint(1, total + 2) + end = rng.choice([0, 0, rng.randint(start, start + 30)]) + ok, out = _render_file(text, start, end, cap=cap) + if start == 1 and end == 0 and len(text) <= cap: + assert ok and out == text + continue + if start > total: + assert not ok and out.startswith(f"Error: start_line={start} is past the end") + continue + assert ok + idx = out.rfind("\n") + shown, marker = out[:idx], out[idx + 1 :] + last = min(end, total) if end else total + if m := _MARKER_RE.fullmatch(marker): + a, b, tot = int(m[2]), int(m[3]), int(m[4]) + assert (a, tot) == (start, total) and a <= b <= last, marker + want = "".join(all_lines[a - 1 : b]) + assert shown == want.removesuffix("\n"), marker # exactly those whole lines + assert len(shown) <= cap + truncated = m[1] is not None + assert truncated == (b < last) == (m[5] is not None), marker + if truncated: # …and the next line really didn't fit + assert len(want) + len(all_lines[b].removesuffix("\n")) > cap + assert (m[6] is None) == (b >= total), marker + if m[6]: + assert int(m[6]) == b + 1 + assert (m[7] is not None) == (truncated and last < total), marker + else: + m = _MIDLINE_RE.fullmatch(marker) + assert m, marker + line = all_lines[start - 1].removesuffix("\n") + assert (int(m[1]), int(m[2]), int(m[3])) == (start, total, len(line)) and len(line) > cap + assert shown == line[:cap] + assert (m[6] is None) == (start >= total) diff --git a/uv.lock b/uv.lock index 861ae25..69c7fa1 100644 --- a/uv.lock +++ b/uv.lock @@ -4,5 +4,5 @@ requires-python = ">=3.11" [[package]] name = "github-plugin" -version = "0.7.1" +version = "0.8.0" source = { virtual = "." }