from __future__ import annotations from pathlib import Path import pytest from tests.mock.utils import collect_result from vibe.acp.tools.builtins.write_file import AcpWriteFileState, WriteFile from vibe.core.tools.base import ToolError from vibe.core.tools.builtins.write_file import ( WriteFileArgs, WriteFileConfig, WriteFileResult, ) from vibe.core.types import ToolCallEvent, ToolResultEvent class MockClient: def __init__( self, write_error: Exception | None = None, file_exists: bool = False ) -> None: self._write_error = write_error self._file_exists = file_exists self._write_text_file_called = False self._session_update_called = False self._last_write_params: dict[str, str] = {} async def write_text_file( self, content: str, path: str, session_id: str, **kwargs ) -> None: self._write_text_file_called = True self._last_write_params = { "content": content, "path": path, "session_id": session_id, } if self._write_error: raise self._write_error async def session_update(self, session_id: str, update, **kwargs) -> None: self._session_update_called = True @pytest.fixture def mock_client() -> MockClient: return MockClient() @pytest.fixture def acp_write_file_tool( mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> WriteFile: monkeypatch.chdir(tmp_path) config = WriteFileConfig() state = AcpWriteFileState.model_construct( client=mock_client, session_id="test_session_123" ) return WriteFile(config_getter=lambda: config, state=state) class TestAcpWriteFileBasic: def test_get_name(self) -> None: assert WriteFile.get_name() == "write_file" class TestAcpWriteFileExecution: @pytest.mark.asyncio async def test_run_success_new_file( self, acp_write_file_tool: WriteFile, mock_client: MockClient, tmp_path: Path ) -> None: test_file = tmp_path / "test_file.txt" args = WriteFileArgs(path=str(test_file), content="Hello, world!") result = await collect_result(acp_write_file_tool.run(args)) assert isinstance(result, WriteFileResult) assert result.path == str(test_file) assert result.content == "Hello, world!" assert result.bytes_written == len(b"Hello, world!") assert mock_client._write_text_file_called # Verify write_text_file was called correctly params = mock_client._last_write_params assert params["session_id"] == "test_session_123" assert params["path"] == str(test_file) assert params["content"] == "Hello, world!" @pytest.mark.asyncio async def test_run_existing_file_raises( self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) tool = WriteFile( config_getter=lambda: WriteFileConfig(), state=AcpWriteFileState.model_construct( client=mock_client, session_id="test_session" ), ) test_file = tmp_path / "existing_file.txt" test_file.touch() args = WriteFileArgs(path=str(test_file), content="New content") with pytest.raises(ToolError, match="already exists"): await collect_result(tool.run(args)) assert not mock_client._write_text_file_called @pytest.mark.asyncio @pytest.mark.parametrize("input_newline", ["\r\n", "\r", "\n"]) @pytest.mark.parametrize("os_newline", ["\n", "\r\n"]) async def test_run_writes_with_os_linesep( self, input_newline: str, os_newline: str, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.chdir(tmp_path) monkeypatch.setattr("vibe.acp.tools.builtins.write_file.os.linesep", os_newline) tool = WriteFile( config_getter=lambda: WriteFileConfig(), state=AcpWriteFileState.model_construct( client=mock_client, session_id="test_session" ), ) test_file = tmp_path / "test.txt" content = input_newline.join(["line 1", "line 2", "line 3"]) args = WriteFileArgs(path=str(test_file), content=content) await collect_result(tool.run(args)) assert mock_client._last_write_params["content"] == os_newline.join([ "line 1", "line 2", "line 3", ]) @pytest.mark.asyncio async def test_run_normalizes_mixed_newlines( self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) monkeypatch.setattr("vibe.acp.tools.builtins.write_file.os.linesep", "\n") tool = WriteFile( config_getter=lambda: WriteFileConfig(), state=AcpWriteFileState.model_construct( client=mock_client, session_id="test_session" ), ) test_file = tmp_path / "test.txt" args = WriteFileArgs( path=str(test_file), content="line 1\r\nline 2\nline 3\rline 4" ) await collect_result(tool.run(args)) assert ( mock_client._last_write_params["content"] == "line 1\nline 2\nline 3\nline 4" ) @pytest.mark.asyncio async def test_run_write_error( self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) mock_client._write_error = RuntimeError("Permission denied") tool = WriteFile( config_getter=lambda: WriteFileConfig(), state=AcpWriteFileState.model_construct( client=mock_client, session_id="test_session" ), ) test_file = tmp_path / "test.txt" args = WriteFileArgs(path=str(test_file), content="test") with pytest.raises(ToolError) as exc_info: await collect_result(tool.run(args)) assert str(exc_info.value) == f"Error writing {test_file}: Permission denied" @pytest.mark.asyncio async def test_run_without_connection( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) tool = WriteFile( config_getter=lambda: WriteFileConfig(), state=AcpWriteFileState.model_construct( client=None, session_id="test_session" ), ) args = WriteFileArgs(path=str(tmp_path / "test.txt"), content="test") with pytest.raises(ToolError) as exc_info: await collect_result(tool.run(args)) assert ( str(exc_info.value) == "Client not available in tool state. This tool can only be used within an ACP session." ) @pytest.mark.asyncio async def test_run_without_session_id( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) mock_client = MockClient() tool = WriteFile( config_getter=lambda: WriteFileConfig(), state=AcpWriteFileState.model_construct( client=mock_client, session_id=None ), ) args = WriteFileArgs(path=str(tmp_path / "test.txt"), content="test") with pytest.raises(ToolError) as exc_info: await collect_result(tool.run(args)) assert ( str(exc_info.value) == "Session ID not available in tool state. This tool can only be used within an ACP session." ) class TestAcpWriteFileSessionUpdates: def test_tool_call_session_update(self) -> None: event = ToolCallEvent( tool_name="write_file", tool_call_id="test_call_123", args=WriteFileArgs(path="/tmp/test.txt", content="Hello"), tool_class=WriteFile, ) update = WriteFile.tool_call_session_update(event) assert update is not None assert update.session_update == "tool_call" assert update.tool_call_id == "test_call_123" assert update.kind == "edit" assert update.title is not None assert update.content is not None assert isinstance(update.content, list) assert len(update.content) == 1 assert update.content[0].type == "diff" assert update.content[0].path == "/tmp/test.txt" assert update.content[0].old_text is None assert update.content[0].new_text == "Hello" assert update.locations is not None assert len(update.locations) == 1 assert update.locations[0].path == str(Path("/tmp/test.txt").resolve()) def test_tool_call_session_update_passes_content_through( self, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr("vibe.acp.tools.builtins.write_file.os.linesep", "\r\n") content = "line 1\r\nline 2\nline 3\rline 4" event = ToolCallEvent( tool_name="write_file", tool_call_id="test_call_123", args=WriteFileArgs(path="/tmp/test.txt", content=content), tool_class=WriteFile, ) update = WriteFile.tool_call_session_update(event) assert update is not None assert update.content is not None assert isinstance(update.content, list) assert update.content[0].new_text == content def test_tool_call_session_update_invalid_args(self) -> None: from vibe.core.types import FunctionCall, ToolCall class InvalidArgs: pass event = ToolCallEvent.model_construct( tool_name="write_file", tool_call_id="test_call_123", args=InvalidArgs(), # type: ignore[arg-type] tool_class=WriteFile, llm_tool_call=ToolCall( function=FunctionCall(name="write_file", arguments="{}"), type="function", index=0, ), ) update = WriteFile.tool_call_session_update(event) assert update is not None assert update.title == "write_file" def test_tool_result_session_update(self) -> None: result = WriteFileResult(path="/tmp/test.txt", content="Hello", bytes_written=5) event = ToolResultEvent( tool_name="write_file", tool_call_id="test_call_123", result=result, tool_class=WriteFile, ) update = WriteFile.tool_result_session_update(event) assert update is not None assert update.session_update == "tool_call_update" assert update.tool_call_id == "test_call_123" assert update.status == "completed" assert update.content is not None assert isinstance(update.content, list) assert len(update.content) == 1 assert update.content[0].type == "diff" assert update.content[0].path == "/tmp/test.txt" assert update.content[0].old_text is None assert update.content[0].new_text == "Hello" assert update.locations is not None assert len(update.locations) == 1 assert update.locations[0].path == str(Path("/tmp/test.txt").resolve()) def test_tool_result_session_update_invalid_result(self) -> None: class InvalidResult: pass event = ToolResultEvent.model_construct( tool_name="write_file", tool_call_id="test_call_123", result=InvalidResult(), # type: ignore[arg-type] tool_class=WriteFile, ) update = WriteFile.tool_result_session_update(event) assert update is not None assert update.status == "failed"