Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Hdandria <henri.dandria@mistral.ai> Co-authored-by: Ivana Dunisijevic <ivana.dunisijevic@mistral.ai> Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Mert Unsal <mert.unsal@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
765 lines
28 KiB
Python
765 lines
28 KiB
Python
from __future__ import annotations
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests import TESTS_ROOT
|
|
from tests.conftest import build_test_agent_loop, build_test_vibe_config
|
|
from tests.stubs.fake_backend import FakeBackend
|
|
from vibe.core.agents.manager import AgentManager
|
|
from vibe.core.agents.models import (
|
|
BUILTIN_AGENTS,
|
|
CHAT,
|
|
AgentProfile,
|
|
AgentSafety,
|
|
AgentType,
|
|
BuiltinAgentName,
|
|
_deep_merge,
|
|
)
|
|
from vibe.core.config import VibeConfig
|
|
from vibe.core.prompts import UtilityPrompt
|
|
from vibe.core.tools.base import ToolPermission
|
|
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
|
|
|
|
|
|
class TestDeepMerge:
|
|
def test_simple_merge(self) -> None:
|
|
base = {"a": 1, "b": 2}
|
|
override = {"c": 3}
|
|
result = _deep_merge(base, override)
|
|
assert result == {"a": 1, "b": 2, "c": 3}
|
|
|
|
def test_override_existing_key(self) -> None:
|
|
base = {"a": 1, "b": 2}
|
|
override = {"b": 3}
|
|
result = _deep_merge(base, override)
|
|
assert result == {"a": 1, "b": 3}
|
|
|
|
def test_nested_dict_merge(self) -> None:
|
|
base = {"a": {"x": 1, "y": 2}}
|
|
override = {"a": {"y": 3, "z": 4}}
|
|
result = _deep_merge(base, override)
|
|
assert result == {"a": {"x": 1, "y": 3, "z": 4}}
|
|
|
|
def test_deeply_nested_merge(self) -> None:
|
|
base = {"a": {"b": {"c": 1}}}
|
|
override = {"a": {"b": {"d": 2}}}
|
|
result = _deep_merge(base, override)
|
|
assert result == {"a": {"b": {"c": 1, "d": 2}}}
|
|
|
|
def test_override_dict_with_non_dict(self) -> None:
|
|
base = {"a": {"x": 1}}
|
|
override = {"a": "replaced"}
|
|
result = _deep_merge(base, override)
|
|
assert result == {"a": "replaced"}
|
|
|
|
def test_override_non_dict_with_dict(self) -> None:
|
|
base = {"a": "string"}
|
|
override = {"a": {"x": 1}}
|
|
result = _deep_merge(base, override)
|
|
assert result == {"a": {"x": 1}}
|
|
|
|
def test_preserves_original_base(self) -> None:
|
|
base = {"a": 1, "b": {"c": 2}}
|
|
override = {"b": {"d": 3}}
|
|
_deep_merge(base, override)
|
|
assert base == {"a": 1, "b": {"c": 2}}
|
|
|
|
def test_empty_override(self) -> None:
|
|
base = {"a": 1, "b": 2}
|
|
override: dict = {}
|
|
result = _deep_merge(base, override)
|
|
assert result == {"a": 1, "b": 2}
|
|
|
|
def test_empty_base(self) -> None:
|
|
base: dict = {}
|
|
override = {"a": 1}
|
|
result = _deep_merge(base, override)
|
|
assert result == {"a": 1}
|
|
|
|
def test_lists_are_overridden_not_merged(self) -> None:
|
|
"""Lists should be replaced entirely, not merged element-by-element."""
|
|
base = {"tools": ["read", "grep", "bash"]}
|
|
override = {"tools": ["write_file"]}
|
|
result = _deep_merge(base, override)
|
|
assert result == {"tools": ["write_file"]}
|
|
|
|
def test_nested_lists_are_overridden_not_merged(self) -> None:
|
|
"""Nested lists in dicts should also be replaced, not merged."""
|
|
base = {"config": {"enabled_tools": ["a", "b", "c"], "other": 1}}
|
|
override = {"config": {"enabled_tools": ["x", "y"]}}
|
|
result = _deep_merge(base, override)
|
|
assert result == {"config": {"enabled_tools": ["x", "y"], "other": 1}}
|
|
|
|
|
|
class TestAgentSafety:
|
|
def test_safety_enum_values(self) -> None:
|
|
assert AgentSafety.SAFE == "safe"
|
|
assert AgentSafety.NEUTRAL == "neutral"
|
|
assert AgentSafety.DESTRUCTIVE == "destructive"
|
|
assert AgentSafety.YOLO == "yolo"
|
|
|
|
def test_default_agent_is_neutral(self) -> None:
|
|
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].safety == AgentSafety.NEUTRAL
|
|
|
|
def test_auto_approve_agent_is_yolo(self) -> None:
|
|
assert BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].safety == AgentSafety.YOLO
|
|
|
|
def test_plan_agent_is_safe(self) -> None:
|
|
assert BUILTIN_AGENTS[BuiltinAgentName.PLAN].safety == AgentSafety.SAFE
|
|
|
|
def test_accept_edits_agent_is_destructive(self) -> None:
|
|
assert (
|
|
BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].safety
|
|
== AgentSafety.DESTRUCTIVE
|
|
)
|
|
|
|
|
|
class TestAgentProfile:
|
|
def test_all_builtin_agents_have_valid_names(self) -> None:
|
|
acp_only = {BuiltinAgentName.CHAT}
|
|
assert set(BUILTIN_AGENTS.keys()) == set(BuiltinAgentName) - acp_only
|
|
|
|
def test_display_name_property(self) -> None:
|
|
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].display_name == "Default"
|
|
assert (
|
|
BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].display_name == "Auto Approve"
|
|
)
|
|
assert BUILTIN_AGENTS[BuiltinAgentName.PLAN].display_name == "Plan"
|
|
assert (
|
|
BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].display_name == "Accept Edits"
|
|
)
|
|
|
|
def test_description_property(self) -> None:
|
|
assert (
|
|
"approval" in BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].description.lower()
|
|
)
|
|
assert (
|
|
"auto" in BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].description.lower()
|
|
)
|
|
assert "read-only" in BUILTIN_AGENTS[BuiltinAgentName.PLAN].description.lower()
|
|
assert (
|
|
"edits" in BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].description.lower()
|
|
)
|
|
|
|
def test_explore_is_subagent(self) -> None:
|
|
assert BUILTIN_AGENTS[BuiltinAgentName.EXPLORE].agent_type == AgentType.SUBAGENT
|
|
|
|
def test_agents(self) -> None:
|
|
agents = [
|
|
name
|
|
for name, profile in BUILTIN_AGENTS.items()
|
|
if profile.agent_type == AgentType.AGENT
|
|
]
|
|
assert set(agents) == {
|
|
BuiltinAgentName.DEFAULT,
|
|
BuiltinAgentName.PLAN,
|
|
BuiltinAgentName.ACCEPT_EDITS,
|
|
BuiltinAgentName.AUTO_APPROVE,
|
|
BuiltinAgentName.LEAN,
|
|
}
|
|
|
|
|
|
class TestAgentApplyToConfig:
|
|
def test_profile_disabled_tools_are_merged_with_base_config(self) -> None:
|
|
base = VibeConfig(
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
disabled_tools=["ask_user_question"],
|
|
)
|
|
|
|
result = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].apply_to_config(base)
|
|
|
|
assert set(result.disabled_tools) == {"ask_user_question", "exit_plan_mode"}
|
|
|
|
def test_profile_disabled_tools_preserve_user_disabled_tools(self) -> None:
|
|
base = VibeConfig(
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
disabled_tools=["ask_user_question", "custom_tool"],
|
|
)
|
|
|
|
result = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].apply_to_config(base)
|
|
|
|
assert set(result.disabled_tools) == {
|
|
"ask_user_question",
|
|
"custom_tool",
|
|
"exit_plan_mode",
|
|
}
|
|
|
|
def test_base_disabled_tools_are_filtered_from_profile_enabled_tools(self) -> None:
|
|
base = VibeConfig(
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
disabled_tools=["ask_user_question"],
|
|
)
|
|
|
|
result = CHAT.apply_to_config(base)
|
|
|
|
assert "ask_user_question" not in result.enabled_tools
|
|
assert "grep" in result.enabled_tools
|
|
assert "read" in result.enabled_tools
|
|
assert "task" in result.enabled_tools
|
|
|
|
def test_base_disabled_tools_filter_supports_glob_patterns(self) -> None:
|
|
base = VibeConfig(
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
disabled_tools=["ask_*"],
|
|
)
|
|
agent = AgentProfile(
|
|
name="custom",
|
|
display_name="Custom",
|
|
description="",
|
|
safety=AgentSafety.NEUTRAL,
|
|
overrides={"enabled_tools": ["grep", "ask_user_question", "ask_extra"]},
|
|
)
|
|
|
|
result = agent.apply_to_config(base)
|
|
|
|
assert result.enabled_tools == ["grep"]
|
|
|
|
def test_empty_base_disabled_tools_leaves_enabled_tools_untouched(self) -> None:
|
|
base = VibeConfig(
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
disabled_tools=[],
|
|
)
|
|
|
|
result = CHAT.apply_to_config(base)
|
|
|
|
assert "ask_user_question" in result.enabled_tools
|
|
|
|
def test_custom_prompt_found_in_global_when_missing_from_project(
|
|
self, mock_prompts_dirs: tuple[Path, Path]
|
|
) -> None:
|
|
_, global_prompts = mock_prompts_dirs
|
|
(global_prompts / "cc.md").write_text("Global custom prompt")
|
|
|
|
base = VibeConfig(include_project_context=False, include_prompt_detail=False)
|
|
agent = AgentProfile(
|
|
name="cc",
|
|
display_name="Cc",
|
|
description="",
|
|
safety=AgentSafety.NEUTRAL,
|
|
overrides={"system_prompt_id": "cc"},
|
|
)
|
|
result = agent.apply_to_config(base)
|
|
assert result.system_prompt_id == "cc"
|
|
assert result.system_prompt == "Global custom prompt"
|
|
|
|
def test_custom_prompt_overrides_builtin(
|
|
self, mock_prompts_dirs: tuple[Path, Path]
|
|
) -> None:
|
|
"""Custom prompts in .vibe/prompts/ should override built-in prompts.
|
|
|
|
A user-provided explore.md (or any built-in prompt name) in the
|
|
project or user prompts directory must take priority over the
|
|
bundled SystemPrompt enum.
|
|
"""
|
|
project_prompts, _ = mock_prompts_dirs
|
|
(project_prompts / "explore.md").write_text("My custom explore prompt")
|
|
|
|
config = VibeConfig(
|
|
system_prompt_id="explore",
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
)
|
|
assert config.system_prompt == "My custom explore prompt"
|
|
|
|
def test_custom_compaction_prompt_found_in_global_when_missing_from_project(
|
|
self, mock_prompts_dirs: tuple[Path, Path]
|
|
) -> None:
|
|
_, global_prompts = mock_prompts_dirs
|
|
(global_prompts / "proofs.md").write_text("Global custom compaction prompt")
|
|
|
|
config = VibeConfig(
|
|
compaction_prompt_id="proofs",
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
)
|
|
assert config.compaction_prompt == "Global custom compaction prompt"
|
|
|
|
def test_custom_compaction_prompt_overrides_builtin(
|
|
self, mock_prompts_dirs: tuple[Path, Path]
|
|
) -> None:
|
|
project_prompts, _ = mock_prompts_dirs
|
|
(project_prompts / "compact.md").write_text("My custom compact prompt")
|
|
|
|
config = VibeConfig(
|
|
compaction_prompt_id="compact",
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
)
|
|
assert config.compaction_prompt == "My custom compact prompt"
|
|
|
|
def test_default_compaction_prompt_falls_back_to_builtin(
|
|
self, mock_prompts_dirs: tuple[Path, Path]
|
|
) -> None:
|
|
config = VibeConfig(include_project_context=False, include_prompt_detail=False)
|
|
assert config.compaction_prompt == UtilityPrompt.COMPACT.read()
|
|
|
|
def test_invalid_compaction_prompt_reports_setting_name(
|
|
self, mock_prompts_dirs: tuple[Path, Path]
|
|
) -> None:
|
|
project_prompts, user_prompts = mock_prompts_dirs
|
|
(project_prompts / "alpha.md").write_text("a")
|
|
(user_prompts / "beta.md").write_text("b")
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
VibeConfig(
|
|
compaction_prompt_id="unknown",
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
)
|
|
|
|
error_text = str(exc_info.value)
|
|
assert "Invalid compaction_prompt_id value: 'unknown'" in error_text
|
|
assert 'available prompts ("compact")' in error_text
|
|
assert '(available: "alpha", "beta")' in error_text
|
|
|
|
@pytest.mark.parametrize(
|
|
"malicious_id",
|
|
["../../../etc/passwd", "..", ".", "subdir/compact", "back\\slash", ""],
|
|
)
|
|
def test_prompt_id_rejects_path_traversal(
|
|
self, mock_prompts_dirs: tuple[Path, Path], malicious_id: str
|
|
) -> None:
|
|
with pytest.raises(ValueError, match="must be a bare filename"):
|
|
VibeConfig(
|
|
compaction_prompt_id=malicious_id,
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
)
|
|
|
|
|
|
class TestAgentProfileOverrides:
|
|
def test_default_agent_disables_exit_plan_mode(self) -> None:
|
|
overrides = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].overrides
|
|
assert "exit_plan_mode" in overrides.get("base_disabled", [])
|
|
|
|
def test_auto_approve_agent_sets_bypass_tool_permissions(self) -> None:
|
|
overrides = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].overrides
|
|
assert overrides.get("bypass_tool_permissions") is True
|
|
|
|
def test_plan_agent_restricts_tools(self) -> None:
|
|
overrides = BUILTIN_AGENTS[BuiltinAgentName.PLAN].overrides
|
|
assert "tools" in overrides
|
|
tools = overrides["tools"]
|
|
assert "write_file" in tools
|
|
assert "edit" in tools
|
|
assert tools["write_file"]["permission"] == "never"
|
|
assert tools["edit"]["permission"] == "never"
|
|
assert len(tools["write_file"]["allowlist"]) > 0
|
|
assert len(tools["edit"]["allowlist"]) > 0
|
|
|
|
def test_accept_edits_agent_sets_tool_permissions(self) -> None:
|
|
overrides = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].overrides
|
|
assert "tools" in overrides
|
|
tools_config = overrides["tools"]
|
|
assert "write_file" in tools_config
|
|
assert "edit" in tools_config
|
|
assert tools_config["write_file"]["permission"] == "always"
|
|
assert tools_config["edit"]["permission"] == "always"
|
|
|
|
|
|
class TestAgentManagerCycling:
|
|
@pytest.fixture
|
|
def base_config(self) -> VibeConfig:
|
|
return build_test_vibe_config(
|
|
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"),
|
|
usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
|
|
)
|
|
])
|
|
|
|
def test_get_agent_order_includes_primary_agents(
|
|
self, base_config: VibeConfig, backend: FakeBackend
|
|
) -> None:
|
|
agent = build_test_agent_loop(
|
|
config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
|
)
|
|
order = agent.agent_manager.get_agent_order()
|
|
assert len(order) == 4
|
|
assert BuiltinAgentName.DEFAULT in order
|
|
assert BuiltinAgentName.AUTO_APPROVE in order
|
|
assert BuiltinAgentName.PLAN in order
|
|
assert BuiltinAgentName.ACCEPT_EDITS in order
|
|
|
|
def test_next_agent_cycles_through_all(
|
|
self, base_config: VibeConfig, backend: FakeBackend
|
|
) -> None:
|
|
agent = build_test_agent_loop(
|
|
config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
|
)
|
|
order = agent.agent_manager.get_agent_order()
|
|
current = agent.agent_manager.active_profile
|
|
visited = [current.name]
|
|
for _ in range(len(order) - 1):
|
|
current = agent.agent_manager.next_agent(current)
|
|
visited.append(current.name)
|
|
assert len(set(visited)) == len(order)
|
|
|
|
def test_next_agent_wraps_around(
|
|
self, base_config: VibeConfig, backend: FakeBackend
|
|
) -> None:
|
|
agent = build_test_agent_loop(
|
|
config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
|
)
|
|
order = agent.agent_manager.get_agent_order()
|
|
last_profile = agent.agent_manager.get_agent(order[-1])
|
|
first_profile = agent.agent_manager.get_agent(order[0])
|
|
assert agent.agent_manager.next_agent(last_profile).name == first_profile.name
|
|
|
|
|
|
class TestAgentProfileConfig:
|
|
def test_agent_profile_frozen(self) -> None:
|
|
profile = AgentProfile(
|
|
name="test",
|
|
display_name="Test",
|
|
description="Test agent",
|
|
safety=AgentSafety.NEUTRAL,
|
|
)
|
|
with pytest.raises(AttributeError):
|
|
profile.name = "changed" # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
|
|
class TestAgentSwitchAgent:
|
|
@pytest.fixture
|
|
def base_config(self) -> VibeConfig:
|
|
return build_test_vibe_config(
|
|
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"),
|
|
usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
|
|
)
|
|
])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_switch_to_plan_agent_has_tools_with_restricted_permissions(
|
|
self, base_config: VibeConfig, backend: FakeBackend
|
|
) -> None:
|
|
agent = build_test_agent_loop(
|
|
config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
|
)
|
|
await agent.switch_agent(BuiltinAgentName.PLAN)
|
|
|
|
plan_tool_names = set(agent.tool_manager.available_tools.keys())
|
|
# Plan mode now has all tools available but with restricted permissions
|
|
assert "write_file" in plan_tool_names
|
|
assert "edit" in plan_tool_names
|
|
assert "grep" in plan_tool_names
|
|
assert "read" in plan_tool_names
|
|
assert agent.agent_profile.name == BuiltinAgentName.PLAN
|
|
|
|
# Verify write tools have "never" base permission
|
|
write_config = agent.tool_manager.get_tool_config("write_file")
|
|
assert write_config.permission == ToolPermission.NEVER
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_switch_from_plan_to_default_restores_tools(
|
|
self, base_config: VibeConfig, backend: FakeBackend
|
|
) -> None:
|
|
agent = build_test_agent_loop(
|
|
config=base_config, agent_name=BuiltinAgentName.PLAN, backend=backend
|
|
)
|
|
|
|
await agent.switch_agent(BuiltinAgentName.DEFAULT)
|
|
|
|
# Write tools should revert to default ASK permission
|
|
write_config = agent.tool_manager.get_tool_config("write_file")
|
|
assert write_config.permission == ToolPermission.ASK
|
|
assert agent.agent_profile.name == BuiltinAgentName.DEFAULT
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_switch_agent_preserves_conversation_history(
|
|
self, base_config: VibeConfig, backend: FakeBackend
|
|
) -> None:
|
|
agent = build_test_agent_loop(
|
|
config=base_config, agent_name=BuiltinAgentName.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_agent(BuiltinAgentName.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_agent_is_noop(
|
|
self, base_config: VibeConfig, backend: FakeBackend
|
|
) -> None:
|
|
agent = build_test_agent_loop(
|
|
config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
|
)
|
|
original_config = agent.config
|
|
|
|
await agent.switch_agent(BuiltinAgentName.DEFAULT)
|
|
|
|
assert agent.config is original_config
|
|
assert agent.agent_profile.name == BuiltinAgentName.DEFAULT
|
|
|
|
|
|
class TestAcceptEditsAgent:
|
|
def test_accept_edits_config_sets_write_file_always(self) -> None:
|
|
overrides = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].overrides
|
|
assert overrides["tools"]["write_file"]["permission"] == "always"
|
|
|
|
def test_accept_edits_config_sets_edit_always(self) -> None:
|
|
overrides = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].overrides
|
|
assert overrides["tools"]["edit"]["permission"] == "always"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_accept_edits_agent_auto_approves_write_file(self) -> None:
|
|
backend = FakeBackend([])
|
|
|
|
config = build_test_vibe_config(enabled_tools=["write_file"])
|
|
agent = build_test_agent_loop(
|
|
config=config, agent_name=BuiltinAgentName.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_agent_requires_approval_for_other_tools(self) -> None:
|
|
backend = FakeBackend([])
|
|
|
|
config = build_test_vibe_config(enabled_tools=["bash"])
|
|
agent = build_test_agent_loop(
|
|
config=config, agent_name=BuiltinAgentName.ACCEPT_EDITS, backend=backend
|
|
)
|
|
|
|
perm = agent.tool_manager.get_tool_config("bash").permission
|
|
assert perm == ToolPermission.ASK
|
|
|
|
|
|
class TestPlanAgentToolRestriction:
|
|
@pytest.mark.asyncio
|
|
async def test_plan_agent_has_all_tools_with_restricted_write_permissions(
|
|
self,
|
|
) -> None:
|
|
backend = FakeBackend([
|
|
LLMChunk(
|
|
message=LLMMessage(role=Role.assistant, content="ok"),
|
|
usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
|
|
)
|
|
])
|
|
config = build_test_vibe_config()
|
|
agent = build_test_agent_loop(
|
|
config=config, agent_name=BuiltinAgentName.PLAN, backend=backend
|
|
)
|
|
|
|
tool_names = set(agent.tool_manager.available_tools.keys())
|
|
|
|
# Plan mode now has all tools available
|
|
assert "grep" in tool_names
|
|
assert "read" in tool_names
|
|
assert "write_file" in tool_names
|
|
assert "edit" in tool_names
|
|
|
|
# But write tools have restricted permissions
|
|
write_config = agent.tool_manager.get_tool_config("write_file")
|
|
assert write_config.permission == ToolPermission.NEVER
|
|
assert len(write_config.allowlist) > 0
|
|
|
|
edit_config = agent.tool_manager.get_tool_config("edit")
|
|
assert edit_config.permission == ToolPermission.NEVER
|
|
assert len(edit_config.allowlist) > 0
|
|
|
|
|
|
class TestAgentManagerFiltering:
|
|
def test_enabled_agents_filters_to_only_enabled(self) -> None:
|
|
config = build_test_vibe_config(
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
enabled_agents=["default", "plan"],
|
|
)
|
|
manager = AgentManager(lambda: config)
|
|
|
|
agents = manager.available_agents
|
|
assert len(agents) < len(manager._discovered)
|
|
assert "default" in agents
|
|
assert "plan" in agents
|
|
assert "auto-approve" not in agents
|
|
assert "accept-edits" not in agents
|
|
|
|
def test_disabled_agents_excludes_disabled(self) -> None:
|
|
config = build_test_vibe_config(
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
disabled_agents=["auto-approve", "accept-edits"],
|
|
)
|
|
manager = AgentManager(lambda: config)
|
|
|
|
agents = manager.available_agents
|
|
assert len(agents) < len(manager._discovered)
|
|
assert "default" in agents
|
|
assert "plan" in agents
|
|
assert "auto-approve" not in agents
|
|
assert "accept-edits" not in agents
|
|
|
|
def test_enabled_agents_takes_precedence_over_disabled(self) -> None:
|
|
config = build_test_vibe_config(
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
enabled_agents=["default"],
|
|
disabled_agents=["default"], # Should be ignored
|
|
)
|
|
manager = AgentManager(lambda: config)
|
|
|
|
agents = manager.available_agents
|
|
assert len(agents) == 1
|
|
assert "default" in agents
|
|
|
|
def test_glob_pattern_matching(self) -> None:
|
|
config = build_test_vibe_config(
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
disabled_agents=["auto-*", "accept-*"],
|
|
)
|
|
manager = AgentManager(lambda: config)
|
|
|
|
agents = manager.available_agents
|
|
assert "default" in agents
|
|
assert "plan" in agents
|
|
assert "auto-approve" not in agents
|
|
assert "accept-edits" not in agents
|
|
|
|
def test_regex_pattern_matching(self) -> None:
|
|
config = build_test_vibe_config(
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
enabled_agents=["re:^(default|plan)$"],
|
|
)
|
|
manager = AgentManager(lambda: config)
|
|
|
|
agents = manager.available_agents
|
|
assert len(agents) == 2
|
|
assert "default" in agents
|
|
assert "plan" in agents
|
|
|
|
def test_empty_enabled_agents_returns_all(self) -> None:
|
|
config = build_test_vibe_config(
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
enabled_agents=[],
|
|
)
|
|
manager = AgentManager(lambda: config)
|
|
|
|
agents = manager.available_agents
|
|
assert "default" in agents
|
|
assert "plan" in agents
|
|
assert "auto-approve" in agents
|
|
assert "explore" in agents
|
|
|
|
def test_install_required_agents_hidden_by_default(self) -> None:
|
|
config = build_test_vibe_config(
|
|
include_project_context=False, include_prompt_detail=False
|
|
)
|
|
manager = AgentManager(lambda: config)
|
|
|
|
agents = manager.available_agents
|
|
assert "lean" not in agents
|
|
|
|
def test_install_required_agents_visible_when_installed(self) -> None:
|
|
config = build_test_vibe_config(
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
installed_agents=["lean"],
|
|
)
|
|
manager = AgentManager(lambda: config)
|
|
|
|
agents = manager.available_agents
|
|
assert "lean" in agents
|
|
|
|
def test_get_subagents_respects_filtering(self) -> None:
|
|
config = build_test_vibe_config(
|
|
include_project_context=False,
|
|
include_prompt_detail=False,
|
|
disabled_agents=["explore"],
|
|
)
|
|
manager = AgentManager(lambda: config)
|
|
|
|
subagents = manager.get_subagents()
|
|
names = [a.name for a in subagents]
|
|
assert "explore" not in names
|
|
|
|
|
|
class TestAgentLoopInitialization:
|
|
def test_agent_system_prompt_id_is_applied_on_init(
|
|
self, mock_prompts_dirs: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_, global_prompts = mock_prompts_dirs
|
|
custom_prompt_content = "CUSTOM_AGENT_PROMPT_MARKER"
|
|
(global_prompts / "custom_agent.md").write_text(custom_prompt_content)
|
|
|
|
custom_agent = AgentProfile(
|
|
name="custom_test_agent",
|
|
display_name="Custom Test",
|
|
description="Test agent with custom system prompt",
|
|
safety=AgentSafety.NEUTRAL,
|
|
overrides={"system_prompt_id": "custom_agent"},
|
|
)
|
|
patched_agents = {**BUILTIN_AGENTS, "custom_test_agent": custom_agent}
|
|
monkeypatch.setattr("vibe.core.agents.models.BUILTIN_AGENTS", patched_agents)
|
|
monkeypatch.setattr("vibe.core.agents.manager.BUILTIN_AGENTS", patched_agents)
|
|
|
|
config = build_test_vibe_config(
|
|
include_project_context=False, include_prompt_detail=False
|
|
)
|
|
assert config.system_prompt_id == "cli", (
|
|
"Base config should use default 'cli' prompt"
|
|
)
|
|
|
|
agent_loop = build_test_agent_loop(
|
|
config=config, agent_name="custom_test_agent"
|
|
)
|
|
|
|
assert agent_loop.config.system_prompt_id == "custom_agent", (
|
|
"Merged config should have the agent's system_prompt_id override"
|
|
)
|
|
|
|
system_message = agent_loop.messages[0]
|
|
assert system_message.role == Role.system
|
|
assert system_message.content is not None
|
|
assert custom_prompt_content in system_message.content, (
|
|
f"System message should contain custom prompt content. "
|
|
f"Expected '{custom_prompt_content}' to be in system message."
|
|
)
|
|
|
|
|
|
class TestActConsumersUseAclosing:
|
|
def test_no_bare_async_for_over_act(self) -> None:
|
|
vibe_pkg = TESTS_ROOT.parent / "vibe"
|
|
violations: list[str] = []
|
|
for path in vibe_pkg.rglob("*.py"):
|
|
tree = ast.parse(path.read_text(), filename=str(path))
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.AsyncFor):
|
|
continue
|
|
match node.iter:
|
|
case ast.Call(func=ast.Attribute(attr="act")):
|
|
violations.append(f"{path}:{node.lineno}")
|
|
|
|
assert not violations, (
|
|
"Bare `async for ... in .act()` found — wrap in "
|
|
"contextlib.aclosing(). See issue #569.\n" + "\n".join(violations)
|
|
)
|