diff --git a/src/kimi_cli/soul/agent.py b/src/kimi_cli/soul/agent.py index 76985eed..ae3f03d2 100644 --- a/src/kimi_cli/soul/agent.py +++ b/src/kimi_cli/soul/agent.py @@ -14,6 +14,7 @@ from kimi_cli.soul.runtime import BuiltinSystemPromptArgs, Runtime from kimi_cli.soul.toolset import CustomToolset from kimi_cli.tools import SkipThisTool +from kimi_cli.tools.file import FileOpsWindow from kimi_cli.utils.logging import logger @@ -55,6 +56,7 @@ async def load_agent( Session: runtime.session, DenwaRenji: runtime.denwa_renji, Approval: runtime.approval, + FileOpsWindow: FileOpsWindow(), } tools = agent_spec.tools if agent_spec.exclude_tools: diff --git a/src/kimi_cli/tools/file/__init__.py b/src/kimi_cli/tools/file/__init__.py index 4b72837f..fae21150 100644 --- a/src/kimi_cli/tools/file/__init__.py +++ b/src/kimi_cli/tools/file/__init__.py @@ -1,10 +1,29 @@ from enum import Enum +from pathlib import Path class FileOpsWindow: - """Maintains a window of file operations.""" - - pass + """Track file operations within a session for safety checks.""" + + def __init__(self) -> None: + self._read_files: set[str] = set() + + @staticmethod + def _normalize(path: Path) -> str: + """Normalize paths for tracking.""" + try: + return str(path.resolve()) + except FileNotFoundError: + # Fallback to absolute path when resolving fails (e.g. deleted file) + return str(path.absolute()) + + def mark_read(self, path: Path) -> None: + """Record that a file has been read in the current session.""" + self._read_files.add(self._normalize(path)) + + def has_read(self, path: Path) -> bool: + """Check if the file has been read previously in the session.""" + return self._normalize(path) in self._read_files class FileActions(str, Enum): diff --git a/src/kimi_cli/tools/file/patch.md b/src/kimi_cli/tools/file/patch.md index b9cf0a2e..4612c14b 100644 --- a/src/kimi_cli/tools/file/patch.md +++ b/src/kimi_cli/tools/file/patch.md @@ -6,3 +6,4 @@ Apply a unified diff patch to a file. - The tool will fail with error returned if the patch doesn't apply cleanly. - The file must exist before applying the patch. - You should prefer this tool over WriteFile tool and Bash `sed` command when editing an existing file. +- You must use the `ReadFile` tool on the target file earlier in the session before calling this tool. diff --git a/src/kimi_cli/tools/file/patch.py b/src/kimi_cli/tools/file/patch.py index 330e22a9..ca7ac732 100644 --- a/src/kimi_cli/tools/file/patch.py +++ b/src/kimi_cli/tools/file/patch.py @@ -8,7 +8,7 @@ from kimi_cli.soul.approval import Approval from kimi_cli.soul.runtime import BuiltinSystemPromptArgs -from kimi_cli.tools.file import FileActions +from kimi_cli.tools.file import FileActions, FileOpsWindow from kimi_cli.tools.utils import ToolRejectedError, load_desc @@ -52,10 +52,17 @@ class PatchFile(CallableTool2[Params]): description: str = load_desc(Path(__file__).parent / "patch.md") params: type[Params] = Params - def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs: Any): + def __init__( + self, + builtin_args: BuiltinSystemPromptArgs, + approval: Approval, + file_ops_window: FileOpsWindow, + **kwargs: Any, + ): super().__init__(**kwargs) self._work_dir = builtin_args.KIMI_WORK_DIR self._approval = approval + self._file_ops_window = file_ops_window def _validate_path(self, path: Path) -> ToolError | None: """Validate that the path is safe to patch.""" @@ -104,6 +111,15 @@ async def __call__(self, params: Params) -> ToolReturnType: brief="Invalid path", ) + if not self._file_ops_window.has_read(p): + return ToolError( + message=( + f"You must read `{params.path}` with the ReadFile tool before patching it. " + "Call ReadFile first and then retry PatchFile." + ), + brief="Read file first", + ) + # Request approval if not await self._approval.request( self.name, diff --git a/src/kimi_cli/tools/file/read.py b/src/kimi_cli/tools/file/read.py index e6169277..7a0dec63 100644 --- a/src/kimi_cli/tools/file/read.py +++ b/src/kimi_cli/tools/file/read.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, Field from kimi_cli.soul.runtime import BuiltinSystemPromptArgs +from kimi_cli.tools.file import FileOpsWindow from kimi_cli.tools.utils import load_desc, truncate_line MAX_LINES = 1000 @@ -47,10 +48,13 @@ class ReadFile(CallableTool2[Params]): ) params: type[Params] = Params - def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs: Any) -> None: + def __init__( + self, builtin_args: BuiltinSystemPromptArgs, file_ops_window: FileOpsWindow, **kwargs: Any + ) -> None: super().__init__(**kwargs) self._work_dir = builtin_args.KIMI_WORK_DIR + self._file_ops_window = file_ops_window @override async def __call__(self, params: Params) -> ToolReturnType: @@ -108,6 +112,9 @@ async def __call__(self, params: Params) -> ToolReturnType: max_bytes_reached = True break + # Mark file as read after successfully accessing it + self._file_ops_window.mark_read(p) + # Format output with line numbers like `cat -n` lines_with_no: list[str] = [] for line_num, line in zip( diff --git a/src/kimi_cli/tools/file/replace.md b/src/kimi_cli/tools/file/replace.md index 6ca220c9..5ebb3438 100644 --- a/src/kimi_cli/tools/file/replace.md +++ b/src/kimi_cli/tools/file/replace.md @@ -5,3 +5,4 @@ Replace specific strings within a specified file. - Multi-line strings are supported. - Can specify a single edit or a list of edits in one call. - You should prefer this tool over WriteFile tool and Bash `sed` command. +- You must use the `Read` tool on the target file earlier in the session before calling this tool. \ No newline at end of file diff --git a/src/kimi_cli/tools/file/replace.py b/src/kimi_cli/tools/file/replace.py index 4098202a..48ccacae 100644 --- a/src/kimi_cli/tools/file/replace.py +++ b/src/kimi_cli/tools/file/replace.py @@ -7,7 +7,7 @@ from kimi_cli.soul.approval import Approval from kimi_cli.soul.runtime import BuiltinSystemPromptArgs -from kimi_cli.tools.file import FileActions +from kimi_cli.tools.file import FileActions, FileOpsWindow from kimi_cli.tools.utils import ToolRejectedError, load_desc @@ -32,10 +32,17 @@ class StrReplaceFile(CallableTool2[Params]): description: str = load_desc(Path(__file__).parent / "replace.md") params: type[Params] = Params - def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs: Any): + def __init__( + self, + builtin_args: BuiltinSystemPromptArgs, + approval: Approval, + file_ops_window: FileOpsWindow, + **kwargs: Any, + ): super().__init__(**kwargs) self._work_dir = builtin_args.KIMI_WORK_DIR self._approval = approval + self._file_ops_window = file_ops_window def _validate_path(self, path: Path) -> ToolError | None: """Validate that the path is safe to edit.""" @@ -91,6 +98,15 @@ async def __call__(self, params: Params) -> ToolReturnType: brief="Invalid path", ) + if not self._file_ops_window.has_read(p): + return ToolError( + message=( + f"You must read `{params.path}` with the ReadFile tool before editing it. " + "Call ReadFile first and then retry StrReplaceFile." + ), + brief="Read file first", + ) + # Request approval if not await self._approval.request( self.name, diff --git a/tests/conftest.py b/tests/conftest.py index 9ef778db..6e36aba3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,7 @@ from kimi_cli.soul.runtime import BuiltinSystemPromptArgs, Runtime from kimi_cli.tools.bash import Bash from kimi_cli.tools.dmail import SendDMail +from kimi_cli.tools.file import FileOpsWindow from kimi_cli.tools.file.glob import Glob from kimi_cli.tools.file.grep import Grep from kimi_cli.tools.file.patch import PatchFile @@ -159,6 +160,12 @@ def think_tool() -> Think: return Think() +@pytest.fixture +def file_ops_window() -> FileOpsWindow: + """Track file reads within a test session.""" + return FileOpsWindow() + + @pytest.fixture def set_todo_list_tool() -> SetTodoList: """Create a SetTodoList tool instance.""" @@ -173,9 +180,11 @@ def bash_tool(approval: Approval) -> Generator[Bash]: @pytest.fixture -def read_file_tool(builtin_args: BuiltinSystemPromptArgs) -> ReadFile: +def read_file_tool( + builtin_args: BuiltinSystemPromptArgs, file_ops_window: FileOpsWindow +) -> ReadFile: """Create a ReadFile tool instance.""" - return ReadFile(builtin_args) + return ReadFile(builtin_args, file_ops_window) @pytest.fixture @@ -201,20 +210,24 @@ def write_file_tool( @pytest.fixture def str_replace_file_tool( - builtin_args: BuiltinSystemPromptArgs, approval: Approval + builtin_args: BuiltinSystemPromptArgs, + approval: Approval, + file_ops_window: FileOpsWindow, ) -> Generator[StrReplaceFile]: """Create a StrReplaceFile tool instance.""" with tool_call_context("StrReplaceFile"): - yield StrReplaceFile(builtin_args, approval) + yield StrReplaceFile(builtin_args, approval, file_ops_window) @pytest.fixture def patch_file_tool( - builtin_args: BuiltinSystemPromptArgs, approval: Approval + builtin_args: BuiltinSystemPromptArgs, + approval: Approval, + file_ops_window: FileOpsWindow, ) -> Generator[PatchFile]: """Create a PatchFile tool instance.""" with tool_call_context("PatchFile"): - yield PatchFile(builtin_args, approval) + yield PatchFile(builtin_args, approval, file_ops_window) @pytest.fixture diff --git a/tests/test_patch_file.py b/tests/test_patch_file.py index 4a3442bc..4aae151d 100644 --- a/tests/test_patch_file.py +++ b/tests/test_patch_file.py @@ -4,11 +4,20 @@ import pytest -from kimi_cli.tools.file.patch import Params, PatchFile, ToolError, ToolOk +from kimi_cli.tools.file.patch import Params as PatchParams, PatchFile, ToolError, ToolOk +from kimi_cli.tools.file.read import Params as ReadParams, ReadFile + + +async def _mark_file_as_read(read_file_tool: ReadFile, path: Path) -> None: + """Ensure the file has been read before invoking patch tool.""" + result = await read_file_tool(ReadParams(path=str(path))) + assert isinstance(result, ToolOk) @pytest.mark.asyncio -async def test_simple_patch(patch_file_tool: PatchFile, temp_work_dir: Path) -> None: +async def test_simple_patch( + patch_file_tool: PatchFile, read_file_tool: ReadFile, temp_work_dir: Path +) -> None: """Test basic patch application.""" # Create test file test_file = temp_work_dir / "test.txt" @@ -24,7 +33,9 @@ async def test_simple_patch(patch_file_tool: PatchFile, temp_work_dir: Path) -> Line 3 """ - result = await patch_file_tool(Params(path=str(test_file), diff=patch)) + await _mark_file_as_read(read_file_tool, test_file) + + result = await patch_file_tool(PatchParams(path=str(test_file), diff=patch)) assert isinstance(result, ToolOk) assert "successfully patched" in result.message @@ -32,7 +43,29 @@ async def test_simple_patch(patch_file_tool: PatchFile, temp_work_dir: Path) -> @pytest.mark.asyncio -async def test_multiple_hunks(patch_file_tool: PatchFile, temp_work_dir: Path) -> None: +async def test_patch_requires_read(patch_file_tool: PatchFile, temp_work_dir: Path) -> None: + """Ensure patch tool enforces prior ReadFile usage.""" + test_file = temp_work_dir / "require_read_patch.txt" + test_file.write_text("one\ntwo\n") + + patch = """--- test.txt ++++ test.txt +@@ -1,2 +1,2 @@ +-one ++ONE + two +""" + + result = await patch_file_tool(PatchParams(path=str(test_file), diff=patch)) + + assert isinstance(result, ToolError) + assert "ReadFile" in result.message + + +@pytest.mark.asyncio +async def test_multiple_hunks( + patch_file_tool: PatchFile, read_file_tool: ReadFile, temp_work_dir: Path +) -> None: """Test patch with multiple hunks.""" test_file = temp_work_dir / "test.txt" test_file.write_text("Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n") @@ -50,7 +83,9 @@ async def test_multiple_hunks(patch_file_tool: PatchFile, temp_work_dir: Path) - +Modified Line 5 """ - result = await patch_file_tool(Params(path=str(test_file), diff=patch)) + await _mark_file_as_read(read_file_tool, test_file) + + result = await patch_file_tool(PatchParams(path=str(test_file), diff=patch)) assert isinstance(result, ToolOk) expected: str = "Line 1\nModified Line 2\nLine 3\nLine 4\nModified Line 5\n" @@ -58,7 +93,9 @@ async def test_multiple_hunks(patch_file_tool: PatchFile, temp_work_dir: Path) - @pytest.mark.asyncio -async def test_unicode_support(patch_file_tool: PatchFile, temp_work_dir: Path) -> None: +async def test_unicode_support( + patch_file_tool: PatchFile, read_file_tool: ReadFile, temp_work_dir: Path +) -> None: """Test Unicode content patching.""" test_file = temp_work_dir / "unicode.txt" test_file.write_text("Hello 世界\ncafé naïve\n", encoding="utf-8") @@ -71,18 +108,22 @@ async def test_unicode_support(patch_file_tool: PatchFile, temp_work_dir: Path) café naïve """ - result = await patch_file_tool(Params(path=str(test_file), diff=patch)) + await _mark_file_as_read(read_file_tool, test_file) + + result = await patch_file_tool(PatchParams(path=str(test_file), diff=patch)) assert isinstance(result, ToolOk) assert "Hello 地球" in test_file.read_text(encoding="utf-8") @pytest.mark.asyncio -async def test_error_cases(patch_file_tool: PatchFile, temp_work_dir: Path) -> None: +async def test_error_cases( + patch_file_tool: PatchFile, read_file_tool: ReadFile, temp_work_dir: Path +) -> None: """Test various error conditions.""" # 1. Relative path error result = await patch_file_tool( - Params( + PatchParams( path="relative/path/file.txt", diff="--- test\n+++ test\n@@ -1 +1 @@\n-old\n+new\n", ) @@ -93,7 +134,7 @@ async def test_error_cases(patch_file_tool: PatchFile, temp_work_dir: Path) -> N # 2. Nonexistent file nonexistent: Path = temp_work_dir / "nonexistent.txt" result = await patch_file_tool( - Params( + PatchParams( path=str(nonexistent), diff="--- test\n+++ test\n@@ -1 +1 @@\n-old\n+new\n", ) @@ -104,12 +145,16 @@ async def test_error_cases(patch_file_tool: PatchFile, temp_work_dir: Path) -> N # 3. Invalid diff format test_file: Path = temp_work_dir / "test.txt" test_file.write_text("content") - result = await patch_file_tool(Params(path=str(test_file), diff="This is not a valid diff")) + await _mark_file_as_read(read_file_tool, test_file) + result = await patch_file_tool( + PatchParams(path=str(test_file), diff="This is not a valid diff") + ) assert isinstance(result, ToolError) assert "invalid patch format" in result.message # 4. Mismatched patch test_file.write_text("Different content") + await _mark_file_as_read(read_file_tool, test_file) mismatch_patch: str = """--- test.txt 2023-01-01 00:00:00.000000000 +0000 +++ test.txt 2023-01-01 00:00:00.000000000 +0000 @@ -1,3 +1,3 @@ @@ -118,13 +163,15 @@ async def test_error_cases(patch_file_tool: PatchFile, temp_work_dir: Path) -> N +Modified second line Third line """ - result = await patch_file_tool(Params(path=str(test_file), diff=mismatch_patch)) + result = await patch_file_tool(PatchParams(path=str(test_file), diff=mismatch_patch)) assert isinstance(result, ToolError) assert "Failed to apply patch" in result.message @pytest.mark.asyncio -async def test_empty_file(patch_file_tool: PatchFile, temp_work_dir: Path) -> None: +async def test_empty_file( + patch_file_tool: PatchFile, read_file_tool: ReadFile, temp_work_dir: Path +) -> None: """Test patching empty file.""" test_file: Path = temp_work_dir / "empty.txt" test_file.write_text("") @@ -136,7 +183,9 @@ async def test_empty_file(patch_file_tool: PatchFile, temp_work_dir: Path) -> No +Second line """ - result = await patch_file_tool(Params(path=str(test_file), diff=patch)) + await _mark_file_as_read(read_file_tool, test_file) + + result = await patch_file_tool(PatchParams(path=str(test_file), diff=patch)) # Empty file patch may succeed or fail depending on patch-ng judgment if isinstance(result, ToolOk): @@ -146,7 +195,9 @@ async def test_empty_file(patch_file_tool: PatchFile, temp_work_dir: Path) -> No @pytest.mark.asyncio -async def test_adding_lines(patch_file_tool: PatchFile, temp_work_dir: Path) -> None: +async def test_adding_lines( + patch_file_tool: PatchFile, read_file_tool: ReadFile, temp_work_dir: Path +) -> None: """Test adding new lines.""" test_file: Path = temp_work_dir / "test.txt" test_file.write_text("First line\nLast line\n") @@ -159,14 +210,18 @@ async def test_adding_lines(patch_file_tool: PatchFile, temp_work_dir: Path) -> Last line """ - result = await patch_file_tool(Params(path=str(test_file), diff=patch)) + await _mark_file_as_read(read_file_tool, test_file) + + result = await patch_file_tool(PatchParams(path=str(test_file), diff=patch)) assert isinstance(result, ToolOk) assert test_file.read_text() == "First line\nNew middle line\nLast line\n" @pytest.mark.asyncio -async def test_removing_lines(patch_file_tool: PatchFile, temp_work_dir: Path) -> None: +async def test_removing_lines( + patch_file_tool: PatchFile, read_file_tool: ReadFile, temp_work_dir: Path +) -> None: """Test removing lines.""" test_file: Path = temp_work_dir / "test.txt" test_file.write_text("First line\nMiddle line to remove\nLast line\n") @@ -179,14 +234,18 @@ async def test_removing_lines(patch_file_tool: PatchFile, temp_work_dir: Path) - Last line """ - result = await patch_file_tool(Params(path=str(test_file), diff=patch)) + await _mark_file_as_read(read_file_tool, test_file) + + result = await patch_file_tool(PatchParams(path=str(test_file), diff=patch)) assert isinstance(result, ToolOk) assert test_file.read_text() == "First line\nLast line\n" @pytest.mark.asyncio -async def test_no_changes_made(patch_file_tool: PatchFile, temp_work_dir: Path) -> None: +async def test_no_changes_made( + patch_file_tool: PatchFile, read_file_tool: ReadFile, temp_work_dir: Path +) -> None: """Test patch that makes no changes.""" test_file: Path = temp_work_dir / "test.txt" original_content: str = "Line 1\nLine 2\n" @@ -200,7 +259,9 @@ async def test_no_changes_made(patch_file_tool: PatchFile, temp_work_dir: Path) Line 2 """ - result = await patch_file_tool(Params(path=str(test_file), diff=patch)) + await _mark_file_as_read(read_file_tool, test_file) + + result = await patch_file_tool(PatchParams(path=str(test_file), diff=patch)) assert isinstance(result, ToolError) assert "No changes were made" in result.message diff --git a/tests/test_str_replace_file.py b/tests/test_str_replace_file.py index d881c583..4f4af0d7 100644 --- a/tests/test_str_replace_file.py +++ b/tests/test_str_replace_file.py @@ -5,20 +5,29 @@ import pytest from kosong.tooling import ToolError, ToolOk -from kimi_cli.tools.file.replace import Edit, Params, StrReplaceFile +from kimi_cli.tools.file.read import Params as ReadParams, ReadFile +from kimi_cli.tools.file.replace import Edit, Params as ReplaceParams, StrReplaceFile + + +async def _mark_file_as_read(read_file_tool: ReadFile, path: Path) -> None: + """Ensure the file is recorded as read before attempting modifications.""" + result = await read_file_tool(ReadParams(path=str(path))) + assert isinstance(result, ToolOk) @pytest.mark.asyncio async def test_replace_single_occurrence( - str_replace_file_tool: StrReplaceFile, temp_work_dir: Path + str_replace_file_tool: StrReplaceFile, read_file_tool: ReadFile, temp_work_dir: Path ): """Test replacing a single occurrence.""" file_path = temp_work_dir / "test.txt" original_content = "Hello world! This is a test." file_path.write_text(original_content) + await _mark_file_as_read(read_file_tool, file_path) + result = await str_replace_file_tool( - Params(path=str(file_path), edit=Edit(old="world", new="universe")) + ReplaceParams(path=str(file_path), edit=Edit(old="world", new="universe")) ) assert isinstance(result, ToolOk) @@ -27,14 +36,34 @@ async def test_replace_single_occurrence( @pytest.mark.asyncio -async def test_replace_all_occurrences(str_replace_file_tool: StrReplaceFile, temp_work_dir: Path): +async def test_replace_requires_read( + str_replace_file_tool: StrReplaceFile, temp_work_dir: Path +): + """Ensure replace tool fails if the file was not read this session.""" + file_path = temp_work_dir / "require_read.txt" + file_path.write_text("hello") + + result = await str_replace_file_tool( + ReplaceParams(path=str(file_path), edit=Edit(old="hello", new="hi")) + ) + + assert isinstance(result, ToolError) + assert "ReadFile" in result.message + + +@pytest.mark.asyncio +async def test_replace_all_occurrences( + str_replace_file_tool: StrReplaceFile, read_file_tool: ReadFile, temp_work_dir: Path +): """Test replacing all occurrences.""" file_path = temp_work_dir / "test.txt" original_content = "apple banana apple cherry apple" file_path.write_text(original_content) + await _mark_file_as_read(read_file_tool, file_path) + result = await str_replace_file_tool( - Params( + ReplaceParams( path=str(file_path), edit=Edit(old="apple", new="fruit", replace_all=True), ) @@ -46,14 +75,18 @@ async def test_replace_all_occurrences(str_replace_file_tool: StrReplaceFile, te @pytest.mark.asyncio -async def test_replace_multiple_edits(str_replace_file_tool: StrReplaceFile, temp_work_dir: Path): +async def test_replace_multiple_edits( + str_replace_file_tool: StrReplaceFile, read_file_tool: ReadFile, temp_work_dir: Path +): """Test applying multiple edits.""" file_path = temp_work_dir / "test.txt" original_content = "Hello world! Goodbye world!" file_path.write_text(original_content) + await _mark_file_as_read(read_file_tool, file_path) + result = await str_replace_file_tool( - Params( + ReplaceParams( path=str(file_path), edit=[ Edit(old="Hello", new="Hi"), @@ -69,15 +102,17 @@ async def test_replace_multiple_edits(str_replace_file_tool: StrReplaceFile, tem @pytest.mark.asyncio async def test_replace_multiline_content( - str_replace_file_tool: StrReplaceFile, temp_work_dir: Path + str_replace_file_tool: StrReplaceFile, read_file_tool: ReadFile, temp_work_dir: Path ): """Test replacing multi-line content.""" file_path = temp_work_dir / "test.txt" original_content = "Line 1\nLine 2\nLine 3\n" file_path.write_text(original_content) + await _mark_file_as_read(read_file_tool, file_path) + result = await str_replace_file_tool( - Params( + ReplaceParams( path=str(file_path), edit=Edit(old="Line 2\nLine 3", new="Modified line 2\nModified line 3"), ) @@ -89,14 +124,18 @@ async def test_replace_multiline_content( @pytest.mark.asyncio -async def test_replace_unicode_content(str_replace_file_tool: StrReplaceFile, temp_work_dir: Path): +async def test_replace_unicode_content( + str_replace_file_tool: StrReplaceFile, read_file_tool: ReadFile, temp_work_dir: Path +): """Test replacing unicode content.""" file_path = temp_work_dir / "test.txt" original_content = "Hello 世界! café" file_path.write_text(original_content) + await _mark_file_as_read(read_file_tool, file_path) + result = await str_replace_file_tool( - Params(path=str(file_path), edit=Edit(old="世界", new="地球")) + ReplaceParams(path=str(file_path), edit=Edit(old="世界", new="地球")) ) assert isinstance(result, ToolOk) @@ -105,14 +144,18 @@ async def test_replace_unicode_content(str_replace_file_tool: StrReplaceFile, te @pytest.mark.asyncio -async def test_replace_no_match(str_replace_file_tool: StrReplaceFile, temp_work_dir: Path): +async def test_replace_no_match( + str_replace_file_tool: StrReplaceFile, read_file_tool: ReadFile, temp_work_dir: Path +): """Test replacing when the old string is not found.""" file_path = temp_work_dir / "test.txt" original_content = "Hello world!" file_path.write_text(original_content) + await _mark_file_as_read(read_file_tool, file_path) + result = await str_replace_file_tool( - Params(path=str(file_path), edit=Edit(old="notfound", new="replacement")) + ReplaceParams(path=str(file_path), edit=Edit(old="notfound", new="replacement")) ) assert isinstance(result, ToolError) @@ -124,7 +167,7 @@ async def test_replace_no_match(str_replace_file_tool: StrReplaceFile, temp_work async def test_replace_with_relative_path(str_replace_file_tool: StrReplaceFile): """Test replacing with a relative path (should fail).""" result = await str_replace_file_tool( - Params(path="relative/path/file.txt", edit=Edit(old="old", new="new")) + ReplaceParams(path="relative/path/file.txt", edit=Edit(old="old", new="new")) ) assert isinstance(result, ToolError) @@ -135,7 +178,7 @@ async def test_replace_with_relative_path(str_replace_file_tool: StrReplaceFile) async def test_replace_outside_work_directory(str_replace_file_tool: StrReplaceFile): """Test replacing outside the working directory (should fail).""" result = await str_replace_file_tool( - Params(path="/tmp/outside.txt", edit=Edit(old="old", new="new")) + ReplaceParams(path="/tmp/outside.txt", edit=Edit(old="old", new="new")) ) assert isinstance(result, ToolError) @@ -148,7 +191,7 @@ async def test_replace_nonexistent_file(str_replace_file_tool: StrReplaceFile, t file_path = temp_work_dir / "nonexistent.txt" result = await str_replace_file_tool( - Params(path=str(file_path), edit=Edit(old="old", new="new")) + ReplaceParams(path=str(file_path), edit=Edit(old="old", new="new")) ) assert isinstance(result, ToolError) @@ -164,7 +207,7 @@ async def test_replace_directory_instead_of_file( dir_path.mkdir() result = await str_replace_file_tool( - Params(path=str(dir_path), edit=Edit(old="old", new="new")) + ReplaceParams(path=str(dir_path), edit=Edit(old="old", new="new")) ) assert isinstance(result, ToolError) @@ -173,15 +216,17 @@ async def test_replace_directory_instead_of_file( @pytest.mark.asyncio async def test_replace_mixed_multiple_edits( - str_replace_file_tool: StrReplaceFile, temp_work_dir: Path + str_replace_file_tool: StrReplaceFile, read_file_tool: ReadFile, temp_work_dir: Path ): """Test multiple edits with different replace_all settings.""" file_path = temp_work_dir / "test.txt" original_content = "apple apple banana apple cherry" file_path.write_text(original_content) + await _mark_file_as_read(read_file_tool, file_path) + result = await str_replace_file_tool( - Params( + ReplaceParams( path=str(file_path), edit=[ Edit(old="apple", new="fruit", replace_all=False), # Only first occurrence @@ -198,14 +243,18 @@ async def test_replace_mixed_multiple_edits( @pytest.mark.asyncio -async def test_replace_empty_strings(str_replace_file_tool: StrReplaceFile, temp_work_dir: Path): +async def test_replace_empty_strings( + str_replace_file_tool: StrReplaceFile, read_file_tool: ReadFile, temp_work_dir: Path +): """Test replacing with empty strings.""" file_path = temp_work_dir / "test.txt" original_content = "Hello world!" file_path.write_text(original_content) + await _mark_file_as_read(read_file_tool, file_path) + result = await str_replace_file_tool( - Params(path=str(file_path), edit=Edit(old="world", new="")) + ReplaceParams(path=str(file_path), edit=Edit(old="world", new="")) ) assert isinstance(result, ToolOk)