Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai>
Co-Authored-By: Clément Drouin <clement.drouin@mistral.ai>
Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-Authored-By: Clément Siriex <clement.sirieix@mistral.ai>
Co-Authored-By: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-Authored-By: Thaddee Tyl <thaddee.tyl@gmail.com>
Co-Authored-By: David Brochart <david.brochart@gmail.com>
Co-Authored-By: Joseph Guhlin <joseph.guhlin@gmail.com>
Co-Authored-By: Thomas Kenbeek <thomaskenbeek@gmail.com>
Co-Authored-By: Remenby31 <baptiste.cruvellier31@gmail.com>
This commit is contained in:
Mathias Gesbert 2026-01-27 16:39:30 +01:00 committed by Mathias Gesbert
parent 79f215d91c
commit d33db9fff8
217 changed files with 16911 additions and 4305 deletions

View file

@ -2,9 +2,10 @@ from __future__ import annotations
from pathlib import Path
from acp import ReadTextFileRequest, ReadTextFileResponse, WriteTextFileRequest
from acp import ReadTextFileResponse
import pytest
from tests.mock.utils import collect_result
from vibe.acp.tools.builtins.search_replace import AcpSearchReplaceState, SearchReplace
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.search_replace import (
@ -15,7 +16,7 @@ from vibe.core.tools.builtins.search_replace import (
from vibe.core.types import ToolCallEvent, ToolResultEvent
class MockConnection:
class MockClient:
def __init__(
self,
file_content: str = "original line 1\noriginal line 2\noriginal line 3",
@ -28,43 +29,59 @@ class MockConnection:
self._read_text_file_called = False
self._write_text_file_called = False
self._session_update_called = False
self._last_read_request: ReadTextFileRequest | None = None
self._last_write_request: WriteTextFileRequest | None = None
self._write_calls: list[WriteTextFileRequest] = []
self._last_read_params: dict[str, str | int | None] = {}
self._last_write_params: dict[str, str] = {}
self._write_calls: list[dict[str, str]] = []
async def readTextFile(self, request: ReadTextFileRequest) -> ReadTextFileResponse:
async def read_text_file(
self,
path: str,
session_id: str,
limit: int | None = None,
line: int | None = None,
**kwargs,
) -> ReadTextFileResponse:
self._read_text_file_called = True
self._last_read_request = request
self._last_read_params = {
"path": path,
"session_id": session_id,
"limit": limit,
"line": line,
}
if self._read_error:
raise self._read_error
return ReadTextFileResponse(content=self._file_content)
async def writeTextFile(self, request: WriteTextFileRequest) -> None:
async def write_text_file(
self, content: str, path: str, session_id: str, **kwargs
) -> None:
self._write_text_file_called = True
self._last_write_request = request
self._write_calls.append(request)
params = {"content": content, "path": path, "session_id": session_id}
self._last_write_params = params
self._write_calls.append(params)
if self._write_error:
raise self._write_error
async def sessionUpdate(self, notification) -> None:
async def session_update(self, session_id: str, update, **kwargs) -> None:
self._session_update_called = True
@pytest.fixture
def mock_connection() -> MockConnection:
return MockConnection()
def mock_client() -> MockClient:
return MockClient()
@pytest.fixture
def acp_search_replace_tool(
mock_connection: MockConnection, tmp_path: Path
mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> SearchReplace:
config = SearchReplaceConfig(workdir=tmp_path)
monkeypatch.chdir(tmp_path)
config = SearchReplaceConfig()
state = AcpSearchReplaceState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
client=mock_client,
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
@ -81,7 +98,7 @@ class TestAcpSearchReplaceExecution:
async def test_run_success(
self,
acp_search_replace_tool: SearchReplace,
mock_connection: MockConnection,
mock_client: MockClient,
tmp_path: Path,
) -> None:
test_file = tmp_path / "test_file.txt"
@ -92,41 +109,39 @@ class TestAcpSearchReplaceExecution:
args = SearchReplaceArgs(
file_path=str(test_file), content=search_replace_content
)
result = await acp_search_replace_tool.run(args)
result = await collect_result(acp_search_replace_tool.run(args))
assert isinstance(result, SearchReplaceResult)
assert result.file == str(test_file)
assert result.blocks_applied == 1
assert mock_connection._read_text_file_called
assert mock_connection._write_text_file_called
assert mock_connection._session_update_called
assert mock_client._read_text_file_called
assert mock_client._write_text_file_called
assert mock_client._session_update_called
# Verify ReadTextFileRequest was created correctly
read_request = mock_connection._last_read_request
assert read_request is not None
assert read_request.sessionId == "test_session_123"
assert read_request.path == str(test_file)
# Verify read_text_file was called correctly
read_params = mock_client._last_read_params
assert read_params["session_id"] == "test_session_123"
assert read_params["path"] == str(test_file)
# Verify WriteTextFileRequest was created correctly
write_request = mock_connection._last_write_request
assert write_request is not None
assert write_request.sessionId == "test_session_123"
assert write_request.path == str(test_file)
# Verify write_text_file was called correctly
write_params = mock_client._last_write_params
assert write_params["session_id"] == "test_session_123"
assert write_params["path"] == str(test_file)
assert (
write_request.content == "original line 1\nmodified line 2\noriginal line 3"
write_params["content"]
== "original line 1\nmodified line 2\noriginal line 3"
)
@pytest.mark.asyncio
async def test_run_with_backup(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config = SearchReplaceConfig(create_backup=True, workdir=tmp_path)
monkeypatch.chdir(tmp_path)
config = SearchReplaceConfig(create_backup=True)
tool = SearchReplace(
config=config,
state=AcpSearchReplaceState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
@ -138,26 +153,25 @@ class TestAcpSearchReplaceExecution:
args = SearchReplaceArgs(
file_path=str(test_file), content=search_replace_content
)
result = await tool.run(args)
result = await collect_result(tool.run(args))
assert result.blocks_applied == 1
# Should have written the main file and the backup
assert len(mock_connection._write_calls) >= 1
assert len(mock_client._write_calls) >= 1
# Check if backup was written (it should be written to .bak file)
assert sum(w.path.endswith(".bak") for w in mock_connection._write_calls) == 1
assert sum(w["path"].endswith(".bak") for w in mock_client._write_calls) == 1
@pytest.mark.asyncio
async def test_run_read_error(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_connection._read_error = RuntimeError("File not found")
monkeypatch.chdir(tmp_path)
mock_client._read_error = RuntimeError("File not found")
tool = SearchReplace(
config=SearchReplaceConfig(workdir=tmp_path),
config=SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
@ -168,7 +182,7 @@ class TestAcpSearchReplaceExecution:
file_path=str(test_file), content=search_replace_content
)
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert (
str(exc_info.value)
@ -177,19 +191,18 @@ class TestAcpSearchReplaceExecution:
@pytest.mark.asyncio
async def test_run_write_error(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_connection._write_error = RuntimeError("Permission denied")
monkeypatch.chdir(tmp_path)
mock_client._write_error = RuntimeError("Permission denied")
test_file = tmp_path / "test.txt"
test_file.touch()
mock_connection._file_content = "old" # Update mock to return correct content
mock_client._file_content = "old" # Update mock to return correct content
tool = SearchReplace(
config=SearchReplaceConfig(workdir=tmp_path),
config=SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
@ -198,21 +211,21 @@ class TestAcpSearchReplaceExecution:
file_path=str(test_file), content=search_replace_content
)
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert str(exc_info.value) == f"Error writing {test_file}: Permission denied"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"connection,session_id,expected_error",
"client,session_id,expected_error",
[
(
None,
"test_session",
"Connection not available in tool state. This tool can only be used within an ACP session.",
"Client not available in tool state. This tool can only be used within an ACP session.",
),
(
MockConnection(),
MockClient(),
None,
"Session ID not available in tool state. This tool can only be used within an ACP session.",
),
@ -221,18 +234,18 @@ class TestAcpSearchReplaceExecution:
async def test_run_without_required_state(
self,
tmp_path: Path,
connection: MockConnection | None,
client: MockClient | None,
session_id: str | None,
expected_error: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test.txt"
test_file.touch()
tool = SearchReplace(
config=SearchReplaceConfig(workdir=tmp_path),
config=SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
connection=connection, # type: ignore[arg-type]
session_id=session_id,
tool_call_id="test_call",
client=client, session_id=session_id, tool_call_id="test_call"
),
)
@ -241,7 +254,7 @@ class TestAcpSearchReplaceExecution:
file_path=str(test_file), content=search_replace_content
)
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert str(exc_info.value) == expected_error
@ -262,16 +275,17 @@ class TestAcpSearchReplaceSessionUpdates:
update = SearchReplace.tool_call_session_update(event)
assert update is not None
assert update.sessionUpdate == "tool_call"
assert update.toolCallId == "test_call_123"
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].oldText == "old text"
assert update.content[0].newText == "new text"
assert update.content[0].old_text == "old text"
assert update.content[0].new_text == "new text"
assert update.locations is not None
assert len(update.locations) == 1
assert update.locations[0].path == "/tmp/test.txt"
@ -311,15 +325,16 @@ class TestAcpSearchReplaceSessionUpdates:
update = SearchReplace.tool_result_session_update(event)
assert update is not None
assert update.sessionUpdate == "tool_call_update"
assert update.toolCallId == "test_call_123"
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].oldText == "old text"
assert update.content[0].newText == "new text"
assert update.content[0].old_text == "old text"
assert update.content[0].new_text == "new text"
assert update.locations is not None
assert len(update.locations) == 1
assert update.locations[0].path == "/tmp/test.txt"