deer-flow/backend/tests/test_read_file_line_range.py
Totoro 3df3b3c630
feat(sandbox): show source line numbers for short read_file ranges (#8109)
* feat(sandbox): show source line numbers for short read_file ranges

* fix(sandbox): address numbered read review feedback

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-11-11 11:08:39 +08:00

218 lines
8.7 KiB
Python

"""read_file tool line-range handling for one-sided (single-bound) ranges.
Previously ``read_file`` only sliced when BOTH ``start_line`` and ``end_line``
were supplied; a lone ``start_line`` (or lone ``end_line``) was silently ignored
and the whole file was returned. These tests pin the one-sided range contract:
tail-from-start, head-to-end, rejection of non-positive line numbers, and clean
error strings for an inverted range or a start beyond EOF (instead of an
empty/garbage slice).
"""
from pathlib import Path
from types import SimpleNamespace
import pytest
from deerflow.sandbox.local.local_sandbox import LocalSandbox
from deerflow.sandbox.tools import read_file_tool
_FIVE_LINES = "line1\nline2\nline3\nline4\nline5"
def _local_runtime(tmp_path: Path) -> SimpleNamespace:
for sub in ("workspace", "uploads", "outputs"):
(tmp_path / sub).mkdir(parents=True, exist_ok=True)
thread_data = {
"workspace_path": str(tmp_path / "workspace"),
"uploads_path": str(tmp_path / "uploads"),
"outputs_path": str(tmp_path / "outputs"),
}
return SimpleNamespace(
state={"sandbox": {"sandbox_id": "local:t1"}, "thread_data": thread_data},
context={"thread_id": "t1"},
)
def _read(tmp_path, monkeypatch, **kwargs) -> str:
runtime = _local_runtime(tmp_path)
(tmp_path / "uploads" / "five.txt").write_text(_FIVE_LINES, encoding="utf-8", newline="\n")
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox("t1"))
monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None)
return read_file_tool.func(
runtime=runtime,
description="read a line range",
path="/mnt/user-data/uploads/five.txt",
**kwargs,
)
def test_only_start_line_returns_tail_from_that_line(tmp_path, monkeypatch) -> None:
result = _read(tmp_path, monkeypatch, start_line=3)
assert result == "line3\nline4\nline5"
def test_only_end_line_returns_head_up_to_that_line(tmp_path, monkeypatch) -> None:
result = _read(tmp_path, monkeypatch, end_line=2)
assert result == "line1\nline2"
def test_start_line_zero_returns_clean_error(tmp_path, monkeypatch) -> None:
result = _read(tmp_path, monkeypatch, start_line=0)
assert "start_line must be >= 1" in result
assert "line1" not in result
def test_start_line_negative_returns_clean_error(tmp_path, monkeypatch) -> None:
result = _read(tmp_path, monkeypatch, start_line=-1)
assert "start_line must be >= 1" in result
assert "line5" not in result
def test_start_line_greater_than_end_line_returns_clean_error(tmp_path, monkeypatch) -> None:
from deerflow.sandbox.read_file_contract import READ_FILE_EMPTY_RANGE
result = _read(tmp_path, monkeypatch, start_line=4, end_line=2)
assert result == READ_FILE_EMPTY_RANGE
# No garbage slice content leaked into the error.
assert "line4" not in result
def test_start_line_beyond_eof_returns_clean_error(tmp_path, monkeypatch) -> None:
result = _read(tmp_path, monkeypatch, start_line=99)
assert "start_line exceeds file length" in result
def test_both_bounds_still_slice_inclusive_range(tmp_path, monkeypatch) -> None:
result = _read(tmp_path, monkeypatch, start_line=2, end_line=4)
assert result == "line2\nline3\nline4"
def test_only_end_line_zero_returns_clean_error(tmp_path, monkeypatch) -> None:
result = _read(tmp_path, monkeypatch, end_line=0)
assert "end_line must be >= 1" in result
# No leaked line content in the error.
assert "line1" not in result
def test_only_end_line_negative_returns_clean_error(tmp_path, monkeypatch) -> None:
result = _read(tmp_path, monkeypatch, end_line=-1)
assert "end_line must be >= 1" in result
assert "line4" not in result
def test_end_line_past_eof_clamps_to_last_line(tmp_path, monkeypatch) -> None:
result = _read(tmp_path, monkeypatch, end_line=99)
assert result == _FIVE_LINES
def test_contract_constants_parity_with_localsandbox() -> None:
"""count_file_lines must agree with LocalSandbox line numbering and all no-content
strings must remain in READ_FILE_NO_CONTENT_RESULTS."""
from deerflow.sandbox.read_file_contract import (
READ_FILE_INVALID_RANGE,
READ_FILE_NO_CONTENT_RESULTS,
count_file_lines,
)
assert READ_FILE_INVALID_RANGE in READ_FILE_NO_CONTENT_RESULTS
# Parity with LocalSandbox.read_file (Python text-mode, universal \n only):
assert count_file_lines("") == 0
assert count_file_lines("line1\n") == 1
assert count_file_lines("line1\nline2\n") == 2
assert count_file_lines("line1\nline2") == 2 # no trailing newline
assert count_file_lines("page\fend\n") == 1 # \f not a line separator
assert count_file_lines("x\u2028y\n") == 1 # \u2028 not a line separator
def _model_read(tmp_path, monkeypatch, content, *, directory="uploads", **kwargs):
runtime = _local_runtime(tmp_path)
runtime.tool_call_id = "read-1"
(tmp_path / directory / "five.txt").write_bytes(content)
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox("t1"))
return read_file_tool.func(runtime=runtime, path=f"/mnt/user-data/{directory}/five.txt", **kwargs)
@pytest.mark.parametrize("directory", ["uploads", "workspace"])
def test_short_model_range_labels_original_source_lines(tmp_path, monkeypatch, directory) -> None:
result = _model_read(
tmp_path,
monkeypatch,
"first\r\nsource\u2028note\r\n\r\n12 | literal payload\r\nlast\r\n".encode(),
directory=directory,
start_line=2,
end_line=4,
)
assert result.content == ("Source lines (for exact edits, copy the text after the first ' | '):\n2 | source\u2028note\n3 | \n4 | 12 | literal payload")
assert result.status == "success"
assert result.additional_kwargs["deerflow_tool_meta"]["status"] == "success"
@pytest.mark.parametrize("start_line", [1, 2])
def test_model_start_only_range_labels_trailing_blank_line(tmp_path, monkeypatch, start_line) -> None:
result = _model_read(tmp_path, monkeypatch, b"a\nb\n\n", start_line=start_line)
assert result.content.split("\n")[1:] == ["1 | a", "2 | b", "3 | "][start_line - 1 :]
def test_model_full_read_keeps_original_display(tmp_path, monkeypatch) -> None:
assert _model_read(tmp_path, monkeypatch, _FIVE_LINES.encode()).content == _FIVE_LINES
def test_model_range_with_over_forty_actual_lines_keeps_original_display(tmp_path, monkeypatch) -> None:
content = "\n".join(f"line{number}" for number in range(1, 43))
assert _model_read(tmp_path, monkeypatch, content.encode(), start_line=2).content == "\n".join(content.split("\n")[1:])
@pytest.mark.parametrize(
("source_end", "bounds", "first", "last"),
[
(111, {"start_line": 97}, 97, 111),
(111, {"end_line": 25}, 1, 25),
(5, {"start_line": 1, "end_line": 41}, 1, 5),
(69, {"start_line": 30, "end_line": 150}, 30, 69),
],
ids=["short-tail", "short-head", "eof-shortens-window", "forty-actual-lines"],
)
def test_short_model_range_uses_effective_start_and_actual_line_count(
tmp_path,
monkeypatch,
source_end,
bounds,
first,
last,
) -> None:
content = "\n".join(f"line{number}" for number in range(1, source_end + 1))
result = _model_read(tmp_path, monkeypatch, content.encode(), **bounds)
rows = result.content.split("\n")[1:]
assert rows == [f"{number} | line{number}" for number in range(first, last + 1)]
@pytest.mark.parametrize("limit", [85, 240])
def test_numbered_display_does_not_change_output_when_it_exceeds_budget(tmp_path, monkeypatch, limit) -> None:
content = ("a" * 48 + "\n" + "b" * 48 + "\n" + "c" * 106).encode()
monkeypatch.setattr(
"deerflow.config.app_config.get_app_config",
lambda: SimpleNamespace(sandbox=SimpleNamespace(read_file_output_max_chars=limit)),
)
model = _model_read(tmp_path, monkeypatch, content, start_line=1, end_line=3)
raw = read_file_tool.func(
runtime=_local_runtime(tmp_path),
path="/mnt/user-data/uploads/five.txt",
start_line=1,
end_line=3,
)
assert model.content == raw
assert len(model.content) <= limit
@pytest.mark.parametrize("content", ["Error: remote read unavailable", " (start_line exceeds file length) "])
def test_model_range_preserves_in_band_read_failure_text(tmp_path, monkeypatch, content) -> None:
runtime = _local_runtime(tmp_path)
runtime.tool_call_id = "read-1"
monkeypatch.setattr("deerflow.sandbox.tools._read_file_from_sandbox", lambda *args, **kwargs: content)
result = read_file_tool.func(runtime=runtime, path="/mnt/user-data/uploads/five.txt", start_line=2, end_line=4)
assert result.content == content