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>
This commit is contained in:
Mathias Gesbert 2026-06-04 18:26:35 +02:00 committed by GitHub
parent ad0d5c9520
commit 3f8487f761
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
197 changed files with 10819 additions and 2830 deletions

View file

@ -101,6 +101,13 @@ def vibe_home_grep_ask(tmp_path: Path) -> Path:
return _create_vibe_home_dir(tmp_path, {"tools": {"grep": {"permission": "ask"}}})
@pytest.fixture
def acp_project_dir(tmp_path: Path) -> Path:
project_dir = tmp_path / "project"
project_dir.mkdir()
return project_dir
class JsonRpcRequest(BaseModel):
jsonrpc: str = "2.0"
id: int | str
@ -358,7 +365,10 @@ def parse_conversation(message_texts: list[str]) -> list[JsonRpcMessage]:
return parsed_messages
async def initialize_session(acp_agent_loop_process: asyncio.subprocess.Process) -> str:
async def initialize_session(
acp_agent_loop_process: asyncio.subprocess.Process,
session_cwd: Path = PLAYGROUND_DIR,
) -> str:
await send_json_rpc(
acp_agent_loop_process,
InitializeJsonRpcRequest(id=1, params=InitializeRequest(protocol_version=1)),
@ -371,7 +381,7 @@ async def initialize_session(acp_agent_loop_process: asyncio.subprocess.Process)
await send_json_rpc(
acp_agent_loop_process,
NewSessionJsonRpcRequest(
id=2, params=NewSessionRequest(cwd=str(PLAYGROUND_DIR), mcp_servers=[])
id=2, params=NewSessionRequest(cwd=str(session_cwd), mcp_servers=[])
),
)
session_response = await read_response_for_id(acp_agent_loop_process, expected_id=2)
@ -551,9 +561,9 @@ class TestSessionUpdates:
async def start_session_with_request_permission(
process: asyncio.subprocess.Process, prompt: str
process: asyncio.subprocess.Process, prompt: str, cwd: Path = PLAYGROUND_DIR
) -> RequestPermissionJsonRpcRequest:
session_id = await initialize_session(process)
session_id = await initialize_session(process, session_cwd=cwd)
await send_json_rpc(
process,
PromptJsonRpcRequest(
@ -580,7 +590,7 @@ async def start_session_with_request_permission(
class TestToolCallStructure:
@pytest.mark.asyncio
async def test_tool_call_request_permission_structure(
self, vibe_home_grep_ask: Path
self, vibe_home_grep_ask: Path, acp_project_dir: Path
) -> None:
custom_results = [
mock_llm_chunk(
@ -600,7 +610,7 @@ class TestToolCallStructure:
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
session_id = await initialize_session(process)
session_id = await initialize_session(process, session_cwd=acp_project_dir)
await send_json_rpc(
process,
PromptJsonRpcRequest(
@ -638,8 +648,9 @@ class TestToolCallStructure:
@pytest.mark.asyncio
async def test_tool_call_update_approved_structure(
self, vibe_home_grep_ask: Path
self, vibe_home_grep_ask: Path, acp_project_dir: Path
) -> None:
(acp_project_dir / "fixture.txt").write_text("auth\n", encoding="utf-8")
custom_results = [
mock_llm_chunk(
tool_calls=[
@ -661,7 +672,9 @@ class TestToolCallStructure:
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
permission_request = await start_session_with_request_permission(
process, "Search for files containing the pattern 'auth'"
process,
"Search for files containing the pattern 'auth'",
cwd=acp_project_dir,
)
assert permission_request.params is not None
selected_option_id = ToolOption.ALLOW_ONCE
@ -698,7 +711,7 @@ class TestToolCallStructure:
@pytest.mark.asyncio
async def test_tool_call_update_rejected_structure(
self, vibe_home_grep_ask: Path
self, vibe_home_grep_ask: Path, acp_project_dir: Path
) -> None:
custom_results = [
mock_llm_chunk(
@ -723,7 +736,9 @@ class TestToolCallStructure:
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
permission_request = await start_session_with_request_permission(
process, "Search for files containing the pattern 'auth'"
process,
"Search for files containing the pattern 'auth'",
cwd=acp_project_dir,
)
assert permission_request.params is not None
selected_option_id = ToolOption.REJECT_ONCE
@ -760,7 +775,7 @@ class TestToolCallStructure:
@pytest.mark.asyncio
async def test_permission_options_include_granular_labels_for_bash(
self, vibe_home_dir: Path
self, vibe_home_dir: Path, acp_project_dir: Path
) -> None:
"""Bash 'npm install foo' should produce granular labels in permission options."""
custom_results = [
@ -782,7 +797,7 @@ class TestToolCallStructure:
mock_env=mock_env, vibe_home=vibe_home_dir
):
permission_request = await start_session_with_request_permission(
process, "Run npm install foo"
process, "Run npm install foo", cwd=acp_project_dir
)
assert permission_request.params is not None
@ -799,7 +814,7 @@ class TestToolCallStructure:
@pytest.mark.skip(reason="Long running tool call updates are not implemented yet")
@pytest.mark.asyncio
async def test_tool_call_in_progress_update_structure(
self, vibe_home_grep_ask: Path
self, vibe_home_grep_ask: Path, acp_project_dir: Path
) -> None:
custom_results = [
mock_llm_chunk(
@ -821,7 +836,7 @@ class TestToolCallStructure:
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
session_id = await initialize_session(process)
session_id = await initialize_session(process, session_cwd=acp_project_dir)
await send_json_rpc(
process,
PromptJsonRpcRequest(
@ -856,7 +871,7 @@ class TestToolCallStructure:
@pytest.mark.asyncio
async def test_tool_call_result_update_failure_structure(
self, vibe_home_grep_ask: Path
self, vibe_home_grep_ask: Path, acp_project_dir: Path
) -> None:
custom_results = [
mock_llm_chunk(
@ -883,6 +898,7 @@ class TestToolCallStructure:
permission_request = await start_session_with_request_permission(
process,
"Search for files containing the pattern 'auth' in /nonexistent",
cwd=acp_project_dir,
)
assert permission_request.params is not None
selected_option_id = ToolOption.ALLOW_ONCE
@ -934,8 +950,7 @@ class TestCancellationStructure:
ToolCall(
function=FunctionCall(
name="write_file",
arguments='{"path":"test.txt","content":"hello, world!"'
',"overwrite":false}',
arguments='{"path":"test.txt","content":"hello, world!"}',
),
type="function",
index=0,

View file

@ -0,0 +1,35 @@
from __future__ import annotations
from tests.conftest import build_test_vibe_config
from vibe.acp.acp_agent_loop import (
NON_INTERACTIVE_DISABLED_TOOLS,
_merge_non_interactive_disabled_tools,
)
def test_merge_preserves_toml_disabled_tools():
config = build_test_vibe_config(disabled_tools=["task"])
_merge_non_interactive_disabled_tools(config)
assert "task" in config.disabled_tools
for tool in NON_INTERACTIVE_DISABLED_TOOLS:
assert tool in config.disabled_tools
def test_merge_deduplicates():
config = build_test_vibe_config(disabled_tools=["ask_user_question", "task"])
_merge_non_interactive_disabled_tools(config)
assert config.disabled_tools.count("ask_user_question") == 1
assert "task" in config.disabled_tools
assert "exit_plan_mode" in config.disabled_tools
def test_merge_on_empty_disabled_tools():
config = build_test_vibe_config()
_merge_non_interactive_disabled_tools(config)
assert set(config.disabled_tools) == set(NON_INTERACTIVE_DISABLED_TOOLS)

View file

@ -6,23 +6,12 @@ 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.acp.tools.builtins.edit import AcpEditState, Edit
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.search_replace import (
SearchReplaceArgs,
SearchReplaceConfig,
SearchReplaceResult,
)
from vibe.core.tools.builtins.edit import EditArgs, EditConfig, EditResult
from vibe.core.types import ToolCallEvent, ToolResultEvent
def _make_block(search: str, replace: str) -> str:
head = "<" * 7 + " SEARCH"
sep = "=" * 7
tail = ">" * 7 + " REPLACE"
return f"{head}\n{search}\n{sep}\n{replace}\n{tail}"
class MockClient:
def __init__(
self,
@ -82,52 +71,45 @@ def mock_client() -> MockClient:
@pytest.fixture
def acp_search_replace_tool(
def acp_edit_tool(
mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> SearchReplace:
) -> Edit:
monkeypatch.chdir(tmp_path)
config = SearchReplaceConfig()
state = AcpSearchReplaceState.model_construct(
config = EditConfig()
state = AcpEditState.model_construct(
client=mock_client, session_id="test_session_123"
)
return SearchReplace(config_getter=lambda: config, state=state)
return Edit(config_getter=lambda: config, state=state)
class TestAcpSearchReplaceBasic:
class TestAcpEditBasic:
def test_get_name(self) -> None:
assert SearchReplace.get_name() == "search_replace"
assert Edit.get_name() == "edit"
class TestAcpSearchReplaceExecution:
class TestAcpEditExecution:
@pytest.mark.asyncio
async def test_run_success(
self,
acp_search_replace_tool: SearchReplace,
mock_client: MockClient,
tmp_path: Path,
self, acp_edit_tool: Edit, mock_client: MockClient, tmp_path: Path
) -> None:
test_file = tmp_path / "test_file.txt"
test_file.write_text("original line 1\noriginal line 2\noriginal line 3")
search_replace_content = (
"<<<<<<< SEARCH\noriginal line 2\n=======\nmodified line 2\n>>>>>>> REPLACE"
args = EditArgs(
file_path=str(test_file),
old_string="original line 2",
new_string="modified line 2",
)
args = SearchReplaceArgs(
file_path=str(test_file), content=search_replace_content
)
result = await collect_result(acp_search_replace_tool.run(args))
result = await collect_result(acp_edit_tool.run(args))
assert isinstance(result, SearchReplaceResult)
assert isinstance(result, EditResult)
assert result.file == str(test_file)
assert result.blocks_applied == 1
assert mock_client._read_text_file_called
assert mock_client._write_text_file_called
# 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 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)
@ -136,32 +118,6 @@ class TestAcpSearchReplaceExecution:
== "original line 1\nmodified line 2\noriginal line 3"
)
@pytest.mark.asyncio
async def test_run_with_backup(
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
config = SearchReplaceConfig(create_backup=True)
tool = SearchReplace(
config_getter=lambda: config,
state=AcpSearchReplaceState.model_construct(
client=mock_client, session_id="test_session"
),
)
test_file = tmp_path / "test_file.txt"
test_file.write_text("original line 1\noriginal line 2\noriginal line 3")
search_replace_content = (
"<<<<<<< SEARCH\noriginal line 1\n=======\nmodified line 1\n>>>>>>> REPLACE"
)
args = SearchReplaceArgs(
file_path=str(test_file), content=search_replace_content
)
result = await collect_result(tool.run(args))
assert result.blocks_applied == 1
assert sum(w["path"].endswith(".bak") for w in mock_client._write_calls) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("newline", ["\r\n", "\r", "\n"])
async def test_run_preserves_line_endings(
@ -178,18 +134,19 @@ class TestAcpSearchReplaceExecution:
"original line 3",
])
tool = SearchReplace(
config_getter=lambda: SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
tool = Edit(
config_getter=lambda: EditConfig(),
state=AcpEditState.model_construct(
client=mock_client, session_id="test_session"
),
)
test_file = tmp_path / "test_file.txt"
test_file.touch()
args = SearchReplaceArgs(
args = EditArgs(
file_path=str(test_file),
content=_make_block("original line 2", "modified line 2"),
old_string="original line 2",
new_string="modified line 2",
)
await collect_result(tool.run(args))
@ -199,35 +156,6 @@ class TestAcpSearchReplaceExecution:
"original line 3",
])
@pytest.mark.asyncio
async def test_run_backup_preserves_crlf_byte_for_byte(
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
original = "original line 1\r\noriginal line 2\r\noriginal line 3"
mock_client._file_content = original
tool = SearchReplace(
config_getter=lambda: SearchReplaceConfig(create_backup=True),
state=AcpSearchReplaceState.model_construct(
client=mock_client, session_id="test_session"
),
)
test_file = tmp_path / "test_file.txt"
test_file.touch()
args = SearchReplaceArgs(
file_path=str(test_file),
content=_make_block("original line 1", "modified line 1"),
)
await collect_result(tool.run(args))
backup_calls = [
w for w in mock_client._write_calls if w["path"].endswith(".bak")
]
assert len(backup_calls) == 1
assert backup_calls[0]["content"] == original
@pytest.mark.asyncio
async def test_run_read_error(
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@ -235,26 +163,20 @@ class TestAcpSearchReplaceExecution:
monkeypatch.chdir(tmp_path)
mock_client._read_error = RuntimeError("File not found")
tool = SearchReplace(
config_getter=lambda: SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
tool = Edit(
config_getter=lambda: EditConfig(),
state=AcpEditState.model_construct(
client=mock_client, session_id="test_session"
),
)
test_file = tmp_path / "test.txt"
test_file.touch()
search_replace_content = "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE"
args = SearchReplaceArgs(
file_path=str(test_file), content=search_replace_content
)
args = EditArgs(file_path=str(test_file), old_string="old", new_string="new")
with pytest.raises(ToolError) as exc_info:
await collect_result(tool.run(args))
assert (
str(exc_info.value)
== f"Unexpected error reading {test_file}: File not found"
)
assert str(exc_info.value) == f"Error reading {test_file}: File not found"
@pytest.mark.asyncio
async def test_run_write_error(
@ -264,19 +186,16 @@ class TestAcpSearchReplaceExecution:
mock_client._write_error = RuntimeError("Permission denied")
test_file = tmp_path / "test.txt"
test_file.touch()
mock_client._file_content = "old" # Update mock to return correct content
mock_client._file_content = "old"
tool = SearchReplace(
config_getter=lambda: SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
tool = Edit(
config_getter=lambda: EditConfig(),
state=AcpEditState.model_construct(
client=mock_client, session_id="test_session"
),
)
search_replace_content = "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE"
args = SearchReplaceArgs(
file_path=str(test_file), content=search_replace_content
)
args = EditArgs(file_path=str(test_file), old_string="old", new_string="new")
with pytest.raises(ToolError) as exc_info:
await collect_result(tool.run(args))
@ -309,38 +228,30 @@ class TestAcpSearchReplaceExecution:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test.txt"
test_file.touch()
tool = SearchReplace(
config_getter=lambda: SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
client=client, session_id=session_id
),
tool = Edit(
config_getter=lambda: EditConfig(),
state=AcpEditState.model_construct(client=client, session_id=session_id),
)
search_replace_content = "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE"
args = SearchReplaceArgs(
file_path=str(test_file), content=search_replace_content
)
args = EditArgs(file_path=str(test_file), old_string="old", new_string="new")
with pytest.raises(ToolError) as exc_info:
await collect_result(tool.run(args))
assert str(exc_info.value) == expected_error
class TestAcpSearchReplaceSessionUpdates:
class TestAcpEditSessionUpdates:
def test_tool_call_session_update(self) -> None:
search_replace_content = (
"<<<<<<< SEARCH\nold text\n=======\nnew text\n>>>>>>> REPLACE"
)
event = ToolCallEvent(
tool_name="search_replace",
tool_name="edit",
tool_call_id="test_call_123",
args=SearchReplaceArgs(
file_path="/tmp/test.txt", content=search_replace_content
args=EditArgs(
file_path="/tmp/test.txt", old_string="old text", new_string="new text"
),
tool_class=SearchReplace,
tool_class=Edit,
)
update = SearchReplace.tool_call_session_update(event)
update = Edit.tool_call_session_update(event)
assert update is not None
assert update.session_update == "tool_call"
assert update.tool_call_id == "test_call_123"
@ -362,40 +273,37 @@ class TestAcpSearchReplaceSessionUpdates:
pass
event = ToolCallEvent.model_construct(
tool_name="search_replace",
tool_name="edit",
tool_call_id="test_call_123",
args=InvalidArgs(), # type: ignore[arg-type]
tool_class=SearchReplace,
tool_class=Edit,
)
update = SearchReplace.tool_call_session_update(event)
update = Edit.tool_call_session_update(event)
assert update is not None
assert update.title == "search_replace"
assert update.title == "edit"
def test_tool_result_session_update(self) -> None:
search_replace_content = (
"<<<<<<< SEARCH\nold text\n=======\nnew text\n>>>>>>> REPLACE"
)
result = SearchReplaceResult(
result = EditResult(
file="/tmp/test.txt",
blocks_applied=1,
lines_changed=1,
content=search_replace_content,
warnings=[],
message="The file has been updated successfully.",
old_string="old text",
new_string="new text",
)
event = ToolResultEvent(
tool_name="search_replace",
tool_name="edit",
tool_call_id="test_call_123",
result=result,
tool_class=SearchReplace,
tool_class=Edit,
)
update = SearchReplace.tool_result_session_update(event)
update = Edit.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.kind == "edit"
assert update.content is not None
assert isinstance(update.content, list)
assert len(update.content) == 1
@ -412,12 +320,12 @@ class TestAcpSearchReplaceSessionUpdates:
pass
event = ToolResultEvent.model_construct(
tool_name="search_replace",
tool_name="edit",
tool_call_id="test_call_123",
result=InvalidResult(), # type: ignore[arg-type]
tool_class=SearchReplace,
tool_class=Edit,
)
update = SearchReplace.tool_result_session_update(event)
update = Edit.tool_result_session_update(event)
assert update is not None
assert update.status == "failed"

View file

@ -83,7 +83,7 @@ class TestACPForkSession:
id="call-1",
index=0,
function=FunctionCall(
name="read_file", arguments='{"path":"a.txt"}'
name="read", arguments='{"file_path":"a.txt"}'
),
)
],

View file

@ -62,7 +62,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.13.0"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.14.0"
)
assert response.auth_methods is not None
@ -94,7 +94,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.13.0"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.14.0"
)
assert response.auth_methods is not None

View file

@ -238,8 +238,8 @@ class TestLoadSession:
"id": "call_123",
"type": "function",
"function": {
"name": "read_file",
"arguments": '{"path": "/tmp/test.txt"}',
"name": "read",
"arguments": '{"file_path": "/tmp/test.txt"}',
},
}
],
@ -254,7 +254,7 @@ class TestLoadSession:
u for u in client._session_updates if isinstance(u.update, ToolCallStart)
]
assert len(tool_call_starts) == 1
assert tool_call_starts[0].update.title == "read_file"
assert tool_call_starts[0].update.title == "read"
assert tool_call_starts[0].update.tool_call_id == "call_123"
tool_results = [

View file

@ -6,12 +6,13 @@ from acp import ReadTextFileResponse
import pytest
from tests.mock.utils import collect_result
from vibe.acp.tools.builtins.read_file import AcpReadFileState, ReadFile
from vibe.acp.tools.builtins.read import AcpReadState, Read
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.read_file import (
ReadFileArgs,
ReadFileResult,
ReadFileToolConfig,
from vibe.core.tools.builtins.read import (
DEFAULT_LINE_LIMIT,
ReadArgs,
ReadConfig,
ReadResult,
)
@ -49,7 +50,7 @@ class MockClient:
content = self._file_content
if line is not None or limit is not None:
lines = content.splitlines(keepends=True)
start_line = (line or 1) - 1 # Convert to 0-indexed
start_line = (line or 1) - 1
end_line = start_line + limit if limit is not None else len(lines)
lines = lines[start_line:end_line]
content = "".join(lines)
@ -66,45 +67,43 @@ def mock_client() -> MockClient:
@pytest.fixture
def acp_read_file_tool(
def acp_read_tool(
mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> ReadFile:
) -> Read:
monkeypatch.chdir(tmp_path)
config = ReadFileToolConfig()
state = AcpReadFileState.model_construct(
config = ReadConfig()
state = AcpReadState.model_construct(
client=mock_client, # type: ignore[arg-type]
session_id="test_session_123",
)
return ReadFile(config_getter=lambda: config, state=state)
return Read(config_getter=lambda: config, state=state)
class TestAcpReadFileBasic:
class TestAcpReadBasic:
def test_get_name(self) -> None:
assert ReadFile.get_name() == "read_file"
assert Read.get_name() == "read"
class TestAcpReadFileExecution:
class TestAcpReadExecution:
@pytest.mark.asyncio
async def test_run_success(
self, acp_read_file_tool: ReadFile, mock_client: MockClient, tmp_path: Path
self, acp_read_tool: Read, mock_client: MockClient, tmp_path: Path
) -> None:
test_file = tmp_path / "test_file.txt"
test_file.touch()
args = ReadFileArgs(path=str(test_file))
result = await collect_result(acp_read_file_tool.run(args))
args = ReadArgs(file_path=str(test_file))
result = await collect_result(acp_read_tool.run(args))
assert isinstance(result, ReadFileResult)
assert result.path == str(test_file)
assert result.content == "line 1\nline 2\nline 3"
assert result.lines_read == 3
assert isinstance(result, ReadResult)
assert result.file_path == str(test_file)
assert result.num_lines == 3
assert mock_client._read_text_file_called
# Verify read_text_file was called correctly
params = mock_client._last_read_params
assert params["session_id"] == "test_session_123"
assert params["path"] == str(test_file)
assert params["line"] is None # offset=0 means no line specified
assert params["limit"] is None
assert params["line"] is None
assert params["limit"] == DEFAULT_LINE_LIMIT + 1
@pytest.mark.asyncio
async def test_run_with_offset(
@ -113,21 +112,21 @@ class TestAcpReadFileExecution:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
tool = Read(
config_getter=lambda: ReadConfig(),
state=AcpReadState.model_construct(
client=mock_client, session_id="test_session"
),
)
args = ReadFileArgs(path=str(test_file), offset=1)
args = ReadArgs(file_path=str(test_file), offset=2)
result = await collect_result(tool.run(args))
assert result.lines_read == 2
assert result.content == "line 2\nline 3"
assert result.num_lines == 2
assert result.start_line == 2
params = mock_client._last_read_params
assert params["line"] == 2 # offset=1 means line 2 (1-indexed)
assert params["line"] == 2
@pytest.mark.asyncio
async def test_run_with_limit(
@ -136,21 +135,20 @@ class TestAcpReadFileExecution:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
tool = Read(
config_getter=lambda: ReadConfig(),
state=AcpReadState.model_construct(
client=mock_client, session_id="test_session"
),
)
args = ReadFileArgs(path=str(test_file), limit=2)
args = ReadArgs(file_path=str(test_file), limit=2)
result = await collect_result(tool.run(args))
assert result.lines_read == 2
assert result.content == "line 1\nline 2\n"
assert result.num_lines == 2
params = mock_client._last_read_params
assert params["limit"] == 2
assert params["limit"] == 3
@pytest.mark.asyncio
async def test_run_with_offset_and_limit(
@ -159,22 +157,22 @@ class TestAcpReadFileExecution:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
tool = Read(
config_getter=lambda: ReadConfig(),
state=AcpReadState.model_construct(
client=mock_client, session_id="test_session"
),
)
args = ReadFileArgs(path=str(test_file), offset=1, limit=1)
args = ReadArgs(file_path=str(test_file), offset=2, limit=1)
result = await collect_result(tool.run(args))
assert result.lines_read == 1
assert result.content == "line 2\n"
assert result.num_lines == 1
assert result.start_line == 2
params = mock_client._last_read_params
assert params["line"] == 2
assert params["limit"] == 1
assert params["limit"] == 2
@pytest.mark.asyncio
async def test_run_read_error(
@ -184,14 +182,14 @@ class TestAcpReadFileExecution:
mock_client._read_error = RuntimeError("File not found")
test_file = tmp_path / "test.txt"
test_file.touch()
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
tool = Read(
config_getter=lambda: ReadConfig(),
state=AcpReadState.model_construct(
client=mock_client, session_id="test_session"
),
)
args = ReadFileArgs(path=str(test_file))
args = ReadArgs(file_path=str(test_file))
with pytest.raises(ToolError) as exc_info:
await collect_result(tool.run(args))
@ -204,14 +202,12 @@ class TestAcpReadFileExecution:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test.txt"
test_file.touch()
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
client=None, session_id="test_session"
),
tool = Read(
config_getter=lambda: ReadConfig(),
state=AcpReadState.model_construct(client=None, session_id="test_session"),
)
args = ReadFileArgs(path=str(test_file))
args = ReadArgs(file_path=str(test_file))
with pytest.raises(ToolError) as exc_info:
await collect_result(tool.run(args))
@ -228,12 +224,12 @@ class TestAcpReadFileExecution:
test_file = tmp_path / "test.txt"
test_file.touch()
mock_client = MockClient()
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(client=mock_client, session_id=None),
tool = Read(
config_getter=lambda: ReadConfig(),
state=AcpReadState.model_construct(client=mock_client, session_id=None),
)
args = ReadFileArgs(path=str(test_file))
args = ReadArgs(file_path=str(test_file))
with pytest.raises(ToolError) as exc_info:
await collect_result(tool.run(args))

View file

@ -0,0 +1,177 @@
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
from tests.stubs.fake_client import FakeClient
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.acp.exceptions import InvalidRequestError
from vibe.core.session import last_session_pointer
def _write_saved_session(
session_dir: Path, timestamp: str, session_id: str, cwd: str
) -> Path:
saved_session_dir = session_dir / f"session_{timestamp}_{session_id[:8]}"
saved_session_dir.mkdir()
(saved_session_dir / "messages.jsonl").write_text(
json.dumps({"role": "user", "content": "Hello"}) + "\n", encoding="utf-8"
)
(saved_session_dir / "meta.json").write_text(
json.dumps({
"session_id": session_id,
"start_time": "2024-01-01T12:00:00Z",
"end_time": "2024-01-01T12:05:00Z",
"git_commit": None,
"git_branch": None,
"username": "test-user",
"environment": {"working_directory": cwd},
"title": "Saved session",
}),
encoding="utf-8",
)
return saved_session_dir
class TestSessionDelete:
@pytest.mark.asyncio
async def test_deletes_saved_but_not_loaded_session(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
create_test_session,
) -> None:
acp_agent = acp_agent_with_session_config[0]
session_id = "offline-session-12345678"
session_dir = create_test_session(temp_session_dir, session_id, str(Path.cwd()))
result = await acp_agent.ext_method("session/delete", {"sessionId": session_id})
assert result == {}
assert not session_dir.exists()
response = await acp_agent.list_sessions()
assert response.sessions == []
@pytest.mark.asyncio
async def test_deletes_saved_session_and_clears_last_session_pointer(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
create_test_session,
) -> None:
acp_agent = acp_agent_with_session_config[0]
session_id = "pointer-session-12345678"
session_dir = create_test_session(temp_session_dir, session_id, str(Path.cwd()))
pointer_dir = temp_session_dir / last_session_pointer.POINTER_DIR_NAME
pointer_dir.mkdir()
matching_pointer = pointer_dir / "ttys001"
other_pointer = pointer_dir / "ttys002"
matching_pointer.write_text(f"{session_id}\n", encoding="utf-8")
other_pointer.write_text("other-session\n", encoding="utf-8")
result = await acp_agent.ext_method("session/delete", {"sessionId": session_id})
assert result == {}
assert not session_dir.exists()
assert not matching_pointer.exists()
assert other_pointer.read_text(encoding="utf-8") == "other-session\n"
@pytest.mark.asyncio
async def test_deletes_loaded_saved_session(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
create_test_session,
) -> None:
acp_agent = acp_agent_with_session_config[0]
saved_session_id = "saved-session-12345678"
acp_session_id = saved_session_id[:8]
cwd = str(Path.cwd())
session_dir = create_test_session(temp_session_dir, saved_session_id, cwd)
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=acp_session_id)
session = acp_agent.sessions[acp_session_id]
session.agent_loop.telemetry_client.aclose = AsyncMock()
result = await acp_agent.ext_method(
"session/delete", {"sessionId": saved_session_id}
)
assert result == {}
assert acp_session_id not in acp_agent.sessions
assert not session_dir.exists()
response = await acp_agent.list_sessions()
assert response.sessions == []
@pytest.mark.asyncio
async def test_deletes_live_unsaved_session_without_saved_history(
self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient]
) -> None:
acp_agent = acp_agent_with_session_config[0]
response = await acp_agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
assert response is not None
session = acp_agent.sessions[response.session_id]
session.agent_loop.telemetry_client.aclose = AsyncMock()
assert not session.agent_loop.session_logger.metadata_filepath.exists()
result = await acp_agent.ext_method(
"session/delete", {"sessionId": response.session_id}
)
assert result == {}
assert response.session_id not in acp_agent.sessions
@pytest.mark.asyncio
async def test_raises_on_invalid_params(
self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient]
) -> None:
acp_agent = acp_agent_with_session_config[0]
with pytest.raises(InvalidRequestError):
await acp_agent.ext_method("session/delete", {})
with pytest.raises(InvalidRequestError):
await acp_agent.ext_method("session/delete", {"sessionId": " "})
with pytest.raises(InvalidRequestError):
await acp_agent.ext_method(
"session/delete", {"savedSessionId": "unsupported-session"}
)
@pytest.mark.asyncio
async def test_succeeds_when_session_cannot_be_found(
self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient]
) -> None:
acp_agent = acp_agent_with_session_config[0]
result = await acp_agent.ext_method(
"session/delete", {"sessionId": "missing-session"}
)
assert result == {}
@pytest.mark.asyncio
async def test_requires_exact_saved_session_id_before_deleting(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
) -> None:
acp_agent = acp_agent_with_session_config[0]
cwd = str(Path.cwd())
collision_dir = _write_saved_session(
temp_session_dir, "20240101_120000", "aaaaaaaa-1111", cwd
)
target_dir = _write_saved_session(
temp_session_dir, "20240101_120500", "aaaaaaaa-2222", cwd
)
result = await acp_agent.ext_method(
"session/delete", {"sessionId": "aaaaaaaa-2222"}
)
assert result == {}
assert collision_dir.exists()
assert not target_dir.exists()

64
tests/acp/test_todo.py Normal file
View file

@ -0,0 +1,64 @@
from __future__ import annotations
from vibe.acp.tools.builtins.todo import Todo
from vibe.core.tools.builtins.todo import TodoItem, TodoPriority, TodoResult, TodoStatus
from vibe.core.types import ToolResultEvent
class TestAcpTodoSessionUpdates:
def test_tool_result_session_update(self) -> None:
result = TodoResult(
message="Updated 2 todos",
todos=[
TodoItem(
id="1",
content="First",
status=TodoStatus.IN_PROGRESS,
priority=TodoPriority.HIGH,
),
TodoItem(id="2", content="Second", status=TodoStatus.PENDING),
],
total_count=2,
)
event = ToolResultEvent(
tool_name="todo",
tool_call_id="test_call_123",
result=result,
tool_class=Todo,
)
update = Todo.tool_result_session_update(event)
assert update is not None
assert update.session_update == "plan"
assert len(update.entries) == 2
assert update.entries[0].content == "First"
assert update.entries[0].status == "in_progress"
assert update.entries[0].priority == "high"
def test_tool_result_session_update_failed_result(self) -> None:
event = ToolResultEvent(
tool_name="todo",
tool_call_id="test_call_123",
error="Todo IDs must be unique",
tool_class=Todo,
)
update = Todo.tool_result_session_update(event)
assert update is not None
assert update.status == "failed"
def test_tool_result_session_update_invalid_result(self) -> None:
class InvalidResult:
pass
event = ToolResultEvent.model_construct(
tool_name="todo",
tool_call_id="test_call_123",
result=InvalidResult(), # type: ignore[arg-type]
tool_class=Todo,
)
update = Todo.tool_result_session_update(event)
assert update is not None
assert update.status == "failed"

View file

@ -2,19 +2,19 @@ from __future__ import annotations
from acp.schema import ToolCallStart
from vibe.acp.tools.builtins.read_file import ReadFile
from vibe.acp.tools.builtins.read import Read
from vibe.acp.tools.session_update import tool_call_session_update
from vibe.core.tools.builtins.read_file import ReadFileArgs
from vibe.core.tools.builtins.read import ReadArgs
from vibe.core.types import ToolCallEvent
class TestToolCallSessionUpdate:
def _create_event(self) -> ToolCallEvent:
return ToolCallEvent(
tool_name="read_file",
tool_name="read",
tool_call_id="test_call_123",
args=ReadFileArgs(path="/tmp/test.txt"),
tool_class=ReadFile,
args=ReadArgs(file_path="/tmp/test.txt"),
tool_class=Read,
)
def test_returns_tool_call_start(self) -> None:
@ -29,10 +29,7 @@ class TestToolCallSessionUpdate:
def test_returns_tool_call_start_for_streaming_event(self) -> None:
event = ToolCallEvent(
tool_name="read_file",
tool_call_id="test_call_123",
tool_class=ReadFile,
args=None,
tool_name="read", tool_call_id="test_call_123", tool_class=Read, args=None
)
update = tool_call_session_update(event)

View file

@ -8,7 +8,7 @@ from acp.schema import ToolCallProgress, ToolCallStart
from pydantic import BaseModel
from vibe.acp.tools.builtins.grep import Grep
from vibe.acp.tools.builtins.read_file import ReadFile
from vibe.acp.tools.builtins.read import Read
from vibe.acp.tools.builtins.skill import Skill
from vibe.acp.tools.builtins.task import Task
from vibe.acp.tools.builtins.web_fetch import WebFetch
@ -19,7 +19,7 @@ from vibe.acp.tools.session_update import (
tool_result_session_update,
)
from vibe.core.tools.builtins.grep import GrepArgs, GrepResult
from vibe.core.tools.builtins.read_file import ReadFileArgs, ReadFileResult
from vibe.core.tools.builtins.read import ReadArgs, ReadResult
from vibe.core.tools.builtins.skill import SkillArgs, SkillResult
from vibe.core.tools.builtins.task import TaskArgs, TaskResult
from vibe.core.tools.builtins.webfetch import WebFetchArgs, WebFetchResult
@ -99,10 +99,10 @@ class TestGrepFieldMeta:
assert update.field_meta == {"tool_name": "grep"}
class TestReadFileFieldMeta:
class TestReadFieldMeta:
def test_call_location_has_offset_and_limit(self) -> None:
event = _call_event(
"read_file", ReadFile, ReadFileArgs(path="/tmp/f.txt", offset=10, limit=50)
"read", Read, ReadArgs(file_path="/tmp/f.txt", offset=10, limit=50)
)
update = tool_call_session_update(event)
@ -110,26 +110,26 @@ class TestReadFileFieldMeta:
assert update.locations is not None
loc = update.locations[0]
assert loc.field_meta == {"type": "file_range", "offset": 10, "limit": 50}
assert update.field_meta == {"tool_name": "read_file"}
assert update.field_meta == {"tool_name": "read"}
def test_call_defaults_offset_zero_limit_none(self) -> None:
event = _call_event("read_file", ReadFile, ReadFileArgs(path="/tmp/f.txt"))
def test_call_defaults_offset_none_limit_default(self) -> None:
event = _call_event("read", Read, ReadArgs(file_path="/tmp/f.txt"))
update = tool_call_session_update(event)
assert isinstance(update, ToolCallStart)
assert update.locations is not None
loc = update.locations[0]
assert loc.field_meta == {"type": "file_range", "offset": 0, "limit": None}
assert loc.field_meta == {"type": "file_range", "offset": None, "limit": 2000}
def test_result_location_has_offset_and_lines_read(self) -> None:
result = ReadFileResult(
path="/tmp/f.txt",
content="line1\nline2\nline3\n",
lines_read=3,
was_truncated=False,
offset=10,
def test_result_location_has_start_line_and_num_lines(self) -> None:
result = ReadResult(
file_path="/tmp/f.txt",
content=" 1→line1\n 2→line2\n 3→line3",
num_lines=3,
start_line=10,
total_lines=20,
)
event = _result_event("read_file", ReadFile, result)
event = _result_event("read", Read, result)
update = tool_result_session_update(event)
assert isinstance(update, ToolCallProgress)
@ -287,9 +287,7 @@ class TestWriteFileFieldMeta:
assert update.locations[0].path == str(Path("out.txt").resolve())
def test_result_location_is_resolved_path(self) -> None:
result = WriteFileResult(
path="out.txt", content="hello", bytes_written=5, file_existed=False
)
result = WriteFileResult(path="out.txt", content="hello", bytes_written=5)
event = _result_event("write_file", WriteFile, result)
update = tool_result_session_update(event)

View file

@ -77,7 +77,6 @@ class TestAcpWriteFileExecution:
assert result.path == str(test_file)
assert result.content == "Hello, world!"
assert result.bytes_written == len(b"Hello, world!")
assert result.file_existed is False
assert mock_client._write_text_file_called
# Verify write_text_file was called correctly
@ -87,7 +86,7 @@ class TestAcpWriteFileExecution:
assert params["content"] == "Hello, world!"
@pytest.mark.asyncio
async def test_run_success_overwrite(
async def test_run_existing_file_raises(
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
@ -100,23 +99,11 @@ class TestAcpWriteFileExecution:
test_file = tmp_path / "existing_file.txt"
test_file.touch()
# Simulate existing file by checking in the core tool logic
# The ACP tool doesn't check existence, it's handled by the core tool
args = WriteFileArgs(path=str(test_file), content="New content", overwrite=True)
result = await collect_result(tool.run(args))
args = WriteFileArgs(path=str(test_file), content="New content")
with pytest.raises(ToolError, match="already exists"):
await collect_result(tool.run(args))
assert isinstance(result, WriteFileResult)
assert result.path == str(test_file)
assert result.content == "New content"
assert result.bytes_written == len(b"New content")
assert result.file_existed is True
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"
assert params["path"] == str(test_file)
assert params["content"] == "New content"
assert not mock_client._write_text_file_called
@pytest.mark.asyncio
@pytest.mark.parametrize("input_newline", ["\r\n", "\r", "\n"])
@ -305,9 +292,7 @@ class TestAcpWriteFileSessionUpdates:
assert update.title == "write_file"
def test_tool_result_session_update(self) -> None:
result = WriteFileResult(
path="/tmp/test.txt", content="Hello", bytes_written=5, file_existed=False
)
result = WriteFileResult(path="/tmp/test.txt", content="Hello", bytes_written=5)
event = ToolResultEvent(
tool_name="write_file",