Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai>
Co-authored-by: Peter Evers <pevers90@gmail.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@protonmail.com>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@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: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-04-09 18:40:46 +02:00 committed by GitHub
parent 90763daf81
commit e9a9217cc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
113 changed files with 7202 additions and 541 deletions

View file

@ -18,7 +18,7 @@ from vibe.core.types import ToolCallEvent, ToolResultEvent
@pytest.fixture
def tool():
config = AskUserQuestionConfig()
return AskUserQuestion(config=config, state=BaseToolState())
return AskUserQuestion(config_getter=lambda: config, state=BaseToolState())
@pytest.fixture

View file

@ -12,7 +12,7 @@ from vibe.core.tools.permissions import PermissionContext
def bash(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = BashToolConfig()
return Bash(config=config, state=BaseToolState())
return Bash(config_getter=lambda: config, state=BaseToolState())
@pytest.mark.asyncio
@ -39,7 +39,7 @@ async def test_fails_cat_command_with_missing_file(bash):
async def test_uses_effective_workdir(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = BashToolConfig()
bash_tool = Bash(config=config, state=BaseToolState())
bash_tool = Bash(config_getter=lambda: config, state=BaseToolState())
result = await collect_result(bash_tool.run(BashArgs(command="pwd")))
@ -57,7 +57,7 @@ async def test_handles_timeout(bash):
@pytest.mark.asyncio
async def test_truncates_output_to_max_bytes(bash):
config = BashToolConfig(max_output_bytes=5)
bash_tool = Bash(config=config, state=BaseToolState())
bash_tool = Bash(config_getter=lambda: config, state=BaseToolState())
result = await collect_result(
bash_tool.run(BashArgs(command="printf 'abcdefghij'"))
@ -78,7 +78,7 @@ async def test_decodes_non_utf8_bytes(bash):
def test_find_not_in_default_allowlist():
bash_tool = Bash(config=BashToolConfig(), state=BaseToolState())
bash_tool = Bash(config_getter=lambda: BashToolConfig(), state=BaseToolState())
# find -exec runs arbitrary commands; must not be allowlisted by default
permission = bash_tool.resolve_permission(BashArgs(command="find . -exec id \\;"))
assert (
@ -89,7 +89,7 @@ def test_find_not_in_default_allowlist():
def test_resolve_permission():
config = BashToolConfig(allowlist=["echo", "pwd"], denylist=["rm"])
bash_tool = Bash(config=config, state=BaseToolState())
bash_tool = Bash(config_getter=lambda: config, state=BaseToolState())
allowlisted = bash_tool.resolve_permission(BashArgs(command="echo hi"))
denylisted = bash_tool.resolve_permission(BashArgs(command="rm -rf /tmp"))
@ -111,7 +111,7 @@ class TestResolvePermissionWindowsSyntax:
def _make_bash(self, **kwargs) -> Bash:
config = BashToolConfig(**kwargs)
return Bash(config=config, state=BaseToolState())
return Bash(config_getter=lambda: config, state=BaseToolState())
def test_dir_with_windows_flags_allowlisted(self):
bash_tool = self._make_bash(allowlist=["dir"])
@ -215,7 +215,7 @@ class TestDenylistWordBoundary:
def _make_bash(self, **kwargs) -> Bash:
config = BashToolConfig(**kwargs)
return Bash(config=config, state=BaseToolState())
return Bash(config_getter=lambda: config, state=BaseToolState())
def test_vi_blocks_vi_exact(self):
bash_tool = self._make_bash(denylist=["vi"])

View file

@ -56,7 +56,9 @@ def _default_profile() -> AgentProfile:
@pytest.fixture
def tool() -> ExitPlanMode:
return ExitPlanMode(config=ExitPlanModeConfig(), state=BaseToolState())
return ExitPlanMode(
config_getter=lambda: ExitPlanModeConfig(), state=BaseToolState()
)
@pytest.fixture

View file

@ -46,7 +46,7 @@ class TestBashGranularPermissions:
def _bash(self, **kwargs):
config = BashToolConfig(**kwargs)
return Bash(config=config, state=BaseToolState())
return Bash(config_getter=lambda: config, state=BaseToolState())
def test_allowlisted_command_always(self):
bash = self._bash()
@ -264,7 +264,7 @@ class TestReadFileGranularPermissions:
def _read_file(self, **kwargs):
config = ReadFileToolConfig(**kwargs)
return ReadFile(config=config, state=ReadFileState())
return ReadFile(config_getter=lambda: config, state=ReadFileState())
def test_in_workdir_normal_file_returns_none(self):
(self.workdir / "test.py").touch()
@ -346,7 +346,7 @@ class TestWriteFileGranularPermissions:
def _write_file(self):
config = WriteFileConfig()
return WriteFile(config=config, state=BaseToolState())
return WriteFile(config_getter=lambda: config, state=BaseToolState())
def test_in_workdir_returns_none(self):
tool = self._write_file()
@ -379,7 +379,7 @@ class TestSearchReplaceGranularPermissions:
def test_outside_workdir_returns_permission_context(self):
config = SearchReplaceConfig()
tool = SearchReplace(config=config, state=BaseToolState())
tool = SearchReplace(config_getter=lambda: config, state=BaseToolState())
result = tool.resolve_permission(
SearchReplaceArgs(file_path="/tmp/file.py", content="x")
)
@ -395,7 +395,7 @@ class TestGrepGranularPermissions:
def _grep(self):
config = GrepToolConfig()
return Grep(config=config, state=BaseToolState())
return Grep(config_getter=lambda: config, state=BaseToolState())
def test_in_workdir_normal_path_returns_none(self):
tool = self._grep()
@ -442,7 +442,7 @@ class TestApprovalFlowSimulation:
session_pattern="mkdir *",
)
]
bash = Bash(config=BashToolConfig(), state=BaseToolState())
bash = Bash(config_getter=lambda: BashToolConfig(), state=BaseToolState())
result = bash.resolve_permission(BashArgs(command="mkdir another_dir"))
assert isinstance(result, PermissionContext)
uncovered = [
@ -460,7 +460,7 @@ class TestApprovalFlowSimulation:
session_pattern="mkdir *",
)
]
bash = Bash(config=BashToolConfig(), state=BaseToolState())
bash = Bash(config_getter=lambda: BashToolConfig(), state=BaseToolState())
result = bash.resolve_permission(BashArgs(command="npm install"))
assert isinstance(result, PermissionContext)
uncovered = [
@ -472,7 +472,7 @@ class TestApprovalFlowSimulation:
assert uncovered[0].session_pattern == "npm install *"
def test_outside_dir_approved_covers_subsequent(self):
bash = Bash(config=BashToolConfig(), state=BaseToolState())
bash = Bash(config_getter=lambda: BashToolConfig(), state=BaseToolState())
result = bash.resolve_permission(BashArgs(command="mkdir /tmp/newdir"))
assert isinstance(result, PermissionContext)
outside_rps = [
@ -509,7 +509,7 @@ class TestApprovalFlowSimulation:
session_pattern="rm *",
)
]
bash = Bash(config=BashToolConfig(), state=BaseToolState())
bash = Bash(config_getter=lambda: BashToolConfig(), state=BaseToolState())
result = bash.resolve_permission(BashArgs(command="rm -rf /tmp/something"))
assert isinstance(result, PermissionContext)
cmd_perms = [
@ -529,7 +529,7 @@ class TestApprovalFlowSimulation:
session_pattern="sudo apt install foo",
)
]
bash = Bash(config=BashToolConfig(), state=BaseToolState())
bash = Bash(config_getter=lambda: BashToolConfig(), state=BaseToolState())
result = bash.resolve_permission(BashArgs(command="sudo apt install bar"))
assert isinstance(result, PermissionContext)
cmd_perms = [
@ -575,7 +575,7 @@ class TestApprovalFlowSimulation:
class TestWebFetchPermissions:
def _make_webfetch(self) -> WebFetch:
return WebFetch(config=WebFetchConfig(), state=BaseToolState())
return WebFetch(config_getter=lambda: WebFetchConfig(), state=BaseToolState())
def test_returns_url_pattern_with_domain(self):
wf = self._make_webfetch()
@ -671,7 +671,7 @@ class TestWebFetchPermissions:
def test_config_permission_always_honored(self):
wf = WebFetch(
config=WebFetchConfig(permission=ToolPermission.ALWAYS),
config_getter=lambda: WebFetchConfig(permission=ToolPermission.ALWAYS),
state=BaseToolState(),
)
result = wf.resolve_permission(WebFetchArgs(url="https://example.com"))
@ -680,7 +680,7 @@ class TestWebFetchPermissions:
def test_config_permission_never_honored(self):
wf = WebFetch(
config=WebFetchConfig(permission=ToolPermission.NEVER),
config_getter=lambda: WebFetchConfig(permission=ToolPermission.NEVER),
state=BaseToolState(),
)
result = wf.resolve_permission(WebFetchArgs(url="https://example.com"))
@ -689,7 +689,8 @@ class TestWebFetchPermissions:
def test_config_permission_ask_falls_through_to_domain(self):
wf = WebFetch(
config=WebFetchConfig(permission=ToolPermission.ASK), state=BaseToolState()
config_getter=lambda: WebFetchConfig(permission=ToolPermission.ASK),
state=BaseToolState(),
)
result = wf.resolve_permission(WebFetchArgs(url="https://example.com"))
assert isinstance(result, PermissionContext)

View file

@ -13,7 +13,7 @@ from vibe.core.tools.builtins.grep import Grep, GrepArgs, GrepBackend, GrepToolC
def grep(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = GrepToolConfig()
return Grep(config=config, state=BaseToolState())
return Grep(config_getter=lambda: config, state=BaseToolState())
@pytest.fixture
@ -28,7 +28,7 @@ def grep_gnu_only(tmp_path, monkeypatch):
monkeypatch.setattr("shutil.which", mock_which)
config = GrepToolConfig()
return Grep(config=config, state=BaseToolState())
return Grep(config_getter=lambda: config, state=BaseToolState())
def test_detects_ripgrep_when_available(grep):
@ -137,7 +137,7 @@ async def test_truncates_to_max_matches(grep, tmp_path):
async def test_truncates_to_max_output_bytes(grep, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = GrepToolConfig(max_output_bytes=100)
grep_tool = Grep(config=config, state=BaseToolState())
grep_tool = Grep(config_getter=lambda: config, state=BaseToolState())
(tmp_path / "test.py").write_text("\n".join("x" * 100 for _ in range(10)))
result = await collect_result(grep_tool.run(GrepArgs(pattern="x")))
@ -189,7 +189,7 @@ async def test_ignores_comments_in_vibeignore(grep, tmp_path):
async def test_uses_effective_workdir(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = GrepToolConfig()
grep_tool = Grep(config=config, state=BaseToolState())
grep_tool = Grep(config_getter=lambda: config, state=BaseToolState())
(tmp_path / "test.py").write_text("match\n")
result = await collect_result(grep_tool.run(GrepArgs(pattern="match", path=".")))

View file

@ -36,7 +36,7 @@ class SimpleTool(BaseTool[SimpleArgs, SimpleResult, BaseToolConfig, BaseToolStat
@pytest.fixture
def simple_tool() -> SimpleTool:
return SimpleTool(config=BaseToolConfig(), state=BaseToolState())
return SimpleTool(config_getter=lambda: BaseToolConfig(), state=BaseToolState())
class TestInvokeContext:

View file

@ -54,7 +54,7 @@ def _make_ctx(skill_manager: SkillManager | None = None) -> InvokeContext:
@pytest.fixture
def skill_tool() -> Skill:
return Skill(config=SkillToolConfig(), state=BaseToolState())
return Skill(config_getter=lambda: SkillToolConfig(), state=BaseToolState())
class TestSkillRun:

View file

@ -16,7 +16,7 @@ from vibe.core.types import AssistantEvent, LLMMessage, Role
@pytest.fixture
def task_tool() -> Task:
return Task(config=TaskToolConfig(), state=BaseToolState())
return Task(config_getter=lambda: TaskToolConfig(), state=BaseToolState())
class TestTaskArgs:
@ -91,7 +91,7 @@ class TestTaskToolResolvePermission:
def test_denylist_takes_precedence(self) -> None:
config = TaskToolConfig(allowlist=["explore"], denylist=["explore"])
tool = Task(config=config, state=BaseToolState())
tool = Task(config_getter=lambda: config, state=BaseToolState())
args = TaskArgs(task="do something", agent="explore")
result = tool.resolve_permission(args)
assert isinstance(result, PermissionContext)
@ -99,7 +99,7 @@ class TestTaskToolResolvePermission:
def test_glob_pattern_in_allowlist(self) -> None:
config = TaskToolConfig(allowlist=["exp*"])
tool = Task(config=config, state=BaseToolState())
tool = Task(config_getter=lambda: config, state=BaseToolState())
args = TaskArgs(task="do something", agent="explore")
result = tool.resolve_permission(args)
assert isinstance(result, PermissionContext)
@ -107,7 +107,7 @@ class TestTaskToolResolvePermission:
def test_glob_pattern_in_denylist(self) -> None:
config = TaskToolConfig(denylist=["danger*"])
tool = Task(config=config, state=BaseToolState())
tool = Task(config_getter=lambda: config, state=BaseToolState())
args = TaskArgs(task="do something", agent="dangerous_agent")
result = tool.resolve_permission(args)
assert isinstance(result, PermissionContext)
@ -115,7 +115,7 @@ class TestTaskToolResolvePermission:
def test_empty_lists_returns_none(self) -> None:
config = TaskToolConfig(allowlist=[], denylist=[])
tool = Task(config=config, state=BaseToolState())
tool = Task(config_getter=lambda: config, state=BaseToolState())
args = TaskArgs(task="do something", agent="explore")
result = tool.resolve_permission(args)
assert result is None

View file

@ -5,9 +5,13 @@ import time
import pytest
from textual.widgets import Static
from tests.conftest import build_test_agent_loop, build_test_vibe_app
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.messages import BashOutputMessage, ErrorMessage
from vibe.core.types import Role
async def _wait_for_bash_output_message(
@ -118,3 +122,38 @@ async def test_ui_handles_non_utf8_stderr(vibe_app: VibeApp) -> None:
output_widget = message.query_one(".bash-output", Static)
assert str(output_widget.render()) == "<EFBFBD><EFBFBD>"
assert_no_command_error(vibe_app)
@pytest.mark.asyncio
async def test_ui_sends_manual_command_output_to_next_agent_turn() -> None:
backend = FakeBackend(mock_llm_chunk(content="I saw it."))
vibe_app = build_test_vibe_app(agent_loop=build_test_agent_loop(backend=backend))
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!echo hello"
await pilot.press("enter")
await _wait_for_bash_output_message(vibe_app, pilot)
injected_message = vibe_app.agent_loop.messages[-1]
assert injected_message.role == Role.user
assert injected_message.injected is True
assert injected_message.content is not None
assert "Manual `!` command result from the user." in injected_message.content
assert "Command: `echo hello`" in injected_message.content
assert "Exit code: 0" in injected_message.content
assert "Stdout:\n```text\nhello\n```" in injected_message.content
chat_input.value = "what did the command print?"
await pilot.press("enter")
await pilot.app.workers.wait_for_complete()
assert len(backend.requests_messages) == 1
user_messages = [
msg for msg in backend.requests_messages[0] if msg.role == Role.user
]
assert len(user_messages) >= 2
assert user_messages[-2].content == injected_message.content
assert user_messages[-2].injected is True
assert user_messages[-1].content == "what did the command print?"

View file

@ -12,13 +12,13 @@ from vibe.core.tools.builtins.webfetch import WebFetch, WebFetchArgs, WebFetchCo
@pytest.fixture
def webfetch():
config = WebFetchConfig()
return WebFetch(config=config, state=BaseToolState())
return WebFetch(config_getter=lambda: config, state=BaseToolState())
@pytest.fixture
def webfetch_small():
config = WebFetchConfig(max_content_bytes=100)
return WebFetch(config=config, state=BaseToolState())
return WebFetch(config_getter=lambda: config, state=BaseToolState())
@pytest.mark.asyncio
@ -32,6 +32,7 @@ async def test_bare_domain_gets_https(webfetch):
result = await collect_result(webfetch.run(WebFetchArgs(url="example.com")))
assert result.url == "https://example.com"
assert result.content == "ok"
assert result.was_truncated is False
@pytest.mark.asyncio
@ -167,6 +168,7 @@ async def test_truncates_to_max_bytes_with_disclaimer(webfetch_small):
)
assert result.content.startswith("a" * 100)
assert "[Content truncated due to size limit]" in result.content
assert result.was_truncated is True
@pytest.mark.asyncio
@ -189,6 +191,7 @@ async def test_truncates_html_with_disclaimer(webfetch_small):
assert "## first title" in result.content
assert "## second title" not in result.content
assert "[Content truncated due to size limit]" in result.content
assert result.was_truncated is True
@pytest.mark.asyncio

View file

@ -38,7 +38,7 @@ def _make_response(
def websearch(monkeypatch):
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
config = WebSearchConfig()
return WebSearch(config=config, state=BaseToolState())
return WebSearch(config_getter=lambda: config, state=BaseToolState())
def test_parse_text_chunks(websearch):
@ -104,7 +104,7 @@ def test_parse_skips_non_message_entries(websearch):
async def test_run_missing_api_key(monkeypatch):
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
config = WebSearchConfig()
ws = WebSearch(config=config, state=BaseToolState())
ws = WebSearch(config_getter=lambda: config, state=BaseToolState())
with pytest.raises(ToolError, match="MISTRAL_API_KEY"):
await collect_result(ws.run(WebSearchArgs(query="test")))