Co-authored-by: Albert Jiang <aj@mistral.ai> Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Laure Hugo <201583486+laure0303@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Mert Unsal <mert.unsal@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
367 lines
11 KiB
Python
367 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.mock.utils import collect_result
|
|
from vibe.core.tools.base import BaseToolState, ToolError
|
|
from vibe.core.tools.builtins.edit import Edit, EditArgs, EditConfig, EditResult
|
|
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay
|
|
from vibe.core.types import ToolResultEvent
|
|
|
|
|
|
def _make_edit() -> Edit:
|
|
return Edit(config_getter=lambda: EditConfig(), state=BaseToolState())
|
|
|
|
|
|
def _write(tmp_path: Path, name: str, content: str) -> Path:
|
|
p = tmp_path / name
|
|
p.write_text(content)
|
|
return p
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exact_match_replaces(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write(tmp_path, "f.txt", "hello world\n")
|
|
edit = _make_edit()
|
|
|
|
result = await collect_result(
|
|
edit.run(EditArgs(file_path="f.txt", old_string="hello", new_string="goodbye"))
|
|
)
|
|
|
|
assert (tmp_path / "f.txt").read_text() == "goodbye world\n"
|
|
assert result.file == str(tmp_path / "f.txt")
|
|
assert result.message == "The file has been updated successfully."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_replace_all(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write(tmp_path, "f.txt", "aaa bbb aaa\n")
|
|
edit = _make_edit()
|
|
|
|
result = await collect_result(
|
|
edit.run(
|
|
EditArgs(
|
|
file_path="f.txt", old_string="aaa", new_string="ccc", replace_all=True
|
|
)
|
|
)
|
|
)
|
|
|
|
assert (tmp_path / "f.txt").read_text() == "ccc bbb ccc\n"
|
|
assert result.message == (
|
|
"The file has been updated. All occurrences were successfully replaced"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_not_found_raises(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write(tmp_path, "f.txt", "hello world\n")
|
|
edit = _make_edit()
|
|
|
|
with pytest.raises(ToolError, match="String to replace not found in file"):
|
|
await collect_result(
|
|
edit.run(EditArgs(file_path="f.txt", old_string="missing", new_string="x"))
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_matches_without_replace_all_raises(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write(tmp_path, "f.txt", "aaa bbb aaa\n")
|
|
edit = _make_edit()
|
|
|
|
with pytest.raises(ToolError, match="Found 2 matches"):
|
|
await collect_result(
|
|
edit.run(EditArgs(file_path="f.txt", old_string="aaa", new_string="ccc"))
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_old_equals_new_raises(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write(tmp_path, "f.txt", "hello\n")
|
|
edit = _make_edit()
|
|
|
|
with pytest.raises(ToolError, match="No changes to make"):
|
|
await collect_result(
|
|
edit.run(
|
|
EditArgs(file_path="f.txt", old_string="hello", new_string="hello")
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_old_string_raises(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write(tmp_path, "f.txt", "hello\n")
|
|
edit = _make_edit()
|
|
|
|
with pytest.raises(ToolError, match="old_string cannot be empty"):
|
|
await collect_result(
|
|
edit.run(EditArgs(file_path="f.txt", old_string="", new_string="x"))
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_not_found_raises(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
edit = _make_edit()
|
|
|
|
with pytest.raises(ToolError, match="File does not exist"):
|
|
await collect_result(
|
|
edit.run(
|
|
EditArgs(
|
|
file_path="/nonexistent/file.py", old_string="x", new_string="y"
|
|
)
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_file_path_raises(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
edit = _make_edit()
|
|
|
|
with pytest.raises(ToolError, match="File path cannot be empty"):
|
|
await collect_result(
|
|
edit.run(EditArgs(file_path="", old_string="x", new_string="y"))
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deletion_removes_exact_string(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write(tmp_path, "f.txt", "line1\nline2\nline3\n")
|
|
edit = _make_edit()
|
|
|
|
await collect_result(
|
|
edit.run(EditArgs(file_path="f.txt", old_string="line2\n", new_string=""))
|
|
)
|
|
|
|
assert (tmp_path / "f.txt").read_text() == "line1\nline3\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parallel_edits_same_file_all_land(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write(tmp_path, "f.txt", "A\nB\nC\nD\n")
|
|
edit = _make_edit()
|
|
|
|
await asyncio.gather(
|
|
collect_result(
|
|
edit.run(EditArgs(file_path="f.txt", old_string="A", new_string="A1"))
|
|
),
|
|
collect_result(
|
|
edit.run(EditArgs(file_path="f.txt", old_string="B", new_string="B1"))
|
|
),
|
|
collect_result(
|
|
edit.run(EditArgs(file_path="f.txt", old_string="C", new_string="C1"))
|
|
),
|
|
collect_result(
|
|
edit.run(EditArgs(file_path="f.txt", old_string="D", new_string="D1"))
|
|
),
|
|
)
|
|
|
|
assert (tmp_path / "f.txt").read_text() == "A1\nB1\nC1\nD1\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_relative_path_resolved_from_cwd(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
(tmp_path / "sub").mkdir(exist_ok=True)
|
|
_write(tmp_path / "sub", "f.txt", "old")
|
|
edit = _make_edit()
|
|
|
|
result = await collect_result(
|
|
edit.run(EditArgs(file_path="sub/f.txt", old_string="old", new_string="new"))
|
|
)
|
|
|
|
assert (tmp_path / "sub" / "f.txt").read_text() == "new"
|
|
assert result.file == str(tmp_path / "sub" / "f.txt")
|
|
|
|
|
|
def test_format_call_display() -> None:
|
|
args = EditArgs(file_path="/abs/foo.py", old_string="old", new_string="new")
|
|
display = Edit.format_call_display(args)
|
|
|
|
assert isinstance(display, ToolCallDisplay)
|
|
assert display.summary == "Editing foo.py"
|
|
|
|
|
|
def test_get_result_display() -> None:
|
|
result = EditResult(
|
|
file="/path/to/foo.py",
|
|
message="The file has been updated successfully.",
|
|
old_string="old",
|
|
new_string="new",
|
|
)
|
|
event = ToolResultEvent(
|
|
tool_call_id="test", tool_name="edit", tool_class=None, result=result
|
|
)
|
|
display = Edit.get_result_display(event)
|
|
|
|
assert isinstance(display, ToolResultDisplay)
|
|
assert display.success is True
|
|
assert "foo.py" in display.message
|
|
|
|
|
|
def test_ui_hints_not_part_of_model_contract() -> None:
|
|
result = EditResult(file="/x", message="m", old_string="a", new_string="b")
|
|
result._ui_occurrences = [(42, "a", "b")]
|
|
|
|
assert result.ui_start_lines == [42]
|
|
assert result.ui_occurrences == [(42, "a", "b")]
|
|
for key in ("ui_start_lines", "ui_occurrences", "_ui_occurrences"):
|
|
assert key not in result.model_dump()
|
|
assert key not in result.model_dump_json()
|
|
assert key not in dict(result)
|
|
assert "ui_occurrences" not in EditResult.model_fields
|
|
assert "ui_occurrences" not in EditResult.model_json_schema().get("properties", {})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ui_start_lines_computed_at_edit_site(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write(tmp_path, "f.txt", "alpha\nbeta\ngamma\ndelta\n")
|
|
edit = _make_edit()
|
|
|
|
result = await collect_result(
|
|
edit.run(EditArgs(file_path="f.txt", old_string="gamma", new_string="GAMMA"))
|
|
)
|
|
|
|
assert result.ui_start_lines == [3]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ui_start_lines_set_for_pure_deletion(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write(tmp_path, "f.txt", "keep1\nkeep2\nremove\nkeep3\n")
|
|
edit = _make_edit()
|
|
|
|
result = await collect_result(
|
|
edit.run(EditArgs(file_path="f.txt", old_string="remove\n", new_string=""))
|
|
)
|
|
|
|
assert result.ui_start_lines == [3]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ui_start_lines_lists_all_occurrences_for_replace_all(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write(tmp_path, "f.txt", "x\ntgt\ny\ntgt\nz\ntgt\n")
|
|
edit = _make_edit()
|
|
|
|
result = await collect_result(
|
|
edit.run(
|
|
EditArgs(
|
|
file_path="f.txt", old_string="tgt", new_string="TGT", replace_all=True
|
|
)
|
|
)
|
|
)
|
|
|
|
assert result.ui_start_lines == [2, 4, 6]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ui_start_lines_single_entry_without_replace_all(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write(tmp_path, "f.txt", "a\nuniq\nb\n")
|
|
edit = _make_edit()
|
|
|
|
result = await collect_result(
|
|
edit.run(EditArgs(file_path="f.txt", old_string="uniq", new_string="UNIQ"))
|
|
)
|
|
|
|
assert result.ui_start_lines == [2]
|
|
|
|
|
|
def test_ui_occurrences_fall_back_to_snippet_when_unset() -> None:
|
|
result = EditResult(file="/x", message="m", old_string="a", new_string="b")
|
|
|
|
assert result.ui_occurrences == [(None, "a", "b")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ui_occurrences_expand_mid_line_snippet(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write(tmp_path, "f.txt", "foo = bar + baz\n")
|
|
edit = _make_edit()
|
|
|
|
result = await collect_result(
|
|
edit.run(EditArgs(file_path="f.txt", old_string="bar", new_string="qux"))
|
|
)
|
|
|
|
assert result.ui_occurrences == [(1, "foo = bar + baz", "foo = qux + baz")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ui_occurrences_for_pure_deletion(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write(tmp_path, "f.txt", "keep1\nkeep2\nremove\nkeep3\n")
|
|
edit = _make_edit()
|
|
|
|
result = await collect_result(
|
|
edit.run(EditArgs(file_path="f.txt", old_string="remove\n", new_string=""))
|
|
)
|
|
|
|
assert result.ui_occurrences == [(3, "remove\n", "")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ui_occurrences_use_per_occurrence_lines_for_replace_all(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write(tmp_path, "f.txt", "x = bar + 1\ny = bar - 2\nz = bar\n")
|
|
edit = _make_edit()
|
|
|
|
result = await collect_result(
|
|
edit.run(
|
|
EditArgs(
|
|
file_path="f.txt", old_string="bar", new_string="qux", replace_all=True
|
|
)
|
|
)
|
|
)
|
|
|
|
assert result.ui_occurrences == [
|
|
(1, "x = bar + 1", "x = qux + 1"),
|
|
(2, "y = bar - 2", "y = qux - 2"),
|
|
(3, "z = bar", "z = qux"),
|
|
]
|