2.0.0
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:
parent
79f215d91c
commit
d33db9fff8
217 changed files with 16911 additions and 4305 deletions
42
tests/acp/conftest.py
Normal file
42
tests/acp/conftest.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
83
tests/acp/test_compact_session_updates.py
Normal file
83
tests/acp/test_compact_session_updates.py
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue