v1.2.0
Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai> Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai> Co-Authored-By: Kracekumar <kracethekingmaker@gmail.com>
@@ -83,6 +83,9 @@ def _create_vibe_home_dir(tmp_path: Path, *sections: dict[str, Any]) -> Path:
|
||||
with config_file.open("wb") as f:
|
||||
tomli_w.dump(base_config_dict, f)
|
||||
|
||||
trusted_folters_file = vibe_home / "trusted_folders.toml"
|
||||
trusted_folters_file.write_text("trusted = []\nuntrusted = []", encoding="utf-8")
|
||||
|
||||
return vibe_home
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ class TestACPInitialize:
|
||||
),
|
||||
)
|
||||
assert response.agentInfo == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.1.3"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.2.0"
|
||||
)
|
||||
|
||||
assert response.authMethods == []
|
||||
@@ -63,7 +63,7 @@ class TestACPInitialize:
|
||||
),
|
||||
)
|
||||
assert response.agentInfo == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.1.3"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.2.0"
|
||||
)
|
||||
|
||||
assert response.authMethods is not None
|
||||
|
||||
@@ -9,9 +9,9 @@ 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.modes import AgentMode
|
||||
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
|
||||
|
||||
|
||||
@@ -101,18 +101,21 @@ class TestACPNewSession:
|
||||
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 len(session_response.modes.availableModes) == 4
|
||||
|
||||
assert session_response.modes.currentModeId == VibeSessionMode.APPROVAL_REQUIRED
|
||||
assert session_response.modes.currentModeId == AgentMode.DEFAULT.value
|
||||
assert session_response.modes.availableModes[0].id == AgentMode.DEFAULT.value
|
||||
assert session_response.modes.availableModes[0].name == "Default"
|
||||
assert (
|
||||
session_response.modes.availableModes[0].id
|
||||
== VibeSessionMode.APPROVAL_REQUIRED
|
||||
)
|
||||
assert session_response.modes.availableModes[0].name == "Approval Required"
|
||||
assert (
|
||||
session_response.modes.availableModes[1].id == VibeSessionMode.AUTO_APPROVE
|
||||
session_response.modes.availableModes[1].id == AgentMode.AUTO_APPROVE.value
|
||||
)
|
||||
assert session_response.modes.availableModes[1].name == "Auto Approve"
|
||||
assert session_response.modes.availableModes[2].id == AgentMode.PLAN.value
|
||||
assert session_response.modes.availableModes[2].name == "Plan"
|
||||
assert (
|
||||
session_response.modes.availableModes[3].id == AgentMode.ACCEPT_EDITS.value
|
||||
)
|
||||
assert session_response.modes.availableModes[3].name == "Accept Edits"
|
||||
|
||||
@pytest.mark.skip(reason="TODO: Fix this test")
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -9,8 +9,8 @@ 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.modes import AgentMode
|
||||
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
||||
|
||||
class TestACPSetMode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_to_approval_required(self, acp_agent: VibeAcpAgent) -> None:
|
||||
async def test_set_mode_to_default(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
@@ -59,21 +59,18 @@ class TestACPSetMode:
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
acp_session.agent.auto_approve = True
|
||||
acp_session.mode_id = VibeSessionMode.AUTO_APPROVE
|
||||
await acp_session.agent.switch_mode(AgentMode.AUTO_APPROVE)
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(
|
||||
sessionId=session_id, modeId=VibeSessionMode.APPROVAL_REQUIRED
|
||||
)
|
||||
SetSessionModeRequest(sessionId=session_id, modeId=AgentMode.DEFAULT.value)
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.mode_id == VibeSessionMode.APPROVAL_REQUIRED
|
||||
assert acp_session.agent.mode == AgentMode.DEFAULT
|
||||
assert acp_session.agent.auto_approve is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_to_AUTO_APPROVE(self, acp_agent: VibeAcpAgent) -> None:
|
||||
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=[])
|
||||
)
|
||||
@@ -83,19 +80,67 @@ class TestACPSetMode:
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
assert acp_session.mode_id == VibeSessionMode.APPROVAL_REQUIRED
|
||||
assert acp_session.agent.mode == AgentMode.DEFAULT
|
||||
assert acp_session.agent.auto_approve is False
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(
|
||||
sessionId=session_id, modeId=VibeSessionMode.AUTO_APPROVE
|
||||
sessionId=session_id, modeId=AgentMode.AUTO_APPROVE.value
|
||||
)
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.mode_id == VibeSessionMode.AUTO_APPROVE
|
||||
assert acp_session.agent.mode == AgentMode.AUTO_APPROVE
|
||||
assert acp_session.agent.auto_approve is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_to_plan(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
assert acp_session.agent.mode == AgentMode.DEFAULT
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(sessionId=session_id, modeId=AgentMode.PLAN.value)
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent.mode == AgentMode.PLAN
|
||||
assert (
|
||||
acp_session.agent.auto_approve is True
|
||||
) # Plan mode auto-approves read-only tools
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_to_accept_edits(self, acp_agent: VibeAcpAgent) -> None:
|
||||
session_response = await acp_agent.newSession(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
session_id = session_response.sessionId
|
||||
acp_session = next(
|
||||
(s for s in acp_agent.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
assert acp_session.agent.mode == AgentMode.DEFAULT
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(
|
||||
sessionId=session_id, modeId=AgentMode.ACCEPT_EDITS.value
|
||||
)
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent.mode == AgentMode.ACCEPT_EDITS
|
||||
assert (
|
||||
acp_session.agent.auto_approve is False
|
||||
) # Accept Edits mode doesn't auto-approve all
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_invalid_mode_returns_none(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
@@ -109,7 +154,7 @@ class TestACPSetMode:
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
initial_mode_id = acp_session.mode_id
|
||||
initial_mode = acp_session.agent.mode
|
||||
initial_auto_approve = acp_session.agent.auto_approve
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
@@ -117,7 +162,7 @@ class TestACPSetMode:
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.mode_id == initial_mode_id
|
||||
assert acp_session.agent.mode == initial_mode
|
||||
assert acp_session.agent.auto_approve == initial_auto_approve
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -131,15 +176,15 @@ class TestACPSetMode:
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
initial_mode_id = VibeSessionMode.APPROVAL_REQUIRED
|
||||
assert acp_session.mode_id == initial_mode_id
|
||||
initial_mode = AgentMode.DEFAULT
|
||||
assert acp_session.agent.mode == initial_mode
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(sessionId=session_id, modeId=initial_mode_id)
|
||||
SetSessionModeRequest(sessionId=session_id, modeId=initial_mode.value)
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.mode_id == initial_mode_id
|
||||
assert acp_session.agent.mode == initial_mode
|
||||
assert acp_session.agent.auto_approve is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -153,7 +198,7 @@ class TestACPSetMode:
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
initial_mode_id = acp_session.mode_id
|
||||
initial_mode = acp_session.agent.mode
|
||||
initial_auto_approve = acp_session.agent.auto_approve
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
@@ -161,5 +206,5 @@ class TestACPSetMode:
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.mode_id == initial_mode_id
|
||||
assert acp_session.agent.mode == initial_mode
|
||||
assert acp_session.agent.auto_approve == initial_auto_approve
|
||||
|
||||
@@ -105,7 +105,7 @@ def test_on_text_change_clears_suggestions_when_no_matches() -> None:
|
||||
assert view.reset_count >= 1
|
||||
|
||||
|
||||
def test_on_text_change_limits_the_number_of_results_to_five_and_preserve_insertion_order() -> (
|
||||
def test_on_text_change_limits_the_number_of_results_and_preserves_insertion_order() -> (
|
||||
None
|
||||
):
|
||||
controller, view = make_controller(prefix="/")
|
||||
@@ -113,13 +113,15 @@ def test_on_text_change_limits_the_number_of_results_to_five_and_preserve_insert
|
||||
controller.on_text_changed("/", cursor_index=1)
|
||||
|
||||
suggestions, selected_index = view.suggestion_events[-1]
|
||||
assert len(suggestions) == 5
|
||||
assert len(suggestions) == 7
|
||||
assert [suggestion.alias for suggestion in suggestions] == [
|
||||
"/config",
|
||||
"/compact",
|
||||
"/help",
|
||||
"/summarize",
|
||||
"/logpath",
|
||||
"/exit",
|
||||
"/vim",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -29,13 +29,13 @@ async def test_popup_appears_with_matching_suggestions(vibe_app: VibeApp) -> Non
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"/sum")
|
||||
await pilot.press(*"/com")
|
||||
|
||||
popup_content = str(popup.render())
|
||||
assert popup.styles.display == "block"
|
||||
assert "/summarize" in popup_content
|
||||
assert "/compact" in popup_content
|
||||
assert "Compact conversation history by summarizing" in popup_content
|
||||
assert chat_input.value == "/sum"
|
||||
assert chat_input.value == "/com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -88,11 +88,11 @@ async def test_arrow_navigation_updates_selected_suggestion(vibe_app: VibeApp) -
|
||||
|
||||
await pilot.press(*"/c")
|
||||
|
||||
ensure_selected_command(popup, "/cfg")
|
||||
await pilot.press("down")
|
||||
ensure_selected_command(popup, "/config")
|
||||
await pilot.press("down")
|
||||
ensure_selected_command(popup, "/clear")
|
||||
await pilot.press("up")
|
||||
ensure_selected_command(popup, "/cfg")
|
||||
ensure_selected_command(popup, "/config")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -100,13 +100,13 @@ async def test_arrow_navigation_cycles_through_suggestions(vibe_app: VibeApp) ->
|
||||
async with vibe_app.run_test() as pilot:
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"/st")
|
||||
await pilot.press(*"/co")
|
||||
|
||||
ensure_selected_command(popup, "/stats")
|
||||
ensure_selected_command(popup, "/config")
|
||||
await pilot.press("down")
|
||||
ensure_selected_command(popup, "/status")
|
||||
ensure_selected_command(popup, "/compact")
|
||||
await pilot.press("up")
|
||||
ensure_selected_command(popup, "/stats")
|
||||
ensure_selected_command(popup, "/config")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -7,7 +7,8 @@ from typing import Any
|
||||
import pytest
|
||||
import tomli_w
|
||||
|
||||
from vibe.core import config_path
|
||||
from vibe.core.paths import global_paths
|
||||
from vibe.core.paths.config_paths import unlock_config_paths
|
||||
|
||||
|
||||
def get_base_config() -> dict[str, Any]:
|
||||
@@ -31,6 +32,15 @@ def get_base_config() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def tmp_working_directory(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
|
||||
) -> Path:
|
||||
tmp_working_directory = tmp_path_factory.mktemp("test_cwd")
|
||||
monkeypatch.chdir(tmp_working_directory)
|
||||
return tmp_working_directory
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def config_dir(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
|
||||
@@ -41,10 +51,15 @@ def config_dir(
|
||||
config_file = config_dir / "config.toml"
|
||||
config_file.write_text(tomli_w.dumps(get_base_config()), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(config_path, "_DEFAULT_VIBE_HOME", config_dir)
|
||||
monkeypatch.setattr(global_paths, "_DEFAULT_VIBE_HOME", config_dir)
|
||||
return config_dir
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _unlock_config_paths():
|
||||
unlock_config_paths()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "mock")
|
||||
|
||||
@@ -4,28 +4,41 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.config_path import CONFIG_FILE, GLOBAL_CONFIG_FILE, VIBE_HOME
|
||||
from vibe.core.paths.config_paths import CONFIG_FILE
|
||||
from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, VIBE_HOME
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
|
||||
|
||||
class TestResolveConfigFile:
|
||||
def test_resolves_local_config_when_exists(
|
||||
def test_resolves_local_config_when_exists_and_folder_is_trusted(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Test that local .vibe/config.toml is found when it exists."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
local_config_dir = tmp_path / ".vibe"
|
||||
local_config_dir.mkdir()
|
||||
local_config = local_config_dir / "config.toml"
|
||||
local_config.write_text('active_model = "test"', encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
|
||||
|
||||
assert CONFIG_FILE.path == local_config
|
||||
assert CONFIG_FILE.path.is_file()
|
||||
assert CONFIG_FILE.path.read_text(encoding="utf-8") == 'active_model = "test"'
|
||||
|
||||
def test_resolves_local_config_when_exists_and_folder_is_not_trusted(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
local_config_dir = tmp_path / ".vibe"
|
||||
local_config_dir.mkdir()
|
||||
local_config = local_config_dir / "config.toml"
|
||||
local_config.write_text('active_model = "test"', encoding="utf-8")
|
||||
|
||||
assert CONFIG_FILE.path == GLOBAL_CONFIG_FILE.path
|
||||
|
||||
def test_falls_back_to_global_config_when_local_missing(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Test that global config is returned when local config doesn't exist."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
# Ensure no local config exists
|
||||
assert not (tmp_path / ".vibe" / "config.toml").exists()
|
||||
@@ -35,7 +48,6 @@ class TestResolveConfigFile:
|
||||
def test_respects_vibe_home_env_var(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Test that VIBE_HOME environment variable affects VIBE_HOME.path."""
|
||||
assert VIBE_HOME.path != tmp_path
|
||||
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
|
||||
assert VIBE_HOME.path == tmp_path
|
||||
|
||||
205
tests/core/test_trusted_folders.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import tomli_w
|
||||
|
||||
from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE
|
||||
from vibe.core.trusted_folders import TrustedFoldersManager
|
||||
|
||||
|
||||
class TestTrustedFoldersManager:
|
||||
def test_initializes_with_empty_lists_when_file_does_not_exist(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
trusted_file = TRUSTED_FOLDERS_FILE.path
|
||||
assert not trusted_file.is_file()
|
||||
|
||||
manager = TrustedFoldersManager()
|
||||
assert manager.is_trusted(tmp_path) is None
|
||||
assert trusted_file.is_file()
|
||||
|
||||
def test_loads_existing_file(self, tmp_path: Path) -> None:
|
||||
trusted_file = TRUSTED_FOLDERS_FILE.path
|
||||
|
||||
data = {"trusted": [str(tmp_path.resolve())], "untrusted": []}
|
||||
with trusted_file.open("wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
|
||||
manager = TrustedFoldersManager()
|
||||
|
||||
assert manager.is_trusted(tmp_path) is True
|
||||
|
||||
def test_handles_corrupted_file(self, tmp_path: Path) -> None:
|
||||
trusted_file = TRUSTED_FOLDERS_FILE.path
|
||||
trusted_file.write_text("invalid toml content {[", encoding="utf-8")
|
||||
|
||||
manager = TrustedFoldersManager()
|
||||
|
||||
assert manager.is_trusted(tmp_path) is None
|
||||
assert trusted_file.is_file()
|
||||
|
||||
def test_normalizes_paths_to_absolute(
|
||||
self, tmp_working_directory, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
manager = TrustedFoldersManager()
|
||||
|
||||
manager.add_trusted(Path("."))
|
||||
assert manager.is_trusted(tmp_working_directory) is True
|
||||
assert manager.is_trusted(Path(".")) is True
|
||||
|
||||
def test_expands_user_home_in_paths(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
manager = TrustedFoldersManager()
|
||||
|
||||
manager.add_trusted(Path("~/test"))
|
||||
assert manager.is_trusted(tmp_path / "test") is True
|
||||
|
||||
def test_is_trusted_returns_true_for_trusted_path(self, tmp_path: Path) -> None:
|
||||
manager = TrustedFoldersManager()
|
||||
manager.add_trusted(tmp_path)
|
||||
|
||||
assert manager.is_trusted(tmp_path) is True
|
||||
|
||||
def test_is_trusted_returns_false_for_untrusted_path(self, tmp_path: Path) -> None:
|
||||
manager = TrustedFoldersManager()
|
||||
manager.add_untrusted(tmp_path)
|
||||
|
||||
assert manager.is_trusted(tmp_path) is False
|
||||
|
||||
def test_is_trusted_returns_none_for_unknown_path(self, tmp_path: Path) -> None:
|
||||
manager = TrustedFoldersManager()
|
||||
|
||||
assert manager.is_trusted(tmp_path) is None
|
||||
|
||||
def test_add_trusted_adds_path_to_trusted_list(self, tmp_path: Path) -> None:
|
||||
trusted_file = TRUSTED_FOLDERS_FILE.path
|
||||
manager = TrustedFoldersManager()
|
||||
manager.add_trusted(tmp_path)
|
||||
|
||||
assert manager.is_trusted(tmp_path) is True
|
||||
with trusted_file.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert str(tmp_path.resolve()) in data["trusted"]
|
||||
|
||||
def test_add_trusted_removes_path_from_untrusted(self, tmp_path: Path) -> None:
|
||||
trusted_file = TRUSTED_FOLDERS_FILE.path
|
||||
manager = TrustedFoldersManager()
|
||||
|
||||
manager.add_untrusted(tmp_path)
|
||||
assert manager.is_trusted(tmp_path) is False
|
||||
|
||||
manager.add_trusted(tmp_path)
|
||||
assert manager.is_trusted(tmp_path) is True
|
||||
|
||||
with trusted_file.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert str(tmp_path.resolve()) not in data["untrusted"]
|
||||
assert str(tmp_path.resolve()) in data["trusted"]
|
||||
|
||||
def test_add_trusted_idempotent(self, tmp_path: Path) -> None:
|
||||
trusted_file = TRUSTED_FOLDERS_FILE.path
|
||||
|
||||
manager = TrustedFoldersManager()
|
||||
manager.add_trusted(tmp_path)
|
||||
manager.add_trusted(tmp_path)
|
||||
manager.add_trusted(tmp_path)
|
||||
|
||||
assert manager.is_trusted(tmp_path) is True
|
||||
with trusted_file.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert data["trusted"].count(str(tmp_path.resolve())) == 1
|
||||
|
||||
def test_add_untrusted_adds_path_to_untrusted_list(self, tmp_path: Path) -> None:
|
||||
trusted_file = TRUSTED_FOLDERS_FILE.path
|
||||
manager = TrustedFoldersManager()
|
||||
manager.add_untrusted(tmp_path)
|
||||
|
||||
assert manager.is_trusted(tmp_path) is False
|
||||
with trusted_file.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert str(tmp_path.resolve()) in data["untrusted"]
|
||||
|
||||
def test_add_untrusted_removes_path_from_trusted(self, tmp_path: Path) -> None:
|
||||
trusted_file = TRUSTED_FOLDERS_FILE.path
|
||||
manager = TrustedFoldersManager()
|
||||
|
||||
manager.add_trusted(tmp_path)
|
||||
assert manager.is_trusted(tmp_path) is True
|
||||
|
||||
manager.add_untrusted(tmp_path)
|
||||
assert manager.is_trusted(tmp_path) is False
|
||||
|
||||
with trusted_file.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert str(tmp_path.resolve()) not in data["trusted"]
|
||||
assert str(tmp_path.resolve()) in data["untrusted"]
|
||||
|
||||
def test_add_untrusted_idempotent(self, tmp_path: Path) -> None:
|
||||
trusted_file = TRUSTED_FOLDERS_FILE.path
|
||||
|
||||
manager = TrustedFoldersManager()
|
||||
manager.add_untrusted(tmp_path)
|
||||
manager.add_untrusted(tmp_path)
|
||||
manager.add_untrusted(tmp_path)
|
||||
|
||||
assert manager.is_trusted(tmp_path) is False
|
||||
with trusted_file.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert data["untrusted"].count(str(tmp_path.resolve())) == 1
|
||||
|
||||
def test_persistence_across_instances(self, tmp_path: Path) -> None:
|
||||
manager1 = TrustedFoldersManager()
|
||||
manager1.add_trusted(tmp_path)
|
||||
|
||||
manager2 = TrustedFoldersManager()
|
||||
assert manager2.is_trusted(tmp_path) is True
|
||||
|
||||
def test_handles_multiple_paths(self, tmp_path: Path) -> None:
|
||||
trusted1 = tmp_path / "trusted1"
|
||||
trusted2 = tmp_path / "trusted2"
|
||||
untrusted1 = tmp_path / "untrusted1"
|
||||
untrusted2 = tmp_path / "untrusted2"
|
||||
for p in [trusted1, trusted2, untrusted1, untrusted2]:
|
||||
p.mkdir()
|
||||
|
||||
manager = TrustedFoldersManager()
|
||||
manager.add_trusted(trusted1)
|
||||
manager.add_trusted(trusted2)
|
||||
manager.add_untrusted(untrusted1)
|
||||
manager.add_untrusted(untrusted2)
|
||||
|
||||
assert manager.is_trusted(trusted1) is True
|
||||
assert manager.is_trusted(trusted2) is True
|
||||
assert manager.is_trusted(untrusted1) is False
|
||||
assert manager.is_trusted(untrusted2) is False
|
||||
|
||||
def test_handles_switching_between_trusted_and_untrusted(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
manager = TrustedFoldersManager()
|
||||
|
||||
manager.add_trusted(tmp_path)
|
||||
assert manager.is_trusted(tmp_path) is True
|
||||
|
||||
manager.add_untrusted(tmp_path)
|
||||
assert manager.is_trusted(tmp_path) is False
|
||||
|
||||
manager.add_trusted(tmp_path)
|
||||
assert manager.is_trusted(tmp_path) is True
|
||||
|
||||
def test_handles_missing_file_during_save(self, tmp_path: Path) -> None:
|
||||
manager = TrustedFoldersManager()
|
||||
|
||||
def mock_open(*args, **kwargs):
|
||||
raise OSError("Permission denied")
|
||||
|
||||
with patch("pathlib.Path.open", side_effect=mock_open):
|
||||
manager.add_trusted(tmp_path)
|
||||
|
||||
assert manager.is_trusted(tmp_path) is True
|
||||
@@ -14,12 +14,15 @@ from unittest.mock import patch
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from tests import TESTS_ROOT
|
||||
from tests.mock.utils import MOCK_DATA_ENV_VAR
|
||||
from vibe.core.types import LLMChunk
|
||||
from vibe.core.paths.config_paths import unlock_config_paths
|
||||
|
||||
if __name__ == "__main__":
|
||||
unlock_config_paths()
|
||||
|
||||
from tests import TESTS_ROOT
|
||||
from tests.mock.utils import MOCK_DATA_ENV_VAR
|
||||
from vibe.core.types import LLMChunk
|
||||
|
||||
def mock_llm_output() -> None:
|
||||
sys.path.insert(0, str(TESTS_ROOT))
|
||||
|
||||
# Apply mocking before importing any vibe modules
|
||||
@@ -57,10 +60,6 @@ def mock_llm_output() -> None:
|
||||
side_effect=mock_complete_streaming,
|
||||
).start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mock_llm_output()
|
||||
|
||||
from vibe.acp.entrypoint import main
|
||||
|
||||
main()
|
||||
|
||||
@@ -10,7 +10,7 @@ from textual.geometry import Size
|
||||
from textual.pilot import Pilot
|
||||
from textual.widgets import Input
|
||||
|
||||
from vibe.core.config_path import GLOBAL_CONFIG_FILE, GLOBAL_ENV_FILE
|
||||
from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, GLOBAL_ENV_FILE
|
||||
from vibe.setup.onboarding import OnboardingApp
|
||||
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
|
||||
from vibe.setup.onboarding.screens.theme_selection import THEMES, ThemeSelectionScreen
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 21 KiB |
@@ -37,7 +37,7 @@ class BaseSnapshotTestApp(VibeApp):
|
||||
|
||||
self.agent = Agent(
|
||||
config,
|
||||
auto_approve=self.auto_approve,
|
||||
mode=self._current_agent_mode,
|
||||
enable_streaming=self.enable_streaming,
|
||||
backend=FakeBackend(),
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ class SnapshotTestAppWithConversation(BaseSnapshotTestApp):
|
||||
super().__init__(config=config)
|
||||
self.agent = Agent(
|
||||
config,
|
||||
auto_approve=self.auto_approve,
|
||||
mode=self._current_agent_mode,
|
||||
enable_streaming=self.enable_streaming,
|
||||
backend=fake_backend,
|
||||
)
|
||||
|
||||
84
tests/snapshots/test_ui_snapshot_modes.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
|
||||
|
||||
def test_snapshot_default_mode(snap_compare: SnapCompare) -> None:
|
||||
"""Test that default mode is displayed correctly at startup."""
|
||||
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"base_snapshot_test_app.py:BaseSnapshotTestApp",
|
||||
terminal_size=(120, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_cycle_to_plan_mode(snap_compare: SnapCompare) -> None:
|
||||
"""Test that shift+tab cycles from default to plan mode."""
|
||||
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("shift+tab") # default -> plan
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"base_snapshot_test_app.py:BaseSnapshotTestApp",
|
||||
terminal_size=(120, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_cycle_to_accept_edits_mode(snap_compare: SnapCompare) -> None:
|
||||
"""Test that shift+tab cycles from plan to accept edits mode."""
|
||||
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("shift+tab") # default -> plan
|
||||
await pilot.press("shift+tab") # plan -> accept edits
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"base_snapshot_test_app.py:BaseSnapshotTestApp",
|
||||
terminal_size=(120, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_cycle_to_auto_approve_mode(snap_compare: SnapCompare) -> None:
|
||||
"""Test that shift+tab cycles to auto approve mode."""
|
||||
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("shift+tab") # default -> plan
|
||||
await pilot.press("shift+tab") # plan -> accept edits
|
||||
await pilot.press("shift+tab") # accept edits -> auto approve
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"base_snapshot_test_app.py:BaseSnapshotTestApp",
|
||||
terminal_size=(120, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_cycle_wraps_to_default(snap_compare: SnapCompare) -> None:
|
||||
"""Test that shift+tab cycles back to default mode after auto approve."""
|
||||
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("shift+tab") # default -> plan
|
||||
await pilot.press("shift+tab") # plan -> accept edits
|
||||
await pilot.press("shift+tab") # accept edits -> auto approve
|
||||
await pilot.press("shift+tab") # auto approve -> default (wrap)
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"base_snapshot_test_app.py:BaseSnapshotTestApp",
|
||||
terminal_size=(120, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
@@ -18,6 +18,7 @@ from vibe.core.middleware import (
|
||||
MiddlewareResult,
|
||||
ResetReason,
|
||||
)
|
||||
from vibe.core.modes import AgentMode
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.tools.builtins.todo import TodoArgs
|
||||
from vibe.core.types import (
|
||||
@@ -187,7 +188,7 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
||||
),
|
||||
backend=backend,
|
||||
auto_approve=True,
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
enable_streaming=True,
|
||||
)
|
||||
|
||||
@@ -229,7 +230,7 @@ async def test_act_handles_tool_call_chunk_with_content() -> None:
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
||||
),
|
||||
backend=backend,
|
||||
auto_approve=True,
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
enable_streaming=True,
|
||||
)
|
||||
|
||||
@@ -274,7 +275,7 @@ async def test_act_merges_streamed_tool_call_arguments() -> None:
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
||||
),
|
||||
backend=backend,
|
||||
auto_approve=True,
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
enable_streaming=True,
|
||||
)
|
||||
|
||||
@@ -366,7 +367,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ASK)},
|
||||
),
|
||||
backend=backend,
|
||||
auto_approve=False,
|
||||
mode=AgentMode.DEFAULT,
|
||||
enable_streaming=True,
|
||||
)
|
||||
middleware = CountingMiddleware()
|
||||
@@ -392,7 +393,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
|
||||
assert events[-1].skipped is True
|
||||
assert events[-1].skip_reason is not None
|
||||
assert "<user_cancellation>" in events[-1].skip_reason
|
||||
assert agent.interaction_logger.save_interaction.await_count == 2
|
||||
assert agent.interaction_logger.save_interaction.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -14,6 +14,7 @@ from vibe.core.config import (
|
||||
SessionLoggingConfig,
|
||||
VibeConfig,
|
||||
)
|
||||
from vibe.core.modes import AgentMode
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.types import (
|
||||
AgentStats,
|
||||
@@ -193,7 +194,7 @@ class TestReloadPreservesStats:
|
||||
mock_llm_chunk(content="Done", finish_reason="stop"),
|
||||
])
|
||||
config = make_config(enabled_tools=["todo"])
|
||||
agent = Agent(config, auto_approve=True, backend=backend)
|
||||
agent = Agent(config, mode=AgentMode.AUTO_APPROVE, backend=backend)
|
||||
|
||||
async for _ in agent.act("Check todos"):
|
||||
pass
|
||||
@@ -257,9 +258,7 @@ class TestReloadPreservesStats:
|
||||
assert agent.stats.context_tokens == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_resets_context_tokens_when_system_prompt_changes(
|
||||
self,
|
||||
) -> None:
|
||||
async def test_reload_preserves_context_tokens_when_messages_exist(self) -> None:
|
||||
backend = FakeBackend([
|
||||
mock_llm_chunk(content="Response", finish_reason="stop")
|
||||
])
|
||||
@@ -267,13 +266,14 @@ class TestReloadPreservesStats:
|
||||
config2 = make_config(system_prompt_id="cli")
|
||||
agent = Agent(config1, backend=backend)
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
assert agent.stats.context_tokens > 0
|
||||
original_context_tokens = agent.stats.context_tokens
|
||||
assert original_context_tokens > 0
|
||||
assert len(agent.messages) > 1
|
||||
|
||||
await agent.reload_with_initial_messages(config=config2)
|
||||
|
||||
assert len(agent.messages) > 1
|
||||
assert agent.stats.context_tokens == 0
|
||||
assert agent.stats.context_tokens == original_context_tokens
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_updates_pricing_from_new_model(self, monkeypatch) -> None:
|
||||
@@ -464,7 +464,7 @@ class TestCompactStatsHandling:
|
||||
mock_llm_chunk(content="<summary>", finish_reason="stop"),
|
||||
])
|
||||
config = make_config(enabled_tools=["todo"])
|
||||
agent = Agent(config, auto_approve=True, backend=backend)
|
||||
agent = Agent(config, mode=AgentMode.AUTO_APPROVE, backend=backend)
|
||||
|
||||
async for _ in agent.act("Check todos"):
|
||||
pass
|
||||
|
||||
@@ -11,6 +11,7 @@ from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_tool import FakeTool
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
from vibe.core.modes import AgentMode
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.tools.builtins.todo import TodoItem
|
||||
from vibe.core.types import (
|
||||
@@ -57,10 +58,9 @@ def make_agent(
|
||||
backend: FakeBackend,
|
||||
approval_callback: SyncApprovalCallback | None = None,
|
||||
) -> Agent:
|
||||
mode = AgentMode.AUTO_APPROVE if auto_approve else AgentMode.DEFAULT
|
||||
agent = Agent(
|
||||
make_config(todo_permission=todo_permission),
|
||||
auto_approve=auto_approve,
|
||||
backend=backend,
|
||||
make_config(todo_permission=todo_permission), mode=mode, backend=backend
|
||||
)
|
||||
if approval_callback:
|
||||
agent.set_approval_callback(approval_callback)
|
||||
@@ -403,7 +403,7 @@ async def test_tool_call_can_be_interrupted(
|
||||
)
|
||||
agent = Agent(
|
||||
config,
|
||||
auto_approve=True,
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
backend=FakeBackend([
|
||||
mock_llm_chunk(content="Let me use the tool.", tool_calls=[tool_call]),
|
||||
mock_llm_chunk(content="Tool execution completed.", finish_reason="stop"),
|
||||
@@ -432,7 +432,7 @@ async def test_tool_call_can_be_interrupted(
|
||||
async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
|
||||
agent = Agent(
|
||||
make_config(),
|
||||
auto_approve=True,
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
backend=FakeBackend([mock_llm_chunk(content="ok", finish_reason="stop")]),
|
||||
)
|
||||
tool_calls_messages = [
|
||||
@@ -472,7 +472,7 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
|
||||
async def test_ensure_assistant_after_tool_appends_understood() -> None:
|
||||
agent = Agent(
|
||||
make_config(),
|
||||
auto_approve=True,
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
backend=FakeBackend([mock_llm_chunk(content="ok", finish_reason="stop")]),
|
||||
)
|
||||
tool_msg = LLMMessage(
|
||||
|
||||
@@ -126,7 +126,6 @@ def test_run_programmatic_ignores_system_messages_in_previous(
|
||||
content="Second system message that should be ignored.",
|
||||
),
|
||||
],
|
||||
auto_approve=True,
|
||||
)
|
||||
|
||||
roles = [r for r, _ in spy.emitted]
|
||||
|
||||
126
tests/test_middleware.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
from vibe.core.middleware import (
|
||||
PLAN_MODE_REMINDER,
|
||||
ConversationContext,
|
||||
MiddlewareAction,
|
||||
MiddlewarePipeline,
|
||||
PlanModeMiddleware,
|
||||
)
|
||||
from vibe.core.modes import AgentMode
|
||||
from vibe.core.types import AgentStats
|
||||
|
||||
|
||||
def make_context() -> ConversationContext:
|
||||
config = VibeConfig(session_logging=SessionLoggingConfig(enabled=False))
|
||||
return ConversationContext(messages=[], stats=AgentStats(), config=config)
|
||||
|
||||
|
||||
class TestPlanModeMiddleware:
|
||||
@pytest.mark.asyncio
|
||||
async def test_injects_reminder_when_plan_mode_active(self) -> None:
|
||||
middleware = PlanModeMiddleware(lambda: AgentMode.PLAN)
|
||||
ctx = make_context()
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_MODE_REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_inject_when_default_mode(self) -> None:
|
||||
middleware = PlanModeMiddleware(lambda: AgentMode.DEFAULT)
|
||||
ctx = make_context()
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_inject_when_auto_approve_mode(self) -> None:
|
||||
middleware = PlanModeMiddleware(lambda: AgentMode.AUTO_APPROVE)
|
||||
ctx = make_context()
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_inject_when_accept_edits_mode(self) -> None:
|
||||
middleware = PlanModeMiddleware(lambda: AgentMode.ACCEPT_EDITS)
|
||||
ctx = make_context()
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_after_turn_always_continues(self) -> None:
|
||||
middleware = PlanModeMiddleware(lambda: AgentMode.PLAN)
|
||||
ctx = make_context()
|
||||
|
||||
result = await middleware.after_turn(ctx)
|
||||
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dynamically_checks_mode(self) -> None:
|
||||
current_mode = AgentMode.DEFAULT
|
||||
middleware = PlanModeMiddleware(lambda: current_mode)
|
||||
ctx = make_context()
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
current_mode = AgentMode.PLAN
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
|
||||
current_mode = AgentMode.AUTO_APPROVE
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_reminder(self) -> None:
|
||||
custom_reminder = "Custom plan mode reminder"
|
||||
middleware = PlanModeMiddleware(
|
||||
lambda: AgentMode.PLAN, reminder=custom_reminder
|
||||
)
|
||||
ctx = make_context()
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
|
||||
assert result.message == custom_reminder
|
||||
|
||||
def test_reset_does_nothing(self) -> None:
|
||||
middleware = PlanModeMiddleware(lambda: AgentMode.PLAN)
|
||||
middleware.reset()
|
||||
|
||||
|
||||
class TestMiddlewarePipelineWithPlanMode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_includes_plan_mode_injection(self) -> None:
|
||||
pipeline = MiddlewarePipeline()
|
||||
pipeline.add(PlanModeMiddleware(lambda: AgentMode.PLAN))
|
||||
ctx = make_context()
|
||||
|
||||
result = await pipeline.run_before_turn(ctx)
|
||||
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert PLAN_MODE_REMINDER in (result.message or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_skips_injection_when_not_plan_mode(self) -> None:
|
||||
pipeline = MiddlewarePipeline()
|
||||
pipeline.add(PlanModeMiddleware(lambda: AgentMode.DEFAULT))
|
||||
ctx = make_context()
|
||||
|
||||
result = await pipeline.run_before_turn(ctx)
|
||||
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
323
tests/test_modes.py
Normal file
@@ -0,0 +1,323 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
from vibe.core.llm.format import get_active_tool_classes
|
||||
from vibe.core.modes import (
|
||||
MODE_CONFIGS,
|
||||
PLAN_MODE_TOOLS,
|
||||
AgentMode,
|
||||
ModeConfig,
|
||||
ModeSafety,
|
||||
get_mode_order,
|
||||
next_mode,
|
||||
)
|
||||
from vibe.core.tools.base import ToolPermission
|
||||
from vibe.core.types import (
|
||||
FunctionCall,
|
||||
LLMChunk,
|
||||
LLMMessage,
|
||||
LLMUsage,
|
||||
Role,
|
||||
ToolCall,
|
||||
ToolResultEvent,
|
||||
)
|
||||
|
||||
|
||||
class TestModeSafety:
|
||||
def test_safety_enum_values(self) -> None:
|
||||
assert ModeSafety.SAFE == "safe"
|
||||
assert ModeSafety.NEUTRAL == "neutral"
|
||||
assert ModeSafety.DESTRUCTIVE == "destructive"
|
||||
assert ModeSafety.YOLO == "yolo"
|
||||
|
||||
def test_default_mode_is_neutral(self) -> None:
|
||||
assert AgentMode.DEFAULT.safety == ModeSafety.NEUTRAL
|
||||
|
||||
def test_auto_approve_mode_is_yolo(self) -> None:
|
||||
assert AgentMode.AUTO_APPROVE.safety == ModeSafety.YOLO
|
||||
|
||||
def test_plan_mode_is_safe(self) -> None:
|
||||
assert AgentMode.PLAN.safety == ModeSafety.SAFE
|
||||
|
||||
def test_accept_edits_mode_is_destructive(self) -> None:
|
||||
assert AgentMode.ACCEPT_EDITS.safety == ModeSafety.DESTRUCTIVE
|
||||
|
||||
|
||||
class TestAgentMode:
|
||||
def test_all_modes_have_configs(self) -> None:
|
||||
for mode in AgentMode:
|
||||
assert mode in MODE_CONFIGS
|
||||
|
||||
def test_display_name_property(self) -> None:
|
||||
assert AgentMode.DEFAULT.display_name == "Default"
|
||||
assert AgentMode.AUTO_APPROVE.display_name == "Auto Approve"
|
||||
assert AgentMode.PLAN.display_name == "Plan"
|
||||
assert AgentMode.ACCEPT_EDITS.display_name == "Accept Edits"
|
||||
|
||||
def test_description_property(self) -> None:
|
||||
assert "approval" in AgentMode.DEFAULT.description.lower()
|
||||
assert "auto" in AgentMode.AUTO_APPROVE.description.lower()
|
||||
assert "read-only" in AgentMode.PLAN.description.lower()
|
||||
assert "edits" in AgentMode.ACCEPT_EDITS.description.lower()
|
||||
|
||||
def test_auto_approve_property(self) -> None:
|
||||
assert AgentMode.DEFAULT.auto_approve is False
|
||||
assert AgentMode.AUTO_APPROVE.auto_approve is True
|
||||
assert AgentMode.PLAN.auto_approve is True
|
||||
assert AgentMode.ACCEPT_EDITS.auto_approve is False
|
||||
|
||||
def test_from_string_valid(self) -> None:
|
||||
assert AgentMode.from_string("default") == AgentMode.DEFAULT
|
||||
assert AgentMode.from_string("AUTO_APPROVE") == AgentMode.AUTO_APPROVE
|
||||
assert AgentMode.from_string("Plan") == AgentMode.PLAN
|
||||
assert AgentMode.from_string("accept_edits") == AgentMode.ACCEPT_EDITS
|
||||
|
||||
def test_from_string_invalid(self) -> None:
|
||||
assert AgentMode.from_string("invalid") is None
|
||||
assert AgentMode.from_string("") is None
|
||||
|
||||
|
||||
class TestModeConfigOverrides:
|
||||
def test_default_mode_has_no_overrides(self) -> None:
|
||||
assert AgentMode.DEFAULT.config_overrides == {}
|
||||
|
||||
def test_auto_approve_mode_has_no_overrides(self) -> None:
|
||||
assert AgentMode.AUTO_APPROVE.config_overrides == {}
|
||||
|
||||
def test_plan_mode_restricts_tools(self) -> None:
|
||||
overrides = AgentMode.PLAN.config_overrides
|
||||
assert "enabled_tools" in overrides
|
||||
assert overrides["enabled_tools"] == PLAN_MODE_TOOLS
|
||||
|
||||
def test_accept_edits_mode_sets_tool_permissions(self) -> None:
|
||||
overrides = AgentMode.ACCEPT_EDITS.config_overrides
|
||||
assert "tools" in overrides
|
||||
tools_config = overrides["tools"]
|
||||
assert "write_file" in tools_config
|
||||
assert "search_replace" in tools_config
|
||||
assert tools_config["write_file"]["permission"] == "always"
|
||||
assert tools_config["search_replace"]["permission"] == "always"
|
||||
|
||||
|
||||
class TestModeCycling:
|
||||
def test_get_mode_order_includes_all_modes(self) -> None:
|
||||
order = get_mode_order()
|
||||
assert len(order) == 4
|
||||
assert AgentMode.DEFAULT in order
|
||||
assert AgentMode.AUTO_APPROVE in order
|
||||
assert AgentMode.PLAN in order
|
||||
assert AgentMode.ACCEPT_EDITS in order
|
||||
|
||||
def test_next_mode_cycles_through_all(self) -> None:
|
||||
order = get_mode_order()
|
||||
current = order[0]
|
||||
visited = [current]
|
||||
for _ in range(len(order) - 1):
|
||||
current = next_mode(current)
|
||||
visited.append(current)
|
||||
assert len(set(visited)) == len(order)
|
||||
|
||||
def test_next_mode_wraps_around(self) -> None:
|
||||
order = get_mode_order()
|
||||
last_mode = order[-1]
|
||||
first_mode = order[0]
|
||||
assert next_mode(last_mode) == first_mode
|
||||
|
||||
|
||||
class TestModeConfig:
|
||||
def test_mode_config_defaults(self) -> None:
|
||||
config = ModeConfig(display_name="Test", description="Test mode")
|
||||
assert config.safety == ModeSafety.NEUTRAL
|
||||
assert config.auto_approve is False
|
||||
assert config.config_overrides == {}
|
||||
|
||||
def test_mode_config_frozen(self) -> None:
|
||||
config = ModeConfig(display_name="Test", description="Test mode")
|
||||
with pytest.raises(AttributeError):
|
||||
config.display_name = "Changed" # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
|
||||
class TestAgentSwitchMode:
|
||||
@pytest.fixture
|
||||
def base_config(self) -> VibeConfig:
|
||||
return VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
auto_compact_threshold=0,
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def backend(self) -> FakeBackend:
|
||||
return FakeBackend([
|
||||
LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content="Test response"),
|
||||
finish_reason="stop",
|
||||
usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
|
||||
)
|
||||
])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_to_plan_mode_restricts_tools(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = Agent(base_config, mode=AgentMode.DEFAULT, backend=backend)
|
||||
initial_tools = get_active_tool_classes(agent.tool_manager, agent.config)
|
||||
initial_tool_names = {t.get_name() for t in initial_tools}
|
||||
assert len(initial_tool_names) > len(PLAN_MODE_TOOLS)
|
||||
|
||||
await agent.switch_mode(AgentMode.PLAN)
|
||||
|
||||
plan_tools = get_active_tool_classes(agent.tool_manager, agent.config)
|
||||
plan_tool_names = {t.get_name() for t in plan_tools}
|
||||
assert plan_tool_names == set(PLAN_MODE_TOOLS)
|
||||
assert agent.mode == AgentMode.PLAN
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_from_plan_to_normal_restores_tools(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
plan_config = VibeConfig.model_validate({
|
||||
**base_config.model_dump(),
|
||||
**AgentMode.PLAN.config_overrides,
|
||||
})
|
||||
agent = Agent(plan_config, mode=AgentMode.PLAN, backend=backend)
|
||||
plan_tools = get_active_tool_classes(agent.tool_manager, agent.config)
|
||||
assert len(plan_tools) == len(PLAN_MODE_TOOLS)
|
||||
|
||||
await agent.switch_mode(AgentMode.DEFAULT)
|
||||
|
||||
normal_tools = get_active_tool_classes(agent.tool_manager, agent.config)
|
||||
assert len(normal_tools) > len(PLAN_MODE_TOOLS)
|
||||
assert agent.mode == AgentMode.DEFAULT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_mode_preserves_conversation_history(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = Agent(base_config, mode=AgentMode.DEFAULT, backend=backend)
|
||||
user_msg = LLMMessage(role=Role.user, content="Hello")
|
||||
assistant_msg = LLMMessage(role=Role.assistant, content="Hi there")
|
||||
agent.messages.append(user_msg)
|
||||
agent.messages.append(assistant_msg)
|
||||
|
||||
await agent.switch_mode(AgentMode.PLAN)
|
||||
|
||||
assert len(agent.messages) == 3 # system + user + assistant
|
||||
assert agent.messages[1].content == "Hello"
|
||||
assert agent.messages[2].content == "Hi there"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_to_same_mode_is_noop(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = Agent(base_config, mode=AgentMode.DEFAULT, backend=backend)
|
||||
original_config = agent.config
|
||||
|
||||
await agent.switch_mode(AgentMode.DEFAULT)
|
||||
|
||||
assert agent.config is original_config
|
||||
assert agent.mode == AgentMode.DEFAULT
|
||||
|
||||
|
||||
class TestAcceptEditsMode:
|
||||
def test_accept_edits_config_sets_write_file_always(self) -> None:
|
||||
overrides = AgentMode.ACCEPT_EDITS.config_overrides
|
||||
assert overrides["tools"]["write_file"]["permission"] == "always"
|
||||
|
||||
def test_accept_edits_config_sets_search_replace_always(self) -> None:
|
||||
overrides = AgentMode.ACCEPT_EDITS.config_overrides
|
||||
assert overrides["tools"]["search_replace"]["permission"] == "always"
|
||||
|
||||
def test_accept_edits_mode_not_auto_approve(self) -> None:
|
||||
assert AgentMode.ACCEPT_EDITS.auto_approve is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_edits_mode_auto_approves_write_file(self) -> None:
|
||||
backend = FakeBackend([])
|
||||
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
auto_compact_threshold=0,
|
||||
enabled_tools=["write_file"],
|
||||
**AgentMode.ACCEPT_EDITS.config_overrides,
|
||||
)
|
||||
agent = Agent(config, mode=AgentMode.ACCEPT_EDITS, backend=backend)
|
||||
|
||||
perm = agent.tool_manager.get_tool_config("write_file").permission
|
||||
assert perm == ToolPermission.ALWAYS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_edits_mode_requires_approval_for_other_tools(self) -> None:
|
||||
backend = FakeBackend([])
|
||||
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
auto_compact_threshold=0,
|
||||
enabled_tools=["bash"],
|
||||
**AgentMode.ACCEPT_EDITS.config_overrides,
|
||||
)
|
||||
agent = Agent(config, mode=AgentMode.ACCEPT_EDITS, backend=backend)
|
||||
|
||||
perm = agent.tool_manager.get_tool_config("bash").permission
|
||||
assert perm == ToolPermission.ASK
|
||||
|
||||
|
||||
class TestPlanModeToolRestriction:
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_mode_only_exposes_read_tools_to_llm(self) -> None:
|
||||
backend = FakeBackend([
|
||||
LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content="ok"),
|
||||
finish_reason="stop",
|
||||
usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
|
||||
)
|
||||
])
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
auto_compact_threshold=0,
|
||||
**AgentMode.PLAN.config_overrides,
|
||||
)
|
||||
agent = Agent(config, mode=AgentMode.PLAN, backend=backend)
|
||||
|
||||
active_tools = get_active_tool_classes(agent.tool_manager, agent.config)
|
||||
tool_names = {t.get_name() for t in active_tools}
|
||||
|
||||
assert "bash" not in tool_names
|
||||
assert "write_file" not in tool_names
|
||||
assert "search_replace" not in tool_names
|
||||
for plan_tool in PLAN_MODE_TOOLS:
|
||||
assert plan_tool in tool_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_mode_rejects_non_plan_tool_call(self) -> None:
|
||||
tool_call = ToolCall(
|
||||
id="call_1",
|
||||
function=FunctionCall(name="bash", arguments='{"command": "ls"}'),
|
||||
)
|
||||
backend = FakeBackend([
|
||||
mock_llm_chunk(content="Let me run bash", tool_calls=[tool_call]),
|
||||
mock_llm_chunk(content="Tool not available", finish_reason="stop"),
|
||||
])
|
||||
|
||||
config = VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
auto_compact_threshold=0,
|
||||
**AgentMode.PLAN.config_overrides,
|
||||
)
|
||||
agent = Agent(config, mode=AgentMode.PLAN, backend=backend)
|
||||
|
||||
events = [ev async for ev in agent.act("Run ls")]
|
||||
|
||||
tool_result = next((e for e in events if isinstance(e, ToolResultEvent)), None)
|
||||
assert tool_result is not None
|
||||
assert tool_result.error is not None
|
||||
assert (
|
||||
"not found" in tool_result.error.lower()
|
||||
or "error" in tool_result.error.lower()
|
||||
)
|
||||
@@ -21,6 +21,7 @@ from vibe.cli.update_notifier import (
|
||||
VersionUpdateGatewayError,
|
||||
)
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
from vibe.core.modes import AgentMode
|
||||
|
||||
|
||||
async def _wait_for_notification(
|
||||
@@ -66,7 +67,7 @@ class VibeAppFactory(Protocol):
|
||||
notifier: FakeVersionUpdateGateway,
|
||||
update_cache_repository: FakeUpdateCacheRepository | None = None,
|
||||
config: VibeConfig | None = None,
|
||||
auto_approve: bool = False,
|
||||
initial_mode: AgentMode = AgentMode.DEFAULT,
|
||||
current_version: str = "0.1.0",
|
||||
) -> VibeApp: ...
|
||||
|
||||
@@ -81,12 +82,12 @@ def make_vibe_app(vibe_config_with_update_checks_enabled: VibeConfig) -> VibeApp
|
||||
update_cache_repository: FakeUpdateCacheRepository
|
||||
| None = update_cache_repository,
|
||||
config: VibeConfig | None = None,
|
||||
auto_approve: bool = False,
|
||||
initial_mode: AgentMode = AgentMode.DEFAULT,
|
||||
current_version: str = "0.1.0",
|
||||
) -> VibeApp:
|
||||
return VibeApp(
|
||||
config=config or vibe_config_with_update_checks_enabled,
|
||||
auto_approve=auto_approve,
|
||||
initial_mode=initial_mode,
|
||||
version_update_notifier=notifier,
|
||||
update_cache_repository=update_cache_repository,
|
||||
current_version=current_version,
|
||||
|
||||