diff --git a/code_puppy/tools/file_operations.py b/code_puppy/tools/file_operations.py index 3796e6e49..5e01787a1 100644 --- a/code_puppy/tools/file_operations.py +++ b/code_puppy/tools/file_operations.py @@ -1,5 +1,6 @@ # file_operations.py +import math import os import shutil import subprocess @@ -193,10 +194,12 @@ def _list_files( break if not rg_path and recursive: - # Only need ripgrep for recursive listings - error_msg = "Error: ripgrep (rg) not found. Please install ripgrep to use this tool." - return ListFileOutput(content=error_msg, error=error_msg) - + # Fall back to non-recursive listing when ripgrep is not available + output_lines.append( + "Warning: ripgrep (rg) not found. Falling back to non-recursive listing. " + "Install ripgrep for full recursive support." + ) + recursive = False # Only use ripgrep for recursive listings if recursive: # Build command for ripgrep --files @@ -512,8 +515,8 @@ def _read_file( for char in content ) - # Simple approximation: ~4 characters per token - num_tokens = len(content) // 4 + # Token estimation consistent with BaseAgent (~2.5 characters per token) + num_tokens = max(1, math.floor(len(content) / 2.5)) if num_tokens > 10000: return ReadFileOutput( content=None, diff --git a/tests/tools/test_file_operations_coverage.py b/tests/tools/test_file_operations_coverage.py index e14d62773..79de8cfc7 100644 --- a/tests/tools/test_file_operations_coverage.py +++ b/tests/tools/test_file_operations_coverage.py @@ -268,8 +268,9 @@ def test_list_files_ripgrep_not_found_recursive(self, tmp_path): ): result = _list_files(None, str(tmp_path), recursive=True) - assert result.error is not None - assert "ripgrep" in result.error.lower() or "rg" in result.error.lower() + # Fallback behavior: warning in content, no hard error, files still listed + assert result.content is not None + assert result.error is None or "falling back" in (result.content or "").lower() def test_list_files_non_recursive_without_ripgrep(self, tmp_path): """Test non-recursive listing works without ripgrep.""" diff --git a/tests/tools/test_file_operations_extended.py b/tests/tools/test_file_operations_extended.py index e535c702f..11cd384bf 100644 --- a/tests/tools/test_file_operations_extended.py +++ b/tests/tools/test_file_operations_extended.py @@ -75,7 +75,7 @@ def test_read_file_line_range_out_of_bounds(self, tmp_path): assert result.error is None assert result.content == "" # Should return empty string - assert result.num_tokens == 0 + assert result.num_tokens == 1 def test_read_file_line_range_negative_start(self, tmp_path): """Test reading with negative start line is rejected.""" @@ -124,7 +124,7 @@ def test_read_file_empty_file(self, tmp_path): assert result.error is None assert result.content == "" - assert result.num_tokens == 0 + assert result.num_tokens == 1 # ==================== LIST FILES TESTS ==================== @@ -430,7 +430,7 @@ def test_read_large_file_with_token_limit(self, tmp_path): """Test that large files are handled and tokens are counted.""" test_file = tmp_path / "large.txt" # Create file with 500 lines - lines = [f"Line {i}: " + ("x" * 50) for i in range(500)] + lines = [f"Line {i}: " + ("x" * 30) for i in range(400)] test_file.write_text("\n".join(lines)) result = _read_file(None, str(test_file)) diff --git a/tests/tools/test_list_files_ripgrep_fallback.py b/tests/tools/test_list_files_ripgrep_fallback.py new file mode 100644 index 000000000..0cf091b6c --- /dev/null +++ b/tests/tools/test_list_files_ripgrep_fallback.py @@ -0,0 +1,51 @@ +"""Regression test for ripgrep fallback in _list_files. + +When ripgrep is not installed, _list_files should fall back to +non-recursive os.listdir instead of returning an error. +""" + +import os +import tempfile +from unittest.mock import patch + +from code_puppy.tools.file_operations import _list_files + + +class TestListFilesRipgrepFallback: + """_list_files should gracefully handle missing ripgrep.""" + + def test_falls_back_when_ripgrep_not_found(self): + """ + When ripgrep is not installed, _list_files should return + a non-recursive listing instead of an error. + """ + with tempfile.TemporaryDirectory() as tmpdir: + test_file = os.path.join(tmpdir, "test.py") + with open(test_file, "w") as f: + f.write("print('hello')") + + with patch("shutil.which", return_value=None): + result = _list_files(None, tmpdir, recursive=True) + + # Should not return a hard error + assert result.content is not None + assert ( + "not found" not in (result.content or "").lower() + or "falling back" in (result.content or "").lower() + ) + # Should still return file listing + assert "test.py" in result.content + + def test_returns_files_without_ripgrep(self): + """ + Files in the directory should be listed even without ripgrep. + """ + with tempfile.TemporaryDirectory() as tmpdir: + test_file = os.path.join(tmpdir, "myfile.py") + with open(test_file, "w") as f: + f.write("x = 1") + + with patch("shutil.which", return_value=None): + result = _list_files(None, tmpdir, recursive=True) + + assert "myfile.py" in result.content