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

42
tests/acp/conftest.py Normal file
View file

@ -0,0 +1,42 @@
from __future__ import annotations
from unittest.mock import patch
import pytest
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_client import FakeClient
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agent_loop import AgentLoop
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
@pytest.fixture
def backend() -> FakeBackend:
backend = FakeBackend(
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Hi"),
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
)
)
return backend
def _create_acp_agent() -> VibeAcpAgentLoop:
vibe_acp_agent = VibeAcpAgentLoop()
client = FakeClient()
vibe_acp_agent.on_connect(client)
client.on_connect(vibe_acp_agent)
return vibe_acp_agent # pyright: ignore[reportReturnType]
@pytest.fixture
def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop:
class PatchedAgent(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs, backend=backend)
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgent).start()
return _create_acp_agent()

View file

@ -189,7 +189,7 @@ class WriteTextFileJsonRpcResponse(JsonRpcResponse):
result: None = None
async def get_acp_agent_process(
async def get_acp_agent_loop_process(
mock_env: dict[str, str], vibe_home: Path
) -> AsyncGenerator[asyncio.subprocess.Process]:
current_env = os.environ.copy()
@ -358,43 +358,43 @@ def parse_conversation(message_texts: list[str]) -> list[JsonRpcMessage]:
return parsed_messages
async def initialize_session(acp_agent_process: asyncio.subprocess.Process) -> str:
async def initialize_session(acp_agent_loop_process: asyncio.subprocess.Process) -> str:
await send_json_rpc(
acp_agent_process,
InitializeJsonRpcRequest(id=1, params=InitializeRequest(protocolVersion=1)),
acp_agent_loop_process,
InitializeJsonRpcRequest(id=1, params=InitializeRequest(protocol_version=1)),
)
initialize_response = await read_response_for_id(
acp_agent_process, expected_id=1, timeout=5.0
acp_agent_loop_process, expected_id=1, timeout=5.0
)
assert initialize_response is not None
await send_json_rpc(
acp_agent_process,
acp_agent_loop_process,
NewSessionJsonRpcRequest(
id=2, params=NewSessionRequest(cwd=str(PLAYGROUND_DIR), mcpServers=[])
id=2, params=NewSessionRequest(cwd=str(PLAYGROUND_DIR), mcp_servers=[])
),
)
session_response = await read_response_for_id(acp_agent_process, expected_id=2)
session_response = await read_response_for_id(acp_agent_loop_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
return session_response_obj.result.session_id
class TestSessionManagement:
@pytest.mark.asyncio
async def test_multiple_sessions_unique_ids(self, vibe_home_dir: Path) -> None:
mock_env = get_mocking_env(mock_chunks=[mock_llm_chunk() for _ in range(3)])
async for process in get_acp_agent_process(
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_dir
):
await send_json_rpc(
process,
InitializeJsonRpcRequest(
id=1, params=InitializeRequest(protocolVersion=1)
id=1, params=InitializeRequest(protocol_version=1)
),
)
await read_response_for_id(process, expected_id=1, timeout=5.0)
@ -406,7 +406,7 @@ class TestSessionManagement:
NewSessionJsonRpcRequest(
id=i + 2,
params=NewSessionRequest(
cwd=str(PLAYGROUND_DIR), mcpServers=[]
cwd=str(PLAYGROUND_DIR), mcp_servers=[]
),
),
)
@ -418,16 +418,18 @@ class TestSessionManagement:
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)
session_ids.append(response.result.session_id)
assert len(set(session_ids)) == 3
class TestSessionUpdates:
@pytest.mark.asyncio
async def test_agent_message_chunk_structure(self, vibe_home_dir: Path) -> None:
async def test_agent_loop_message_chunk_structure(
self, vibe_home_dir: Path
) -> None:
mock_env = get_mocking_env([mock_llm_chunk(content="Hi")])
async for process in get_acp_agent_process(
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_dir
):
# Check stderr for error details if process failed
@ -444,11 +446,19 @@ class TestSessionUpdates:
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
sessionId=session_id,
session_id=session_id,
prompt=[TextContentBlock(type="text", text="Just say hi")],
),
),
)
user_response_text = await read_response(process)
assert user_response_text is not None
user_response = UpdateJsonRpcNotification.model_validate(
json.loads(user_response_text)
)
assert user_response.params is not None
assert user_response.params.update.session_update == "user_message_chunk"
text_response = await read_response(process)
assert text_response is not None
response = UpdateJsonRpcNotification.model_validate(
@ -456,8 +466,9 @@ class TestSessionUpdates:
)
assert response.params is not None
assert response.params.update.sessionUpdate == "agent_message_chunk"
assert response.params.update.session_update == "agent_message_chunk"
assert response.params.update.content is not None
assert isinstance(response.params.update.content, TextContentBlock)
assert response.params.update.content.type == "text"
assert response.params.update.content.text is not None
assert response.params.update.content.text == "Hi"
@ -478,7 +489,7 @@ class TestSessionUpdates:
),
mock_llm_chunk(content="The files containing the pattern 'auth' are ..."),
])
async for process in get_acp_agent_process(
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_dir
):
session_id = await initialize_session(process)
@ -488,7 +499,7 @@ class TestSessionUpdates:
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
sessionId=session_id,
session_id=session_id,
prompt=[
TextContentBlock(
type="text",
@ -511,7 +522,7 @@ class TestSessionUpdates:
for r in responses
if isinstance(r, UpdateJsonRpcNotification)
and r.params is not None
and r.params.update.sessionUpdate == "tool_call"
and r.params.update.session_update == "tool_call"
),
None,
)
@ -519,11 +530,11 @@ class TestSessionUpdates:
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.session_update == "tool_call"
assert tool_call.params.update.kind == "search"
assert tool_call.params.update.title == "grep: 'auth'"
assert tool_call.params.update.title == "Grepping 'auth'"
assert (
tool_call.params.update.rawInput
tool_call.params.update.raw_input
== '{"pattern":"auth","path":".","max_matches":null,"use_default_ignore":true}'
)
@ -537,7 +548,7 @@ async def start_session_with_request_permission(
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
sessionId=session_id,
session_id=session_id,
prompt=[TextContentBlock(type="text", text=prompt)],
),
),
@ -575,7 +586,7 @@ class TestToolCallStructure:
)
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(
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)
@ -584,7 +595,7 @@ class TestToolCallStructure:
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
sessionId=session_id,
session_id=session_id,
prompt=[
TextContentBlock(
type="text",
@ -611,8 +622,8 @@ class TestToolCallStructure:
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
assert first_request.params.tool_call is not None
assert first_request.params.tool_call.tool_call_id is not None
@pytest.mark.asyncio
async def test_tool_call_update_approved_structure(
@ -635,7 +646,7 @@ class TestToolCallStructure:
mock_llm_chunk(content="The file test.txt has been created"),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
permission_request = await start_session_with_request_permission(
@ -649,7 +660,7 @@ class TestToolCallStructure:
id=permission_request.id,
result=RequestPermissionResponse(
outcome=AllowedOutcome(
outcome="selected", optionId=selected_option_id
outcome="selected", option_id=selected_option_id
)
),
),
@ -665,9 +676,9 @@ class TestToolCallStructure:
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.session_update == "tool_call_update"
and r.params.update.tool_call_id
== (permission_request.params.tool_call.tool_call_id)
and r.params.update.status == "completed"
),
None,
@ -697,7 +708,7 @@ class TestToolCallStructure:
),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
permission_request = await start_session_with_request_permission(
@ -712,7 +723,7 @@ class TestToolCallStructure:
id=permission_request.id,
result=RequestPermissionResponse(
outcome=AllowedOutcome(
outcome="selected", optionId=selected_option_id
outcome="selected", option_id=selected_option_id
)
),
),
@ -727,9 +738,9 @@ class TestToolCallStructure:
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.session_update == "tool_call_update"
and r.params.update.tool_call_id
== (permission_request.params.tool_call.tool_call_id)
and r.params.update.status == "failed"
),
None,
@ -758,7 +769,7 @@ class TestToolCallStructure:
mock_llm_chunk(content="The command sleep 3 has been run"),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(
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)
@ -767,7 +778,7 @@ class TestToolCallStructure:
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
sessionId=session_id,
session_id=session_id,
prompt=[
TextContentBlock(
type="text",
@ -786,7 +797,7 @@ class TestToolCallStructure:
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.session_update == "tool_call_update"
and r.params.update.status == "in_progress"
]
@ -817,7 +828,7 @@ class TestToolCallStructure:
),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
permission_request = await start_session_with_request_permission(
@ -832,7 +843,7 @@ class TestToolCallStructure:
id=permission_request.id,
result=RequestPermissionResponse(
outcome=AllowedOutcome(
outcome="selected", optionId=selected_option_id
outcome="selected", option_id=selected_option_id
)
),
),
@ -847,10 +858,10 @@ class TestToolCallStructure:
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.session_update == "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
and r.params.update.raw_output is not None
and r.params.update.tool_call_id is not None
),
None,
)
@ -888,7 +899,7 @@ class TestCancellationStructure:
),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_dir
):
permission_request = await start_session_with_request_permission(
@ -920,9 +931,9 @@ class TestCancellationStructure:
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.session_update == "tool_call_update"
and r.params.update.tool_call_id
== (permission_request.params.tool_call.tool_call_id)
and r.params.update.status == "failed"
),
None,
@ -935,7 +946,7 @@ class TestCancellationStructure:
for r in responses
if isinstance(r, PromptJsonRpcResponse)
and r.result is not None
and r.result.stopReason == "cancelled"
and r.result.stop_reason == "cancelled"
),
None,
)

View file

@ -2,9 +2,10 @@ from __future__ import annotations
import asyncio
from acp.schema import TerminalOutputResponse, WaitForTerminalExitResponse
from acp.schema import EnvVariable, TerminalOutputResponse, WaitForTerminalExitResponse
import pytest
from tests.mock.utils import collect_result
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
@ -26,7 +27,7 @@ class MockTerminalHandle:
async def wait_for_exit(self) -> WaitForTerminalExitResponse:
await asyncio.sleep(self._wait_delay)
return WaitForTerminalExitResponse(exitCode=self._exit_code)
return WaitForTerminalExitResponse(exit_code=self._exit_code)
async def current_output(self) -> TerminalOutputResponse:
return TerminalOutputResponse(output=self._output, truncated=False)
@ -38,36 +39,72 @@ class MockTerminalHandle:
pass
class MockConnection:
class MockClient:
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
self._last_create_params: dict[
str, str | list[str] | list[EnvVariable] | int | None
] = {}
async def createTerminal(self, request) -> MockTerminalHandle:
async def create_terminal(
self,
command: str,
session_id: str,
args: list[str] | None = None,
cwd: str | None = None,
env: list | None = None,
output_byte_limit: int | None = None,
**kwargs,
) -> MockTerminalHandle:
self._create_terminal_called = True
self._last_create_request = request
self._last_create_params = {
"command": command,
"session_id": session_id,
"args": args,
"cwd": cwd,
"env": env,
"output_byte_limit": output_byte_limit,
}
if self._create_terminal_error:
raise self._create_terminal_error
return self._terminal_handle
async def sessionUpdate(self, notification) -> None:
async def terminal_output(
self, session_id: str, terminal_id: str, **kwargs
) -> TerminalOutputResponse:
return await self._terminal_handle.current_output()
async def wait_for_terminal_exit(
self, session_id: str, terminal_id: str, **kwargs
) -> WaitForTerminalExitResponse:
return await self._terminal_handle.wait_for_exit()
async def release_terminal(
self, session_id: str, terminal_id: str, **kwargs
) -> None:
await self._terminal_handle.release()
async def kill_terminal(self, session_id: str, terminal_id: str, **kwargs) -> None:
await self._terminal_handle.kill()
async def session_update(self, session_id: str, update, **kwargs) -> None:
self._session_update_called = True
@pytest.fixture
def mock_connection() -> MockConnection:
return MockConnection()
def mock_client() -> MockClient:
return MockClient()
@pytest.fixture
def acp_bash_tool(mock_connection: MockConnection) -> Bash:
def acp_bash_tool(mock_client: MockClient) -> Bash:
config = BashToolConfig()
# Use model_construct to bypass Pydantic validation for testing
state = AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
client=mock_client,
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
@ -128,72 +165,69 @@ class TestAcpBashBasic:
class TestAcpBashExecution:
@pytest.mark.asyncio
async def test_run_success(
self, acp_bash_tool: Bash, mock_connection: MockConnection
self, acp_bash_tool: Bash, mock_client: MockClient
) -> None:
from pathlib import Path
args = BashArgs(command="echo hello")
result = await acp_bash_tool.run(args)
result = await collect_result(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
assert mock_client._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
# Verify create_terminal was called correctly
params = mock_client._last_create_params
assert params["session_id"] == "test_session_123"
assert params["command"] == "echo"
assert params["args"] == ["hello"]
assert params["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
self, mock_client: MockClient
) -> 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",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="NODE_ENV=test npm run build")
await tool.run(args)
await collect_result(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"]
params = mock_client._last_create_params
env = params["env"]
assert env is not None
assert (
isinstance(env, list) and len(env) > 0 and isinstance(env[0], EnvVariable)
)
assert len(env) == 1
assert env[0].name == "NODE_ENV"
assert env[0].value == "test"
assert params["command"] == "npm"
assert params["args"] == ["run", "build"]
@pytest.mark.asyncio
async def test_run_with_nonzero_exit_code(
self, mock_connection: MockConnection
) -> None:
async def test_run_with_nonzero_exit_code(self, mock_client: MockClient) -> None:
custom_handle = MockTerminalHandle(
terminal_id="custom_terminal", exit_code=1, output="error: command failed"
)
mock_connection._terminal_handle = custom_handle
mock_client._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",
client=mock_client, 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)
await collect_result(tool.run(args))
assert (
str(exc_info.value)
@ -201,23 +235,19 @@ class TestAcpBashExecution:
)
@pytest.mark.asyncio
async def test_run_create_terminal_failure(
self, mock_connection: MockConnection
) -> None:
mock_connection._create_terminal_error = RuntimeError("Connection failed")
async def test_run_create_terminal_failure(self, mock_client: MockClient) -> None:
mock_client._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",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert (
str(exc_info.value)
@ -225,38 +255,36 @@ class TestAcpBashExecution:
)
@pytest.mark.asyncio
async def test_run_without_connection(self) -> None:
async def test_run_without_client(self) -> None:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=None, session_id="test_session", tool_call_id="test_call"
client=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)
await collect_result(tool.run(args))
assert (
str(exc_info.value)
== "Connection not available in tool state. This tool can only be used within an ACP session."
== "Client not available in tool state. This tool can only be used within an ACP session."
)
@pytest.mark.asyncio
async def test_run_without_session_id(self) -> None:
mock_connection = MockConnection()
mock_client = MockClient()
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id=None,
tool_call_id="test_call",
client=mock_client, session_id=None, tool_call_id="test_call"
),
)
args = BashArgs(command="test")
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert (
str(exc_info.value)
@ -264,25 +292,21 @@ class TestAcpBashExecution:
)
@pytest.mark.asyncio
async def test_run_with_none_exit_code(
self, mock_connection: MockConnection
) -> None:
async def test_run_with_none_exit_code(self, mock_client: MockClient) -> None:
custom_handle = MockTerminalHandle(
terminal_id="none_exit_terminal", exit_code=None, output="output"
)
mock_connection._terminal_handle = custom_handle
mock_client._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",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test_command")
result = await tool.run(args)
result = await collect_result(tool.run(args))
assert result.returncode == 0
assert result.stdout == "output"
@ -291,41 +315,39 @@ class TestAcpBashExecution:
class TestAcpBashTimeout:
@pytest.mark.asyncio
async def test_run_with_timeout_raises_error_and_kills(
self, mock_connection: MockConnection
self, mock_client: MockClient
) -> 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
mock_client._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",
client=mock_client, 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)
await collect_result(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
self, mock_client: MockClient
) -> None:
custom_handle = MockTerminalHandle(
terminal_id="kill_failure_terminal",
wait_delay=20, # Longer than the 1 second timeout
)
mock_connection._terminal_handle = custom_handle
mock_client._terminal_handle = custom_handle
async def failing_kill() -> None:
raise RuntimeError("Kill failed")
@ -335,78 +357,70 @@ class TestAcpBashTimeout:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
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)
await collect_result(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:
async def test_run_with_embedding(self, mock_client: MockClient) -> 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",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
await tool.run(args)
await collect_result(tool.run(args))
assert mock_connection._session_update_called
assert mock_client._session_update_called
@pytest.mark.asyncio
async def test_run_embedding_without_tool_call_id(
self, mock_connection: MockConnection
self, mock_client: MockClient
) -> None:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id=None,
client=mock_client, session_id="test_session", tool_call_id=None
),
)
args = BashArgs(command="test")
await tool.run(args)
await collect_result(tool.run(args))
# Embedding should be skipped when tool_call_id is None
assert not mock_connection._session_update_called
assert not mock_client._session_update_called
@pytest.mark.asyncio
async def test_run_embedding_handles_exception(
self, mock_connection: MockConnection
self, mock_client: MockClient
) -> None:
# Make sessionUpdate raise an exception
async def failing_session_update(notification) -> None:
# Make session_update raise an exception
async def failing_session_update(session_id: str, update, **kwargs) -> None:
raise RuntimeError("Session update failed")
mock_connection.sessionUpdate = failing_session_update
mock_client.session_update = 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",
client=mock_client, 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)
result = await collect_result(tool.run(args))
assert result is not None
assert result.stdout == "test output"
@ -415,25 +429,23 @@ class TestAcpBashEmbedding:
class TestAcpBashConfig:
@pytest.mark.asyncio
async def test_run_uses_config_default_timeout(
self, mock_connection: MockConnection
self, mock_client: MockClient
) -> None:
custom_handle = MockTerminalHandle(
terminal_id="config_timeout_terminal",
wait_delay=0.01, # Shorter than config timeout
)
mock_connection._terminal_handle = custom_handle
mock_client._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",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="fast", timeout=None)
result = await tool.run(args)
result = await collect_result(tool.run(args))
# Should succeed with config timeout
assert result.returncode == 0
@ -442,10 +454,10 @@ class TestAcpBashConfig:
class TestAcpBashCleanup:
@pytest.mark.asyncio
async def test_run_releases_terminal_on_success(
self, mock_connection: MockConnection
self, mock_client: MockClient
) -> None:
custom_handle = MockTerminalHandle(terminal_id="cleanup_terminal")
mock_connection._terminal_handle = custom_handle
mock_client._terminal_handle = custom_handle
release_called = False
@ -458,20 +470,18 @@ class TestAcpBashCleanup:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
await tool.run(args)
await collect_result(tool.run(args))
assert release_called
@pytest.mark.asyncio
async def test_run_releases_terminal_on_timeout(
self, mock_connection: MockConnection
self, mock_client: MockClient
) -> None:
# The handle will wait 2 seconds, but timeout is 1 second,
# so asyncio.wait_for() will raise TimeoutError
@ -479,7 +489,7 @@ class TestAcpBashCleanup:
terminal_id="timeout_cleanup_terminal",
wait_delay=2.0, # Longer than the 1 second timeout
)
mock_connection._terminal_handle = custom_handle
mock_client._terminal_handle = custom_handle
release_called = False
@ -492,45 +502,39 @@ class TestAcpBashCleanup:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="slow", timeout=1)
# Timeout raises an error, but terminal should still be released
try:
await tool.run(args)
await collect_result(tool.run(args))
except ToolError:
pass
assert release_called
@pytest.mark.asyncio
async def test_run_handles_release_failure(
self, mock_connection: MockConnection
) -> None:
async def test_run_handles_release_failure(self, mock_client: MockClient) -> 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
mock_client._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",
client=mock_client, 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)
result = await collect_result(tool.run(args))
assert result is not None
assert result.stdout == "test output"

View file

@ -0,0 +1,83 @@
from __future__ import annotations
from pathlib import Path
from typing import cast
from unittest.mock import patch
from acp.schema import TextContentBlock, ToolCallProgress, ToolCallStart
import pytest
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_client import FakeClient
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
@pytest.fixture
def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop:
class PatchedAgent(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
# Force our config with auto_compact_threshold=1
kwargs["config"] = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
auto_compact_threshold=1,
)
super().__init__(*args, **kwargs, backend=backend)
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgent).start()
vibe_acp_agent = VibeAcpAgentLoop()
client = FakeClient()
vibe_acp_agent.on_connect(client)
client.on_connect(vibe_acp_agent)
return vibe_acp_agent
class TestCompactEventHandling:
@pytest.mark.asyncio
async def test_prompt_handles_compact_events(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
"""Verify prompt() sends tool_call session updates for compact events."""
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session = acp_agent_loop.sessions[session_response.session_id]
session.agent_loop.stats.context_tokens = 2
await acp_agent_loop.prompt(
prompt=[TextContentBlock(type="text", text="Hello")],
session_id=session_response.session_id,
)
mock_client = cast(FakeClient, acp_agent_loop.client)
updates = [n.update for n in mock_client._session_updates]
compact_start = next(
(
u
for u in updates
if isinstance(u, ToolCallStart)
and u.title.startswith("Compacting conversation history")
),
None,
)
assert compact_start is not None
assert compact_start.session_update == "tool_call"
assert compact_start.kind == "other"
assert compact_start.status == "in_progress"
compact_end = next(
(
u
for u in updates
if isinstance(u, ToolCallProgress)
and u.tool_call_id == compact_start.tool_call_id
),
None,
)
assert compact_end is not None
assert compact_end.session_update == "tool_call_update"
assert compact_end.status == "completed"
assert compact_start.tool_call_id == compact_end.tool_call_id

View file

@ -1,9 +1,8 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from acp import AgentSideConnection, NewSessionRequest, PromptRequest
from acp import PromptRequest
from acp.schema import (
EmbeddedResourceContentBlock,
ResourceContentBlock,
@ -13,58 +12,28 @@ from acp.schema import (
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(
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Hi"),
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]
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.types import Role
class TestACPContent:
@pytest.mark.asyncio
async def test_text_content(
self, acp_agent: VibeAcpAgent, backend: FakeBackend
self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
prompt_request = PromptRequest(
prompt=[TextContentBlock(type="text", text="Say hi")],
sessionId=session_response.sessionId,
session_id=session_response.session_id,
)
response = await acp_agent.prompt(params=prompt_request)
response = await acp_agent_loop.prompt(
prompt=prompt_request.prompt, session_id=session_response.session_id
)
assert response.stopReason == "end_turn"
assert response.stop_reason == "end_turn"
user_message = next(
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
None,
@ -74,12 +43,13 @@ class TestACPContent:
@pytest.mark.asyncio
async def test_resource_content(
self, acp_agent: VibeAcpAgent, backend: FakeBackend
self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
prompt_request = PromptRequest(
response = await acp_agent_loop.prompt(
prompt=[
TextContentBlock(type="text", text="What does this file do?"),
EmbeddedResourceContentBlock(
@ -87,16 +57,14 @@ class TestACPContent:
resource=TextResourceContents(
uri="file:///home/my_file.py",
text="def hello():\n print('Hello, world!')",
mimeType="text/x-python",
mime_type="text/x-python",
),
),
],
sessionId=session_response.sessionId,
session_id=session_response.session_id,
)
response = await acp_agent.prompt(params=prompt_request)
assert response.stopReason == "end_turn"
assert response.stop_reason == "end_turn"
user_message = next(
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
None,
@ -111,12 +79,13 @@ class TestACPContent:
@pytest.mark.asyncio
async def test_resource_link_content(
self, acp_agent: VibeAcpAgent, backend: FakeBackend
self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
prompt_request = PromptRequest(
response = await acp_agent_loop.prompt(
prompt=[
TextContentBlock(type="text", text="Analyze this resource"),
ResourceContentBlock(
@ -125,16 +94,14 @@ class TestACPContent:
name="document.pdf",
title="Important Document",
description="A PDF document containing project specifications",
mimeType="application/pdf",
mime_type="application/pdf",
size=1024,
),
],
sessionId=session_response.sessionId,
session_id=session_response.session_id,
)
response = await acp_agent.prompt(params=prompt_request)
assert response.stopReason == "end_turn"
assert response.stop_reason == "end_turn"
user_message = next(
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
None,
@ -146,19 +113,20 @@ class TestACPContent:
+ "\nname: document.pdf"
+ "\ntitle: Important Document"
+ "\ndescription: A PDF document containing project specifications"
+ "\nmimeType: application/pdf"
+ "\nmime_type: 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
self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
prompt_request = PromptRequest(
response = await acp_agent_loop.prompt(
prompt=[
ResourceContentBlock(
type="resource_link",
@ -166,12 +134,10 @@ class TestACPContent:
name="minimal.txt",
)
],
sessionId=session_response.sessionId,
session_id=session_response.session_id,
)
response = await acp_agent.prompt(params=prompt_request)
assert response.stopReason == "end_turn"
assert response.stop_reason == "end_turn"
user_message = next(
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
None,

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from acp import PROTOCOL_VERSION, AgentSideConnection, InitializeRequest
from acp import PROTOCOL_VERSION
from acp.schema import (
AgentCapabilities,
ClientCapabilities,
@ -9,66 +9,51 @@ from acp.schema import (
)
import pytest
from tests.stubs.fake_connection import FakeAgentSideConnection
from vibe.acp.acp_agent import VibeAcpAgent
@pytest.fixture
def acp_agent() -> VibeAcpAgent:
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]
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
class TestACPInitialize:
@pytest.mark.asyncio
async def test_initialize(self, acp_agent: VibeAcpAgent) -> None:
"""Test regular initialize without terminal-auth capabilities."""
request = InitializeRequest(protocolVersion=PROTOCOL_VERSION)
response = await acp_agent.initialize(request)
async def test_initialize(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
assert response.protocolVersion == PROTOCOL_VERSION
assert response.agentCapabilities == AgentCapabilities(
loadSession=False,
promptCapabilities=PromptCapabilities(
audio=False, embeddedContext=True, image=False
assert response.protocol_version == PROTOCOL_VERSION
assert response.agent_capabilities == AgentCapabilities(
load_session=False,
prompt_capabilities=PromptCapabilities(
audio=False, embedded_context=True, image=False
),
)
assert response.agentInfo == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.5"
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.0"
)
assert response.authMethods == []
assert response.auth_methods == []
@pytest.mark.asyncio
async def test_initialize_with_terminal_auth(self, acp_agent: VibeAcpAgent) -> None:
async def test_initialize_with_terminal_auth(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
"""Test initialize with terminal-auth capabilities to check it was included."""
client_capabilities = ClientCapabilities(field_meta={"terminal-auth": True})
request = InitializeRequest(
protocolVersion=PROTOCOL_VERSION, clientCapabilities=client_capabilities
response = await acp_agent_loop.initialize(
protocol_version=PROTOCOL_VERSION, client_capabilities=client_capabilities
)
response = await acp_agent.initialize(request)
assert response.protocolVersion == PROTOCOL_VERSION
assert response.agentCapabilities == AgentCapabilities(
loadSession=False,
promptCapabilities=PromptCapabilities(
audio=False, embeddedContext=True, image=False
assert response.protocol_version == PROTOCOL_VERSION
assert response.agent_capabilities == AgentCapabilities(
load_session=False,
prompt_capabilities=PromptCapabilities(
audio=False, embedded_context=True, image=False
),
)
assert response.agentInfo == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.5"
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.0"
)
assert response.authMethods is not None
assert len(response.authMethods) == 1
auth_method = response.authMethods[0]
assert response.auth_methods is not None
assert len(response.auth_methods) == 1
auth_method = response.auth_methods[0]
assert auth_method.id == "vibe-setup"
assert auth_method.name == "Register your API Key"
assert auth_method.description == "Register your API Key inside Mistral Vibe"

View file

@ -2,101 +2,52 @@ 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 import PROTOCOL_VERSION, 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.acp.acp_agent_loop import VibeAcpAgentLoop
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
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
session1_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
session1_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session1 = acp_agent.sessions[session1_response.sessionId]
session2_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session1 = acp_agent_loop.sessions[session1_response.session_id]
session2_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session2 = acp_agent.sessions[session2_response.sessionId]
session2 = acp_agent_loop.sessions[session2_response.session_id]
assert session1.id != session2.id
# Each agent should be independent
assert session1.agent is not session2.agent
assert id(session1.agent) != id(session2.agent)
# Each agent loop should be independent
assert session1.agent_loop is not session2.agent_loop
assert id(session1.agent_loop) != id(session2.agent_loop)
@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=[])
)
async def test_error_on_nonexistent_session(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
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!")],
)
await acp_agent_loop.prompt(
session_id=fake_session_id,
prompt=[TextContentBlock(type="text", text="Hello, world!")],
)
assert isinstance(exc_info.value, RequestError)
@ -104,17 +55,17 @@ class TestMultiSessionCore:
@pytest.mark.asyncio
async def test_simultaneous_message_processing(
self, acp_agent: VibeAcpAgent, backend: FakeBackend
self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
) -> None:
await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
session1_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
session1_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session1 = acp_agent.sessions[session1_response.sessionId]
session2_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session1 = acp_agent_loop.sessions[session1_response.session_id]
session2_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session2 = acp_agent.sessions[session2_response.sessionId]
session2 = acp_agent_loop.sessions[session2_response.session_id]
backend._streams = [
[mock_llm_chunk(content="Response 1")],
@ -122,40 +73,38 @@ class TestMultiSessionCore:
]
async def run_session1():
await acp_agent.prompt(
PromptRequest(
sessionId=session1.id,
prompt=[TextContentBlock(type="text", text="Prompt for session 1")],
)
await acp_agent_loop.prompt(
session_id=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 acp_agent_loop.prompt(
session_id=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
(msg for msg in session1.agent_loop.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
(msg for msg in session1.agent_loop.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
(msg for msg in session2.agent_loop.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
(msg for msg in session2.agent_loop.messages if msg.role == Role.assistant),
None,
)
assert assistant_message2 is not None
assert assistant_message2.content == "Response 2"

View file

@ -3,31 +3,17 @@ 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 tests.acp.conftest import _create_acp_agent
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import ModelConfig, VibeConfig
from vibe.core.modes import AgentMode
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
@pytest.fixture
def backend() -> FakeBackend:
backend = FakeBackend(
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Hi"),
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
)
)
return backend
@pytest.fixture
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
def acp_agent_loop(backend) -> VibeAcpAgentLoop:
config = VibeConfig(
active_model="devstral-latest",
models=[
@ -40,101 +26,90 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
],
)
class PatchedAgent(Agent):
class PatchedAgentLoop(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **{**kwargs, "backend": backend})
self.config = config
self._base_config = config
self.agent_manager.invalidate_config()
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).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]
return _create_acp_agent()
class TestACPNewSession:
@pytest.mark.asyncio
async def test_new_session_response_structure(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
assert session_response.sessionId is not None
assert session_response.session_id is not None
acp_session = next(
(
s
for s in acp_agent.sessions.values()
if s.id == session_response.sessionId
for s in acp_agent_loop.sessions.values()
if s.id == session_response.session_id
),
None,
)
assert acp_session is not None
assert (
acp_session.agent.interaction_logger.session_id
== session_response.sessionId
acp_session.agent_loop.session_logger.session_id
== session_response.session_id
)
assert session_response.sessionId == acp_session.agent.session_id
assert session_response.session_id == acp_session.agent_loop.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.current_model_id is not None
assert session_response.models.available_models is not None
assert len(session_response.models.available_models) == 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.models.current_model_id == "devstral-latest"
assert session_response.models.available_models[0].model_id == "devstral-latest"
assert session_response.models.available_models[0].name == "devstral-latest"
assert session_response.models.available_models[1].model_id == "devstral-small"
assert session_response.models.available_models[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) == 4
assert session_response.modes.current_mode_id is not None
assert session_response.modes.available_modes is not None
assert len(session_response.modes.available_modes) == 4
assert session_response.modes.currentModeId == AgentMode.DEFAULT.value
assert session_response.modes.availableModes[0].id == AgentMode.DEFAULT.value
assert session_response.modes.availableModes[0].name == "Default"
assert (
session_response.modes.availableModes[1].id == AgentMode.AUTO_APPROVE.value
)
assert session_response.modes.availableModes[1].name == "Auto Approve"
assert session_response.modes.availableModes[2].id == AgentMode.PLAN.value
assert session_response.modes.availableModes[2].name == "Plan"
assert (
session_response.modes.availableModes[3].id == AgentMode.ACCEPT_EDITS.value
)
assert session_response.modes.availableModes[3].name == "Accept Edits"
assert session_response.modes.current_mode_id == BuiltinAgentName.DEFAULT
# Check that all primary agents are available (order may vary)
mode_ids = {m.id for m in session_response.modes.available_modes}
assert mode_ids == {
BuiltinAgentName.DEFAULT,
BuiltinAgentName.AUTO_APPROVE,
BuiltinAgentName.PLAN,
BuiltinAgentName.ACCEPT_EDITS,
}
@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
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
assert session_response.models is not None
assert session_response.models.currentModelId == "devstral-latest"
assert session_response.models.current_model_id == "devstral-latest"
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id="devstral-small"
)
assert response is not None
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
assert session_response.models is not None
assert session_response.models.currentModelId == "devstral-small"
assert session_response.models.current_model_id == "devstral-small"

View file

@ -2,9 +2,10 @@ from __future__ import annotations
from pathlib import Path
from acp import ReadTextFileRequest, ReadTextFileResponse
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.core.tools.base import ToolError
from vibe.core.tools.builtins.read_file import (
@ -14,7 +15,7 @@ from vibe.core.tools.builtins.read_file import (
)
class MockConnection:
class MockClient:
def __init__(
self,
file_content: str = "line 1\nline 2\nline 3",
@ -24,41 +25,54 @@ class MockConnection:
self._read_error = read_error
self._read_text_file_called = False
self._session_update_called = False
self._last_read_request: ReadTextFileRequest | None = None
self._last_read_params: dict[str, str | int | None] = {}
async def readTextFile(self, request: ReadTextFileRequest) -> ReadTextFileResponse:
async def read_text_file(
self,
path: str,
session_id: str,
limit: int | None = None,
line: int | None = None,
**kwargs,
) -> ReadTextFileResponse:
self._read_text_file_called = True
self._last_read_request = request
self._last_read_params = {
"path": path,
"session_id": session_id,
"limit": limit,
"line": line,
}
if self._read_error:
raise self._read_error
content = self._file_content
if request.line is not None or request.limit is not None:
if line is not None or 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)
)
start_line = (line or 1) - 1 # Convert to 0-indexed
end_line = start_line + limit if 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:
async def session_update(self, session_id: str, update, **kwargs) -> None:
self._session_update_called = True
@pytest.fixture
def mock_connection() -> MockConnection:
return MockConnection()
def mock_client() -> MockClient:
return MockClient()
@pytest.fixture
def acp_read_file_tool(mock_connection: MockConnection, tmp_path: Path) -> ReadFile:
config = ReadFileToolConfig(workdir=tmp_path)
def acp_read_file_tool(
mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> ReadFile:
monkeypatch.chdir(tmp_path)
config = ReadFileToolConfig()
state = AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
client=mock_client, # type: ignore[arg-type]
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
@ -73,166 +87,159 @@ class TestAcpReadFileBasic:
class TestAcpReadFileExecution:
@pytest.mark.asyncio
async def test_run_success(
self,
acp_read_file_tool: ReadFile,
mock_connection: MockConnection,
tmp_path: Path,
self, acp_read_file_tool: ReadFile, 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 acp_read_file_tool.run(args)
result = await collect_result(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
assert mock_client._read_text_file_called
assert mock_client._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
# 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
@pytest.mark.asyncio
async def test_run_with_offset(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file), offset=1)
result = await tool.run(args)
result = await collect_result(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)
params = mock_client._last_read_params
assert params["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
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file), limit=2)
result = await tool.run(args)
result = await collect_result(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
params = mock_client._last_read_params
assert params["limit"] == 2
@pytest.mark.asyncio
async def test_run_with_offset_and_limit(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file), offset=1, limit=1)
result = await tool.run(args)
result = await collect_result(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
params = mock_client._last_read_params
assert params["line"] == 2
assert params["limit"] == 1
@pytest.mark.asyncio
async def test_run_read_error(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_connection._read_error = RuntimeError("File not found")
monkeypatch.chdir(tmp_path)
mock_client._read_error = RuntimeError("File not found")
test_file = tmp_path / "test.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file))
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(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:
async def test_run_without_connection(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
connection=None, session_id="test_session", tool_call_id="test_call"
client=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)
await collect_result(tool.run(args))
assert (
str(exc_info.value)
== "Connection not available in tool state. This tool can only be used within an ACP session."
== "Client not available in tool state. This tool can only be used within an ACP session."
)
@pytest.mark.asyncio
async def test_run_without_session_id(self, tmp_path: Path) -> None:
async def test_run_without_session_id(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test.txt"
test_file.touch()
mock_connection = MockConnection()
mock_client = MockClient()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id=None,
tool_call_id="test_call",
client=mock_client, 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)
await collect_result(tool.run(args))
assert (
str(exc_info.value)

View file

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

View file

@ -1,207 +1,178 @@
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.core.agent import Agent
from vibe.core.modes import AgentMode
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
@pytest.fixture
def backend() -> FakeBackend:
backend = FakeBackend(
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Hi"),
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]
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agents.models import BuiltinAgentName
class TestACPSetMode:
@pytest.mark.asyncio
async def test_set_mode_to_default(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
async def test_set_mode_to_default(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
await acp_session.agent.switch_mode(AgentMode.AUTO_APPROVE)
await acp_session.agent_loop.switch_agent(BuiltinAgentName.AUTO_APPROVE)
response = await acp_agent.setSessionMode(
SetSessionModeRequest(sessionId=session_id, modeId=AgentMode.DEFAULT.value)
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id=BuiltinAgentName.DEFAULT
)
assert response is not None
assert acp_session.agent.mode == AgentMode.DEFAULT
assert acp_session.agent.auto_approve is False
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
assert acp_session.agent_loop.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=[])
async def test_set_mode_to_auto_approve(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
assert acp_session.agent.mode == AgentMode.DEFAULT
assert acp_session.agent.auto_approve is False
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
assert acp_session.agent_loop.auto_approve is False
response = await acp_agent.setSessionMode(
SetSessionModeRequest(
sessionId=session_id, modeId=AgentMode.AUTO_APPROVE.value
)
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id=BuiltinAgentName.AUTO_APPROVE
)
assert response is not None
assert acp_session.agent.mode == AgentMode.AUTO_APPROVE
assert acp_session.agent.auto_approve is True
@pytest.mark.asyncio
async def test_set_mode_to_plan(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.mode == AgentMode.DEFAULT
response = await acp_agent.setSessionMode(
SetSessionModeRequest(sessionId=session_id, modeId=AgentMode.PLAN.value)
)
assert response is not None
assert acp_session.agent.mode == AgentMode.PLAN
assert (
acp_session.agent.auto_approve is True
acp_session.agent_loop.agent_profile.name == BuiltinAgentName.AUTO_APPROVE
)
assert acp_session.agent_loop.auto_approve is True
@pytest.mark.asyncio
async def test_set_mode_to_plan(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id=BuiltinAgentName.PLAN
)
assert response is not None
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.PLAN
assert (
acp_session.agent_loop.auto_approve is True
) # Plan mode auto-approves read-only tools
@pytest.mark.asyncio
async def test_set_mode_to_accept_edits(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
async def test_set_mode_to_accept_edits(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
assert acp_session.agent.mode == AgentMode.DEFAULT
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
response = await acp_agent.setSessionMode(
SetSessionModeRequest(
sessionId=session_id, modeId=AgentMode.ACCEPT_EDITS.value
)
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id=BuiltinAgentName.ACCEPT_EDITS
)
assert response is not None
assert acp_session.agent.mode == AgentMode.ACCEPT_EDITS
assert (
acp_session.agent.auto_approve is False
acp_session.agent_loop.agent_profile.name == BuiltinAgentName.ACCEPT_EDITS
)
assert (
acp_session.agent_loop.auto_approve is False
) # Accept Edits mode doesn't auto-approve all
@pytest.mark.asyncio
async def test_set_mode_invalid_mode_returns_none(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_mode = acp_session.agent.mode
initial_auto_approve = acp_session.agent.auto_approve
initial_agent = acp_session.agent_loop.agent_profile.name
initial_auto_approve = acp_session.agent_loop.auto_approve
response = await acp_agent.setSessionMode(
SetSessionModeRequest(sessionId=session_id, modeId="invalid-mode")
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id="invalid-mode"
)
assert response is None
assert acp_session.agent.mode == initial_mode
assert acp_session.agent.auto_approve == initial_auto_approve
assert acp_session.agent_loop.agent_profile.name == initial_agent
assert acp_session.agent_loop.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=[])
async def test_set_mode_to_same_mode(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_mode = AgentMode.DEFAULT
assert acp_session.agent.mode == initial_mode
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
response = await acp_agent.setSessionMode(
SetSessionModeRequest(sessionId=session_id, modeId=initial_mode.value)
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id=BuiltinAgentName.DEFAULT
)
assert response is not None
assert acp_session.agent.mode == initial_mode
assert acp_session.agent.auto_approve is False
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
assert acp_session.agent_loop.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=[])
async def test_set_mode_with_empty_string(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_mode = acp_session.agent.mode
initial_auto_approve = acp_session.agent.auto_approve
initial_agent = acp_session.agent_loop.agent_profile.name
initial_auto_approve = acp_session.agent_loop.auto_approve
response = await acp_agent.setSessionMode(
SetSessionModeRequest(sessionId=session_id, modeId="")
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id=""
)
assert response is None
assert acp_session.agent.mode == initial_mode
assert acp_session.agent.auto_approve == initial_auto_approve
assert acp_session.agent_loop.agent_profile.name == initial_agent
assert acp_session.agent_loop.auto_approve == initial_auto_approve

View file

@ -3,30 +3,17 @@ 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 tests.acp.conftest import _create_acp_agent
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import ModelConfig, VibeConfig
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
from vibe.core.types import LLMMessage, Role
@pytest.fixture
def backend() -> FakeBackend:
backend = FakeBackend(
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Hi"),
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
)
)
return backend
@pytest.fixture
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
def acp_agent_loop(backend) -> VibeAcpAgentLoop:
config = VibeConfig(
active_model="devstral-latest",
models=[
@ -49,10 +36,11 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
VibeConfig.dump_config(config.model_dump())
class PatchedAgent(Agent):
class PatchedAgentLoop(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **{**kwargs, "backend": backend})
self.config = config
self._base_config = config
self.agent_manager.invalidate_config()
try:
active_model = config.get_active_model()
self.stats.input_price_per_million = active_model.input_price
@ -60,90 +48,86 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
except ValueError:
pass
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).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]
return _create_acp_agent()
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=[])
async def test_set_model_success(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
assert acp_session.agent.config.active_model == "devstral-latest"
assert acp_session.agent_loop.config.active_model == "devstral-latest"
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id="devstral-small"
)
assert response is not None
assert acp_session.agent.config.active_model == "devstral-small"
assert acp_session.agent_loop.config.active_model == "devstral-small"
@pytest.mark.asyncio
async def test_set_model_invalid_model_returns_none(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_model = acp_session.agent.config.active_model
initial_model = acp_session.agent_loop.config.active_model
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="non-existent-model")
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id="non-existent-model"
)
assert response is None
assert acp_session.agent.config.active_model == initial_model
assert acp_session.agent_loop.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=[])
async def test_set_model_to_same_model(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.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
assert acp_session.agent_loop.config.active_model == initial_model
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId=initial_model)
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id=initial_model
)
assert response is not None
assert acp_session.agent.config.active_model == initial_model
assert acp_session.agent_loop.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=[])
async def test_set_model_saves_to_config(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
with patch("vibe.acp.acp_agent.VibeConfig.save_updates") as mock_save:
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
with patch("vibe.acp.acp_agent_loop.VibeConfig.save_updates") as mock_save:
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id="devstral-small"
)
assert response is not None
@ -151,157 +135,171 @@ class TestACPSetModel:
@pytest.mark.asyncio
async def test_set_model_does_not_save_on_invalid_model(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
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"
)
with patch("vibe.acp.acp_agent_loop.VibeConfig.save_updates") as mock_save:
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id="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=[])
async def test_set_model_with_empty_string(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_model = acp_session.agent.config.active_model
initial_model = acp_session.agent_loop.config.active_model
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="")
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id=""
)
assert response is None
assert acp_session.agent.config.active_model == initial_model
assert acp_session.agent_loop.config.active_model == initial_model
@pytest.mark.asyncio
async def test_set_model_updates_active_model(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.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_loop.config.get_active_model().alias == "devstral-latest"
)
assert acp_session.agent.config.get_active_model().alias == "devstral-small"
await acp_agent_loop.set_session_model(
session_id=session_id, model_id="devstral-small"
)
assert (
acp_session.agent_loop.config.get_active_model().alias == "devstral-small"
)
@pytest.mark.asyncio
async def test_set_model_calls_reload_with_initial_messages(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
with patch.object(
acp_session.agent, "reload_with_initial_messages"
acp_session.agent_loop, "reload_with_initial_messages"
) as mock_reload:
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id="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"
assert call_args.kwargs["base_config"] is not None
assert call_args.kwargs["base_config"].active_model == "devstral-small"
@pytest.mark.asyncio
async def test_set_model_preserves_conversation_history(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.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)
acp_session.agent_loop.messages.append(user_msg)
acp_session.agent_loop.messages.append(assistant_msg)
assert len(acp_session.agent.messages) == 3
assert len(acp_session.agent_loop.messages) == 3
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id="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!"
assert len(acp_session.agent_loop.messages) == 3
assert acp_session.agent_loop.messages[0].role == Role.system
assert acp_session.agent_loop.messages[1].content == "Hello"
assert acp_session.agent_loop.messages[2].content == "Hi there!"
@pytest.mark.asyncio
async def test_set_model_resets_stats_with_new_model_pricing(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_model = acp_session.agent.config.get_active_model()
initial_model = acp_session.agent_loop.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
initial_stats_input = acp_session.agent_loop.stats.input_price_per_million
initial_stats_output = acp_session.agent_loop.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
assert (
acp_session.agent_loop.stats.input_price_per_million == initial_input_price
)
assert (
acp_session.agent_loop.stats.output_price_per_million
== initial_output_price
)
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id="devstral-small"
)
assert response is not None
new_model = acp_session.agent.config.get_active_model()
new_model = acp_session.agent_loop.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_loop.stats.input_price_per_million == new_input_price
assert acp_session.agent_loop.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
assert (
acp_session.agent_loop.stats.input_price_per_million != initial_stats_input
)
assert (
acp_session.agent_loop.stats.output_price_per_million
!= initial_stats_output
)

View file

@ -2,9 +2,9 @@ from __future__ import annotations
from pathlib import Path
from acp import WriteTextFileRequest
import pytest
from tests.mock.utils import collect_result
from vibe.acp.tools.builtins.write_file import AcpWriteFileState, WriteFile
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.write_file import (
@ -15,7 +15,7 @@ from vibe.core.tools.builtins.write_file import (
from vibe.core.types import ToolCallEvent, ToolResultEvent
class MockConnection:
class MockClient:
def __init__(
self, write_error: Exception | None = None, file_exists: bool = False
) -> None:
@ -23,29 +23,38 @@ class MockConnection:
self._file_exists = file_exists
self._write_text_file_called = False
self._session_update_called = False
self._last_write_request: WriteTextFileRequest | None = None
self._last_write_params: dict[str, str] = {}
async def writeTextFile(self, request: WriteTextFileRequest) -> None:
async def write_text_file(
self, content: str, path: str, session_id: str, **kwargs
) -> None:
self._write_text_file_called = True
self._last_write_request = request
self._last_write_params = {
"content": content,
"path": path,
"session_id": session_id,
}
if self._write_error:
raise self._write_error
async def sessionUpdate(self, notification) -> None:
async def session_update(self, session_id: str, update, **kwargs) -> None:
self._session_update_called = True
@pytest.fixture
def mock_connection() -> MockConnection:
return MockConnection()
def mock_client() -> MockClient:
return MockClient()
@pytest.fixture
def acp_write_file_tool(mock_connection: MockConnection, tmp_path: Path) -> WriteFile:
config = WriteFileConfig(workdir=tmp_path)
def acp_write_file_tool(
mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> WriteFile:
monkeypatch.chdir(tmp_path)
config = WriteFileConfig()
state = AcpWriteFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
client=mock_client,
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
@ -60,40 +69,35 @@ class TestAcpWriteFileBasic:
class TestAcpWriteFileExecution:
@pytest.mark.asyncio
async def test_run_success_new_file(
self,
acp_write_file_tool: WriteFile,
mock_connection: MockConnection,
tmp_path: Path,
self, acp_write_file_tool: WriteFile, mock_client: MockClient, tmp_path: Path
) -> None:
test_file = tmp_path / "test_file.txt"
args = WriteFileArgs(path=str(test_file), content="Hello, world!")
result = await acp_write_file_tool.run(args)
result = await collect_result(acp_write_file_tool.run(args))
assert isinstance(result, WriteFileResult)
assert result.path == str(test_file)
assert result.content == "Hello, world!"
assert result.bytes_written == len(b"Hello, world!")
assert result.file_existed is False
assert mock_connection._write_text_file_called
assert mock_connection._session_update_called
assert mock_client._write_text_file_called
assert mock_client._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!"
# Verify write_text_file was called correctly
params = mock_client._last_write_params
assert params["session_id"] == "test_session_123"
assert params["path"] == str(test_file)
assert params["content"] == "Hello, world!"
@pytest.mark.asyncio
async def test_run_success_overwrite(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
tool = WriteFile(
config=WriteFileConfig(workdir=tmp_path),
config=WriteFileConfig(),
state=AcpWriteFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
@ -102,78 +106,80 @@ class TestAcpWriteFileExecution:
# 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)
result = 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_connection._write_text_file_called
assert mock_connection._session_update_called
assert mock_client._write_text_file_called
assert mock_client._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"
# 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"
@pytest.mark.asyncio
async def test_run_write_error(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_connection._write_error = RuntimeError("Permission denied")
monkeypatch.chdir(tmp_path)
mock_client._write_error = RuntimeError("Permission denied")
tool = WriteFile(
config=WriteFileConfig(workdir=tmp_path),
config=WriteFileConfig(),
state=AcpWriteFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
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)
await collect_result(tool.run(args))
assert str(exc_info.value) == f"Error writing {test_file}: Permission denied"
@pytest.mark.asyncio
async def test_run_without_connection(self, tmp_path: Path) -> None:
async def test_run_without_connection(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
tool = WriteFile(
config=WriteFileConfig(workdir=tmp_path),
config=WriteFileConfig(),
state=AcpWriteFileState.model_construct(
connection=None, session_id="test_session", tool_call_id="test_call"
client=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)
await collect_result(tool.run(args))
assert (
str(exc_info.value)
== "Connection not available in tool state. This tool can only be used within an ACP session."
== "Client not available in tool state. This tool can only be used within an ACP session."
)
@pytest.mark.asyncio
async def test_run_without_session_id(self, tmp_path: Path) -> None:
mock_connection = MockConnection()
async def test_run_without_session_id(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
mock_client = MockClient()
tool = WriteFile(
config=WriteFileConfig(workdir=tmp_path),
config=WriteFileConfig(),
state=AcpWriteFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id=None,
tool_call_id="test_call",
client=mock_client, 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)
await collect_result(tool.run(args))
assert (
str(exc_info.value)
@ -192,16 +198,17 @@ class TestAcpWriteFileSessionUpdates:
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.session_update == "tool_call"
assert update.tool_call_id == "test_call_123"
assert update.kind == "edit"
assert update.title is not None
assert update.content is not None
assert isinstance(update.content, list)
assert len(update.content) == 1
assert update.content[0].type == "diff"
assert update.content[0].path == "/tmp/test.txt"
assert update.content[0].oldText is None
assert update.content[0].newText == "Hello"
assert update.content[0].old_text is None
assert update.content[0].new_text == "Hello"
assert update.locations is not None
assert len(update.locations) == 1
assert update.locations[0].path == "/tmp/test.txt"
@ -241,15 +248,16 @@ class TestAcpWriteFileSessionUpdates:
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.session_update == "tool_call_update"
assert update.tool_call_id == "test_call_123"
assert update.status == "completed"
assert update.content is not None
assert isinstance(update.content, list)
assert len(update.content) == 1
assert update.content[0].type == "diff"
assert update.content[0].path == "/tmp/test.txt"
assert update.content[0].oldText is None
assert update.content[0].newText == "Hello"
assert update.content[0].old_text is None
assert update.content[0].new_text == "Hello"
assert update.locations is not None
assert len(update.locations) == 1
assert update.locations[0].path == "/tmp/test.txt"