Co-authored-by: Alexis Tacnet <alexis@mistral.ai> Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com> Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: p.vezia <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Hiba Chaabnia <Hiba-Chaabnia@users.noreply.github.com> Co-authored-by: Nikhil Bhima <nikhilbhima@users.noreply.github.com> Co-authored-by: Nkipohcs <Nkipohcs@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
333 lines
12 KiB
Python
333 lines
12 KiB
Python
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"
|