v2.8.0 (#616)
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
95336528f6
commit
04305bd77c
68 changed files with 3473 additions and 1764 deletions
|
|
@ -4,7 +4,8 @@ import logging
|
|||
import os
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from pydantic import ValidationError
|
||||
import pytest
|
||||
|
|
@ -17,8 +18,10 @@ from vibe.core.tools.mcp import (
|
|||
_mcp_stderr_capture,
|
||||
_parse_call_result,
|
||||
_stderr_logger_thread,
|
||||
call_tool_stdio,
|
||||
create_mcp_http_proxy_tool_class,
|
||||
create_mcp_stdio_proxy_tool_class,
|
||||
list_tools_stdio,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -566,3 +569,144 @@ class TestMCPRegistry:
|
|||
assert "cached_ct" in tools
|
||||
assert "new_nt" in tools
|
||||
assert len(registry._cache) == 2
|
||||
|
||||
|
||||
class TestMCPStdioCwd:
|
||||
def test_mcp_stdio_cwd_defaults_to_none(self):
|
||||
config = MCPStdio(name="test", transport="stdio", command="python -m srv")
|
||||
|
||||
assert config.cwd is None
|
||||
|
||||
def test_mcp_stdio_cwd_accepts_string(self):
|
||||
config = MCPStdio(
|
||||
name="test",
|
||||
transport="stdio",
|
||||
command="python -m srv",
|
||||
cwd="/tmp/myproject",
|
||||
)
|
||||
|
||||
assert config.cwd == "/tmp/myproject"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_stdio_passes_cwd_to_params(self):
|
||||
with (
|
||||
patch("vibe.core.tools.mcp.tools.stdio_client") as mock_client,
|
||||
patch("vibe.core.tools.mcp.tools.ClientSession") as mock_session_cls,
|
||||
patch("vibe.core.tools.mcp.tools.StdioServerParameters") as mock_params_cls,
|
||||
):
|
||||
mock_client.return_value.__aenter__ = AsyncMock(
|
||||
return_value=(MagicMock(), MagicMock())
|
||||
)
|
||||
mock_client.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session = MagicMock()
|
||||
mock_session.initialize = AsyncMock()
|
||||
mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[]))
|
||||
mock_session_cls.return_value.__aenter__ = AsyncMock(
|
||||
return_value=mock_session
|
||||
)
|
||||
mock_session_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
await list_tools_stdio(["python", "-m", "srv"], cwd="/tmp/myproject")
|
||||
|
||||
mock_params_cls.assert_called_once_with(
|
||||
command="python", args=["-m", "srv"], env=None, cwd="/tmp/myproject"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_stdio_passes_cwd_to_params(self):
|
||||
with (
|
||||
patch("vibe.core.tools.mcp.tools.stdio_client") as mock_client,
|
||||
patch("vibe.core.tools.mcp.tools.ClientSession") as mock_session_cls,
|
||||
patch("vibe.core.tools.mcp.tools.StdioServerParameters") as mock_params_cls,
|
||||
patch("vibe.core.tools.mcp.tools._parse_call_result") as mock_parse,
|
||||
):
|
||||
mock_client.return_value.__aenter__ = AsyncMock(
|
||||
return_value=(MagicMock(), MagicMock())
|
||||
)
|
||||
mock_client.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session = MagicMock()
|
||||
mock_session.initialize = AsyncMock()
|
||||
mock_session.call_tool = AsyncMock(return_value=MagicMock())
|
||||
mock_session_cls.return_value.__aenter__ = AsyncMock(
|
||||
return_value=mock_session
|
||||
)
|
||||
mock_session_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_parse.return_value = MagicMock(spec=MCPToolResult)
|
||||
|
||||
await call_tool_stdio(
|
||||
["python", "-m", "srv"], "my_tool", {}, cwd="/tmp/myproject"
|
||||
)
|
||||
|
||||
mock_params_cls.assert_called_once_with(
|
||||
command="python", args=["-m", "srv"], env=None, cwd="/tmp/myproject"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_stdio_passes_cwd_to_list_tools(self):
|
||||
registry = MCPRegistry()
|
||||
srv = MCPStdio(
|
||||
name="local",
|
||||
transport="stdio",
|
||||
command="python -m srv",
|
||||
cwd="/tmp/myproject",
|
||||
)
|
||||
remote = RemoteTool(name="run", description="Run it")
|
||||
|
||||
with patch(
|
||||
"vibe.core.tools.mcp.registry.list_tools_stdio", return_value=[remote]
|
||||
) as mock_list:
|
||||
await registry._discover_stdio(srv)
|
||||
|
||||
mock_list.assert_called_once_with(
|
||||
["python", "-m", "srv"],
|
||||
env=None,
|
||||
cwd="/tmp/myproject",
|
||||
startup_timeout_sec=srv.startup_timeout_sec,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_stdio_passes_cwd_to_proxy_class(self):
|
||||
registry = MCPRegistry()
|
||||
srv = MCPStdio(
|
||||
name="local",
|
||||
transport="stdio",
|
||||
command="python -m srv",
|
||||
cwd="/tmp/myproject",
|
||||
)
|
||||
remote = RemoteTool(name="run", description="Run it")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vibe.core.tools.mcp.registry.list_tools_stdio", return_value=[remote]
|
||||
),
|
||||
patch(
|
||||
"vibe.core.tools.mcp.registry.create_mcp_stdio_proxy_tool_class",
|
||||
wraps=create_mcp_stdio_proxy_tool_class,
|
||||
) as mock_create,
|
||||
):
|
||||
await registry._discover_stdio(srv)
|
||||
|
||||
_, kwargs = mock_create.call_args
|
||||
assert kwargs["cwd"] == "/tmp/myproject"
|
||||
|
||||
def test_proxy_tool_stores_cwd(self):
|
||||
remote = RemoteTool(name="run")
|
||||
proxy_cls = cast(
|
||||
Any,
|
||||
create_mcp_stdio_proxy_tool_class(
|
||||
command=["python", "-m", "srv"], remote=remote, cwd="/tmp/myproject"
|
||||
),
|
||||
)
|
||||
|
||||
assert proxy_cls._cwd == "/tmp/myproject"
|
||||
|
||||
def test_proxy_tool_cwd_defaults_to_none(self):
|
||||
remote = RemoteTool(name="run")
|
||||
proxy_cls = cast(
|
||||
Any,
|
||||
create_mcp_stdio_proxy_tool_class(
|
||||
command=["python", "-m", "srv"], remote=remote
|
||||
),
|
||||
)
|
||||
|
||||
assert proxy_cls._cwd is None
|
||||
|
|
|
|||
|
|
@ -8,14 +8,13 @@ import pytest
|
|||
from tests.mock.utils import collect_result
|
||||
from vibe.core.skills.manager import SkillManager
|
||||
from vibe.core.skills.models import SkillInfo
|
||||
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
|
||||
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError, ToolPermission
|
||||
from vibe.core.tools.builtins.skill import (
|
||||
Skill,
|
||||
SkillArgs,
|
||||
SkillResult,
|
||||
SkillToolConfig,
|
||||
)
|
||||
from vibe.core.tools.permissions import PermissionScope
|
||||
|
||||
|
||||
def _make_skill_dir(
|
||||
|
|
@ -37,7 +36,10 @@ def _make_skill_dir(
|
|||
file_path.write_text(f"content of {f}", encoding="utf-8")
|
||||
|
||||
return SkillInfo(
|
||||
name=name, description=description, skill_path=skill_dir / "SKILL.md"
|
||||
name=name,
|
||||
description=description,
|
||||
skill_path=skill_dir / "SKILL.md",
|
||||
prompt=body,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -83,11 +85,13 @@ class TestSkillRun:
|
|||
ctx = _make_ctx(manager)
|
||||
|
||||
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
|
||||
skill_dir = info.skill_dir
|
||||
assert skill_dir is not None
|
||||
|
||||
assert "<skill_files>" in result.content
|
||||
assert "<file>scripts/run.sh</file>" in result.content
|
||||
assert "<file>references/guide.md</file>" in result.content
|
||||
assert f"<file>{info.skill_dir / 'scripts/run.sh'}</file>" not in result.content
|
||||
assert f"<file>{skill_dir / 'scripts/run.sh'}</file>" not in result.content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_excludes_skill_md_from_file_list(
|
||||
|
|
@ -137,8 +141,10 @@ class TestSkillRun:
|
|||
ctx = _make_ctx(manager)
|
||||
|
||||
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
|
||||
skill_dir = info.skill_dir
|
||||
assert skill_dir is not None
|
||||
|
||||
assert result.skill_dir == str(info.skill_dir)
|
||||
assert result.skill_dir == str(skill_dir)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_includes_base_directory(
|
||||
|
|
@ -149,8 +155,30 @@ class TestSkillRun:
|
|||
ctx = _make_ctx(manager)
|
||||
|
||||
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
|
||||
skill_dir = info.skill_dir
|
||||
assert skill_dir is not None
|
||||
|
||||
assert f"Base directory for this skill: {info.skill_dir}" in result.content
|
||||
assert f"Base directory for this skill: {skill_dir}" in result.content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_in_memory_prompt_when_available(
|
||||
self, skill_tool: Skill
|
||||
) -> None:
|
||||
info = SkillInfo(
|
||||
name="inline-skill",
|
||||
description="Inline prompt skill",
|
||||
prompt="Inline instructions from Python object.",
|
||||
)
|
||||
manager = _make_skill_manager({"inline-skill": info})
|
||||
ctx = _make_ctx(manager)
|
||||
|
||||
result = await collect_result(
|
||||
skill_tool.run(SkillArgs(name="inline-skill"), ctx)
|
||||
)
|
||||
|
||||
assert "Inline instructions from Python object." in result.content
|
||||
assert "Base directory for this skill:" not in result.content
|
||||
assert result.skill_dir is None
|
||||
|
||||
|
||||
class TestSkillErrors:
|
||||
|
|
@ -182,30 +210,35 @@ class TestSkillErrors:
|
|||
await collect_result(skill_tool.run(SkillArgs(name="missing"), ctx=ctx))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unreadable_skill_file(
|
||||
async def test_ignores_unreadable_file_when_prompt_is_available(
|
||||
self, tmp_path: Path, skill_tool: Skill
|
||||
) -> None:
|
||||
info = SkillInfo(
|
||||
name="broken",
|
||||
description="Broken skill",
|
||||
skill_path=tmp_path / "nonexistent" / "SKILL.md",
|
||||
prompt="Use prompt from state.",
|
||||
)
|
||||
manager = _make_skill_manager({"broken": info})
|
||||
ctx = _make_ctx(manager)
|
||||
|
||||
with pytest.raises(ToolError, match="Cannot load skill file"):
|
||||
await collect_result(skill_tool.run(SkillArgs(name="broken"), ctx=ctx))
|
||||
result = await collect_result(skill_tool.run(SkillArgs(name="broken"), ctx=ctx))
|
||||
assert "Use prompt from state." in result.content
|
||||
|
||||
|
||||
class TestSkillPermission:
|
||||
def test_resolve_permission_returns_file_pattern(self, skill_tool: Skill) -> None:
|
||||
def test_resolve_permission_always_allowed(self, skill_tool: Skill) -> None:
|
||||
perm = skill_tool.resolve_permission(SkillArgs(name="my-skill"))
|
||||
|
||||
assert perm is not None
|
||||
assert len(perm.required_permissions) == 1
|
||||
assert perm.required_permissions[0].scope == PermissionScope.FILE_PATTERN
|
||||
assert perm.required_permissions[0].invocation_pattern == "my-skill"
|
||||
assert perm.required_permissions[0].session_pattern == "my-skill"
|
||||
assert perm.permission == ToolPermission.ALWAYS
|
||||
assert perm.required_permissions == []
|
||||
|
||||
def test_non_builtin_skill_is_still_always_allowed(self, skill_tool: Skill) -> None:
|
||||
perm = skill_tool.resolve_permission(SkillArgs(name="custom-skill"))
|
||||
|
||||
assert perm is not None
|
||||
assert perm.permission == ToolPermission.ALWAYS
|
||||
|
||||
|
||||
class TestSkillMeta:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue