From 132ac520ec800588fb89ecaa543e302936c243b9 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 15 Aug 2026 17:31:43 +0800 Subject: [PATCH] core/tools: mark only displayed lines read on truncated read_file read_file(with_lineno=true) marked the whole requested slice before applying the max_chars cap, so a truncated read silently unlocked every line of the file for edit_lineno even though the model never saw them. Mark only the complete numbered lines actually returned (excluding the [Truncated] marker); the unseen tail stays unread. --- src/plyngent/tools/file/read.py | 21 ++++++++++++++------- tests/test_tools/test_edit_lineno.py | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/plyngent/tools/file/read.py b/src/plyngent/tools/file/read.py index f9e0c2c..69a352d 100644 --- a/src/plyngent/tools/file/read.py +++ b/src/plyngent/tools/file/read.py @@ -83,14 +83,10 @@ async def read_file( if start >= len(lines): return "" slice_lines = lines[start:end] - if with_lineno: - mark_lineno_read(str(target), set(range(start + 1, end + 1))) - body = _format_with_lineno(slice_lines, start_lineno=start + 1) - else: - body = "".join(slice_lines) + body = _format_with_lineno(slice_lines, start_lineno=start + 1) if with_lineno else "".join(slice_lines) if max_chars >= 1: char_start = len("".join(lines[:start])) - body, _ = truncate_with_token( + body, token = truncate_with_token( body, max_chars, kind="file", @@ -102,10 +98,21 @@ async def read_file( # returned slice itself was cut by max_chars (not just "file continues"). emit_more_after=False, ) + else: + token = None if not body: return "" if with_lineno: - return body # per-line numbers already show the range + # Mark only the lines actually displayed: complete numbered lines in the + # returned body (the [Truncated] marker, when present, is not a file line). + # The max_chars-truncated tail was never shown, so it stays unread — + # edit_lineno must not trust line numbers the model never saw. + counted = body + if token is not None: + counted = body.split("\n[Truncated", 1)[0] + shown = counted.count("\n") + mark_lineno_read(str(target), set(range(start + 1, start + 1 + shown))) + return body begin = start + 1 end_line = start + len(slice_lines) return f"L{begin}-{end_line}\n{body}" diff --git a/tests/test_tools/test_edit_lineno.py b/tests/test_tools/test_edit_lineno.py index 91c1054..b4a18e8 100644 --- a/tests/test_tools/test_edit_lineno.py +++ b/tests/test_tools/test_edit_lineno.py @@ -22,6 +22,22 @@ def test_edit_lineno_replace_middle(workspace: object) -> None: assert call_sync(read_file, "a.txt") == "L1-4\none\nTWO\nTHREE\nfour\n" +def test_edit_lineno_read_truncated_marks_only_displayed_lines(workspace: object) -> None: + """A max_chars-truncated numbered read must not mark the unseen tail.""" + del workspace + _ = call_sync(write_file, "t.txt", "".join(f"line {i}\n" for i in range(1, 101))) + # Displayed complete lines are editable... + _ = reset_lineno_tracker() + _ = call_sync(read_file, "t.txt", with_lineno=True, max_chars=80) + out = call_sync(edit_lineno, "t.txt", 5, 5, "FIVE\n") + assert "replaced lines 5-5" in out + # ...but the truncated tail (never shown) stays unread. + _ = reset_lineno_tracker() + _ = call_sync(read_file, "t.txt", with_lineno=True, max_chars=80) + out = call_sync(edit_lineno, "t.txt", 90, 90, "X\n") + assert "not read" in out and "90" in out + + def test_edit_lineno_requires_all_lines_read(workspace: object) -> None: del workspace _ = call_sync(write_file, "r.txt", "a\nb\nc\nd\ne\n")