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>
536 lines
17 KiB
Python
536 lines
17 KiB
Python
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"
|