diff --git a/emrg/tools/read_tool.py b/emrg/tools/read_tool.py index 738c4c71..ab98ccb9 100644 --- a/emrg/tools/read_tool.py +++ b/emrg/tools/read_tool.py @@ -286,10 +286,23 @@ async def execute(self, arguments: dict) -> ToolResult: for i, line in enumerate(selected): result_lines.append(f"{start + i + 1:6d}\t{line}") + # Reached exactly when `start_line` is past the last line: `start >= end` forces + # `end == total_lines`, so the `lines {start + 1}-{end}` this used to print was + # **always descending** — a range that cannot be a range, offered where the caller + # asked why nothing came back. Measured 2026-10-10 (`cyc20261010-204628`) on a + # 10-line file: `start_line=50` answered `(empty range: lines 50-10 of 10)` and + # `start_line=11` answered `(empty range: lines 11-10 of 10)`. The test that pinned + # it (`test_read_start_line_beyond_eof`) asserted only that the substring + # `empty range` appeared, so the descending range satisfied it. This branch names + # the condition and the two numbers it holds — the request, and the subject's size. if not result_lines: + plural = "" if total_lines == 1 else "s" return ToolResult( name="read", - content=f"(empty range: lines {start + 1}-{end} of {total_lines})", + content=( + f"(no lines: start_line={start_line} is past the end of the file, " + f"which has {total_lines} line{plural})" + ), ) if offset_note is not None: diff --git a/tests/test_read_tool.py b/tests/test_read_tool.py index ca434af5..3c3211b2 100644 --- a/tests/test_read_tool.py +++ b/tests/test_read_tool.py @@ -107,11 +107,40 @@ def test_read_binary_fails(temp_file): def test_read_start_line_beyond_eof(temp_file): + """The message names the request and the subject's size — and is not a range. + + The branch is reached exactly when `start_line` is past the last line, so the + `lines {start + 1}-{end}` it used to print was always descending (measured 2026-10-10, + `cyc20261010-204628`: a 10-line file answered `start_line=50` with `lines 50-10 of + 10`). This test asserted the substring `empty range`, which that descending range + satisfies — a wording pinned by the presence of its words rather than by what they + say. Both numbers the message holds are asserted now, and the descending shape is + refused, so a message naming the wrong start line or the wrong file size fails here. + """ tool = ReadTool() f, _ = temp_file - # 6 lines (5 + trailing newline), start_line=100 is way beyond + # 5 lines (the fixture writes 5, and the trailing newline is not a line) result = _run(tool.execute({"file_path": str(f), "start_line": 100})) - assert "(empty range" in result.content or "empty range" in result.content + assert "start_line=100" in result.content, result.content + assert "5 lines" in result.content, result.content + assert "100-5" not in result.content, result.content + assert "lines 100" not in result.content, result.content + + +def test_a_one_line_file_past_its_end_is_counted_in_the_singular(tmp_path): + """The singular half of the message above, which no test reached. + + `plural = "" if total_lines == 1 else "s"` is a branch, and a branch needs a reading: + measured 2026-10-10, replacing it with the bare `"s"` left every test in this file + green, so a one-line file read past its end could have been made to say `1 lines` + with no red anywhere. It is reachable — `start_line=2` on a one-line file goes + through this branch — and the plural side above does not reach it. + """ + f = tmp_path / "one.txt" + f.write_text("only\n") + result = _run(ReadTool().execute({"file_path": str(f), "start_line": 2})) + assert "which has 1 line" in result.content, result.content + assert "1 lines" not in result.content, result.content def test_read_truncation_message(temp_file):