from __future__ import annotations from collections.abc import Iterator from pathlib import Path import pytest from tests.mock.utils import collect_result from vibe.core.config.harness_files import ( init_harness_files_manager, reset_harness_files_manager, ) from vibe.core.tools.base import ToolError from vibe.core.tools.builtins.read import ( DEFAULT_LINE_LIMIT, MAX_BYTES, Read, ReadArgs, ReadConfig, ReadResult, ReadState, _add_line_numbers, ) from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay from vibe.core.trusted_folders import trusted_folders_manager from vibe.core.types import ToolResultEvent from vibe.core.utils import VIBE_WARNING_TAG def _make_read() -> Read: return Read(config_getter=lambda: ReadConfig(), state=ReadState()) @pytest.mark.asyncio async def test_reads_entire_small_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) (tmp_path / "hello.txt").write_text("line one\nline two\n", encoding="utf-8") tool = _make_read() result = await collect_result( tool.run(ReadArgs(file_path=str(tmp_path / "hello.txt"))) ) assert result.num_lines == 2 assert result.total_lines == 2 assert result.start_line == 1 assert "line one" in result.content assert "line two" in result.content @pytest.mark.asyncio async def test_reads_with_offset_and_limit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) content = "".join(f"line {i}\n" for i in range(1, 11)) (tmp_path / "f.txt").write_text(content, encoding="utf-8") tool = _make_read() result = await collect_result( tool.run(ReadArgs(file_path=str(tmp_path / "f.txt"), offset=3, limit=2)) ) assert result.num_lines == 2 assert result.start_line == 3 # Bounded read stops at the limit, so the true total is unknown. assert result.total_lines is None assert result.was_truncated is True assert "line 3" in result.content assert "line 4" in result.content assert "line 2" not in result.content assert "line 5" not in result.content @pytest.mark.asyncio async def test_empty_file_returns_warning( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) (tmp_path / "empty.txt").write_text("", encoding="utf-8") tool = _make_read() result = await collect_result( tool.run(ReadArgs(file_path=str(tmp_path / "empty.txt"))) ) assert result.num_lines == 0 assert VIBE_WARNING_TAG in result.content assert "empty" in result.content @pytest.mark.asyncio async def test_offset_beyond_file_returns_warning( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) (tmp_path / "short.txt").write_text("one\ntwo\n", encoding="utf-8") tool = _make_read() result = await collect_result( tool.run(ReadArgs(file_path=str(tmp_path / "short.txt"), offset=100)) ) assert result.num_lines == 0 assert VIBE_WARNING_TAG in result.content assert "shorter" in result.content @pytest.mark.asyncio async def test_exceeds_max_bytes_raises( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) # Create file that generates output exceeding MAX_BYTES big_line = "x" * 200 + "\n" lines_needed = (MAX_BYTES // len(big_line)) + 100 (tmp_path / "big.txt").write_text(big_line * lines_needed, encoding="utf-8") tool = _make_read() with pytest.raises(ToolError, match="exceeds maximum allowed size"): await collect_result(tool.run(ReadArgs(file_path=str(tmp_path / "big.txt")))) @pytest.mark.asyncio async def test_truncated_when_more_lines_than_limit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) content = "".join(f"line {i}\n" for i in range(1, 101)) (tmp_path / "f.txt").write_text(content, encoding="utf-8") tool = _make_read() result = await collect_result( tool.run(ReadArgs(file_path=str(tmp_path / "f.txt"), limit=10)) ) assert result.num_lines == 10 assert result.was_truncated is True assert result.total_lines is None @pytest.mark.asyncio async def test_single_oversized_line_raises( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) (tmp_path / "wide.txt").write_text("x" * (MAX_BYTES + 10), encoding="utf-8") tool = _make_read() with pytest.raises(ToolError, match="exceeds maximum allowed size"): await collect_result(tool.run(ReadArgs(file_path=str(tmp_path / "wide.txt")))) @pytest.mark.asyncio async def test_file_not_found_raises( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) tool = _make_read() with pytest.raises(ToolError, match="File not found"): await collect_result(tool.run(ReadArgs(file_path=str(tmp_path / "nope.txt")))) @pytest.mark.asyncio async def test_empty_path_raises( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) tool = _make_read() with pytest.raises(ToolError, match="file_path cannot be empty"): await collect_result(tool.run(ReadArgs(file_path=""))) @pytest.mark.asyncio async def test_directory_raises( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) (tmp_path / "adir").mkdir() tool = _make_read() with pytest.raises(ToolError, match="directory"): await collect_result(tool.run(ReadArgs(file_path=str(tmp_path / "adir")))) @pytest.mark.asyncio async def test_relative_path_resolved( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) (tmp_path / "sub").mkdir() (tmp_path / "sub" / "f.txt").write_text("ok\n", encoding="utf-8") tool = _make_read() result = await collect_result(tool.run(ReadArgs(file_path="sub/f.txt"))) assert result.num_lines == 1 assert str(tmp_path / "sub" / "f.txt") == result.file_path def test_line_number_format() -> None: formatted = _add_line_numbers(["hello", "world"], start=1) lines = formatted.split("\n") assert lines[0] == " 1\u2192hello" assert lines[1] == " 2\u2192world" def test_default_limit_is_2000() -> None: assert DEFAULT_LINE_LIMIT == 2000 def test_format_call_display() -> None: args = ReadArgs(file_path="/some/file.py") display = Read.format_call_display(args) assert isinstance(display, ToolCallDisplay) assert "file.py" in display.summary def test_format_call_display_with_offset_limit() -> None: args = ReadArgs(file_path="/some/file.py", offset=10, limit=50) display = Read.format_call_display(args) assert "from line 10" in display.summary assert "limit 50" in display.summary def test_get_result_display() -> None: result = ReadResult( file_path="/path/to/foo.py", content="...", num_lines=10, start_line=1, total_lines=10, ) event = ToolResultEvent( tool_call_id="test", tool_name="read", tool_class=None, result=result ) display = Read.get_result_display(event) assert isinstance(display, ToolResultDisplay) assert display.success is True assert "foo.py" in display.message def test_get_result_display_truncated() -> None: result = ReadResult( file_path="/path/to/foo.py", content="...", num_lines=10, start_line=1, total_lines=100, ) event = ToolResultEvent( tool_call_id="test", tool_name="read", tool_class=None, result=result ) display = Read.get_result_display(event) assert "truncated" in display.suffix def test_get_result_display_truncated_via_flag() -> None: result = ReadResult( file_path="/path/to/foo.py", content="...", num_lines=10, start_line=1, total_lines=None, was_truncated=True, ) event = ToolResultEvent( tool_call_id="test", tool_name="read", tool_class=None, result=result ) display = Read.get_result_display(event) assert "truncated" in display.suffix @pytest.fixture() def _setup_manager(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: monkeypatch.chdir(tmp_path) monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) monkeypatch.setattr( trusted_folders_manager, "find_trust_root", lambda _: tmp_path.resolve() ) reset_harness_files_manager() init_harness_files_manager("user", "project") yield reset_harness_files_manager() @pytest.mark.usefixtures("_setup_manager") def test_agents_md_injection(tmp_path: Path) -> None: sub = tmp_path / "sub" sub.mkdir() (sub / "AGENTS.md").write_text("# Sub instructions", encoding="utf-8") target = sub / "file.py" target.write_text("hello", encoding="utf-8") tool = _make_read() result = ReadResult( file_path=str(target), content="hello", num_lines=1, start_line=1, total_lines=1 ) annotation = tool.get_result_extra(result) assert annotation is not None assert VIBE_WARNING_TAG in annotation assert "# Sub instructions" in annotation @pytest.mark.usefixtures("_setup_manager") def test_agents_md_deduplicates(tmp_path: Path) -> None: sub = tmp_path / "sub" sub.mkdir() (sub / "AGENTS.md").write_text("# Sub", encoding="utf-8") (sub / "a.py").write_text("a", encoding="utf-8") (sub / "b.py").write_text("b", encoding="utf-8") tool = _make_read() r1 = ReadResult( file_path=str(sub / "a.py"), content="a", num_lines=1, start_line=1, total_lines=1, ) assert tool.get_result_extra(r1) is not None r2 = ReadResult( file_path=str(sub / "b.py"), content="b", num_lines=1, start_line=1, total_lines=1, ) assert tool.get_result_extra(r2) is None def test_agents_md_returns_none_when_not_initialized(tmp_path: Path) -> None: reset_harness_files_manager() tool = _make_read() result = ReadResult( file_path=str(tmp_path / "file.py"), content="", num_lines=0, start_line=1, total_lines=0, ) assert tool.get_result_extra(result) is None reset_harness_files_manager()