Initial commit

Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Laure Hugo <laure.hugo@mistral.ai>
Co-Authored-By: Benjamin Trom <benjamin.trom@mistral.ai>
Co-Authored-By: Mathias Gesbert <mathias.gesbert@ext.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: Valentin Berard <val@mistral.ai>
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Quentin Torroba 2025-12-09 13:13:22 +01:00 committed by Quentin Torroba
commit fa15fc977b
200 changed files with 30484 additions and 0 deletions

925
tests/acp/test_acp.py Normal file
View file

@ -0,0 +1,925 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
import json
import os
from typing import Any
from acp import (
InitializeRequest,
NewSessionRequest,
PromptRequest,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
RequestPermissionResponse,
WriteTextFileRequest,
)
from acp.schema import (
AgentCapabilities,
AllowedOutcome,
DeniedOutcome,
Implementation,
InitializeResponse,
McpCapabilities,
NewSessionResponse,
PromptCapabilities,
PromptResponse,
SessionNotification,
TextContentBlock,
)
from pydantic import BaseModel
import pytest
from tests import TESTS_ROOT
from tests.mock.utils import get_mocking_env, mock_llm_chunk
from vibe.acp.utils import ToolOption
from vibe.core.types import FunctionCall, ToolCall
RESPONSE_TIMEOUT = 2.0
MOCK_ENTRYPOINT_PATH = "tests/mock/mock_entrypoint.py"
PLAYGROUND_DIR = TESTS_ROOT / "playground"
class JsonRpcRequest(BaseModel):
jsonrpc: str = "2.0"
id: int | str
method: str
params: Any | None = None
class JsonRpcError(BaseModel):
code: int
message: str
data: Any | None = None
class JsonRpcResponse(BaseModel):
jsonrpc: str = "2.0"
id: int | str | None = None
result: Any | None = None
error: JsonRpcError | None = None
class JsonRpcNotification(BaseModel):
jsonrpc: str = "2.0"
method: str
params: Any | None = None
type JsonRpcMessage = JsonRpcResponse | JsonRpcNotification | JsonRpcRequest
class InitializeJsonRpcRequest(JsonRpcRequest):
method: str = "initialize"
params: InitializeRequest | None = None
class InitializeJsonRpcResponse(JsonRpcResponse):
result: InitializeResponse | None = None
class NewSessionJsonRpcRequest(JsonRpcRequest):
method: str = "session/new"
params: NewSessionRequest | None = None
class NewSessionJsonRpcResponse(JsonRpcResponse):
result: NewSessionResponse | None = None
class PromptJsonRpcRequest(JsonRpcRequest):
method: str = "session/prompt"
params: PromptRequest | None = None
class PromptJsonRpcResponse(JsonRpcResponse):
result: PromptResponse | None = None
class UpdateJsonRpcNotification(JsonRpcNotification):
method: str = "session/update"
params: SessionNotification | None = None
class RequestPermissionJsonRpcRequest(JsonRpcRequest):
method: str = "session/request_permission"
params: RequestPermissionRequest | None = None
class RequestPermissionJsonRpcResponse(JsonRpcResponse):
result: RequestPermissionResponse | None = None
class ReadTextFileJsonRpcRequest(JsonRpcRequest):
method: str = "fs/read_text_file"
params: ReadTextFileRequest | None = None
class ReadTextFileJsonRpcResponse(JsonRpcResponse):
result: ReadTextFileResponse | None = None
class WriteTextFileJsonRpcRequest(JsonRpcRequest):
method: str = "fs/write_text_file"
params: WriteTextFileRequest | None = None
class WriteTextFileJsonRpcResponse(JsonRpcResponse):
result: None = None
async def get_acp_agent_process(
mock: bool = True, mock_env: dict[str, str] | None = None
) -> AsyncGenerator[asyncio.subprocess.Process]:
current_env = os.environ.copy()
cmd = ["uv", "run", MOCK_ENTRYPOINT_PATH if mock else "vibe-acp"]
process = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=TESTS_ROOT.parent,
env={
**current_env,
**(mock_env or {}),
**({"MISTRAL_API_KEY": "mock"} if mock else {}),
},
)
try:
yield process
finally:
# Cleanup
if process.returncode is None:
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=0.5)
except TimeoutError:
process.kill()
await process.wait()
async def send_json_rpc(
process: asyncio.subprocess.Process, message: JsonRpcMessage
) -> None:
if process.stdin is None:
raise RuntimeError("Process stdin not available")
request = message.model_dump_json()
request_json = request + "\n"
process.stdin.write(request_json.encode())
await process.stdin.drain()
async def read_response(
process: asyncio.subprocess.Process, timeout: float = RESPONSE_TIMEOUT
) -> str | None:
if process.stdout is None:
raise RuntimeError("Process stdout not available")
try:
# Keep reading lines until we find a valid JSON line
while True:
line = await asyncio.wait_for(process.stdout.readline(), timeout=timeout)
if not line:
return None
line_str = line.decode().strip()
if not line_str:
continue
try:
json.loads(line_str)
return line_str
except json.JSONDecodeError:
# Not JSON, skip it (it's a log message)
continue
except TimeoutError:
return None
async def read_response_for_id(
process: asyncio.subprocess.Process,
expected_id: int | str,
timeout: float = RESPONSE_TIMEOUT,
) -> str | None:
loop = asyncio.get_running_loop()
end_time = loop.time() + timeout
while (remaining := end_time - loop.time()) > 0:
response = await read_response(process, timeout=remaining)
if response is None:
return None
response_json = json.loads(response)
if response_json.get("id") == expected_id:
return response
print(
f"Skipping response with id={response_json.get('id')}, expecting {expected_id}"
)
return None
async def read_multiple_responses(
process: asyncio.subprocess.Process,
max_count: int = 10,
timeout_per_response: float = RESPONSE_TIMEOUT,
) -> list[str]:
responses = []
for _ in range(max_count):
response = await read_response(process, timeout=timeout_per_response)
if response:
responses.append(response)
else:
break
return responses
def parse_conversation(message_texts: list[str]) -> list[JsonRpcMessage]:
parsed_messages: list[JsonRpcMessage] = []
for message_text in message_texts:
message_json = json.loads(message_text)
cls = None
has_method = message_json.get("method", None) is not None
has_id = message_json.get("id", None) is not None
has_result = message_json.get("result", None) is not None
is_request = has_method and has_id
is_notification = has_method and not has_id
is_response = has_result
if is_request:
match message_json.get("method"):
case "session/prompt":
cls = PromptJsonRpcRequest
case "session/request_permission":
cls = RequestPermissionJsonRpcRequest
case "fs/read_text_file":
cls = ReadTextFileJsonRpcRequest
case "fs/write_text_file":
cls = WriteTextFileJsonRpcRequest
elif is_notification:
match message_json.get("method"):
case "session/update":
cls = UpdateJsonRpcNotification
elif is_response:
# For responses, since we don't know the method, we need to find
# the matching request.
matching_request = next(
(
m
for m in parsed_messages
if isinstance(m, JsonRpcRequest) and m.id == message_json.get("id")
),
None,
)
if matching_request is None:
# No matching request found in the conversation, it most probably was
# not included in the conversation. We use a generic response class.
cls = JsonRpcResponse
else:
match matching_request.method:
case "session/prompt":
cls = PromptJsonRpcResponse
case "session/request_permission":
cls = RequestPermissionJsonRpcResponse
case "fs/read_text_file":
cls = ReadTextFileJsonRpcResponse
case "fs/write_text_file":
cls = WriteTextFileJsonRpcResponse
if cls is None:
raise ValueError(f"No valid message class found for {message_json}")
parsed_messages.append(cls.model_validate(message_json))
return parsed_messages
async def initialize_session(acp_agent_process: asyncio.subprocess.Process) -> str:
await send_json_rpc(
acp_agent_process,
InitializeJsonRpcRequest(id=1, params=InitializeRequest(protocolVersion=1)),
)
initialize_response = await read_response_for_id(
acp_agent_process, expected_id=1, timeout=5.0
)
assert initialize_response is not None
await send_json_rpc(
acp_agent_process,
NewSessionJsonRpcRequest(
id=2, params=NewSessionRequest(cwd=str(PLAYGROUND_DIR), mcpServers=[])
),
)
session_response = await read_response_for_id(acp_agent_process, expected_id=2)
assert session_response is not None
session_response_json = json.loads(session_response)
session_response_obj = NewSessionJsonRpcResponse.model_validate(
session_response_json
)
assert session_response_obj.result is not None, "No result in response"
return session_response_obj.result.sessionId
class TestInitialization:
@pytest.mark.asyncio
async def test_initialize_request_response(self) -> None:
mock_env = get_mocking_env()
async for process in get_acp_agent_process(mock_env=mock_env):
await send_json_rpc(
process,
InitializeJsonRpcRequest(
id=1, params=InitializeRequest(protocolVersion=1)
),
)
text_response = await read_response(process, timeout=10.0)
assert text_response is not None, "No response to initialize"
response_json = json.loads(text_response)
response = InitializeJsonRpcResponse.model_validate(response_json)
assert response.error is None, f"JSON-RPC error: {response.error}"
assert response.result is not None, "No result in response"
assert response.result.protocolVersion == 1
assert response.result.agentCapabilities == AgentCapabilities(
loadSession=False,
promptCapabilities=PromptCapabilities(
audio=False, embeddedContext=True, image=False
),
mcpCapabilities=McpCapabilities(http=False, sse=False),
)
assert response.result.agentInfo == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="0.1.0"
)
vibe_setup_method = next(
(
method
for method in response.result.authMethods or []
if method.id == "vibe-setup"
),
None,
)
assert vibe_setup_method is not None, "vibe-setup auth not found"
assert vibe_setup_method.field_meta is not None
assert "terminal-auth" in vibe_setup_method.field_meta.keys()
class TestSessionManagement:
@pytest.mark.asyncio
async def test_multiple_sessions_unique_ids(self) -> None:
mock_env = get_mocking_env(mock_chunks=[mock_llm_chunk() for _ in range(3)])
async for process in get_acp_agent_process(mock_env=mock_env):
await send_json_rpc(
process,
InitializeJsonRpcRequest(
id=1, params=InitializeRequest(protocolVersion=1)
),
)
await read_response_for_id(process, expected_id=1, timeout=5.0)
session_ids = []
for i in range(3):
await send_json_rpc(
process,
NewSessionJsonRpcRequest(
id=i + 2,
params=NewSessionRequest(
cwd=str(PLAYGROUND_DIR), mcpServers=[]
),
),
)
text_response = await read_response_for_id(
process, expected_id=i + 2, timeout=RESPONSE_TIMEOUT
)
assert text_response is not None
response_json = json.loads(text_response)
response = NewSessionJsonRpcResponse.model_validate(response_json)
assert response.error is None, f"JSON-RPC error: {response.error}"
assert response.result is not None, "No result in response"
session_ids.append(response.result.sessionId)
assert len(set(session_ids)) == 3
class TestSessionUpdates:
@pytest.mark.asyncio
async def test_agent_message_chunk_structure(self) -> None:
mock_env = get_mocking_env([mock_llm_chunk(content="Hi") for _ in range(2)])
async for process in get_acp_agent_process(mock_env=mock_env):
# Check stderr for error details if process failed
if process.returncode is not None and process.stderr:
stderr_data = await process.stderr.read()
if stderr_data:
# Log stderr for debugging test failures
pass # Could add proper logging here if needed
session_id = await initialize_session(process)
await send_json_rpc(
process,
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
sessionId=session_id,
prompt=[TextContentBlock(type="text", text="Just say hi")],
),
),
)
text_response = await read_response(process)
assert text_response is not None
response = UpdateJsonRpcNotification.model_validate(
json.loads(text_response)
)
assert response.params is not None
assert response.params.update.sessionUpdate == "agent_message_chunk"
assert response.params.update.content is not None
assert response.params.update.content.type == "text"
assert response.params.update.content.text is not None
assert response.params.update.content.text == "Hi"
@pytest.mark.asyncio
async def test_tool_call_update_structure(self) -> None:
mock_env = get_mocking_env([
mock_llm_chunk(content="Hey"),
mock_llm_chunk(
tool_calls=[
ToolCall(
function=FunctionCall(
name="grep", arguments='{"pattern": "auth"}'
),
type="function",
index=0,
)
],
name="bash",
finish_reason="tool_calls",
),
mock_llm_chunk(
content="The files containing the pattern 'auth' are ...",
finish_reason="stop",
),
])
async for process in get_acp_agent_process(mock_env=mock_env):
session_id = await initialize_session(process)
await send_json_rpc(
process,
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
sessionId=session_id,
prompt=[
TextContentBlock(
type="text",
text="Show me files that are related to auth",
)
],
),
),
)
text_responses = await read_multiple_responses(process, max_count=10)
assert len(text_responses) > 0
responses = [
UpdateJsonRpcNotification.model_validate(json.loads(r))
for r in text_responses
]
tool_call = next(
(
r
for r in responses
if isinstance(r, UpdateJsonRpcNotification)
and r.params is not None
and r.params.update.sessionUpdate == "tool_call"
),
None,
)
assert tool_call is not None
assert tool_call.params is not None
assert tool_call.params.update is not None
assert tool_call.params.update.sessionUpdate == "tool_call"
assert tool_call.params.update.kind == "search"
assert tool_call.params.update.title == "grep: 'auth'"
assert (
tool_call.params.update.rawInput
== '{"pattern":"auth","path":".","max_matches":null,"use_default_ignore":true}'
)
async def start_session_with_request_permission(
process: asyncio.subprocess.Process, prompt: str
) -> RequestPermissionJsonRpcRequest:
session_id = await initialize_session(process)
await send_json_rpc(
process,
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
sessionId=session_id,
prompt=[TextContentBlock(type="text", text=prompt)],
),
),
)
text_responses = await read_multiple_responses(
process, max_count=15, timeout_per_response=2.0
)
responses = parse_conversation(text_responses)
last_response = responses[-1]
assert isinstance(last_response, RequestPermissionJsonRpcRequest)
assert last_response.params is not None
assert len(last_response.params.options) == 3
return last_response
@pytest.mark.skip(
reason="Disabled until we have a way to properly mock the fs and acp interactions"
)
class TestToolCallStructure:
@pytest.mark.asyncio
async def test_tool_call_request_permission_structure(self) -> None:
custom_results = [
mock_llm_chunk(content="Hey"),
mock_llm_chunk(
tool_calls=[
ToolCall(
function=FunctionCall(
name="write_file",
arguments='{"path":"test.txt","content":"hello, world!"'
',"overwrite":true}',
),
type="function",
index=0,
)
],
name="write_file",
finish_reason="stop",
),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(mock_env=mock_env):
session_id = await initialize_session(process)
await send_json_rpc(
process,
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
sessionId=session_id,
prompt=[
TextContentBlock(
type="text",
text="Create a new file named test.txt "
"with content 'hello, world!'",
)
],
),
),
)
text_responses = await read_multiple_responses(process, max_count=3)
responses = parse_conversation(text_responses)
# Look for tool call request permission updates
permission_requests = [
r for r in responses if isinstance(r, RequestPermissionJsonRpcRequest)
]
assert len(permission_requests) > 0, (
"No tool call permission requests found"
)
first_request = permission_requests[0]
assert first_request.params is not None
assert first_request.params.toolCall is not None
assert first_request.params.toolCall.toolCallId is not None
@pytest.mark.asyncio
async def test_tool_call_update_approved_structure(self) -> None:
custom_results = [
mock_llm_chunk(content="Hey"),
mock_llm_chunk(
tool_calls=[
ToolCall(
function=FunctionCall(
name="write_file",
arguments='{"path":"test.txt","content":"hello, world!"'
',"overwrite":true}',
),
type="function",
index=0,
)
],
name="write_file",
finish_reason="tool_calls",
),
mock_llm_chunk(
content="The file test.txt has been created", finish_reason="stop"
),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(mock_env=mock_env):
permission_request = await start_session_with_request_permission(
process, "Create a file named test.txt"
)
assert permission_request.params is not None
selected_option_id = ToolOption.ALLOW_ONCE
await send_json_rpc(
process,
RequestPermissionJsonRpcResponse(
id=permission_request.id,
result=RequestPermissionResponse(
outcome=AllowedOutcome(
outcome="selected", optionId=selected_option_id
)
),
),
)
text_responses = await read_multiple_responses(process, max_count=7)
responses = parse_conversation(text_responses)
approved_tool_call = next(
(
r
for r in responses
if isinstance(r, UpdateJsonRpcNotification)
and r.method == "session/update"
and r.params is not None
and r.params.update is not None
and r.params.update.sessionUpdate == "tool_call_update"
and r.params.update.toolCallId
== (permission_request.params.toolCall.toolCallId)
and r.params.update.status == "completed"
),
None,
)
assert approved_tool_call is not None
@pytest.mark.asyncio
async def test_tool_call_update_rejected_structure(self) -> None:
custom_results = [
mock_llm_chunk(content="Hey"),
mock_llm_chunk(
tool_calls=[
ToolCall(
function=FunctionCall(
name="write_file",
arguments='{"path":"test.txt","content":"hello, world!"'
',"overwrite":false}',
),
type="function",
index=0,
)
],
name="write_file",
finish_reason="tool_calls",
),
mock_llm_chunk(
content="The file test.txt has not been created, "
"because you rejected the permission request",
finish_reason="stop",
),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(mock_env=mock_env):
permission_request = await start_session_with_request_permission(
process, "Create a file named test.txt"
)
assert permission_request.params is not None
selected_option_id = ToolOption.REJECT_ONCE
await send_json_rpc(
process,
RequestPermissionJsonRpcResponse(
id=permission_request.id,
result=RequestPermissionResponse(
outcome=AllowedOutcome(
outcome="selected", optionId=selected_option_id
)
),
),
)
text_responses = await read_multiple_responses(process, max_count=5)
responses = parse_conversation(text_responses)
rejected_tool_call = next(
(
r
for r in responses
if isinstance(r, UpdateJsonRpcNotification)
and r.method == "session/update"
and r.params is not None
and r.params.update.sessionUpdate == "tool_call_update"
and r.params.update.toolCallId
== (permission_request.params.toolCall.toolCallId)
and r.params.update.status == "failed"
),
None,
)
assert rejected_tool_call is not None
@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) -> None:
custom_results = [
mock_llm_chunk(content="Hey"),
mock_llm_chunk(
tool_calls=[
ToolCall(
function=FunctionCall(
name="bash",
arguments='{"command":"sleep 3","timeout":null}',
),
type="function",
)
],
name="bash",
finish_reason="tool_calls",
),
mock_llm_chunk(
content="The command sleep 3 has been run", finish_reason="stop"
),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(mock_env=mock_env):
session_id = await initialize_session(process)
await send_json_rpc(
process,
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
sessionId=session_id,
prompt=[
TextContentBlock(
type="text", text="Run sleep 3 in the current directory"
)
],
),
),
)
text_responses = await read_multiple_responses(process, max_count=4)
responses = parse_conversation(text_responses)
# Look for tool call in progress updates
in_progress_calls = [
r
for r in responses
if isinstance(r, UpdateJsonRpcNotification)
and r.params is not None
and r.params.update.sessionUpdate == "tool_call_update"
and r.params.update.status == "in_progress"
]
assert len(in_progress_calls) > 0, (
"No tool call in progress updates found for a long running command"
)
@pytest.mark.asyncio
async def test_tool_call_result_update_failure_structure(self) -> None:
custom_results = [
mock_llm_chunk(content="Hey"),
mock_llm_chunk(
tool_calls=[
ToolCall(
function=FunctionCall(
name="write_file",
arguments='{"path":"/test.txt","content":"hello, world!"'
',"overwrite":true}',
),
type="function",
index=0,
)
],
name="write_file",
finish_reason="tool_calls",
),
mock_llm_chunk(
content="The file /test.txt has not been created "
"because it's outside the project directory",
finish_reason="stop",
),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(mock_env=mock_env):
permission_request = await start_session_with_request_permission(
process, "Create a file named /test.txt"
)
assert permission_request.params is not None
selected_option_id = ToolOption.ALLOW_ONCE
await send_json_rpc(
process,
RequestPermissionJsonRpcResponse(
id=permission_request.id,
result=RequestPermissionResponse(
outcome=AllowedOutcome(
outcome="selected", optionId=selected_option_id
)
),
),
)
text_responses = await read_multiple_responses(process, max_count=7)
responses = parse_conversation(text_responses)
# Look for tool call result failure updates
failure_result = next(
(
r
for r in responses
if isinstance(r, UpdateJsonRpcNotification)
and r.params is not None
and r.params.update.sessionUpdate == "tool_call_update"
and r.params.update.status == "failed"
and r.params.update.rawOutput is not None
and r.params.update.toolCallId is not None
),
None,
)
assert failure_result is not None
class TestCancellationStructure:
@pytest.mark.skip(
reason="Proper cancellation is not implemented yet, we still need to return "
"the right end_turn and be able to cancel at any point in time "
"(and not only at tool call time)"
)
@pytest.mark.asyncio
async def test_tool_call_update_cancelled_structure(self) -> None:
custom_results = [
mock_llm_chunk(content="Hey"),
mock_llm_chunk(
tool_calls=[
ToolCall(
function=FunctionCall(
name="write_file",
arguments='{"path":"test.txt","content":"hello, world!"'
',"overwrite":false}',
),
type="function",
index=0,
)
],
name="write_file",
finish_reason="tool_calls",
),
mock_llm_chunk(
content="The file test.txt has not been created, "
"because you cancelled the permission request",
finish_reason="stop",
),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(mock_env=mock_env):
permission_request = await start_session_with_request_permission(
process, "Create a file named test.txt"
)
assert permission_request.params is not None
await send_json_rpc(
process,
RequestPermissionJsonRpcResponse(
id=permission_request.id,
result=RequestPermissionResponse(
outcome=DeniedOutcome(outcome="cancelled")
),
),
)
text_responses = await read_multiple_responses(process, max_count=5)
responses = parse_conversation(text_responses)
assert len(responses) == 2, (
"There should be only 2 responses: "
"the tool call update and the prompt end turn"
)
cancelled_tool_call = next(
(
r
for r in responses
if isinstance(r, UpdateJsonRpcNotification)
and r.method == "session/update"
and r.params is not None
and r.params.update.sessionUpdate == "tool_call_update"
and r.params.update.toolCallId
== (permission_request.params.toolCall.toolCallId)
and r.params.update.status == "failed"
),
None,
)
assert cancelled_tool_call is not None
cancelled_prompt_response = next(
(
r
for r in responses
if isinstance(r, PromptJsonRpcResponse)
and r.result is not None
and r.result.stopReason == "cancelled"
),
None,
)
assert cancelled_prompt_response is not None

536
tests/acp/test_bash.py Normal file
View file

@ -0,0 +1,536 @@
from __future__ import annotations
import asyncio
from acp.schema import TerminalOutputResponse, WaitForTerminalExitResponse
import pytest
from vibe.acp.tools.builtins.bash import AcpBashState, Bash
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.bash import BashArgs, BashResult, BashToolConfig
class MockTerminalHandle:
def __init__(
self,
terminal_id: str = "test_terminal_123",
exit_code: int | None = 0,
output: str = "test output",
wait_delay: float = 0.01,
) -> None:
self.id = terminal_id
self._exit_code = exit_code
self._output = output
self._wait_delay = wait_delay
self._killed = False
async def wait_for_exit(self) -> WaitForTerminalExitResponse:
await asyncio.sleep(self._wait_delay)
return WaitForTerminalExitResponse(exitCode=self._exit_code)
async def current_output(self) -> TerminalOutputResponse:
return TerminalOutputResponse(output=self._output, truncated=False)
async def kill(self) -> None:
self._killed = True
async def release(self) -> None:
pass
class MockConnection:
def __init__(self, terminal_handle: MockTerminalHandle | None = None) -> None:
self._terminal_handle = terminal_handle or MockTerminalHandle()
self._create_terminal_called = False
self._session_update_called = False
self._create_terminal_error: Exception | None = None
self._last_create_request = None
async def createTerminal(self, request) -> MockTerminalHandle:
self._create_terminal_called = True
self._last_create_request = request
if self._create_terminal_error:
raise self._create_terminal_error
return self._terminal_handle
async def sessionUpdate(self, notification) -> None:
self._session_update_called = True
@pytest.fixture
def mock_connection() -> MockConnection:
return MockConnection()
@pytest.fixture
def acp_bash_tool(mock_connection: MockConnection) -> Bash:
config = BashToolConfig()
# Use model_construct to bypass Pydantic validation for testing
state = AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
return Bash(config=config, state=state)
class TestAcpBashBasic:
def test_get_name(self) -> None:
assert Bash.get_name() == "bash"
def test_get_summary_simple_command(self) -> None:
args = BashArgs(command="ls")
display = Bash.get_summary(args)
assert display == "ls"
def test_get_summary_with_timeout(self) -> None:
args = BashArgs(command="ls", timeout=10)
display = Bash.get_summary(args)
assert display == "ls (timeout 10s)"
def test_parse_command_simple(self) -> None:
tool = Bash(config=BashToolConfig(), state=AcpBashState())
env, command, args = tool._parse_command("ls")
assert env == []
assert command == "ls"
assert args == []
def test_parse_command_with_args(self) -> None:
tool = Bash(config=BashToolConfig(), state=AcpBashState())
env, command, args = tool._parse_command("ls -la src")
assert env == []
assert command == "ls"
assert args == ["-la", "src"]
def test_parse_command_with_env(self) -> None:
tool = Bash(config=BashToolConfig(), state=AcpBashState())
env, command, args = tool._parse_command("NODE_ENV=test DEBUG=1 npm test")
assert len(env) == 2
assert env[0].name == "NODE_ENV"
assert env[0].value == "test"
assert env[1].name == "DEBUG"
assert env[1].value == "1"
assert command == "npm"
assert args == ["test"]
def test_parse_command_with_env_value_contains_equals(self) -> None:
tool = Bash(config=BashToolConfig(), state=AcpBashState())
env, command, args = tool._parse_command(
"PATH=/usr/bin:/usr/local/bin echo hello"
)
assert len(env) == 1
assert env[0].name == "PATH"
assert env[0].value == "/usr/bin:/usr/local/bin"
assert command == "echo"
assert args == ["hello"]
class TestAcpBashExecution:
@pytest.mark.asyncio
async def test_run_success(
self, acp_bash_tool: Bash, mock_connection: MockConnection
) -> None:
from pathlib import Path
args = BashArgs(command="echo hello")
result = await acp_bash_tool.run(args)
assert isinstance(result, BashResult)
assert result.stdout == "test output"
assert result.stderr == ""
assert result.returncode == 0
assert mock_connection._create_terminal_called
# Verify CreateTerminalRequest was created correctly
request = mock_connection._last_create_request
assert request is not None
assert request.sessionId == "test_session_123"
assert request.command == "echo"
assert request.args == ["hello"]
assert request.cwd == str(Path.cwd()) # effective_workdir defaults to cwd
@pytest.mark.asyncio
async def test_run_creates_terminal_with_env_vars(
self, mock_connection: MockConnection
) -> None:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
args = BashArgs(command="NODE_ENV=test npm run build")
await tool.run(args)
request = mock_connection._last_create_request
assert request is not None
assert len(request.env) == 1
assert request.env[0].name == "NODE_ENV"
assert request.env[0].value == "test"
assert request.command == "npm"
assert request.args == ["run", "build"]
@pytest.mark.asyncio
async def test_run_with_nonzero_exit_code(
self, mock_connection: MockConnection
) -> None:
custom_handle = MockTerminalHandle(
terminal_id="custom_terminal", exit_code=1, output="error: command failed"
)
mock_connection._terminal_handle = custom_handle
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
args = BashArgs(command="test_command")
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
assert (
str(exc_info.value)
== "Command failed: 'test_command'\nReturn code: 1\nStdout: error: command failed"
)
@pytest.mark.asyncio
async def test_run_create_terminal_failure(
self, mock_connection: MockConnection
) -> None:
mock_connection._create_terminal_error = RuntimeError("Connection failed")
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
args = BashArgs(command="test")
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
assert (
str(exc_info.value)
== "Failed to create terminal: RuntimeError('Connection failed')"
)
@pytest.mark.asyncio
async def test_run_without_connection(self) -> None:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=None, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
assert (
str(exc_info.value)
== "Connection 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) -> None:
mock_connection = MockConnection()
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id=None,
tool_call_id="test_call",
),
)
args = BashArgs(command="test")
with pytest.raises(ToolError) as exc_info:
await 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."
)
@pytest.mark.asyncio
async def test_run_with_none_exit_code(
self, mock_connection: MockConnection
) -> None:
custom_handle = MockTerminalHandle(
terminal_id="none_exit_terminal", exit_code=None, output="output"
)
mock_connection._terminal_handle = custom_handle
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
args = BashArgs(command="test_command")
result = await tool.run(args)
assert result.returncode == 0
assert result.stdout == "output"
class TestAcpBashTimeout:
@pytest.mark.asyncio
async def test_run_with_timeout_raises_error_and_kills(
self, mock_connection: MockConnection
) -> None:
custom_handle = MockTerminalHandle(
terminal_id="timeout_terminal",
output="partial output",
wait_delay=20, # Longer than the 1 second timeout
)
mock_connection._terminal_handle = custom_handle
# Use a config with different default timeout to verify args timeout overrides it
tool = Bash(
config=BashToolConfig(default_timeout=30),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
args = BashArgs(command="slow_command", timeout=1)
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"
assert custom_handle._killed
@pytest.mark.asyncio
async def test_run_timeout_handles_kill_failure(
self, mock_connection: MockConnection
) -> None:
custom_handle = MockTerminalHandle(
terminal_id="kill_failure_terminal",
wait_delay=20, # Longer than the 1 second timeout
)
mock_connection._terminal_handle = custom_handle
async def failing_kill() -> None:
raise RuntimeError("Kill failed")
custom_handle.kill = failing_kill
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
args = BashArgs(command="slow_command", timeout=1)
# Should still raise timeout error even if kill fails
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"
class TestAcpBashEmbedding:
@pytest.mark.asyncio
async def test_run_with_embedding(self, mock_connection: MockConnection) -> None:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
args = BashArgs(command="test")
await tool.run(args)
assert mock_connection._session_update_called
@pytest.mark.asyncio
async def test_run_embedding_without_tool_call_id(
self, mock_connection: MockConnection
) -> None:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id=None,
),
)
args = BashArgs(command="test")
await tool.run(args)
# Embedding should be skipped when tool_call_id is None
assert not mock_connection._session_update_called
@pytest.mark.asyncio
async def test_run_embedding_handles_exception(
self, mock_connection: MockConnection
) -> None:
# Make sessionUpdate raise an exception
async def failing_session_update(notification) -> None:
raise RuntimeError("Session update failed")
mock_connection.sessionUpdate = failing_session_update
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
args = BashArgs(command="test")
# Should not raise, embedding failure is silently ignored
result = await tool.run(args)
assert result is not None
assert result.stdout == "test output"
class TestAcpBashConfig:
@pytest.mark.asyncio
async def test_run_uses_config_default_timeout(
self, mock_connection: MockConnection
) -> None:
custom_handle = MockTerminalHandle(
terminal_id="config_timeout_terminal",
wait_delay=0.01, # Shorter than config timeout
)
mock_connection._terminal_handle = custom_handle
tool = Bash(
config=BashToolConfig(default_timeout=30),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
args = BashArgs(command="fast", timeout=None)
result = await tool.run(args)
# Should succeed with config timeout
assert result.returncode == 0
class TestAcpBashCleanup:
@pytest.mark.asyncio
async def test_run_releases_terminal_on_success(
self, mock_connection: MockConnection
) -> None:
custom_handle = MockTerminalHandle(terminal_id="cleanup_terminal")
mock_connection._terminal_handle = custom_handle
release_called = False
async def mock_release() -> None:
nonlocal release_called
release_called = True
custom_handle.release = mock_release
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
args = BashArgs(command="test")
await tool.run(args)
assert release_called
@pytest.mark.asyncio
async def test_run_releases_terminal_on_timeout(
self, mock_connection: MockConnection
) -> None:
# The handle will wait 2 seconds, but timeout is 1 second,
# so asyncio.wait_for() will raise TimeoutError
custom_handle = MockTerminalHandle(
terminal_id="timeout_cleanup_terminal",
wait_delay=2.0, # Longer than the 1 second timeout
)
mock_connection._terminal_handle = custom_handle
release_called = False
async def mock_release() -> None:
nonlocal release_called
release_called = True
custom_handle.release = mock_release
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
args = BashArgs(command="slow", timeout=1)
# Timeout raises an error, but terminal should still be released
try:
await tool.run(args)
except ToolError:
pass
assert release_called
@pytest.mark.asyncio
async def test_run_handles_release_failure(
self, mock_connection: MockConnection
) -> None:
custom_handle = MockTerminalHandle(terminal_id="release_failure_terminal")
async def failing_release() -> None:
raise RuntimeError("Release failed")
custom_handle.release = failing_release
mock_connection._terminal_handle = custom_handle
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
args = BashArgs(command="test")
# Should not raise, release failure is silently ignored
result = await tool.run(args)
assert result is not None
assert result.stdout == "test output"

184
tests/acp/test_content.py Normal file
View file

@ -0,0 +1,184 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from acp import AgentSideConnection, NewSessionRequest, PromptRequest
from acp.schema import (
EmbeddedResourceContentBlock,
ResourceContentBlock,
TextContentBlock,
TextResourceContents,
)
import pytest
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_connection import FakeAgentSideConnection
from vibe.acp.acp_agent import VibeAcpAgent
from vibe.core.agent import Agent
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
@pytest.fixture
def backend() -> FakeBackend:
backend = FakeBackend(
results=[
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Hi"),
finish_reason="end_turn",
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
)
]
)
return backend
@pytest.fixture
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
class PatchedAgent(Agent):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs, backend=backend)
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
vibe_acp_agent: VibeAcpAgent | None = None
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
nonlocal vibe_acp_agent
vibe_acp_agent = VibeAcpAgent(connection)
return vibe_acp_agent
FakeAgentSideConnection(_create_agent)
return vibe_acp_agent # pyright: ignore[reportReturnType]
class TestACPContent:
@pytest.mark.asyncio
async def test_text_content(
self, acp_agent: VibeAcpAgent, backend: FakeBackend
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
prompt_request = PromptRequest(
prompt=[TextContentBlock(type="text", text="Say hi")],
sessionId=session_response.sessionId,
)
response = await acp_agent.prompt(params=prompt_request)
assert response.stopReason == "end_turn"
user_message = next(
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
None,
)
assert user_message is not None, "User message not found in backend requests"
assert user_message.content == "Say hi"
@pytest.mark.asyncio
async def test_resource_content(
self, acp_agent: VibeAcpAgent, backend: FakeBackend
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
prompt_request = PromptRequest(
prompt=[
TextContentBlock(type="text", text="What does this file do?"),
EmbeddedResourceContentBlock(
type="resource",
resource=TextResourceContents(
uri="file:///home/my_file.py",
text="def hello():\n print('Hello, world!')",
mimeType="text/x-python",
),
),
],
sessionId=session_response.sessionId,
)
response = await acp_agent.prompt(params=prompt_request)
assert response.stopReason == "end_turn"
user_message = next(
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
None,
)
assert user_message is not None, "User message not found in backend requests"
expected_content = (
"What does this file do?"
+ "\n\npath: file:///home/my_file.py"
+ "\ncontent: def hello():\n print('Hello, world!')"
)
assert user_message.content == expected_content
@pytest.mark.asyncio
async def test_resource_link_content(
self, acp_agent: VibeAcpAgent, backend: FakeBackend
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
prompt_request = PromptRequest(
prompt=[
TextContentBlock(type="text", text="Analyze this resource"),
ResourceContentBlock(
type="resource_link",
uri="file:///home/document.pdf",
name="document.pdf",
title="Important Document",
description="A PDF document containing project specifications",
mimeType="application/pdf",
size=1024,
),
],
sessionId=session_response.sessionId,
)
response = await acp_agent.prompt(params=prompt_request)
assert response.stopReason == "end_turn"
user_message = next(
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
None,
)
assert user_message is not None, "User message not found in backend requests"
expected_content = (
"Analyze this resource"
+ "\n\nuri: file:///home/document.pdf"
+ "\nname: document.pdf"
+ "\ntitle: Important Document"
+ "\ndescription: A PDF document containing project specifications"
+ "\nmimeType: application/pdf"
+ "\nsize: 1024"
)
assert user_message.content == expected_content
@pytest.mark.asyncio
async def test_resource_link_minimal(
self, acp_agent: VibeAcpAgent, backend: FakeBackend
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
prompt_request = PromptRequest(
prompt=[
ResourceContentBlock(
type="resource_link",
uri="file:///home/minimal.txt",
name="minimal.txt",
)
],
sessionId=session_response.sessionId,
)
response = await acp_agent.prompt(params=prompt_request)
assert response.stopReason == "end_turn"
user_message = next(
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
None,
)
assert user_message is not None, "User message not found in backend requests"
expected_content = "uri: file:///home/minimal.txt\nname: minimal.txt"
assert user_message.content == expected_content

View file

@ -0,0 +1,161 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
from unittest.mock import patch
from uuid import uuid4
from acp import (
PROTOCOL_VERSION,
InitializeRequest,
NewSessionRequest,
PromptRequest,
RequestError,
)
from acp.schema import TextContentBlock
import pytest
from pytest import raises
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_connection import FakeAgentSideConnection
from vibe.acp.acp_agent import VibeAcpAgent
from vibe.core.agent import Agent
from vibe.core.config import ModelConfig, VibeConfig
from vibe.core.types import Role
@pytest.fixture
def backend() -> FakeBackend:
backend = FakeBackend()
return backend
@pytest.fixture
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
config = VibeConfig(
active_model="devstral-latest",
models=[
ModelConfig(
name="devstral-latest", provider="mistral", alias="devstral-latest"
)
],
)
class PatchedAgent(Agent):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.backend = backend
self.config = config
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
vibe_acp_agent: VibeAcpAgent | None = None
def _create_agent(connection: Any) -> VibeAcpAgent:
nonlocal vibe_acp_agent
vibe_acp_agent = VibeAcpAgent(connection)
return vibe_acp_agent
FakeAgentSideConnection(_create_agent)
return vibe_acp_agent # pyright: ignore[reportReturnType]
class TestMultiSessionCore:
@pytest.mark.asyncio
async def test_different_sessions_use_different_agents(
self, acp_agent: VibeAcpAgent
) -> None:
await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
session1_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session1 = acp_agent.sessions[session1_response.sessionId]
session2_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session2 = acp_agent.sessions[session2_response.sessionId]
assert session1.id != session2.id
# Each agent should be independent
assert session1.agent is not session2.agent
assert id(session1.agent) != id(session2.agent)
@pytest.mark.asyncio
async def test_error_on_nonexistent_session(self, acp_agent: VibeAcpAgent) -> None:
await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
fake_session_id = "fake-session-id-" + str(uuid4())
with raises(RequestError) as exc_info:
await acp_agent.prompt(
PromptRequest(
sessionId=fake_session_id,
prompt=[TextContentBlock(type="text", text="Hello, world!")],
)
)
assert isinstance(exc_info.value, RequestError)
assert str(exc_info.value) == "Invalid params"
@pytest.mark.asyncio
async def test_simultaneous_message_processing(
self, acp_agent: VibeAcpAgent, backend: FakeBackend
) -> None:
await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
session1_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session1 = acp_agent.sessions[session1_response.sessionId]
session2_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session2 = acp_agent.sessions[session2_response.sessionId]
backend._chunks = [
mock_llm_chunk(content="Response 1", finish_reason="stop"),
mock_llm_chunk(content="Response 2", finish_reason="stop"),
]
async def run_session1():
await acp_agent.prompt(
PromptRequest(
sessionId=session1.id,
prompt=[TextContentBlock(type="text", text="Prompt for session 1")],
)
)
async def run_session2():
await acp_agent.prompt(
PromptRequest(
sessionId=session2.id,
prompt=[TextContentBlock(type="text", text="Prompt for session 2")],
)
)
await asyncio.gather(run_session1(), run_session2())
user_message1 = next(
(msg for msg in session1.agent.messages if msg.role == Role.user), None
)
assert user_message1 is not None
assert user_message1.content == "Prompt for session 1"
assistant_message1 = next(
(msg for msg in session1.agent.messages if msg.role == Role.assistant), None
)
assert assistant_message1 is not None
assert assistant_message1.content == "Response 1"
user_message2 = next(
(msg for msg in session2.agent.messages if msg.role == Role.user), None
)
assert user_message2 is not None
assert user_message2.content == "Prompt for session 2"
assistant_message2 = next(
(msg for msg in session2.agent.messages if msg.role == Role.assistant), None
)
assert assistant_message2 is not None
assert assistant_message2.content == "Response 2"

View file

@ -0,0 +1,140 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from acp import AgentSideConnection, NewSessionRequest, SetSessionModelRequest
import pytest
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_connection import FakeAgentSideConnection
from vibe.acp.acp_agent import VibeAcpAgent
from vibe.acp.utils import VibeSessionMode
from vibe.core.agent import Agent
from vibe.core.config import ModelConfig, VibeConfig
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
@pytest.fixture
def backend() -> FakeBackend:
backend = FakeBackend(
results=[
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Hi"),
finish_reason="end_turn",
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
)
]
)
return backend
@pytest.fixture
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
config = VibeConfig(
active_model="devstral-latest",
models=[
ModelConfig(
name="devstral-latest", provider="mistral", alias="devstral-latest"
),
ModelConfig(
name="devstral-small", provider="mistral", alias="devstral-small"
),
],
)
class PatchedAgent(Agent):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **{**kwargs, "backend": backend})
self.config = config
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
vibe_acp_agent: VibeAcpAgent | None = None
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
nonlocal vibe_acp_agent
vibe_acp_agent = VibeAcpAgent(connection)
return vibe_acp_agent
FakeAgentSideConnection(_create_agent)
return vibe_acp_agent # pyright: ignore[reportReturnType]
class TestACPNewSession:
@pytest.mark.asyncio
async def test_new_session_response_structure(
self, acp_agent: VibeAcpAgent
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
assert session_response.sessionId is not None
acp_session = next(
(
s
for s in acp_agent.sessions.values()
if s.id == session_response.sessionId
),
None,
)
assert acp_session is not None
assert (
acp_session.agent.interaction_logger.session_id
== session_response.sessionId
)
assert session_response.sessionId == acp_session.agent.session_id
assert session_response.models is not None
assert session_response.models.currentModelId is not None
assert session_response.models.availableModels is not None
assert len(session_response.models.availableModels) == 2
assert session_response.models.currentModelId == "devstral-latest"
assert session_response.models.availableModels[0].modelId == "devstral-latest"
assert session_response.models.availableModels[0].name == "devstral-latest"
assert session_response.models.availableModels[1].modelId == "devstral-small"
assert session_response.models.availableModels[1].name == "devstral-small"
assert session_response.modes is not None
assert session_response.modes.currentModeId is not None
assert session_response.modes.availableModes is not None
assert len(session_response.modes.availableModes) == 2
assert session_response.modes.currentModeId == VibeSessionMode.APPROVAL_REQUIRED
assert (
session_response.modes.availableModes[0].id
== VibeSessionMode.APPROVAL_REQUIRED
)
assert session_response.modes.availableModes[0].name == "Approval Required"
assert (
session_response.modes.availableModes[1].id == VibeSessionMode.AUTO_APPROVE
)
assert session_response.modes.availableModes[1].name == "Auto Approve"
@pytest.mark.skip(reason="TODO: Fix this test")
@pytest.mark.asyncio
async def test_new_session_preserves_model_after_set_model(
self, acp_agent: VibeAcpAgent
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
assert session_response.models is not None
assert session_response.models.currentModelId == "devstral-latest"
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
)
assert response is not None
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
assert session_response.models is not None
assert session_response.models.currentModelId == "devstral-small"

240
tests/acp/test_read_file.py Normal file
View file

@ -0,0 +1,240 @@
from __future__ import annotations
from pathlib import Path
from acp import ReadTextFileRequest, ReadTextFileResponse
import pytest
from vibe.acp.tools.builtins.read_file import AcpReadFileState, ReadFile
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.read_file import (
ReadFileArgs,
ReadFileResult,
ReadFileToolConfig,
)
class MockConnection:
def __init__(
self,
file_content: str = "line 1\nline 2\nline 3",
read_error: Exception | None = None,
) -> None:
self._file_content = file_content
self._read_error = read_error
self._read_text_file_called = False
self._session_update_called = False
self._last_read_request: ReadTextFileRequest | None = None
async def readTextFile(self, request: ReadTextFileRequest) -> ReadTextFileResponse:
self._read_text_file_called = True
self._last_read_request = request
if self._read_error:
raise self._read_error
content = self._file_content
if request.line is not None or request.limit is not None:
lines = content.splitlines(keepends=True)
start_line = (request.line or 1) - 1 # Convert to 0-indexed
end_line = (
start_line + request.limit if request.limit is not None else len(lines)
)
lines = lines[start_line:end_line]
content = "".join(lines)
return ReadTextFileResponse(content=content)
async def sessionUpdate(self, notification) -> None:
self._session_update_called = True
@pytest.fixture
def mock_connection() -> MockConnection:
return MockConnection()
@pytest.fixture
def acp_read_file_tool(mock_connection: MockConnection, tmp_path: Path) -> ReadFile:
config = ReadFileToolConfig(workdir=tmp_path)
state = AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
return ReadFile(config=config, state=state)
class TestAcpReadFileBasic:
def test_get_name(self) -> None:
assert ReadFile.get_name() == "read_file"
class TestAcpReadFileExecution:
@pytest.mark.asyncio
async def test_run_success(
self,
acp_read_file_tool: ReadFile,
mock_connection: MockConnection,
tmp_path: Path,
) -> None:
test_file = tmp_path / "test_file.txt"
test_file.touch()
args = ReadFileArgs(path=str(test_file))
result = await acp_read_file_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 mock_connection._read_text_file_called
assert mock_connection._session_update_called
# Verify ReadTextFileRequest was created correctly
request = mock_connection._last_read_request
assert request is not None
assert request.sessionId == "test_session_123"
assert request.path == str(test_file)
assert request.line is None # offset=0 means no line specified
assert request.limit is None
@pytest.mark.asyncio
async def test_run_with_offset(
self, mock_connection: MockConnection, tmp_path: Path
) -> None:
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
state=AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
args = ReadFileArgs(path=str(test_file), offset=1)
result = await tool.run(args)
assert result.lines_read == 2
assert result.content == "line 2\nline 3"
request = mock_connection._last_read_request
assert request is not None
assert request.line == 2 # offset=1 means line 2 (1-indexed)
@pytest.mark.asyncio
async def test_run_with_limit(
self, mock_connection: MockConnection, tmp_path: Path
) -> None:
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
state=AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
args = ReadFileArgs(path=str(test_file), limit=2)
result = await tool.run(args)
assert result.lines_read == 2
assert result.content == "line 1\nline 2\n"
request = mock_connection._last_read_request
assert request is not None
assert request.limit == 2
@pytest.mark.asyncio
async def test_run_with_offset_and_limit(
self, mock_connection: MockConnection, tmp_path: Path
) -> None:
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
state=AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
args = ReadFileArgs(path=str(test_file), offset=1, limit=1)
result = await tool.run(args)
assert result.lines_read == 1
assert result.content == "line 2\n"
request = mock_connection._last_read_request
assert request is not None
assert request.line == 2
assert request.limit == 1
@pytest.mark.asyncio
async def test_run_read_error(
self, mock_connection: MockConnection, tmp_path: Path
) -> None:
mock_connection._read_error = RuntimeError("File not found")
test_file = tmp_path / "test.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
state=AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
args = ReadFileArgs(path=str(test_file))
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
assert str(exc_info.value) == f"Error reading {test_file}: File not found"
@pytest.mark.asyncio
async def test_run_without_connection(self, tmp_path: Path) -> None:
test_file = tmp_path / "test.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
state=AcpReadFileState.model_construct(
connection=None, session_id="test_session", tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file))
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
assert (
str(exc_info.value)
== "Connection 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) -> None:
test_file = tmp_path / "test.txt"
test_file.touch()
mock_connection = MockConnection()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
state=AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id=None,
tool_call_id="test_call",
),
)
args = ReadFileArgs(path=str(test_file))
with pytest.raises(ToolError) as exc_info:
await 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."
)

View file

@ -0,0 +1,339 @@
from __future__ import annotations
from pathlib import Path
from acp import ReadTextFileRequest, ReadTextFileResponse, WriteTextFileRequest
import pytest
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 (
SearchReplaceArgs,
SearchReplaceConfig,
SearchReplaceResult,
)
from vibe.core.types import ToolCallEvent, ToolResultEvent
class MockConnection:
def __init__(
self,
file_content: str = "original line 1\noriginal line 2\noriginal line 3",
read_error: Exception | None = None,
write_error: Exception | None = None,
) -> None:
self._file_content = file_content
self._read_error = read_error
self._write_error = write_error
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] = []
async def readTextFile(self, request: ReadTextFileRequest) -> ReadTextFileResponse:
self._read_text_file_called = True
self._last_read_request = request
if self._read_error:
raise self._read_error
return ReadTextFileResponse(content=self._file_content)
async def writeTextFile(self, request: WriteTextFileRequest) -> None:
self._write_text_file_called = True
self._last_write_request = request
self._write_calls.append(request)
if self._write_error:
raise self._write_error
async def sessionUpdate(self, notification) -> None:
self._session_update_called = True
@pytest.fixture
def mock_connection() -> MockConnection:
return MockConnection()
@pytest.fixture
def acp_search_replace_tool(
mock_connection: MockConnection, tmp_path: Path
) -> SearchReplace:
config = SearchReplaceConfig(workdir=tmp_path)
state = AcpSearchReplaceState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
return SearchReplace(config=config, state=state)
class TestAcpSearchReplaceBasic:
def test_get_name(self) -> None:
assert SearchReplace.get_name() == "search_replace"
class TestAcpSearchReplaceExecution:
@pytest.mark.asyncio
async def test_run_success(
self,
acp_search_replace_tool: SearchReplace,
mock_connection: MockConnection,
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 = SearchReplaceArgs(
file_path=str(test_file), content=search_replace_content
)
result = await 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
# 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 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)
assert (
write_request.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
) -> None:
config = SearchReplaceConfig(create_backup=True, workdir=tmp_path)
tool = SearchReplace(
config=config,
state=AcpSearchReplaceState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
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 tool.run(args)
assert result.blocks_applied == 1
# Should have written the main file and the backup
assert len(mock_connection._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
@pytest.mark.asyncio
async def test_run_read_error(
self, mock_connection: MockConnection, tmp_path: Path
) -> None:
mock_connection._read_error = RuntimeError("File not found")
tool = SearchReplace(
config=SearchReplaceConfig(workdir=tmp_path),
state=AcpSearchReplaceState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
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
)
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
assert (
str(exc_info.value)
== f"Unexpected error reading {test_file}: File not found"
)
@pytest.mark.asyncio
async def test_run_write_error(
self, mock_connection: MockConnection, tmp_path: Path
) -> None:
mock_connection._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
tool = SearchReplace(
config=SearchReplaceConfig(workdir=tmp_path),
state=AcpSearchReplaceState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
search_replace_content = "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE"
args = SearchReplaceArgs(
file_path=str(test_file), content=search_replace_content
)
with pytest.raises(ToolError) as exc_info:
await 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",
[
(
None,
"test_session",
"Connection not available in tool state. This tool can only be used within an ACP session.",
),
(
MockConnection(),
None,
"Session ID not available in tool state. This tool can only be used within an ACP session.",
),
],
)
async def test_run_without_required_state(
self,
tmp_path: Path,
connection: MockConnection | None,
session_id: str | None,
expected_error: str,
) -> None:
test_file = tmp_path / "test.txt"
test_file.touch()
tool = SearchReplace(
config=SearchReplaceConfig(workdir=tmp_path),
state=AcpSearchReplaceState.model_construct(
connection=connection, # type: ignore[arg-type]
session_id=session_id,
tool_call_id="test_call",
),
)
search_replace_content = "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE"
args = SearchReplaceArgs(
file_path=str(test_file), content=search_replace_content
)
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
assert str(exc_info.value) == expected_error
class TestAcpSearchReplaceSessionUpdates:
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_call_id="test_call_123",
args=SearchReplaceArgs(
file_path="/tmp/test.txt", content=search_replace_content
),
tool_class=SearchReplace,
)
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.kind == "edit"
assert update.title is not None
assert update.content is not None
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.locations is not None
assert len(update.locations) == 1
assert update.locations[0].path == "/tmp/test.txt"
def test_tool_call_session_update_invalid_args(self) -> None:
class InvalidArgs:
pass
event = ToolCallEvent.model_construct(
tool_name="search_replace",
tool_call_id="test_call_123",
args=InvalidArgs(), # type: ignore[arg-type]
tool_class=SearchReplace,
)
update = SearchReplace.tool_call_session_update(event)
assert update is None
def test_tool_result_session_update(self) -> None:
search_replace_content = (
"<<<<<<< SEARCH\nold text\n=======\nnew text\n>>>>>>> REPLACE"
)
result = SearchReplaceResult(
file="/tmp/test.txt",
blocks_applied=1,
lines_changed=1,
content=search_replace_content,
warnings=[],
)
event = ToolResultEvent(
tool_name="search_replace",
tool_call_id="test_call_123",
result=result,
tool_class=SearchReplace,
)
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.status == "completed"
assert update.content is not None
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.locations is not None
assert len(update.locations) == 1
assert update.locations[0].path == "/tmp/test.txt"
def test_tool_result_session_update_invalid_result(self) -> None:
class InvalidResult:
pass
event = ToolResultEvent.model_construct(
tool_name="search_replace",
tool_call_id="test_call_123",
result=InvalidResult(), # type: ignore[arg-type]
tool_class=SearchReplace,
)
update = SearchReplace.tool_result_session_update(event)
assert update is None

165
tests/acp/test_set_mode.py Normal file
View file

@ -0,0 +1,165 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from acp import AgentSideConnection, NewSessionRequest, SetSessionModeRequest
import pytest
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_connection import FakeAgentSideConnection
from vibe.acp.acp_agent import VibeAcpAgent
from vibe.acp.utils import VibeSessionMode
from vibe.core.agent import Agent
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
@pytest.fixture
def backend() -> FakeBackend:
backend = FakeBackend(
results=[
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Hi"),
finish_reason="end_turn",
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
)
]
)
return backend
@pytest.fixture
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
class PatchedAgent(Agent):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs, backend=backend)
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
vibe_acp_agent: VibeAcpAgent | None = None
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
nonlocal vibe_acp_agent
vibe_acp_agent = VibeAcpAgent(connection)
return vibe_acp_agent
FakeAgentSideConnection(_create_agent)
return vibe_acp_agent # pyright: ignore[reportReturnType]
class TestACPSetMode:
@pytest.mark.asyncio
async def test_set_mode_to_approval_required(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
acp_session.agent.auto_approve = True
acp_session.mode_id = VibeSessionMode.AUTO_APPROVE
response = await acp_agent.setSessionMode(
SetSessionModeRequest(
sessionId=session_id, modeId=VibeSessionMode.APPROVAL_REQUIRED
)
)
assert response is not None
assert acp_session.mode_id == VibeSessionMode.APPROVAL_REQUIRED
assert acp_session.agent.auto_approve is False
@pytest.mark.asyncio
async def test_set_mode_to_AUTO_APPROVE(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
assert acp_session.mode_id == VibeSessionMode.APPROVAL_REQUIRED
assert acp_session.agent.auto_approve is False
response = await acp_agent.setSessionMode(
SetSessionModeRequest(
sessionId=session_id, modeId=VibeSessionMode.AUTO_APPROVE
)
)
assert response is not None
assert acp_session.mode_id == VibeSessionMode.AUTO_APPROVE
assert acp_session.agent.auto_approve is True
@pytest.mark.asyncio
async def test_set_mode_invalid_mode_returns_none(
self, acp_agent: VibeAcpAgent
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_mode_id = acp_session.mode_id
initial_auto_approve = acp_session.agent.auto_approve
response = await acp_agent.setSessionMode(
SetSessionModeRequest(sessionId=session_id, modeId="invalid-mode")
)
assert response is None
assert acp_session.mode_id == initial_mode_id
assert acp_session.agent.auto_approve == initial_auto_approve
@pytest.mark.asyncio
async def test_set_mode_to_same_mode(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_mode_id = VibeSessionMode.APPROVAL_REQUIRED
assert acp_session.mode_id == initial_mode_id
response = await acp_agent.setSessionMode(
SetSessionModeRequest(sessionId=session_id, modeId=initial_mode_id)
)
assert response is not None
assert acp_session.mode_id == initial_mode_id
assert acp_session.agent.auto_approve is False
@pytest.mark.asyncio
async def test_set_mode_with_empty_string(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_mode_id = acp_session.mode_id
initial_auto_approve = acp_session.agent.auto_approve
response = await acp_agent.setSessionMode(
SetSessionModeRequest(sessionId=session_id, modeId="")
)
assert response is None
assert acp_session.mode_id == initial_mode_id
assert acp_session.agent.auto_approve == initial_auto_approve

308
tests/acp/test_set_model.py Normal file
View file

@ -0,0 +1,308 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from acp import AgentSideConnection, NewSessionRequest, SetSessionModelRequest
import pytest
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_connection import FakeAgentSideConnection
from vibe.acp.acp_agent import VibeAcpAgent
from vibe.core.agent import Agent
from vibe.core.config import ModelConfig, VibeConfig
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
@pytest.fixture
def backend() -> FakeBackend:
backend = FakeBackend(
results=[
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Hi"),
finish_reason="end_turn",
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
)
]
)
return backend
@pytest.fixture
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
config = VibeConfig(
active_model="devstral-latest",
models=[
ModelConfig(
name="devstral-latest",
provider="mistral",
alias="devstral-latest",
input_price=0.4,
output_price=2.0,
),
ModelConfig(
name="devstral-small",
provider="mistral",
alias="devstral-small",
input_price=0.1,
output_price=0.3,
),
],
)
class PatchedAgent(Agent):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **{**kwargs, "backend": backend})
self.config = config
try:
active_model = config.get_active_model()
self.stats.input_price_per_million = active_model.input_price
self.stats.output_price_per_million = active_model.output_price
except ValueError:
pass
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
vibe_acp_agent: VibeAcpAgent | None = None
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
nonlocal vibe_acp_agent
vibe_acp_agent = VibeAcpAgent(connection)
return vibe_acp_agent
FakeAgentSideConnection(_create_agent)
return vibe_acp_agent # pyright: ignore[reportReturnType]
class TestACPSetModel:
@pytest.mark.asyncio
async def test_set_model_success(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
assert acp_session.agent.config.active_model == "devstral-latest"
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
)
assert response is not None
assert acp_session.agent.config.active_model == "devstral-small"
@pytest.mark.asyncio
async def test_set_model_invalid_model_returns_none(
self, acp_agent: VibeAcpAgent
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_model = acp_session.agent.config.active_model
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="non-existent-model")
)
assert response is None
assert acp_session.agent.config.active_model == initial_model
@pytest.mark.asyncio
async def test_set_model_to_same_model(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
)
initial_model = "devstral-latest"
assert acp_session is not None
assert acp_session.agent.config.active_model == initial_model
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId=initial_model)
)
assert response is not None
assert acp_session.agent.config.active_model == initial_model
@pytest.mark.asyncio
async def test_set_model_saves_to_config(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
with patch("vibe.acp.acp_agent.VibeConfig.save_updates") as mock_save:
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
)
assert response is not None
mock_save.assert_called_once_with({"active_model": "devstral-small"})
@pytest.mark.asyncio
async def test_set_model_does_not_save_on_invalid_model(
self, acp_agent: VibeAcpAgent
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
with patch("vibe.acp.acp_agent.VibeConfig.save_updates") as mock_save:
response = await acp_agent.setSessionModel(
SetSessionModelRequest(
sessionId=session_id, modelId="non-existent-model"
)
)
assert response is None
mock_save.assert_not_called()
@pytest.mark.asyncio
async def test_set_model_with_empty_string(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_model = acp_session.agent.config.active_model
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="")
)
assert response is None
assert acp_session.agent.config.active_model == initial_model
@pytest.mark.asyncio
async def test_set_model_updates_active_model(
self, acp_agent: VibeAcpAgent
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
assert acp_session.agent.config.get_active_model().alias == "devstral-latest"
await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
)
assert acp_session.agent.config.get_active_model().alias == "devstral-small"
@pytest.mark.asyncio
async def test_set_model_calls_reload_with_initial_messages(
self, acp_agent: VibeAcpAgent
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
with patch.object(
acp_session.agent, "reload_with_initial_messages"
) as mock_reload:
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
)
assert response is not None
mock_reload.assert_called_once()
call_args = mock_reload.call_args
assert call_args.kwargs["config"] is not None
assert call_args.kwargs["config"].active_model == "devstral-small"
@pytest.mark.asyncio
async def test_set_model_preserves_conversation_history(
self, acp_agent: VibeAcpAgent
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
user_msg = LLMMessage(role=Role.user, content="Hello")
assistant_msg = LLMMessage(role=Role.assistant, content="Hi there!")
acp_session.agent.messages.append(user_msg)
acp_session.agent.messages.append(assistant_msg)
assert len(acp_session.agent.messages) == 3
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
)
assert response is not None
assert len(acp_session.agent.messages) == 3
assert acp_session.agent.messages[0].role == Role.system
assert acp_session.agent.messages[1].content == "Hello"
assert acp_session.agent.messages[2].content == "Hi there!"
@pytest.mark.asyncio
async def test_set_model_resets_stats_with_new_model_pricing(
self, acp_agent: VibeAcpAgent
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_model = acp_session.agent.config.get_active_model()
initial_input_price = initial_model.input_price
initial_output_price = initial_model.output_price
initial_stats_input = acp_session.agent.stats.input_price_per_million
initial_stats_output = acp_session.agent.stats.output_price_per_million
assert acp_session.agent.stats.input_price_per_million == initial_input_price
assert acp_session.agent.stats.output_price_per_million == initial_output_price
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
)
assert response is not None
new_model = acp_session.agent.config.get_active_model()
new_input_price = new_model.input_price
new_output_price = new_model.output_price
assert new_input_price != initial_input_price
assert new_output_price != initial_output_price
assert acp_session.agent.stats.input_price_per_million == new_input_price
assert acp_session.agent.stats.output_price_per_million == new_output_price
assert acp_session.agent.stats.input_price_per_million != initial_stats_input
assert acp_session.agent.stats.output_price_per_million != initial_stats_output

View file

@ -0,0 +1,269 @@
from __future__ import annotations
from pathlib import Path
from acp import WriteTextFileRequest
import pytest
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 MockConnection:
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_request: WriteTextFileRequest | None = None
async def writeTextFile(self, request: WriteTextFileRequest) -> None:
self._write_text_file_called = True
self._last_write_request = request
if self._write_error:
raise self._write_error
async def sessionUpdate(self, notification) -> None:
self._session_update_called = True
@pytest.fixture
def mock_connection() -> MockConnection:
return MockConnection()
@pytest.fixture
def acp_write_file_tool(mock_connection: MockConnection, tmp_path: Path) -> WriteFile:
config = WriteFileConfig(workdir=tmp_path)
state = AcpWriteFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
return WriteFile(config=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_connection: MockConnection,
tmp_path: Path,
) -> None:
test_file = tmp_path / "test_file.txt"
args = WriteFileArgs(path=str(test_file), content="Hello, world!")
result = await 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 result.file_existed is False
assert mock_connection._write_text_file_called
assert mock_connection._session_update_called
# Verify WriteTextFileRequest was created correctly
request = mock_connection._last_write_request
assert request is not None
assert request.sessionId == "test_session_123"
assert request.path == str(test_file)
assert request.content == "Hello, world!"
@pytest.mark.asyncio
async def test_run_success_overwrite(
self, mock_connection: MockConnection, tmp_path: Path
) -> None:
tool = WriteFile(
config=WriteFileConfig(workdir=tmp_path),
state=AcpWriteFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
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 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_connection._write_text_file_called
assert mock_connection._session_update_called
# Verify WriteTextFileRequest was created correctly
request = mock_connection._last_write_request
assert request is not None
assert request.sessionId == "test_session"
assert request.path == str(test_file)
assert request.content == "New content"
@pytest.mark.asyncio
async def test_run_write_error(
self, mock_connection: MockConnection, tmp_path: Path
) -> None:
mock_connection._write_error = RuntimeError("Permission denied")
tool = WriteFile(
config=WriteFileConfig(workdir=tmp_path),
state=AcpWriteFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
),
)
test_file = tmp_path / "test.txt"
args = WriteFileArgs(path=str(test_file), content="test")
with pytest.raises(ToolError) as exc_info:
await 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) -> None:
tool = WriteFile(
config=WriteFileConfig(workdir=tmp_path),
state=AcpWriteFileState.model_construct(
connection=None, session_id="test_session", tool_call_id="test_call"
),
)
args = WriteFileArgs(path=str(tmp_path / "test.txt"), content="test")
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
assert (
str(exc_info.value)
== "Connection 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) -> None:
mock_connection = MockConnection()
tool = WriteFile(
config=WriteFileConfig(workdir=tmp_path),
state=AcpWriteFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id=None,
tool_call_id="test_call",
),
)
args = WriteFileArgs(path=str(tmp_path / "test.txt"), content="test")
with pytest.raises(ToolError) as exc_info:
await 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.sessionUpdate == "tool_call"
assert update.toolCallId == "test_call_123"
assert update.kind == "edit"
assert update.title is not None
assert update.content is not None
assert len(update.content) == 1
assert update.content[0].type == "diff"
assert update.content[0].path == "/tmp/test.txt"
assert update.content[0].oldText is None
assert update.content[0].newText == "Hello"
assert update.locations is not None
assert len(update.locations) == 1
assert update.locations[0].path == "/tmp/test.txt"
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 None
def test_tool_result_session_update(self) -> None:
result = WriteFileResult(
path="/tmp/test.txt", content="Hello", bytes_written=5, file_existed=False
)
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.sessionUpdate == "tool_call_update"
assert update.toolCallId == "test_call_123"
assert update.status == "completed"
assert update.content is not None
assert len(update.content) == 1
assert update.content[0].type == "diff"
assert update.content[0].path == "/tmp/test.txt"
assert update.content[0].oldText is None
assert update.content[0].newText == "Hello"
assert update.locations is not None
assert len(update.locations) == 1
assert update.locations[0].path == "/tmp/test.txt"
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 None