Initial commit
Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai> Co-Authored-By: Laure Hugo <laure.hugo@mistral.ai> Co-Authored-By: Benjamin Trom <benjamin.trom@mistral.ai> Co-Authored-By: Mathias Gesbert <mathias.gesbert@ext.mistral.ai> Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai> Co-Authored-By: Clément Drouin <clement.drouin@mistral.ai> Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-Authored-By: Valentin Berard <val@mistral.ai> Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
commit
fa15fc977b
200 changed files with 30484 additions and 0 deletions
925
tests/acp/test_acp.py
Normal file
925
tests/acp/test_acp.py
Normal file
|
|
@ -0,0 +1,925 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from acp import (
|
||||
InitializeRequest,
|
||||
NewSessionRequest,
|
||||
PromptRequest,
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
WriteTextFileRequest,
|
||||
)
|
||||
from acp.schema import (
|
||||
AgentCapabilities,
|
||||
AllowedOutcome,
|
||||
DeniedOutcome,
|
||||
Implementation,
|
||||
InitializeResponse,
|
||||
McpCapabilities,
|
||||
NewSessionResponse,
|
||||
PromptCapabilities,
|
||||
PromptResponse,
|
||||
SessionNotification,
|
||||
TextContentBlock,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
import pytest
|
||||
|
||||
from tests import TESTS_ROOT
|
||||
from tests.mock.utils import get_mocking_env, mock_llm_chunk
|
||||
from vibe.acp.utils import ToolOption
|
||||
from vibe.core.types import FunctionCall, ToolCall
|
||||
|
||||
RESPONSE_TIMEOUT = 2.0
|
||||
MOCK_ENTRYPOINT_PATH = "tests/mock/mock_entrypoint.py"
|
||||
PLAYGROUND_DIR = TESTS_ROOT / "playground"
|
||||
|
||||
|
||||
class JsonRpcRequest(BaseModel):
|
||||
jsonrpc: str = "2.0"
|
||||
id: int | str
|
||||
method: str
|
||||
params: Any | None = None
|
||||
|
||||
|
||||
class JsonRpcError(BaseModel):
|
||||
code: int
|
||||
message: str
|
||||
data: Any | None = None
|
||||
|
||||
|
||||
class JsonRpcResponse(BaseModel):
|
||||
jsonrpc: str = "2.0"
|
||||
id: int | str | None = None
|
||||
result: Any | None = None
|
||||
error: JsonRpcError | None = None
|
||||
|
||||
|
||||
class JsonRpcNotification(BaseModel):
|
||||
jsonrpc: str = "2.0"
|
||||
method: str
|
||||
params: Any | None = None
|
||||
|
||||
|
||||
type JsonRpcMessage = JsonRpcResponse | JsonRpcNotification | JsonRpcRequest
|
||||
|
||||
|
||||
class InitializeJsonRpcRequest(JsonRpcRequest):
|
||||
method: str = "initialize"
|
||||
params: InitializeRequest | None = None
|
||||
|
||||
|
||||
class InitializeJsonRpcResponse(JsonRpcResponse):
|
||||
result: InitializeResponse | None = None
|
||||
|
||||
|
||||
class NewSessionJsonRpcRequest(JsonRpcRequest):
|
||||
method: str = "session/new"
|
||||
params: NewSessionRequest | None = None
|
||||
|
||||
|
||||
class NewSessionJsonRpcResponse(JsonRpcResponse):
|
||||
result: NewSessionResponse | None = None
|
||||
|
||||
|
||||
class PromptJsonRpcRequest(JsonRpcRequest):
|
||||
method: str = "session/prompt"
|
||||
params: PromptRequest | None = None
|
||||
|
||||
|
||||
class PromptJsonRpcResponse(JsonRpcResponse):
|
||||
result: PromptResponse | None = None
|
||||
|
||||
|
||||
class UpdateJsonRpcNotification(JsonRpcNotification):
|
||||
method: str = "session/update"
|
||||
params: SessionNotification | None = None
|
||||
|
||||
|
||||
class RequestPermissionJsonRpcRequest(JsonRpcRequest):
|
||||
method: str = "session/request_permission"
|
||||
params: RequestPermissionRequest | None = None
|
||||
|
||||
|
||||
class RequestPermissionJsonRpcResponse(JsonRpcResponse):
|
||||
result: RequestPermissionResponse | None = None
|
||||
|
||||
|
||||
class ReadTextFileJsonRpcRequest(JsonRpcRequest):
|
||||
method: str = "fs/read_text_file"
|
||||
params: ReadTextFileRequest | None = None
|
||||
|
||||
|
||||
class ReadTextFileJsonRpcResponse(JsonRpcResponse):
|
||||
result: ReadTextFileResponse | None = None
|
||||
|
||||
|
||||
class WriteTextFileJsonRpcRequest(JsonRpcRequest):
|
||||
method: str = "fs/write_text_file"
|
||||
params: WriteTextFileRequest | None = None
|
||||
|
||||
|
||||
class WriteTextFileJsonRpcResponse(JsonRpcResponse):
|
||||
result: None = None
|
||||
|
||||
|
||||
async def get_acp_agent_process(
|
||||
mock: bool = True, mock_env: dict[str, str] | None = None
|
||||
) -> AsyncGenerator[asyncio.subprocess.Process]:
|
||||
current_env = os.environ.copy()
|
||||
cmd = ["uv", "run", MOCK_ENTRYPOINT_PATH if mock else "vibe-acp"]
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=TESTS_ROOT.parent,
|
||||
env={
|
||||
**current_env,
|
||||
**(mock_env or {}),
|
||||
**({"MISTRAL_API_KEY": "mock"} if mock else {}),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
yield process
|
||||
finally:
|
||||
# Cleanup
|
||||
if process.returncode is None:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=0.5)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
|
||||
async def send_json_rpc(
|
||||
process: asyncio.subprocess.Process, message: JsonRpcMessage
|
||||
) -> None:
|
||||
if process.stdin is None:
|
||||
raise RuntimeError("Process stdin not available")
|
||||
|
||||
request = message.model_dump_json()
|
||||
request_json = request + "\n"
|
||||
process.stdin.write(request_json.encode())
|
||||
await process.stdin.drain()
|
||||
|
||||
|
||||
async def read_response(
|
||||
process: asyncio.subprocess.Process, timeout: float = RESPONSE_TIMEOUT
|
||||
) -> str | None:
|
||||
if process.stdout is None:
|
||||
raise RuntimeError("Process stdout not available")
|
||||
|
||||
try:
|
||||
# Keep reading lines until we find a valid JSON line
|
||||
while True:
|
||||
line = await asyncio.wait_for(process.stdout.readline(), timeout=timeout)
|
||||
|
||||
if not line:
|
||||
return None
|
||||
|
||||
line_str = line.decode().strip()
|
||||
if not line_str:
|
||||
continue
|
||||
|
||||
try:
|
||||
json.loads(line_str)
|
||||
return line_str
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, skip it (it's a log message)
|
||||
continue
|
||||
except TimeoutError:
|
||||
return None
|
||||
|
||||
|
||||
async def read_response_for_id(
|
||||
process: asyncio.subprocess.Process,
|
||||
expected_id: int | str,
|
||||
timeout: float = RESPONSE_TIMEOUT,
|
||||
) -> str | None:
|
||||
loop = asyncio.get_running_loop()
|
||||
end_time = loop.time() + timeout
|
||||
|
||||
while (remaining := end_time - loop.time()) > 0:
|
||||
response = await read_response(process, timeout=remaining)
|
||||
if response is None:
|
||||
return None
|
||||
|
||||
response_json = json.loads(response)
|
||||
if response_json.get("id") == expected_id:
|
||||
return response
|
||||
print(
|
||||
f"Skipping response with id={response_json.get('id')}, expecting {expected_id}"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def read_multiple_responses(
|
||||
process: asyncio.subprocess.Process,
|
||||
max_count: int = 10,
|
||||
timeout_per_response: float = RESPONSE_TIMEOUT,
|
||||
) -> list[str]:
|
||||
responses = []
|
||||
for _ in range(max_count):
|
||||
response = await read_response(process, timeout=timeout_per_response)
|
||||
if response:
|
||||
responses.append(response)
|
||||
else:
|
||||
break
|
||||
return responses
|
||||
|
||||
|
||||
def parse_conversation(message_texts: list[str]) -> list[JsonRpcMessage]:
|
||||
parsed_messages: list[JsonRpcMessage] = []
|
||||
for message_text in message_texts:
|
||||
message_json = json.loads(message_text)
|
||||
cls = None
|
||||
has_method = message_json.get("method", None) is not None
|
||||
has_id = message_json.get("id", None) is not None
|
||||
has_result = message_json.get("result", None) is not None
|
||||
|
||||
is_request = has_method and has_id
|
||||
is_notification = has_method and not has_id
|
||||
is_response = has_result
|
||||
|
||||
if is_request:
|
||||
match message_json.get("method"):
|
||||
case "session/prompt":
|
||||
cls = PromptJsonRpcRequest
|
||||
case "session/request_permission":
|
||||
cls = RequestPermissionJsonRpcRequest
|
||||
case "fs/read_text_file":
|
||||
cls = ReadTextFileJsonRpcRequest
|
||||
case "fs/write_text_file":
|
||||
cls = WriteTextFileJsonRpcRequest
|
||||
elif is_notification:
|
||||
match message_json.get("method"):
|
||||
case "session/update":
|
||||
cls = UpdateJsonRpcNotification
|
||||
elif is_response:
|
||||
# For responses, since we don't know the method, we need to find
|
||||
# the matching request.
|
||||
matching_request = next(
|
||||
(
|
||||
m
|
||||
for m in parsed_messages
|
||||
if isinstance(m, JsonRpcRequest) and m.id == message_json.get("id")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if matching_request is None:
|
||||
# No matching request found in the conversation, it most probably was
|
||||
# not included in the conversation. We use a generic response class.
|
||||
cls = JsonRpcResponse
|
||||
else:
|
||||
match matching_request.method:
|
||||
case "session/prompt":
|
||||
cls = PromptJsonRpcResponse
|
||||
case "session/request_permission":
|
||||
cls = RequestPermissionJsonRpcResponse
|
||||
case "fs/read_text_file":
|
||||
cls = ReadTextFileJsonRpcResponse
|
||||
case "fs/write_text_file":
|
||||
cls = WriteTextFileJsonRpcResponse
|
||||
if cls is None:
|
||||
raise ValueError(f"No valid message class found for {message_json}")
|
||||
parsed_messages.append(cls.model_validate(message_json))
|
||||
return parsed_messages
|
||||
|
||||
|
||||
async def initialize_session(acp_agent_process: asyncio.subprocess.Process) -> str:
|
||||
await send_json_rpc(
|
||||
acp_agent_process,
|
||||
InitializeJsonRpcRequest(id=1, params=InitializeRequest(protocolVersion=1)),
|
||||
)
|
||||
initialize_response = await read_response_for_id(
|
||||
acp_agent_process, expected_id=1, timeout=5.0
|
||||
)
|
||||
assert initialize_response is not None
|
||||
|
||||
await send_json_rpc(
|
||||
acp_agent_process,
|
||||
NewSessionJsonRpcRequest(
|
||||
id=2, params=NewSessionRequest(cwd=str(PLAYGROUND_DIR), mcpServers=[])
|
||||
),
|
||||
)
|
||||
session_response = await read_response_for_id(acp_agent_process, expected_id=2)
|
||||
assert session_response is not None
|
||||
session_response_json = json.loads(session_response)
|
||||
session_response_obj = NewSessionJsonRpcResponse.model_validate(
|
||||
session_response_json
|
||||
)
|
||||
assert session_response_obj.result is not None, "No result in response"
|
||||
return session_response_obj.result.sessionId
|
||||
|
||||
|
||||
class TestInitialization:
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_request_response(self) -> None:
|
||||
mock_env = get_mocking_env()
|
||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
||||
await send_json_rpc(
|
||||
process,
|
||||
InitializeJsonRpcRequest(
|
||||
id=1, params=InitializeRequest(protocolVersion=1)
|
||||
),
|
||||
)
|
||||
|
||||
text_response = await read_response(process, timeout=10.0)
|
||||
assert text_response is not None, "No response to initialize"
|
||||
response_json = json.loads(text_response)
|
||||
response = InitializeJsonRpcResponse.model_validate(response_json)
|
||||
assert response.error is None, f"JSON-RPC error: {response.error}"
|
||||
assert response.result is not None, "No result in response"
|
||||
assert response.result.protocolVersion == 1
|
||||
assert response.result.agentCapabilities == AgentCapabilities(
|
||||
loadSession=False,
|
||||
promptCapabilities=PromptCapabilities(
|
||||
audio=False, embeddedContext=True, image=False
|
||||
),
|
||||
mcpCapabilities=McpCapabilities(http=False, sse=False),
|
||||
)
|
||||
assert response.result.agentInfo == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="0.1.0"
|
||||
)
|
||||
vibe_setup_method = next(
|
||||
(
|
||||
method
|
||||
for method in response.result.authMethods or []
|
||||
if method.id == "vibe-setup"
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert vibe_setup_method is not None, "vibe-setup auth not found"
|
||||
assert vibe_setup_method.field_meta is not None
|
||||
assert "terminal-auth" in vibe_setup_method.field_meta.keys()
|
||||
|
||||
|
||||
class TestSessionManagement:
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_sessions_unique_ids(self) -> None:
|
||||
mock_env = get_mocking_env(mock_chunks=[mock_llm_chunk() for _ in range(3)])
|
||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
||||
await send_json_rpc(
|
||||
process,
|
||||
InitializeJsonRpcRequest(
|
||||
id=1, params=InitializeRequest(protocolVersion=1)
|
||||
),
|
||||
)
|
||||
await read_response_for_id(process, expected_id=1, timeout=5.0)
|
||||
|
||||
session_ids = []
|
||||
for i in range(3):
|
||||
await send_json_rpc(
|
||||
process,
|
||||
NewSessionJsonRpcRequest(
|
||||
id=i + 2,
|
||||
params=NewSessionRequest(
|
||||
cwd=str(PLAYGROUND_DIR), mcpServers=[]
|
||||
),
|
||||
),
|
||||
)
|
||||
text_response = await read_response_for_id(
|
||||
process, expected_id=i + 2, timeout=RESPONSE_TIMEOUT
|
||||
)
|
||||
assert text_response is not None
|
||||
response_json = json.loads(text_response)
|
||||
response = NewSessionJsonRpcResponse.model_validate(response_json)
|
||||
assert response.error is None, f"JSON-RPC error: {response.error}"
|
||||
assert response.result is not None, "No result in response"
|
||||
session_ids.append(response.result.sessionId)
|
||||
|
||||
assert len(set(session_ids)) == 3
|
||||
|
||||
|
||||
class TestSessionUpdates:
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_message_chunk_structure(self) -> None:
|
||||
mock_env = get_mocking_env([mock_llm_chunk(content="Hi") for _ in range(2)])
|
||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
||||
# Check stderr for error details if process failed
|
||||
if process.returncode is not None and process.stderr:
|
||||
stderr_data = await process.stderr.read()
|
||||
if stderr_data:
|
||||
# Log stderr for debugging test failures
|
||||
pass # Could add proper logging here if needed
|
||||
|
||||
session_id = await initialize_session(process)
|
||||
|
||||
await send_json_rpc(
|
||||
process,
|
||||
PromptJsonRpcRequest(
|
||||
id=3,
|
||||
params=PromptRequest(
|
||||
sessionId=session_id,
|
||||
prompt=[TextContentBlock(type="text", text="Just say hi")],
|
||||
),
|
||||
),
|
||||
)
|
||||
text_response = await read_response(process)
|
||||
assert text_response is not None
|
||||
response = UpdateJsonRpcNotification.model_validate(
|
||||
json.loads(text_response)
|
||||
)
|
||||
|
||||
assert response.params is not None
|
||||
assert response.params.update.sessionUpdate == "agent_message_chunk"
|
||||
assert response.params.update.content is not None
|
||||
assert response.params.update.content.type == "text"
|
||||
assert response.params.update.content.text is not None
|
||||
assert response.params.update.content.text == "Hi"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_update_structure(self) -> None:
|
||||
mock_env = get_mocking_env([
|
||||
mock_llm_chunk(content="Hey"),
|
||||
mock_llm_chunk(
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="grep", arguments='{"pattern": "auth"}'
|
||||
),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
],
|
||||
name="bash",
|
||||
finish_reason="tool_calls",
|
||||
),
|
||||
mock_llm_chunk(
|
||||
content="The files containing the pattern 'auth' are ...",
|
||||
finish_reason="stop",
|
||||
),
|
||||
])
|
||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
||||
session_id = await initialize_session(process)
|
||||
|
||||
await send_json_rpc(
|
||||
process,
|
||||
PromptJsonRpcRequest(
|
||||
id=3,
|
||||
params=PromptRequest(
|
||||
sessionId=session_id,
|
||||
prompt=[
|
||||
TextContentBlock(
|
||||
type="text",
|
||||
text="Show me files that are related to auth",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
text_responses = await read_multiple_responses(process, max_count=10)
|
||||
assert len(text_responses) > 0
|
||||
responses = [
|
||||
UpdateJsonRpcNotification.model_validate(json.loads(r))
|
||||
for r in text_responses
|
||||
]
|
||||
|
||||
tool_call = next(
|
||||
(
|
||||
r
|
||||
for r in responses
|
||||
if isinstance(r, UpdateJsonRpcNotification)
|
||||
and r.params is not None
|
||||
and r.params.update.sessionUpdate == "tool_call"
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert tool_call is not None
|
||||
assert tool_call.params is not None
|
||||
assert tool_call.params.update is not None
|
||||
|
||||
assert tool_call.params.update.sessionUpdate == "tool_call"
|
||||
assert tool_call.params.update.kind == "search"
|
||||
assert tool_call.params.update.title == "grep: 'auth'"
|
||||
assert (
|
||||
tool_call.params.update.rawInput
|
||||
== '{"pattern":"auth","path":".","max_matches":null,"use_default_ignore":true}'
|
||||
)
|
||||
|
||||
|
||||
async def start_session_with_request_permission(
|
||||
process: asyncio.subprocess.Process, prompt: str
|
||||
) -> RequestPermissionJsonRpcRequest:
|
||||
session_id = await initialize_session(process)
|
||||
await send_json_rpc(
|
||||
process,
|
||||
PromptJsonRpcRequest(
|
||||
id=3,
|
||||
params=PromptRequest(
|
||||
sessionId=session_id,
|
||||
prompt=[TextContentBlock(type="text", text=prompt)],
|
||||
),
|
||||
),
|
||||
)
|
||||
text_responses = await read_multiple_responses(
|
||||
process, max_count=15, timeout_per_response=2.0
|
||||
)
|
||||
|
||||
responses = parse_conversation(text_responses)
|
||||
last_response = responses[-1]
|
||||
|
||||
assert isinstance(last_response, RequestPermissionJsonRpcRequest)
|
||||
assert last_response.params is not None
|
||||
assert len(last_response.params.options) == 3
|
||||
return last_response
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="Disabled until we have a way to properly mock the fs and acp interactions"
|
||||
)
|
||||
class TestToolCallStructure:
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_request_permission_structure(self) -> None:
|
||||
custom_results = [
|
||||
mock_llm_chunk(content="Hey"),
|
||||
mock_llm_chunk(
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="write_file",
|
||||
arguments='{"path":"test.txt","content":"hello, world!"'
|
||||
',"overwrite":true}',
|
||||
),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
],
|
||||
name="write_file",
|
||||
finish_reason="stop",
|
||||
),
|
||||
]
|
||||
mock_env = get_mocking_env(custom_results)
|
||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
||||
session_id = await initialize_session(process)
|
||||
await send_json_rpc(
|
||||
process,
|
||||
PromptJsonRpcRequest(
|
||||
id=3,
|
||||
params=PromptRequest(
|
||||
sessionId=session_id,
|
||||
prompt=[
|
||||
TextContentBlock(
|
||||
type="text",
|
||||
text="Create a new file named test.txt "
|
||||
"with content 'hello, world!'",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
text_responses = await read_multiple_responses(process, max_count=3)
|
||||
responses = parse_conversation(text_responses)
|
||||
|
||||
# Look for tool call request permission updates
|
||||
permission_requests = [
|
||||
r for r in responses if isinstance(r, RequestPermissionJsonRpcRequest)
|
||||
]
|
||||
|
||||
assert len(permission_requests) > 0, (
|
||||
"No tool call permission requests found"
|
||||
)
|
||||
|
||||
first_request = permission_requests[0]
|
||||
assert first_request.params is not None
|
||||
assert first_request.params.toolCall is not None
|
||||
assert first_request.params.toolCall.toolCallId is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_update_approved_structure(self) -> None:
|
||||
custom_results = [
|
||||
mock_llm_chunk(content="Hey"),
|
||||
mock_llm_chunk(
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="write_file",
|
||||
arguments='{"path":"test.txt","content":"hello, world!"'
|
||||
',"overwrite":true}',
|
||||
),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
],
|
||||
name="write_file",
|
||||
finish_reason="tool_calls",
|
||||
),
|
||||
mock_llm_chunk(
|
||||
content="The file test.txt has been created", finish_reason="stop"
|
||||
),
|
||||
]
|
||||
mock_env = get_mocking_env(custom_results)
|
||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
||||
permission_request = await start_session_with_request_permission(
|
||||
process, "Create a file named test.txt"
|
||||
)
|
||||
assert permission_request.params is not None
|
||||
selected_option_id = ToolOption.ALLOW_ONCE
|
||||
await send_json_rpc(
|
||||
process,
|
||||
RequestPermissionJsonRpcResponse(
|
||||
id=permission_request.id,
|
||||
result=RequestPermissionResponse(
|
||||
outcome=AllowedOutcome(
|
||||
outcome="selected", optionId=selected_option_id
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
text_responses = await read_multiple_responses(process, max_count=7)
|
||||
responses = parse_conversation(text_responses)
|
||||
|
||||
approved_tool_call = next(
|
||||
(
|
||||
r
|
||||
for r in responses
|
||||
if isinstance(r, UpdateJsonRpcNotification)
|
||||
and r.method == "session/update"
|
||||
and r.params is not None
|
||||
and r.params.update is not None
|
||||
and r.params.update.sessionUpdate == "tool_call_update"
|
||||
and r.params.update.toolCallId
|
||||
== (permission_request.params.toolCall.toolCallId)
|
||||
and r.params.update.status == "completed"
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert approved_tool_call is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_update_rejected_structure(self) -> None:
|
||||
custom_results = [
|
||||
mock_llm_chunk(content="Hey"),
|
||||
mock_llm_chunk(
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="write_file",
|
||||
arguments='{"path":"test.txt","content":"hello, world!"'
|
||||
',"overwrite":false}',
|
||||
),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
],
|
||||
name="write_file",
|
||||
finish_reason="tool_calls",
|
||||
),
|
||||
mock_llm_chunk(
|
||||
content="The file test.txt has not been created, "
|
||||
"because you rejected the permission request",
|
||||
finish_reason="stop",
|
||||
),
|
||||
]
|
||||
mock_env = get_mocking_env(custom_results)
|
||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
||||
permission_request = await start_session_with_request_permission(
|
||||
process, "Create a file named test.txt"
|
||||
)
|
||||
assert permission_request.params is not None
|
||||
selected_option_id = ToolOption.REJECT_ONCE
|
||||
|
||||
await send_json_rpc(
|
||||
process,
|
||||
RequestPermissionJsonRpcResponse(
|
||||
id=permission_request.id,
|
||||
result=RequestPermissionResponse(
|
||||
outcome=AllowedOutcome(
|
||||
outcome="selected", optionId=selected_option_id
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
text_responses = await read_multiple_responses(process, max_count=5)
|
||||
responses = parse_conversation(text_responses)
|
||||
|
||||
rejected_tool_call = next(
|
||||
(
|
||||
r
|
||||
for r in responses
|
||||
if isinstance(r, UpdateJsonRpcNotification)
|
||||
and r.method == "session/update"
|
||||
and r.params is not None
|
||||
and r.params.update.sessionUpdate == "tool_call_update"
|
||||
and r.params.update.toolCallId
|
||||
== (permission_request.params.toolCall.toolCallId)
|
||||
and r.params.update.status == "failed"
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert rejected_tool_call is not None
|
||||
|
||||
@pytest.mark.skip(reason="Long running tool call updates are not implemented yet")
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_in_progress_update_structure(self) -> None:
|
||||
custom_results = [
|
||||
mock_llm_chunk(content="Hey"),
|
||||
mock_llm_chunk(
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="bash",
|
||||
arguments='{"command":"sleep 3","timeout":null}',
|
||||
),
|
||||
type="function",
|
||||
)
|
||||
],
|
||||
name="bash",
|
||||
finish_reason="tool_calls",
|
||||
),
|
||||
mock_llm_chunk(
|
||||
content="The command sleep 3 has been run", finish_reason="stop"
|
||||
),
|
||||
]
|
||||
mock_env = get_mocking_env(custom_results)
|
||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
||||
session_id = await initialize_session(process)
|
||||
await send_json_rpc(
|
||||
process,
|
||||
PromptJsonRpcRequest(
|
||||
id=3,
|
||||
params=PromptRequest(
|
||||
sessionId=session_id,
|
||||
prompt=[
|
||||
TextContentBlock(
|
||||
type="text", text="Run sleep 3 in the current directory"
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
text_responses = await read_multiple_responses(process, max_count=4)
|
||||
responses = parse_conversation(text_responses)
|
||||
|
||||
# Look for tool call in progress updates
|
||||
in_progress_calls = [
|
||||
r
|
||||
for r in responses
|
||||
if isinstance(r, UpdateJsonRpcNotification)
|
||||
and r.params is not None
|
||||
and r.params.update.sessionUpdate == "tool_call_update"
|
||||
and r.params.update.status == "in_progress"
|
||||
]
|
||||
|
||||
assert len(in_progress_calls) > 0, (
|
||||
"No tool call in progress updates found for a long running command"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_result_update_failure_structure(self) -> None:
|
||||
custom_results = [
|
||||
mock_llm_chunk(content="Hey"),
|
||||
mock_llm_chunk(
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="write_file",
|
||||
arguments='{"path":"/test.txt","content":"hello, world!"'
|
||||
',"overwrite":true}',
|
||||
),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
],
|
||||
name="write_file",
|
||||
finish_reason="tool_calls",
|
||||
),
|
||||
mock_llm_chunk(
|
||||
content="The file /test.txt has not been created "
|
||||
"because it's outside the project directory",
|
||||
finish_reason="stop",
|
||||
),
|
||||
]
|
||||
mock_env = get_mocking_env(custom_results)
|
||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
||||
permission_request = await start_session_with_request_permission(
|
||||
process, "Create a file named /test.txt"
|
||||
)
|
||||
assert permission_request.params is not None
|
||||
selected_option_id = ToolOption.ALLOW_ONCE
|
||||
await send_json_rpc(
|
||||
process,
|
||||
RequestPermissionJsonRpcResponse(
|
||||
id=permission_request.id,
|
||||
result=RequestPermissionResponse(
|
||||
outcome=AllowedOutcome(
|
||||
outcome="selected", optionId=selected_option_id
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
text_responses = await read_multiple_responses(process, max_count=7)
|
||||
responses = parse_conversation(text_responses)
|
||||
|
||||
# Look for tool call result failure updates
|
||||
failure_result = next(
|
||||
(
|
||||
r
|
||||
for r in responses
|
||||
if isinstance(r, UpdateJsonRpcNotification)
|
||||
and r.params is not None
|
||||
and r.params.update.sessionUpdate == "tool_call_update"
|
||||
and r.params.update.status == "failed"
|
||||
and r.params.update.rawOutput is not None
|
||||
and r.params.update.toolCallId is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
assert failure_result is not None
|
||||
|
||||
|
||||
class TestCancellationStructure:
|
||||
@pytest.mark.skip(
|
||||
reason="Proper cancellation is not implemented yet, we still need to return "
|
||||
"the right end_turn and be able to cancel at any point in time "
|
||||
"(and not only at tool call time)"
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_update_cancelled_structure(self) -> None:
|
||||
custom_results = [
|
||||
mock_llm_chunk(content="Hey"),
|
||||
mock_llm_chunk(
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="write_file",
|
||||
arguments='{"path":"test.txt","content":"hello, world!"'
|
||||
',"overwrite":false}',
|
||||
),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
],
|
||||
name="write_file",
|
||||
finish_reason="tool_calls",
|
||||
),
|
||||
mock_llm_chunk(
|
||||
content="The file test.txt has not been created, "
|
||||
"because you cancelled the permission request",
|
||||
finish_reason="stop",
|
||||
),
|
||||
]
|
||||
mock_env = get_mocking_env(custom_results)
|
||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
||||
permission_request = await start_session_with_request_permission(
|
||||
process, "Create a file named test.txt"
|
||||
)
|
||||
assert permission_request.params is not None
|
||||
|
||||
await send_json_rpc(
|
||||
process,
|
||||
RequestPermissionJsonRpcResponse(
|
||||
id=permission_request.id,
|
||||
result=RequestPermissionResponse(
|
||||
outcome=DeniedOutcome(outcome="cancelled")
|
||||
),
|
||||
),
|
||||
)
|
||||
text_responses = await read_multiple_responses(process, max_count=5)
|
||||
responses = parse_conversation(text_responses)
|
||||
|
||||
assert len(responses) == 2, (
|
||||
"There should be only 2 responses: "
|
||||
"the tool call update and the prompt end turn"
|
||||
)
|
||||
|
||||
cancelled_tool_call = next(
|
||||
(
|
||||
r
|
||||
for r in responses
|
||||
if isinstance(r, UpdateJsonRpcNotification)
|
||||
and r.method == "session/update"
|
||||
and r.params is not None
|
||||
and r.params.update.sessionUpdate == "tool_call_update"
|
||||
and r.params.update.toolCallId
|
||||
== (permission_request.params.toolCall.toolCallId)
|
||||
and r.params.update.status == "failed"
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert cancelled_tool_call is not None
|
||||
|
||||
cancelled_prompt_response = next(
|
||||
(
|
||||
r
|
||||
for r in responses
|
||||
if isinstance(r, PromptJsonRpcResponse)
|
||||
and r.result is not None
|
||||
and r.result.stopReason == "cancelled"
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert cancelled_prompt_response is not None
|
||||
536
tests/acp/test_bash.py
Normal file
536
tests/acp/test_bash.py
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from acp.schema import TerminalOutputResponse, WaitForTerminalExitResponse
|
||||
import pytest
|
||||
|
||||
from vibe.acp.tools.builtins.bash import AcpBashState, Bash
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.builtins.bash import BashArgs, BashResult, BashToolConfig
|
||||
|
||||
|
||||
class MockTerminalHandle:
|
||||
def __init__(
|
||||
self,
|
||||
terminal_id: str = "test_terminal_123",
|
||||
exit_code: int | None = 0,
|
||||
output: str = "test output",
|
||||
wait_delay: float = 0.01,
|
||||
) -> None:
|
||||
self.id = terminal_id
|
||||
self._exit_code = exit_code
|
||||
self._output = output
|
||||
self._wait_delay = wait_delay
|
||||
self._killed = False
|
||||
|
||||
async def wait_for_exit(self) -> WaitForTerminalExitResponse:
|
||||
await asyncio.sleep(self._wait_delay)
|
||||
return WaitForTerminalExitResponse(exitCode=self._exit_code)
|
||||
|
||||
async def current_output(self) -> TerminalOutputResponse:
|
||||
return TerminalOutputResponse(output=self._output, truncated=False)
|
||||
|
||||
async def kill(self) -> None:
|
||||
self._killed = True
|
||||
|
||||
async def release(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class MockConnection:
|
||||
def __init__(self, terminal_handle: MockTerminalHandle | None = None) -> None:
|
||||
self._terminal_handle = terminal_handle or MockTerminalHandle()
|
||||
self._create_terminal_called = False
|
||||
self._session_update_called = False
|
||||
self._create_terminal_error: Exception | None = None
|
||||
self._last_create_request = None
|
||||
|
||||
async def createTerminal(self, request) -> MockTerminalHandle:
|
||||
self._create_terminal_called = True
|
||||
self._last_create_request = request
|
||||
if self._create_terminal_error:
|
||||
raise self._create_terminal_error
|
||||
return self._terminal_handle
|
||||
|
||||
async def sessionUpdate(self, notification) -> None:
|
||||
self._session_update_called = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connection() -> MockConnection:
|
||||
return MockConnection()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_bash_tool(mock_connection: MockConnection) -> Bash:
|
||||
config = BashToolConfig()
|
||||
# Use model_construct to bypass Pydantic validation for testing
|
||||
state = AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session_123",
|
||||
tool_call_id="test_tool_call_456",
|
||||
)
|
||||
return Bash(config=config, state=state)
|
||||
|
||||
|
||||
class TestAcpBashBasic:
|
||||
def test_get_name(self) -> None:
|
||||
assert Bash.get_name() == "bash"
|
||||
|
||||
def test_get_summary_simple_command(self) -> None:
|
||||
args = BashArgs(command="ls")
|
||||
display = Bash.get_summary(args)
|
||||
assert display == "ls"
|
||||
|
||||
def test_get_summary_with_timeout(self) -> None:
|
||||
args = BashArgs(command="ls", timeout=10)
|
||||
display = Bash.get_summary(args)
|
||||
assert display == "ls (timeout 10s)"
|
||||
|
||||
def test_parse_command_simple(self) -> None:
|
||||
tool = Bash(config=BashToolConfig(), state=AcpBashState())
|
||||
env, command, args = tool._parse_command("ls")
|
||||
assert env == []
|
||||
assert command == "ls"
|
||||
assert args == []
|
||||
|
||||
def test_parse_command_with_args(self) -> None:
|
||||
tool = Bash(config=BashToolConfig(), state=AcpBashState())
|
||||
env, command, args = tool._parse_command("ls -la src")
|
||||
assert env == []
|
||||
assert command == "ls"
|
||||
assert args == ["-la", "src"]
|
||||
|
||||
def test_parse_command_with_env(self) -> None:
|
||||
tool = Bash(config=BashToolConfig(), state=AcpBashState())
|
||||
env, command, args = tool._parse_command("NODE_ENV=test DEBUG=1 npm test")
|
||||
assert len(env) == 2
|
||||
assert env[0].name == "NODE_ENV"
|
||||
assert env[0].value == "test"
|
||||
assert env[1].name == "DEBUG"
|
||||
assert env[1].value == "1"
|
||||
assert command == "npm"
|
||||
assert args == ["test"]
|
||||
|
||||
def test_parse_command_with_env_value_contains_equals(self) -> None:
|
||||
tool = Bash(config=BashToolConfig(), state=AcpBashState())
|
||||
env, command, args = tool._parse_command(
|
||||
"PATH=/usr/bin:/usr/local/bin echo hello"
|
||||
)
|
||||
assert len(env) == 1
|
||||
assert env[0].name == "PATH"
|
||||
assert env[0].value == "/usr/bin:/usr/local/bin"
|
||||
assert command == "echo"
|
||||
assert args == ["hello"]
|
||||
|
||||
|
||||
class TestAcpBashExecution:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_success(
|
||||
self, acp_bash_tool: Bash, mock_connection: MockConnection
|
||||
) -> None:
|
||||
from pathlib import Path
|
||||
|
||||
args = BashArgs(command="echo hello")
|
||||
result = await acp_bash_tool.run(args)
|
||||
|
||||
assert isinstance(result, BashResult)
|
||||
assert result.stdout == "test output"
|
||||
assert result.stderr == ""
|
||||
assert result.returncode == 0
|
||||
assert mock_connection._create_terminal_called
|
||||
|
||||
# Verify CreateTerminalRequest was created correctly
|
||||
request = mock_connection._last_create_request
|
||||
assert request is not None
|
||||
assert request.sessionId == "test_session_123"
|
||||
assert request.command == "echo"
|
||||
assert request.args == ["hello"]
|
||||
assert request.cwd == str(Path.cwd()) # effective_workdir defaults to cwd
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_creates_terminal_with_env_vars(
|
||||
self, mock_connection: MockConnection
|
||||
) -> None:
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="NODE_ENV=test npm run build")
|
||||
await tool.run(args)
|
||||
|
||||
request = mock_connection._last_create_request
|
||||
assert request is not None
|
||||
assert len(request.env) == 1
|
||||
assert request.env[0].name == "NODE_ENV"
|
||||
assert request.env[0].value == "test"
|
||||
assert request.command == "npm"
|
||||
assert request.args == ["run", "build"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_nonzero_exit_code(
|
||||
self, mock_connection: MockConnection
|
||||
) -> None:
|
||||
custom_handle = MockTerminalHandle(
|
||||
terminal_id="custom_terminal", exit_code=1, output="error: command failed"
|
||||
)
|
||||
mock_connection._terminal_handle = custom_handle
|
||||
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test_command")
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
== "Command failed: 'test_command'\nReturn code: 1\nStdout: error: command failed"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_create_terminal_failure(
|
||||
self, mock_connection: MockConnection
|
||||
) -> None:
|
||||
mock_connection._create_terminal_error = RuntimeError("Connection failed")
|
||||
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
== "Failed to create terminal: RuntimeError('Connection failed')"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_without_connection(self) -> None:
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=None, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
== "Connection not available in tool state. This tool can only be used within an ACP session."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_without_session_id(self) -> None:
|
||||
mock_connection = MockConnection()
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id=None,
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
== "Session ID not available in tool state. This tool can only be used within an ACP session."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_none_exit_code(
|
||||
self, mock_connection: MockConnection
|
||||
) -> None:
|
||||
custom_handle = MockTerminalHandle(
|
||||
terminal_id="none_exit_terminal", exit_code=None, output="output"
|
||||
)
|
||||
mock_connection._terminal_handle = custom_handle
|
||||
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test_command")
|
||||
result = await tool.run(args)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "output"
|
||||
|
||||
|
||||
class TestAcpBashTimeout:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_timeout_raises_error_and_kills(
|
||||
self, mock_connection: MockConnection
|
||||
) -> None:
|
||||
custom_handle = MockTerminalHandle(
|
||||
terminal_id="timeout_terminal",
|
||||
output="partial output",
|
||||
wait_delay=20, # Longer than the 1 second timeout
|
||||
)
|
||||
mock_connection._terminal_handle = custom_handle
|
||||
|
||||
# Use a config with different default timeout to verify args timeout overrides it
|
||||
tool = Bash(
|
||||
config=BashToolConfig(default_timeout=30),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="slow_command", timeout=1)
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
|
||||
assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"
|
||||
assert custom_handle._killed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_timeout_handles_kill_failure(
|
||||
self, mock_connection: MockConnection
|
||||
) -> None:
|
||||
custom_handle = MockTerminalHandle(
|
||||
terminal_id="kill_failure_terminal",
|
||||
wait_delay=20, # Longer than the 1 second timeout
|
||||
)
|
||||
mock_connection._terminal_handle = custom_handle
|
||||
|
||||
async def failing_kill() -> None:
|
||||
raise RuntimeError("Kill failed")
|
||||
|
||||
custom_handle.kill = failing_kill
|
||||
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="slow_command", timeout=1)
|
||||
# Should still raise timeout error even if kill fails
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
|
||||
assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"
|
||||
|
||||
|
||||
class TestAcpBashEmbedding:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_embedding(self, mock_connection: MockConnection) -> None:
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
await tool.run(args)
|
||||
|
||||
assert mock_connection._session_update_called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_embedding_without_tool_call_id(
|
||||
self, mock_connection: MockConnection
|
||||
) -> None:
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id=None,
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
await tool.run(args)
|
||||
|
||||
# Embedding should be skipped when tool_call_id is None
|
||||
assert not mock_connection._session_update_called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_embedding_handles_exception(
|
||||
self, mock_connection: MockConnection
|
||||
) -> None:
|
||||
# Make sessionUpdate raise an exception
|
||||
async def failing_session_update(notification) -> None:
|
||||
raise RuntimeError("Session update failed")
|
||||
|
||||
mock_connection.sessionUpdate = failing_session_update
|
||||
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
# Should not raise, embedding failure is silently ignored
|
||||
result = await tool.run(args)
|
||||
|
||||
assert result is not None
|
||||
assert result.stdout == "test output"
|
||||
|
||||
|
||||
class TestAcpBashConfig:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_uses_config_default_timeout(
|
||||
self, mock_connection: MockConnection
|
||||
) -> None:
|
||||
custom_handle = MockTerminalHandle(
|
||||
terminal_id="config_timeout_terminal",
|
||||
wait_delay=0.01, # Shorter than config timeout
|
||||
)
|
||||
mock_connection._terminal_handle = custom_handle
|
||||
|
||||
tool = Bash(
|
||||
config=BashToolConfig(default_timeout=30),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="fast", timeout=None)
|
||||
result = await tool.run(args)
|
||||
|
||||
# Should succeed with config timeout
|
||||
assert result.returncode == 0
|
||||
|
||||
|
||||
class TestAcpBashCleanup:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_releases_terminal_on_success(
|
||||
self, mock_connection: MockConnection
|
||||
) -> None:
|
||||
custom_handle = MockTerminalHandle(terminal_id="cleanup_terminal")
|
||||
mock_connection._terminal_handle = custom_handle
|
||||
|
||||
release_called = False
|
||||
|
||||
async def mock_release() -> None:
|
||||
nonlocal release_called
|
||||
release_called = True
|
||||
|
||||
custom_handle.release = mock_release
|
||||
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
await tool.run(args)
|
||||
|
||||
assert release_called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_releases_terminal_on_timeout(
|
||||
self, mock_connection: MockConnection
|
||||
) -> None:
|
||||
# The handle will wait 2 seconds, but timeout is 1 second,
|
||||
# so asyncio.wait_for() will raise TimeoutError
|
||||
custom_handle = MockTerminalHandle(
|
||||
terminal_id="timeout_cleanup_terminal",
|
||||
wait_delay=2.0, # Longer than the 1 second timeout
|
||||
)
|
||||
mock_connection._terminal_handle = custom_handle
|
||||
|
||||
release_called = False
|
||||
|
||||
async def mock_release() -> None:
|
||||
nonlocal release_called
|
||||
release_called = True
|
||||
|
||||
custom_handle.release = mock_release
|
||||
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="slow", timeout=1)
|
||||
# Timeout raises an error, but terminal should still be released
|
||||
try:
|
||||
await tool.run(args)
|
||||
except ToolError:
|
||||
pass
|
||||
|
||||
assert release_called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_handles_release_failure(
|
||||
self, mock_connection: MockConnection
|
||||
) -> None:
|
||||
custom_handle = MockTerminalHandle(terminal_id="release_failure_terminal")
|
||||
|
||||
async def failing_release() -> None:
|
||||
raise RuntimeError("Release failed")
|
||||
|
||||
custom_handle.release = failing_release
|
||||
mock_connection._terminal_handle = custom_handle
|
||||
|
||||
tool = Bash(
|
||||
config=BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
# Should not raise, release failure is silently ignored
|
||||
result = await tool.run(args)
|
||||
|
||||
assert result is not None
|
||||
assert result.stdout == "test output"
|
||||
184
tests/acp/test_content.py
Normal file
184
tests/acp/test_content.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from acp import AgentSideConnection, NewSessionRequest, PromptRequest
|
||||
from acp.schema import (
|
||||
EmbeddedResourceContentBlock,
|
||||
ResourceContentBlock,
|
||||
TextContentBlock,
|
||||
TextResourceContents,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_connection import FakeAgentSideConnection
|
||||
from vibe.acp.acp_agent import VibeAcpAgent
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend() -> FakeBackend:
|
||||
backend = FakeBackend(
|
||||
results=[
|
||||
LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content="Hi"),
|
||||
finish_reason="end_turn",
|
||||
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
|
||||
)
|
||||
]
|
||||
)
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
||||
class PatchedAgent(Agent):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs, backend=backend)
|
||||
|
||||
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
|
||||
|
||||
vibe_acp_agent: VibeAcpAgent | None = None
|
||||
|
||||
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
|
||||
nonlocal vibe_acp_agent
|
||||
vibe_acp_agent = VibeAcpAgent(connection)
|
||||
return vibe_acp_agent
|
||||
|
||||
FakeAgentSideConnection(_create_agent)
|
||||
return vibe_acp_agent # pyright: ignore[reportReturnType]
|
||||
|
||||
|
||||
class TestACPContent:
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_content(
|
||||
self, acp_agent: VibeAcpAgent, backend: FakeBackend
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
prompt_request = PromptRequest(
|
||||
prompt=[TextContentBlock(type="text", text="Say hi")],
|
||||
sessionId=session_response.sessionId,
|
||||
)
|
||||
|
||||
response = await acp_agent.prompt(params=prompt_request)
|
||||
|
||||
assert response.stopReason == "end_turn"
|
||||
user_message = next(
|
||||
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
|
||||
None,
|
||||
)
|
||||
assert user_message is not None, "User message not found in backend requests"
|
||||
assert user_message.content == "Say hi"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_content(
|
||||
self, acp_agent: VibeAcpAgent, backend: FakeBackend
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
prompt_request = PromptRequest(
|
||||
prompt=[
|
||||
TextContentBlock(type="text", text="What does this file do?"),
|
||||
EmbeddedResourceContentBlock(
|
||||
type="resource",
|
||||
resource=TextResourceContents(
|
||||
uri="file:///home/my_file.py",
|
||||
text="def hello():\n print('Hello, world!')",
|
||||
mimeType="text/x-python",
|
||||
),
|
||||
),
|
||||
],
|
||||
sessionId=session_response.sessionId,
|
||||
)
|
||||
|
||||
response = await acp_agent.prompt(params=prompt_request)
|
||||
|
||||
assert response.stopReason == "end_turn"
|
||||
user_message = next(
|
||||
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
|
||||
None,
|
||||
)
|
||||
assert user_message is not None, "User message not found in backend requests"
|
||||
expected_content = (
|
||||
"What does this file do?"
|
||||
+ "\n\npath: file:///home/my_file.py"
|
||||
+ "\ncontent: def hello():\n print('Hello, world!')"
|
||||
)
|
||||
assert user_message.content == expected_content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_link_content(
|
||||
self, acp_agent: VibeAcpAgent, backend: FakeBackend
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
prompt_request = PromptRequest(
|
||||
prompt=[
|
||||
TextContentBlock(type="text", text="Analyze this resource"),
|
||||
ResourceContentBlock(
|
||||
type="resource_link",
|
||||
uri="file:///home/document.pdf",
|
||||
name="document.pdf",
|
||||
title="Important Document",
|
||||
description="A PDF document containing project specifications",
|
||||
mimeType="application/pdf",
|
||||
size=1024,
|
||||
),
|
||||
],
|
||||
sessionId=session_response.sessionId,
|
||||
)
|
||||
|
||||
response = await acp_agent.prompt(params=prompt_request)
|
||||
|
||||
assert response.stopReason == "end_turn"
|
||||
user_message = next(
|
||||
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
|
||||
None,
|
||||
)
|
||||
assert user_message is not None, "User message not found in backend requests"
|
||||
expected_content = (
|
||||
"Analyze this resource"
|
||||
+ "\n\nuri: file:///home/document.pdf"
|
||||
+ "\nname: document.pdf"
|
||||
+ "\ntitle: Important Document"
|
||||
+ "\ndescription: A PDF document containing project specifications"
|
||||
+ "\nmimeType: application/pdf"
|
||||
+ "\nsize: 1024"
|
||||
)
|
||||
assert user_message.content == expected_content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_link_minimal(
|
||||
self, acp_agent: VibeAcpAgent, backend: FakeBackend
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
prompt_request = PromptRequest(
|
||||
prompt=[
|
||||
ResourceContentBlock(
|
||||
type="resource_link",
|
||||
uri="file:///home/minimal.txt",
|
||||
name="minimal.txt",
|
||||
)
|
||||
],
|
||||
sessionId=session_response.sessionId,
|
||||
)
|
||||
|
||||
response = await acp_agent.prompt(params=prompt_request)
|
||||
|
||||
assert response.stopReason == "end_turn"
|
||||
user_message = next(
|
||||
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
|
||||
None,
|
||||
)
|
||||
assert user_message is not None, "User message not found in backend requests"
|
||||
expected_content = "uri: file:///home/minimal.txt\nname: minimal.txt"
|
||||
assert user_message.content == expected_content
|
||||
161
tests/acp/test_multi_session.py
Normal file
161
tests/acp/test_multi_session.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from acp import (
|
||||
PROTOCOL_VERSION,
|
||||
InitializeRequest,
|
||||
NewSessionRequest,
|
||||
PromptRequest,
|
||||
RequestError,
|
||||
)
|
||||
from acp.schema import TextContentBlock
|
||||
import pytest
|
||||
from pytest import raises
|
||||
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_connection import FakeAgentSideConnection
|
||||
from vibe.acp.acp_agent import VibeAcpAgent
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.config import ModelConfig, VibeConfig
|
||||
from vibe.core.types import Role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend() -> FakeBackend:
|
||||
backend = FakeBackend()
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
||||
config = VibeConfig(
|
||||
active_model="devstral-latest",
|
||||
models=[
|
||||
ModelConfig(
|
||||
name="devstral-latest", provider="mistral", alias="devstral-latest"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
class PatchedAgent(Agent):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.backend = backend
|
||||
self.config = config
|
||||
|
||||
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
|
||||
|
||||
vibe_acp_agent: VibeAcpAgent | None = None
|
||||
|
||||
def _create_agent(connection: Any) -> VibeAcpAgent:
|
||||
nonlocal vibe_acp_agent
|
||||
vibe_acp_agent = VibeAcpAgent(connection)
|
||||
return vibe_acp_agent
|
||||
|
||||
FakeAgentSideConnection(_create_agent)
|
||||
return vibe_acp_agent # pyright: ignore[reportReturnType]
|
||||
|
||||
|
||||
class TestMultiSessionCore:
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_sessions_use_different_agents(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
) -> None:
|
||||
await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
|
||||
session1_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session1 = acp_agent.sessions[session1_response.sessionId]
|
||||
session2_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session2 = acp_agent.sessions[session2_response.sessionId]
|
||||
|
||||
assert session1.id != session2.id
|
||||
# Each agent should be independent
|
||||
assert session1.agent is not session2.agent
|
||||
assert id(session1.agent) != id(session2.agent)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_on_nonexistent_session(self, acp_agent: VibeAcpAgent) -> None:
|
||||
await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
|
||||
await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
|
||||
fake_session_id = "fake-session-id-" + str(uuid4())
|
||||
|
||||
with raises(RequestError) as exc_info:
|
||||
await acp_agent.prompt(
|
||||
PromptRequest(
|
||||
sessionId=fake_session_id,
|
||||
prompt=[TextContentBlock(type="text", text="Hello, world!")],
|
||||
)
|
||||
)
|
||||
|
||||
assert isinstance(exc_info.value, RequestError)
|
||||
assert str(exc_info.value) == "Invalid params"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simultaneous_message_processing(
|
||||
self, acp_agent: VibeAcpAgent, backend: FakeBackend
|
||||
) -> None:
|
||||
await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
|
||||
session1_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session1 = acp_agent.sessions[session1_response.sessionId]
|
||||
session2_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session2 = acp_agent.sessions[session2_response.sessionId]
|
||||
|
||||
backend._chunks = [
|
||||
mock_llm_chunk(content="Response 1", finish_reason="stop"),
|
||||
mock_llm_chunk(content="Response 2", finish_reason="stop"),
|
||||
]
|
||||
|
||||
async def run_session1():
|
||||
await acp_agent.prompt(
|
||||
PromptRequest(
|
||||
sessionId=session1.id,
|
||||
prompt=[TextContentBlock(type="text", text="Prompt for session 1")],
|
||||
)
|
||||
)
|
||||
|
||||
async def run_session2():
|
||||
await acp_agent.prompt(
|
||||
PromptRequest(
|
||||
sessionId=session2.id,
|
||||
prompt=[TextContentBlock(type="text", text="Prompt for session 2")],
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(run_session1(), run_session2())
|
||||
|
||||
user_message1 = next(
|
||||
(msg for msg in session1.agent.messages if msg.role == Role.user), None
|
||||
)
|
||||
assert user_message1 is not None
|
||||
assert user_message1.content == "Prompt for session 1"
|
||||
assistant_message1 = next(
|
||||
(msg for msg in session1.agent.messages if msg.role == Role.assistant), None
|
||||
)
|
||||
assert assistant_message1 is not None
|
||||
assert assistant_message1.content == "Response 1"
|
||||
user_message2 = next(
|
||||
(msg for msg in session2.agent.messages if msg.role == Role.user), None
|
||||
)
|
||||
assert user_message2 is not None
|
||||
assert user_message2.content == "Prompt for session 2"
|
||||
assistant_message2 = next(
|
||||
(msg for msg in session2.agent.messages if msg.role == Role.assistant), None
|
||||
)
|
||||
assert assistant_message2 is not None
|
||||
assert assistant_message2.content == "Response 2"
|
||||
140
tests/acp/test_new_session.py
Normal file
140
tests/acp/test_new_session.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from acp import AgentSideConnection, NewSessionRequest, SetSessionModelRequest
|
||||
import pytest
|
||||
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_connection import FakeAgentSideConnection
|
||||
from vibe.acp.acp_agent import VibeAcpAgent
|
||||
from vibe.acp.utils import VibeSessionMode
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.config import ModelConfig, VibeConfig
|
||||
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend() -> FakeBackend:
|
||||
backend = FakeBackend(
|
||||
results=[
|
||||
LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content="Hi"),
|
||||
finish_reason="end_turn",
|
||||
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
|
||||
)
|
||||
]
|
||||
)
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
||||
config = VibeConfig(
|
||||
active_model="devstral-latest",
|
||||
models=[
|
||||
ModelConfig(
|
||||
name="devstral-latest", provider="mistral", alias="devstral-latest"
|
||||
),
|
||||
ModelConfig(
|
||||
name="devstral-small", provider="mistral", alias="devstral-small"
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
class PatchedAgent(Agent):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **{**kwargs, "backend": backend})
|
||||
self.config = config
|
||||
|
||||
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
|
||||
|
||||
vibe_acp_agent: VibeAcpAgent | None = None
|
||||
|
||||
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
|
||||
nonlocal vibe_acp_agent
|
||||
vibe_acp_agent = VibeAcpAgent(connection)
|
||||
return vibe_acp_agent
|
||||
|
||||
FakeAgentSideConnection(_create_agent)
|
||||
return vibe_acp_agent # pyright: ignore[reportReturnType]
|
||||
|
||||
|
||||
class TestACPNewSession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_response_structure(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
|
||||
assert session_response.sessionId is not None
|
||||
acp_session = next(
|
||||
(
|
||||
s
|
||||
for s in acp_agent.sessions.values()
|
||||
if s.id == session_response.sessionId
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert acp_session is not None
|
||||
assert (
|
||||
acp_session.agent.interaction_logger.session_id
|
||||
== session_response.sessionId
|
||||
)
|
||||
|
||||
assert session_response.sessionId == acp_session.agent.session_id
|
||||
|
||||
assert session_response.models is not None
|
||||
assert session_response.models.currentModelId is not None
|
||||
assert session_response.models.availableModels is not None
|
||||
assert len(session_response.models.availableModels) == 2
|
||||
|
||||
assert session_response.models.currentModelId == "devstral-latest"
|
||||
assert session_response.models.availableModels[0].modelId == "devstral-latest"
|
||||
assert session_response.models.availableModels[0].name == "devstral-latest"
|
||||
assert session_response.models.availableModels[1].modelId == "devstral-small"
|
||||
assert session_response.models.availableModels[1].name == "devstral-small"
|
||||
|
||||
assert session_response.modes is not None
|
||||
assert session_response.modes.currentModeId is not None
|
||||
assert session_response.modes.availableModes is not None
|
||||
assert len(session_response.modes.availableModes) == 2
|
||||
|
||||
assert session_response.modes.currentModeId == VibeSessionMode.APPROVAL_REQUIRED
|
||||
assert (
|
||||
session_response.modes.availableModes[0].id
|
||||
== VibeSessionMode.APPROVAL_REQUIRED
|
||||
)
|
||||
assert session_response.modes.availableModes[0].name == "Approval Required"
|
||||
assert (
|
||||
session_response.modes.availableModes[1].id == VibeSessionMode.AUTO_APPROVE
|
||||
)
|
||||
assert session_response.modes.availableModes[1].name == "Auto Approve"
|
||||
|
||||
@pytest.mark.skip(reason="TODO: Fix this test")
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_preserves_model_after_set_model(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
|
||||
assert session_response.models is not None
|
||||
assert session_response.models.currentModelId == "devstral-latest"
|
||||
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
|
||||
)
|
||||
assert response is not None
|
||||
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
|
||||
assert session_response.models is not None
|
||||
assert session_response.models.currentModelId == "devstral-small"
|
||||
240
tests/acp/test_read_file.py
Normal file
240
tests/acp/test_read_file.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from acp import ReadTextFileRequest, ReadTextFileResponse
|
||||
import pytest
|
||||
|
||||
from vibe.acp.tools.builtins.read_file import AcpReadFileState, ReadFile
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.builtins.read_file import (
|
||||
ReadFileArgs,
|
||||
ReadFileResult,
|
||||
ReadFileToolConfig,
|
||||
)
|
||||
|
||||
|
||||
class MockConnection:
|
||||
def __init__(
|
||||
self,
|
||||
file_content: str = "line 1\nline 2\nline 3",
|
||||
read_error: Exception | None = None,
|
||||
) -> None:
|
||||
self._file_content = file_content
|
||||
self._read_error = read_error
|
||||
self._read_text_file_called = False
|
||||
self._session_update_called = False
|
||||
self._last_read_request: ReadTextFileRequest | None = None
|
||||
|
||||
async def readTextFile(self, request: ReadTextFileRequest) -> ReadTextFileResponse:
|
||||
self._read_text_file_called = True
|
||||
self._last_read_request = request
|
||||
|
||||
if self._read_error:
|
||||
raise self._read_error
|
||||
|
||||
content = self._file_content
|
||||
if request.line is not None or request.limit is not None:
|
||||
lines = content.splitlines(keepends=True)
|
||||
start_line = (request.line or 1) - 1 # Convert to 0-indexed
|
||||
end_line = (
|
||||
start_line + request.limit if request.limit is not None else len(lines)
|
||||
)
|
||||
lines = lines[start_line:end_line]
|
||||
content = "".join(lines)
|
||||
|
||||
return ReadTextFileResponse(content=content)
|
||||
|
||||
async def sessionUpdate(self, notification) -> None:
|
||||
self._session_update_called = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connection() -> MockConnection:
|
||||
return MockConnection()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_read_file_tool(mock_connection: MockConnection, tmp_path: Path) -> ReadFile:
|
||||
config = ReadFileToolConfig(workdir=tmp_path)
|
||||
state = AcpReadFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session_123",
|
||||
tool_call_id="test_tool_call_456",
|
||||
)
|
||||
return ReadFile(config=config, state=state)
|
||||
|
||||
|
||||
class TestAcpReadFileBasic:
|
||||
def test_get_name(self) -> None:
|
||||
assert ReadFile.get_name() == "read_file"
|
||||
|
||||
|
||||
class TestAcpReadFileExecution:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_success(
|
||||
self,
|
||||
acp_read_file_tool: ReadFile,
|
||||
mock_connection: MockConnection,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
test_file = tmp_path / "test_file.txt"
|
||||
test_file.touch()
|
||||
args = ReadFileArgs(path=str(test_file))
|
||||
result = await acp_read_file_tool.run(args)
|
||||
|
||||
assert isinstance(result, ReadFileResult)
|
||||
assert result.path == str(test_file)
|
||||
assert result.content == "line 1\nline 2\nline 3"
|
||||
assert result.lines_read == 3
|
||||
assert mock_connection._read_text_file_called
|
||||
assert mock_connection._session_update_called
|
||||
|
||||
# Verify ReadTextFileRequest was created correctly
|
||||
request = mock_connection._last_read_request
|
||||
assert request is not None
|
||||
assert request.sessionId == "test_session_123"
|
||||
assert request.path == str(test_file)
|
||||
assert request.line is None # offset=0 means no line specified
|
||||
assert request.limit is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_offset(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
) -> None:
|
||||
test_file = tmp_path / "test_file.txt"
|
||||
test_file.touch()
|
||||
tool = ReadFile(
|
||||
config=ReadFileToolConfig(workdir=tmp_path),
|
||||
state=AcpReadFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = ReadFileArgs(path=str(test_file), offset=1)
|
||||
result = await tool.run(args)
|
||||
|
||||
assert result.lines_read == 2
|
||||
assert result.content == "line 2\nline 3"
|
||||
|
||||
request = mock_connection._last_read_request
|
||||
assert request is not None
|
||||
assert request.line == 2 # offset=1 means line 2 (1-indexed)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_limit(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
) -> None:
|
||||
test_file = tmp_path / "test_file.txt"
|
||||
test_file.touch()
|
||||
tool = ReadFile(
|
||||
config=ReadFileToolConfig(workdir=tmp_path),
|
||||
state=AcpReadFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = ReadFileArgs(path=str(test_file), limit=2)
|
||||
result = await tool.run(args)
|
||||
|
||||
assert result.lines_read == 2
|
||||
assert result.content == "line 1\nline 2\n"
|
||||
|
||||
request = mock_connection._last_read_request
|
||||
assert request is not None
|
||||
assert request.limit == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_offset_and_limit(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
) -> None:
|
||||
test_file = tmp_path / "test_file.txt"
|
||||
test_file.touch()
|
||||
tool = ReadFile(
|
||||
config=ReadFileToolConfig(workdir=tmp_path),
|
||||
state=AcpReadFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = ReadFileArgs(path=str(test_file), offset=1, limit=1)
|
||||
result = await tool.run(args)
|
||||
|
||||
assert result.lines_read == 1
|
||||
assert result.content == "line 2\n"
|
||||
|
||||
request = mock_connection._last_read_request
|
||||
assert request is not None
|
||||
assert request.line == 2
|
||||
assert request.limit == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_read_error(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
) -> None:
|
||||
mock_connection._read_error = RuntimeError("File not found")
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.touch()
|
||||
tool = ReadFile(
|
||||
config=ReadFileToolConfig(workdir=tmp_path),
|
||||
state=AcpReadFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = ReadFileArgs(path=str(test_file))
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
|
||||
assert str(exc_info.value) == f"Error reading {test_file}: File not found"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_without_connection(self, tmp_path: Path) -> None:
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.touch()
|
||||
tool = ReadFile(
|
||||
config=ReadFileToolConfig(workdir=tmp_path),
|
||||
state=AcpReadFileState.model_construct(
|
||||
connection=None, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = ReadFileArgs(path=str(test_file))
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
== "Connection not available in tool state. This tool can only be used within an ACP session."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_without_session_id(self, tmp_path: Path) -> None:
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.touch()
|
||||
mock_connection = MockConnection()
|
||||
tool = ReadFile(
|
||||
config=ReadFileToolConfig(workdir=tmp_path),
|
||||
state=AcpReadFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id=None,
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = ReadFileArgs(path=str(test_file))
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
== "Session ID not available in tool state. This tool can only be used within an ACP session."
|
||||
)
|
||||
339
tests/acp/test_search_replace.py
Normal file
339
tests/acp/test_search_replace.py
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from acp import ReadTextFileRequest, ReadTextFileResponse, WriteTextFileRequest
|
||||
import pytest
|
||||
|
||||
from vibe.acp.tools.builtins.search_replace import AcpSearchReplaceState, SearchReplace
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.builtins.search_replace import (
|
||||
SearchReplaceArgs,
|
||||
SearchReplaceConfig,
|
||||
SearchReplaceResult,
|
||||
)
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class MockConnection:
|
||||
def __init__(
|
||||
self,
|
||||
file_content: str = "original line 1\noriginal line 2\noriginal line 3",
|
||||
read_error: Exception | None = None,
|
||||
write_error: Exception | None = None,
|
||||
) -> None:
|
||||
self._file_content = file_content
|
||||
self._read_error = read_error
|
||||
self._write_error = write_error
|
||||
self._read_text_file_called = False
|
||||
self._write_text_file_called = False
|
||||
self._session_update_called = False
|
||||
self._last_read_request: ReadTextFileRequest | None = None
|
||||
self._last_write_request: WriteTextFileRequest | None = None
|
||||
self._write_calls: list[WriteTextFileRequest] = []
|
||||
|
||||
async def readTextFile(self, request: ReadTextFileRequest) -> ReadTextFileResponse:
|
||||
self._read_text_file_called = True
|
||||
self._last_read_request = request
|
||||
|
||||
if self._read_error:
|
||||
raise self._read_error
|
||||
|
||||
return ReadTextFileResponse(content=self._file_content)
|
||||
|
||||
async def writeTextFile(self, request: WriteTextFileRequest) -> None:
|
||||
self._write_text_file_called = True
|
||||
self._last_write_request = request
|
||||
self._write_calls.append(request)
|
||||
|
||||
if self._write_error:
|
||||
raise self._write_error
|
||||
|
||||
async def sessionUpdate(self, notification) -> None:
|
||||
self._session_update_called = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connection() -> MockConnection:
|
||||
return MockConnection()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_search_replace_tool(
|
||||
mock_connection: MockConnection, tmp_path: Path
|
||||
) -> SearchReplace:
|
||||
config = SearchReplaceConfig(workdir=tmp_path)
|
||||
state = AcpSearchReplaceState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session_123",
|
||||
tool_call_id="test_tool_call_456",
|
||||
)
|
||||
return SearchReplace(config=config, state=state)
|
||||
|
||||
|
||||
class TestAcpSearchReplaceBasic:
|
||||
def test_get_name(self) -> None:
|
||||
assert SearchReplace.get_name() == "search_replace"
|
||||
|
||||
|
||||
class TestAcpSearchReplaceExecution:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_success(
|
||||
self,
|
||||
acp_search_replace_tool: SearchReplace,
|
||||
mock_connection: MockConnection,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
test_file = tmp_path / "test_file.txt"
|
||||
test_file.write_text("original line 1\noriginal line 2\noriginal line 3")
|
||||
search_replace_content = (
|
||||
"<<<<<<< SEARCH\noriginal line 2\n=======\nmodified line 2\n>>>>>>> REPLACE"
|
||||
)
|
||||
args = SearchReplaceArgs(
|
||||
file_path=str(test_file), content=search_replace_content
|
||||
)
|
||||
result = await acp_search_replace_tool.run(args)
|
||||
|
||||
assert isinstance(result, SearchReplaceResult)
|
||||
assert result.file == str(test_file)
|
||||
assert result.blocks_applied == 1
|
||||
assert mock_connection._read_text_file_called
|
||||
assert mock_connection._write_text_file_called
|
||||
assert mock_connection._session_update_called
|
||||
|
||||
# Verify ReadTextFileRequest was created correctly
|
||||
read_request = mock_connection._last_read_request
|
||||
assert read_request is not None
|
||||
assert read_request.sessionId == "test_session_123"
|
||||
assert read_request.path == str(test_file)
|
||||
|
||||
# Verify WriteTextFileRequest was created correctly
|
||||
write_request = mock_connection._last_write_request
|
||||
assert write_request is not None
|
||||
assert write_request.sessionId == "test_session_123"
|
||||
assert write_request.path == str(test_file)
|
||||
assert (
|
||||
write_request.content == "original line 1\nmodified line 2\noriginal line 3"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_backup(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
) -> None:
|
||||
config = SearchReplaceConfig(create_backup=True, workdir=tmp_path)
|
||||
tool = SearchReplace(
|
||||
config=config,
|
||||
state=AcpSearchReplaceState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
test_file = tmp_path / "test_file.txt"
|
||||
test_file.write_text("original line 1\noriginal line 2\noriginal line 3")
|
||||
search_replace_content = (
|
||||
"<<<<<<< SEARCH\noriginal line 1\n=======\nmodified line 1\n>>>>>>> REPLACE"
|
||||
)
|
||||
args = SearchReplaceArgs(
|
||||
file_path=str(test_file), content=search_replace_content
|
||||
)
|
||||
result = await tool.run(args)
|
||||
|
||||
assert result.blocks_applied == 1
|
||||
# Should have written the main file and the backup
|
||||
assert len(mock_connection._write_calls) >= 1
|
||||
# Check if backup was written (it should be written to .bak file)
|
||||
assert sum(w.path.endswith(".bak") for w in mock_connection._write_calls) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_read_error(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
) -> None:
|
||||
mock_connection._read_error = RuntimeError("File not found")
|
||||
|
||||
tool = SearchReplace(
|
||||
config=SearchReplaceConfig(workdir=tmp_path),
|
||||
state=AcpSearchReplaceState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.touch()
|
||||
search_replace_content = "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE"
|
||||
args = SearchReplaceArgs(
|
||||
file_path=str(test_file), content=search_replace_content
|
||||
)
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
== f"Unexpected error reading {test_file}: File not found"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_write_error(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
) -> None:
|
||||
mock_connection._write_error = RuntimeError("Permission denied")
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.touch()
|
||||
mock_connection._file_content = "old" # Update mock to return correct content
|
||||
|
||||
tool = SearchReplace(
|
||||
config=SearchReplaceConfig(workdir=tmp_path),
|
||||
state=AcpSearchReplaceState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
search_replace_content = "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE"
|
||||
args = SearchReplaceArgs(
|
||||
file_path=str(test_file), content=search_replace_content
|
||||
)
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
|
||||
assert str(exc_info.value) == f"Error writing {test_file}: Permission denied"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"connection,session_id,expected_error",
|
||||
[
|
||||
(
|
||||
None,
|
||||
"test_session",
|
||||
"Connection not available in tool state. This tool can only be used within an ACP session.",
|
||||
),
|
||||
(
|
||||
MockConnection(),
|
||||
None,
|
||||
"Session ID not available in tool state. This tool can only be used within an ACP session.",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_run_without_required_state(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
connection: MockConnection | None,
|
||||
session_id: str | None,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.touch()
|
||||
tool = SearchReplace(
|
||||
config=SearchReplaceConfig(workdir=tmp_path),
|
||||
state=AcpSearchReplaceState.model_construct(
|
||||
connection=connection, # type: ignore[arg-type]
|
||||
session_id=session_id,
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
search_replace_content = "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE"
|
||||
args = SearchReplaceArgs(
|
||||
file_path=str(test_file), content=search_replace_content
|
||||
)
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
|
||||
assert str(exc_info.value) == expected_error
|
||||
|
||||
|
||||
class TestAcpSearchReplaceSessionUpdates:
|
||||
def test_tool_call_session_update(self) -> None:
|
||||
search_replace_content = (
|
||||
"<<<<<<< SEARCH\nold text\n=======\nnew text\n>>>>>>> REPLACE"
|
||||
)
|
||||
event = ToolCallEvent(
|
||||
tool_name="search_replace",
|
||||
tool_call_id="test_call_123",
|
||||
args=SearchReplaceArgs(
|
||||
file_path="/tmp/test.txt", content=search_replace_content
|
||||
),
|
||||
tool_class=SearchReplace,
|
||||
)
|
||||
|
||||
update = SearchReplace.tool_call_session_update(event)
|
||||
assert update is not None
|
||||
assert update.sessionUpdate == "tool_call"
|
||||
assert update.toolCallId == "test_call_123"
|
||||
assert update.kind == "edit"
|
||||
assert update.title is not None
|
||||
assert update.content is not None
|
||||
assert len(update.content) == 1
|
||||
assert update.content[0].type == "diff"
|
||||
assert update.content[0].path == "/tmp/test.txt"
|
||||
assert update.content[0].oldText == "old text"
|
||||
assert update.content[0].newText == "new text"
|
||||
assert update.locations is not None
|
||||
assert len(update.locations) == 1
|
||||
assert update.locations[0].path == "/tmp/test.txt"
|
||||
|
||||
def test_tool_call_session_update_invalid_args(self) -> None:
|
||||
class InvalidArgs:
|
||||
pass
|
||||
|
||||
event = ToolCallEvent.model_construct(
|
||||
tool_name="search_replace",
|
||||
tool_call_id="test_call_123",
|
||||
args=InvalidArgs(), # type: ignore[arg-type]
|
||||
tool_class=SearchReplace,
|
||||
)
|
||||
|
||||
update = SearchReplace.tool_call_session_update(event)
|
||||
assert update is None
|
||||
|
||||
def test_tool_result_session_update(self) -> None:
|
||||
search_replace_content = (
|
||||
"<<<<<<< SEARCH\nold text\n=======\nnew text\n>>>>>>> REPLACE"
|
||||
)
|
||||
result = SearchReplaceResult(
|
||||
file="/tmp/test.txt",
|
||||
blocks_applied=1,
|
||||
lines_changed=1,
|
||||
content=search_replace_content,
|
||||
warnings=[],
|
||||
)
|
||||
|
||||
event = ToolResultEvent(
|
||||
tool_name="search_replace",
|
||||
tool_call_id="test_call_123",
|
||||
result=result,
|
||||
tool_class=SearchReplace,
|
||||
)
|
||||
|
||||
update = SearchReplace.tool_result_session_update(event)
|
||||
assert update is not None
|
||||
assert update.sessionUpdate == "tool_call_update"
|
||||
assert update.toolCallId == "test_call_123"
|
||||
assert update.status == "completed"
|
||||
assert update.content is not None
|
||||
assert len(update.content) == 1
|
||||
assert update.content[0].type == "diff"
|
||||
assert update.content[0].path == "/tmp/test.txt"
|
||||
assert update.content[0].oldText == "old text"
|
||||
assert update.content[0].newText == "new text"
|
||||
assert update.locations is not None
|
||||
assert len(update.locations) == 1
|
||||
assert update.locations[0].path == "/tmp/test.txt"
|
||||
|
||||
def test_tool_result_session_update_invalid_result(self) -> None:
|
||||
class InvalidResult:
|
||||
pass
|
||||
|
||||
event = ToolResultEvent.model_construct(
|
||||
tool_name="search_replace",
|
||||
tool_call_id="test_call_123",
|
||||
result=InvalidResult(), # type: ignore[arg-type]
|
||||
tool_class=SearchReplace,
|
||||
)
|
||||
|
||||
update = SearchReplace.tool_result_session_update(event)
|
||||
assert update is None
|
||||
165
tests/acp/test_set_mode.py
Normal file
165
tests/acp/test_set_mode.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from acp import AgentSideConnection, NewSessionRequest, SetSessionModeRequest
|
||||
import pytest
|
||||
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_connection import FakeAgentSideConnection
|
||||
from vibe.acp.acp_agent import VibeAcpAgent
|
||||
from vibe.acp.utils import VibeSessionMode
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend() -> FakeBackend:
|
||||
backend = FakeBackend(
|
||||
results=[
|
||||
LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content="Hi"),
|
||||
finish_reason="end_turn",
|
||||
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
|
||||
)
|
||||
]
|
||||
)
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
||||
class PatchedAgent(Agent):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs, backend=backend)
|
||||
|
||||
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
|
||||
|
||||
vibe_acp_agent: VibeAcpAgent | None = None
|
||||
|
||||
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
|
||||
nonlocal vibe_acp_agent
|
||||
vibe_acp_agent = VibeAcpAgent(connection)
|
||||
return vibe_acp_agent
|
||||
|
||||
FakeAgentSideConnection(_create_agent)
|
||||
return vibe_acp_agent # pyright: ignore[reportReturnType]
|
||||
|
||||
|
||||
class TestACPSetMode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_to_approval_required(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
acp_session.agent.auto_approve = True
|
||||
acp_session.mode_id = VibeSessionMode.AUTO_APPROVE
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(
|
||||
sessionId=session_id, modeId=VibeSessionMode.APPROVAL_REQUIRED
|
||||
)
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.mode_id == VibeSessionMode.APPROVAL_REQUIRED
|
||||
assert acp_session.agent.auto_approve is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_to_AUTO_APPROVE(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
assert acp_session.mode_id == VibeSessionMode.APPROVAL_REQUIRED
|
||||
assert acp_session.agent.auto_approve is False
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(
|
||||
sessionId=session_id, modeId=VibeSessionMode.AUTO_APPROVE
|
||||
)
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.mode_id == VibeSessionMode.AUTO_APPROVE
|
||||
assert acp_session.agent.auto_approve is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_invalid_mode_returns_none(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
initial_mode_id = acp_session.mode_id
|
||||
initial_auto_approve = acp_session.agent.auto_approve
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(sessionId=session_id, modeId="invalid-mode")
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.mode_id == initial_mode_id
|
||||
assert acp_session.agent.auto_approve == initial_auto_approve
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_to_same_mode(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
initial_mode_id = VibeSessionMode.APPROVAL_REQUIRED
|
||||
assert acp_session.mode_id == initial_mode_id
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(sessionId=session_id, modeId=initial_mode_id)
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.mode_id == initial_mode_id
|
||||
assert acp_session.agent.auto_approve is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_with_empty_string(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
initial_mode_id = acp_session.mode_id
|
||||
initial_auto_approve = acp_session.agent.auto_approve
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(sessionId=session_id, modeId="")
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.mode_id == initial_mode_id
|
||||
assert acp_session.agent.auto_approve == initial_auto_approve
|
||||
308
tests/acp/test_set_model.py
Normal file
308
tests/acp/test_set_model.py
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from acp import AgentSideConnection, NewSessionRequest, SetSessionModelRequest
|
||||
import pytest
|
||||
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_connection import FakeAgentSideConnection
|
||||
from vibe.acp.acp_agent import VibeAcpAgent
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.config import ModelConfig, VibeConfig
|
||||
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend() -> FakeBackend:
|
||||
backend = FakeBackend(
|
||||
results=[
|
||||
LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content="Hi"),
|
||||
finish_reason="end_turn",
|
||||
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
|
||||
)
|
||||
]
|
||||
)
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
||||
config = VibeConfig(
|
||||
active_model="devstral-latest",
|
||||
models=[
|
||||
ModelConfig(
|
||||
name="devstral-latest",
|
||||
provider="mistral",
|
||||
alias="devstral-latest",
|
||||
input_price=0.4,
|
||||
output_price=2.0,
|
||||
),
|
||||
ModelConfig(
|
||||
name="devstral-small",
|
||||
provider="mistral",
|
||||
alias="devstral-small",
|
||||
input_price=0.1,
|
||||
output_price=0.3,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
class PatchedAgent(Agent):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **{**kwargs, "backend": backend})
|
||||
self.config = config
|
||||
try:
|
||||
active_model = config.get_active_model()
|
||||
self.stats.input_price_per_million = active_model.input_price
|
||||
self.stats.output_price_per_million = active_model.output_price
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
|
||||
|
||||
vibe_acp_agent: VibeAcpAgent | None = None
|
||||
|
||||
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
|
||||
nonlocal vibe_acp_agent
|
||||
vibe_acp_agent = VibeAcpAgent(connection)
|
||||
return vibe_acp_agent
|
||||
|
||||
FakeAgentSideConnection(_create_agent)
|
||||
return vibe_acp_agent # pyright: ignore[reportReturnType]
|
||||
|
||||
|
||||
class TestACPSetModel:
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_success(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
assert acp_session.agent.config.active_model == "devstral-latest"
|
||||
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent.config.active_model == "devstral-small"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_invalid_model_returns_none(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
initial_model = acp_session.agent.config.active_model
|
||||
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="non-existent-model")
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.agent.config.active_model == initial_model
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_to_same_model(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
initial_model = "devstral-latest"
|
||||
assert acp_session is not None
|
||||
assert acp_session.agent.config.active_model == initial_model
|
||||
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId=initial_model)
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent.config.active_model == initial_model
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_saves_to_config(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
|
||||
with patch("vibe.acp.acp_agent.VibeConfig.save_updates") as mock_save:
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
mock_save.assert_called_once_with({"active_model": "devstral-small"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_does_not_save_on_invalid_model(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
|
||||
with patch("vibe.acp.acp_agent.VibeConfig.save_updates") as mock_save:
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(
|
||||
sessionId=session_id, modelId="non-existent-model"
|
||||
)
|
||||
)
|
||||
|
||||
assert response is None
|
||||
mock_save.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_with_empty_string(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
initial_model = acp_session.agent.config.active_model
|
||||
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="")
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.agent.config.active_model == initial_model
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_updates_active_model(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
assert acp_session.agent.config.get_active_model().alias == "devstral-latest"
|
||||
|
||||
await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
|
||||
)
|
||||
|
||||
assert acp_session.agent.config.get_active_model().alias == "devstral-small"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_calls_reload_with_initial_messages(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
with patch.object(
|
||||
acp_session.agent, "reload_with_initial_messages"
|
||||
) as mock_reload:
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
mock_reload.assert_called_once()
|
||||
call_args = mock_reload.call_args
|
||||
assert call_args.kwargs["config"] is not None
|
||||
assert call_args.kwargs["config"].active_model == "devstral-small"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_preserves_conversation_history(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
user_msg = LLMMessage(role=Role.user, content="Hello")
|
||||
assistant_msg = LLMMessage(role=Role.assistant, content="Hi there!")
|
||||
acp_session.agent.messages.append(user_msg)
|
||||
acp_session.agent.messages.append(assistant_msg)
|
||||
|
||||
assert len(acp_session.agent.messages) == 3
|
||||
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert len(acp_session.agent.messages) == 3
|
||||
assert acp_session.agent.messages[0].role == Role.system
|
||||
assert acp_session.agent.messages[1].content == "Hello"
|
||||
assert acp_session.agent.messages[2].content == "Hi there!"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_model_resets_stats_with_new_model_pricing(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
initial_model = acp_session.agent.config.get_active_model()
|
||||
initial_input_price = initial_model.input_price
|
||||
initial_output_price = initial_model.output_price
|
||||
|
||||
initial_stats_input = acp_session.agent.stats.input_price_per_million
|
||||
initial_stats_output = acp_session.agent.stats.output_price_per_million
|
||||
|
||||
assert acp_session.agent.stats.input_price_per_million == initial_input_price
|
||||
assert acp_session.agent.stats.output_price_per_million == initial_output_price
|
||||
|
||||
response = await acp_agent.setSessionModel(
|
||||
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
|
||||
new_model = acp_session.agent.config.get_active_model()
|
||||
new_input_price = new_model.input_price
|
||||
new_output_price = new_model.output_price
|
||||
|
||||
assert new_input_price != initial_input_price
|
||||
assert new_output_price != initial_output_price
|
||||
|
||||
assert acp_session.agent.stats.input_price_per_million == new_input_price
|
||||
assert acp_session.agent.stats.output_price_per_million == new_output_price
|
||||
|
||||
assert acp_session.agent.stats.input_price_per_million != initial_stats_input
|
||||
assert acp_session.agent.stats.output_price_per_million != initial_stats_output
|
||||
269
tests/acp/test_write_file.py
Normal file
269
tests/acp/test_write_file.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from acp import WriteTextFileRequest
|
||||
import pytest
|
||||
|
||||
from vibe.acp.tools.builtins.write_file import AcpWriteFileState, WriteFile
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.builtins.write_file import (
|
||||
WriteFileArgs,
|
||||
WriteFileConfig,
|
||||
WriteFileResult,
|
||||
)
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class MockConnection:
|
||||
def __init__(
|
||||
self, write_error: Exception | None = None, file_exists: bool = False
|
||||
) -> None:
|
||||
self._write_error = write_error
|
||||
self._file_exists = file_exists
|
||||
self._write_text_file_called = False
|
||||
self._session_update_called = False
|
||||
self._last_write_request: WriteTextFileRequest | None = None
|
||||
|
||||
async def writeTextFile(self, request: WriteTextFileRequest) -> None:
|
||||
self._write_text_file_called = True
|
||||
self._last_write_request = request
|
||||
|
||||
if self._write_error:
|
||||
raise self._write_error
|
||||
|
||||
async def sessionUpdate(self, notification) -> None:
|
||||
self._session_update_called = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connection() -> MockConnection:
|
||||
return MockConnection()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_write_file_tool(mock_connection: MockConnection, tmp_path: Path) -> WriteFile:
|
||||
config = WriteFileConfig(workdir=tmp_path)
|
||||
state = AcpWriteFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session_123",
|
||||
tool_call_id="test_tool_call_456",
|
||||
)
|
||||
return WriteFile(config=config, state=state)
|
||||
|
||||
|
||||
class TestAcpWriteFileBasic:
|
||||
def test_get_name(self) -> None:
|
||||
assert WriteFile.get_name() == "write_file"
|
||||
|
||||
|
||||
class TestAcpWriteFileExecution:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_success_new_file(
|
||||
self,
|
||||
acp_write_file_tool: WriteFile,
|
||||
mock_connection: MockConnection,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
test_file = tmp_path / "test_file.txt"
|
||||
args = WriteFileArgs(path=str(test_file), content="Hello, world!")
|
||||
result = await acp_write_file_tool.run(args)
|
||||
|
||||
assert isinstance(result, WriteFileResult)
|
||||
assert result.path == str(test_file)
|
||||
assert result.content == "Hello, world!"
|
||||
assert result.bytes_written == len(b"Hello, world!")
|
||||
assert result.file_existed is False
|
||||
assert mock_connection._write_text_file_called
|
||||
assert mock_connection._session_update_called
|
||||
|
||||
# Verify WriteTextFileRequest was created correctly
|
||||
request = mock_connection._last_write_request
|
||||
assert request is not None
|
||||
assert request.sessionId == "test_session_123"
|
||||
assert request.path == str(test_file)
|
||||
assert request.content == "Hello, world!"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_success_overwrite(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
) -> None:
|
||||
tool = WriteFile(
|
||||
config=WriteFileConfig(workdir=tmp_path),
|
||||
state=AcpWriteFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
test_file = tmp_path / "existing_file.txt"
|
||||
test_file.touch()
|
||||
# Simulate existing file by checking in the core tool logic
|
||||
# The ACP tool doesn't check existence, it's handled by the core tool
|
||||
args = WriteFileArgs(path=str(test_file), content="New content", overwrite=True)
|
||||
result = await tool.run(args)
|
||||
|
||||
assert isinstance(result, WriteFileResult)
|
||||
assert result.path == str(test_file)
|
||||
assert result.content == "New content"
|
||||
assert result.bytes_written == len(b"New content")
|
||||
assert result.file_existed is True
|
||||
assert mock_connection._write_text_file_called
|
||||
assert mock_connection._session_update_called
|
||||
|
||||
# Verify WriteTextFileRequest was created correctly
|
||||
request = mock_connection._last_write_request
|
||||
assert request is not None
|
||||
assert request.sessionId == "test_session"
|
||||
assert request.path == str(test_file)
|
||||
assert request.content == "New content"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_write_error(
|
||||
self, mock_connection: MockConnection, tmp_path: Path
|
||||
) -> None:
|
||||
mock_connection._write_error = RuntimeError("Permission denied")
|
||||
|
||||
tool = WriteFile(
|
||||
config=WriteFileConfig(workdir=tmp_path),
|
||||
state=AcpWriteFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id="test_session",
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
test_file = tmp_path / "test.txt"
|
||||
args = WriteFileArgs(path=str(test_file), content="test")
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
|
||||
assert str(exc_info.value) == f"Error writing {test_file}: Permission denied"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_without_connection(self, tmp_path: Path) -> None:
|
||||
tool = WriteFile(
|
||||
config=WriteFileConfig(workdir=tmp_path),
|
||||
state=AcpWriteFileState.model_construct(
|
||||
connection=None, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = WriteFileArgs(path=str(tmp_path / "test.txt"), content="test")
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
== "Connection not available in tool state. This tool can only be used within an ACP session."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_without_session_id(self, tmp_path: Path) -> None:
|
||||
mock_connection = MockConnection()
|
||||
tool = WriteFile(
|
||||
config=WriteFileConfig(workdir=tmp_path),
|
||||
state=AcpWriteFileState.model_construct(
|
||||
connection=mock_connection, # type: ignore[arg-type]
|
||||
session_id=None,
|
||||
tool_call_id="test_call",
|
||||
),
|
||||
)
|
||||
|
||||
args = WriteFileArgs(path=str(tmp_path / "test.txt"), content="test")
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await tool.run(args)
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
== "Session ID not available in tool state. This tool can only be used within an ACP session."
|
||||
)
|
||||
|
||||
|
||||
class TestAcpWriteFileSessionUpdates:
|
||||
def test_tool_call_session_update(self) -> None:
|
||||
event = ToolCallEvent(
|
||||
tool_name="write_file",
|
||||
tool_call_id="test_call_123",
|
||||
args=WriteFileArgs(path="/tmp/test.txt", content="Hello"),
|
||||
tool_class=WriteFile,
|
||||
)
|
||||
|
||||
update = WriteFile.tool_call_session_update(event)
|
||||
assert update is not None
|
||||
assert update.sessionUpdate == "tool_call"
|
||||
assert update.toolCallId == "test_call_123"
|
||||
assert update.kind == "edit"
|
||||
assert update.title is not None
|
||||
assert update.content is not None
|
||||
assert len(update.content) == 1
|
||||
assert update.content[0].type == "diff"
|
||||
assert update.content[0].path == "/tmp/test.txt"
|
||||
assert update.content[0].oldText is None
|
||||
assert update.content[0].newText == "Hello"
|
||||
assert update.locations is not None
|
||||
assert len(update.locations) == 1
|
||||
assert update.locations[0].path == "/tmp/test.txt"
|
||||
|
||||
def test_tool_call_session_update_invalid_args(self) -> None:
|
||||
from vibe.core.types import FunctionCall, ToolCall
|
||||
|
||||
class InvalidArgs:
|
||||
pass
|
||||
|
||||
event = ToolCallEvent.model_construct(
|
||||
tool_name="write_file",
|
||||
tool_call_id="test_call_123",
|
||||
args=InvalidArgs(), # type: ignore[arg-type]
|
||||
tool_class=WriteFile,
|
||||
llm_tool_call=ToolCall(
|
||||
function=FunctionCall(name="write_file", arguments="{}"),
|
||||
type="function",
|
||||
index=0,
|
||||
),
|
||||
)
|
||||
|
||||
update = WriteFile.tool_call_session_update(event)
|
||||
assert update is None
|
||||
|
||||
def test_tool_result_session_update(self) -> None:
|
||||
result = WriteFileResult(
|
||||
path="/tmp/test.txt", content="Hello", bytes_written=5, file_existed=False
|
||||
)
|
||||
|
||||
event = ToolResultEvent(
|
||||
tool_name="write_file",
|
||||
tool_call_id="test_call_123",
|
||||
result=result,
|
||||
tool_class=WriteFile,
|
||||
)
|
||||
|
||||
update = WriteFile.tool_result_session_update(event)
|
||||
assert update is not None
|
||||
assert update.sessionUpdate == "tool_call_update"
|
||||
assert update.toolCallId == "test_call_123"
|
||||
assert update.status == "completed"
|
||||
assert update.content is not None
|
||||
assert len(update.content) == 1
|
||||
assert update.content[0].type == "diff"
|
||||
assert update.content[0].path == "/tmp/test.txt"
|
||||
assert update.content[0].oldText is None
|
||||
assert update.content[0].newText == "Hello"
|
||||
assert update.locations is not None
|
||||
assert len(update.locations) == 1
|
||||
assert update.locations[0].path == "/tmp/test.txt"
|
||||
|
||||
def test_tool_result_session_update_invalid_result(self) -> None:
|
||||
class InvalidResult:
|
||||
pass
|
||||
|
||||
event = ToolResultEvent.model_construct(
|
||||
tool_name="write_file",
|
||||
tool_call_id="test_call_123",
|
||||
result=InvalidResult(), # type: ignore[arg-type]
|
||||
tool_class=WriteFile,
|
||||
)
|
||||
|
||||
update = WriteFile.tool_result_session_update(event)
|
||||
assert update is None
|
||||
Loading…
Add table
Add a link
Reference in a new issue