v2.15.0 (#773)
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai> Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@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: Quentin <quentin.torroba@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:
parent
702d0f412e
commit
cafb6d4147
247 changed files with 12401 additions and 3029 deletions
|
|
@ -69,12 +69,15 @@ async def test_truncates_output_to_max_bytes(bash):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decodes_non_utf8_bytes(bash):
|
||||
result = await collect_result(bash.run(BashArgs(command="printf '\\xff\\xfe'")))
|
||||
async def test_cat_preserves_accents_from_latin1_encoded_file(bash, tmp_path):
|
||||
file = tmp_path / "menu.txt"
|
||||
file.write_bytes("café au lait\nthé glacé\n".encode("latin-1"))
|
||||
|
||||
# accept both possible encodings, as some shells emit escaped bytes as literal strings
|
||||
assert result.stdout in {"<EFBFBD><EFBFBD>", "\xff\xfe", r"\xff\xfe"}
|
||||
assert result.stderr == ""
|
||||
result = await collect_result(bash.run(BashArgs(command=f"cat {file.name}")))
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "\ufffd" not in result.stdout
|
||||
assert result.stdout == "café au lait\nthé glacé\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("predicate", ["-exec", "-execdir", "-ok", "-okdir"])
|
||||
|
|
@ -309,3 +312,94 @@ class TestDenylistWordBoundary:
|
|||
bash_tool = self._make_bash(allowlist=["cat"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="catalog"))
|
||||
assert result is not None and result.permission is not ToolPermission.ALWAYS
|
||||
|
||||
|
||||
def test_default_allowlist_includes_read_only_commands():
|
||||
"""Test that common read-only commands are in the default allowlist."""
|
||||
from vibe.core.tools.builtins.bash import _get_default_allowlist
|
||||
|
||||
allowlist = _get_default_allowlist()
|
||||
|
||||
# Read-only commands that should be in the default allowlist
|
||||
read_only_commands = [
|
||||
"grep",
|
||||
"cut",
|
||||
"sort",
|
||||
"tr",
|
||||
"uniq",
|
||||
"basename",
|
||||
"comm",
|
||||
"date",
|
||||
"diff",
|
||||
"dirname",
|
||||
"du",
|
||||
"fmt",
|
||||
"fold",
|
||||
"join",
|
||||
"less",
|
||||
"md5sum",
|
||||
"more",
|
||||
"nl",
|
||||
"od",
|
||||
"paste",
|
||||
"readlink",
|
||||
"sha1sum",
|
||||
"sha256sum",
|
||||
"shasum",
|
||||
"stat",
|
||||
"sum",
|
||||
"tac",
|
||||
"which",
|
||||
]
|
||||
|
||||
for cmd in read_only_commands:
|
||||
assert cmd in allowlist, (
|
||||
f"Read-only command '{cmd}' should be in default allowlist"
|
||||
)
|
||||
|
||||
|
||||
def test_new_read_only_commands_are_allowlisted():
|
||||
"""Test that newly added read-only commands are automatically allowed."""
|
||||
config = BashToolConfig() # Use default config
|
||||
bash_tool = Bash(config_getter=lambda: config, state=BaseToolState())
|
||||
|
||||
# Test that newly added read-only commands are allowed by default
|
||||
test_commands = [
|
||||
"grep pattern file.txt",
|
||||
"cut -d',' -f1 file.csv",
|
||||
"sort file.txt",
|
||||
"tr 'a' 'b' < file.txt",
|
||||
"uniq file.txt",
|
||||
"basename /path/to/file",
|
||||
"comm file1.txt file2.txt",
|
||||
"date",
|
||||
"diff file1.txt file2.txt",
|
||||
"dirname /path/to/file",
|
||||
"du -sh .",
|
||||
"fmt file.txt",
|
||||
"fold -w 80 file.txt",
|
||||
"join -t',' file1.csv file2.csv",
|
||||
"less file.txt",
|
||||
"md5sum file.txt",
|
||||
"more file.txt",
|
||||
"nl file.txt",
|
||||
"od -c file.bin",
|
||||
"paste file1.txt file2.txt",
|
||||
"readlink -f /path/to/link",
|
||||
"sha1sum file.txt",
|
||||
"sha256sum file.txt",
|
||||
"shasum file.txt",
|
||||
"stat file.txt",
|
||||
"sum file.txt",
|
||||
"tac file.txt",
|
||||
"which python",
|
||||
]
|
||||
|
||||
for cmd in test_commands:
|
||||
permission = bash_tool.resolve_permission(BashArgs(command=cmd))
|
||||
assert isinstance(permission, PermissionContext), (
|
||||
f"Permission should be PermissionContext for '{cmd}'"
|
||||
)
|
||||
assert permission.permission is ToolPermission.ALWAYS, (
|
||||
f"Command '{cmd}' should be always allowed"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -93,6 +93,19 @@ async def test_returns_empty_on_no_matches(grep, tmp_path):
|
|||
assert not result.was_truncated
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preserves_accents_when_matching_latin1_encoded_file(grep, tmp_path):
|
||||
(tmp_path / "menu.txt").write_bytes("café au lait\nthé glacé\n".encode("latin-1"))
|
||||
|
||||
result = await collect_result(
|
||||
grep.run(GrepArgs(pattern="caf")) # typos:disable-line
|
||||
)
|
||||
|
||||
assert result.match_count == 1
|
||||
assert "\ufffd" not in result.matches
|
||||
assert "café au lait" in result.matches
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fails_with_empty_pattern(grep):
|
||||
with pytest.raises(ToolError) as err:
|
||||
|
|
|
|||
|
|
@ -186,7 +186,9 @@ class TestMCPHttpClient:
|
|||
startup_timeout_sec=42.0,
|
||||
)
|
||||
|
||||
create_client.assert_called_once_with({"Authorization": "Bearer token"})
|
||||
create_client.assert_called_once_with(
|
||||
{"Authorization": "Bearer token"}, auth=None
|
||||
)
|
||||
assert fake_client.entered is True
|
||||
assert fake_client.closed is True
|
||||
assert captured["url"] == "https://mcp.example.com"
|
||||
|
|
@ -221,7 +223,9 @@ class TestMCPHttpClient:
|
|||
tool_timeout_sec=12.0,
|
||||
)
|
||||
|
||||
create_client.assert_called_once_with({"Authorization": "Bearer token"})
|
||||
create_client.assert_called_once_with(
|
||||
{"Authorization": "Bearer token"}, auth=None
|
||||
)
|
||||
assert fake_client.entered is True
|
||||
assert fake_client.closed is True
|
||||
assert captured["url"] == "https://mcp.example.com"
|
||||
|
|
|
|||
|
|
@ -209,26 +209,34 @@ async def test_ui_streams_output_incrementally(vibe_app: VibeApp) -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_cancels_running_command_on_new_submit(vibe_app: VibeApp) -> None:
|
||||
"""Submitting new input while a bang command is running should cancel it."""
|
||||
async def test_ui_queues_bash_submitted_while_command_running(
|
||||
vibe_app: VibeApp,
|
||||
) -> None:
|
||||
"""Submitting new bash while a bang command is running should queue, not cancel."""
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
chat_input.value = "!sleep 30"
|
||||
chat_input.value = "!sleep 0.5"
|
||||
|
||||
await pilot.press("enter")
|
||||
await _wait_for_pending_bash_message(vibe_app, pilot)
|
||||
assert vibe_app._bash_task is not None
|
||||
assert not vibe_app._bash_task.done()
|
||||
|
||||
# submit a new command which should cancel the first one
|
||||
chat_input.value = "!echo done"
|
||||
await pilot.press("enter")
|
||||
|
||||
# wait until we have two messages and the second is finished
|
||||
deadline = time.monotonic() + 2.0
|
||||
# The second command should be queued, not cancelled
|
||||
assert len(vibe_app._input_queue) == 1
|
||||
|
||||
# Wait for both to complete (first runs, drain runs second)
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
all_msgs = list(vibe_app.query(BashOutputMessage))
|
||||
if len(all_msgs) == 2 and not all_msgs[1]._pending:
|
||||
if (
|
||||
len(all_msgs) == 2
|
||||
and not all_msgs[0]._pending
|
||||
and not all_msgs[1]._pending
|
||||
):
|
||||
break
|
||||
await pilot.pause(0.05)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@ import respx
|
|||
|
||||
from tests.mock.utils import collect_result
|
||||
from vibe.core.tools.base import BaseToolState, ToolError
|
||||
from vibe.core.tools.builtins.webfetch import WebFetch, WebFetchArgs, WebFetchConfig
|
||||
from vibe.core.tools.builtins.webfetch import (
|
||||
WebFetch,
|
||||
WebFetchArgs,
|
||||
WebFetchConfig,
|
||||
WebFetchResult,
|
||||
)
|
||||
from vibe.core.types import ToolResultEvent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -254,3 +260,58 @@ async def test_over_max_timeout_rejected(webfetch):
|
|||
|
||||
def test_get_status_text():
|
||||
assert WebFetch.get_status_text() == "Fetching URL"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_decodes_response_using_declared_iso_8859_1_charset(webfetch):
|
||||
body = "<html><body><p>café au lait</p></body></html>".encode("latin-1")
|
||||
respx.get("https://example.com").mock(
|
||||
return_value=httpx.Response(
|
||||
200, content=body, headers={"Content-Type": "text/html; charset=iso-8859-1"}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
|
||||
|
||||
assert "\ufffd" not in result.content
|
||||
assert "café au lait" in result.content
|
||||
assert "caf au lait" not in result.content # typos:disable-line
|
||||
|
||||
|
||||
def _fetch_result_event(result: WebFetchResult) -> ToolResultEvent:
|
||||
return ToolResultEvent(
|
||||
tool_name="web_fetch", tool_call_id="t1", tool_class=WebFetch, result=result
|
||||
)
|
||||
|
||||
|
||||
def test_get_result_display_includes_url():
|
||||
url = "https://docs.slack.dev/reference/methods/oauth.v2.user.access"
|
||||
event = _fetch_result_event(
|
||||
WebFetchResult(
|
||||
url=url, content="x" * 14837, content_type="text/html; charset=utf-8"
|
||||
)
|
||||
)
|
||||
|
||||
display = WebFetch.get_result_display(event)
|
||||
|
||||
assert display.success is True
|
||||
assert url in display.message
|
||||
assert "14,837 chars" in display.message
|
||||
assert "text/html" in display.message
|
||||
assert "charset" not in display.message
|
||||
|
||||
|
||||
def test_get_result_display_marks_truncated():
|
||||
event = _fetch_result_event(
|
||||
WebFetchResult(
|
||||
url="https://example.com",
|
||||
content="x" * 100,
|
||||
content_type="text/plain",
|
||||
was_truncated=True,
|
||||
)
|
||||
)
|
||||
|
||||
display = WebFetch.get_result_display(event)
|
||||
|
||||
assert "[truncated]" in display.message
|
||||
|
|
|
|||
|
|
@ -18,9 +18,15 @@ from tests.conftest import build_test_vibe_config
|
|||
from tests.mock.utils import collect_result
|
||||
from vibe.core.config import ProviderConfig, VibeConfig
|
||||
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
|
||||
from vibe.core.tools.builtins.websearch import WebSearch, WebSearchArgs, WebSearchConfig
|
||||
from vibe.core.tools.builtins.websearch import (
|
||||
WebSearch,
|
||||
WebSearchArgs,
|
||||
WebSearchConfig,
|
||||
WebSearchResult,
|
||||
WebSearchSource,
|
||||
)
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
from vibe.core.types import Backend
|
||||
from vibe.core.types import Backend, ToolResultEvent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
|
|
@ -81,11 +87,20 @@ def test_parse_text_chunks(websearch):
|
|||
response = _make_response(
|
||||
content=[TextChunk(text="Hello "), TextChunk(text="world")]
|
||||
)
|
||||
result = websearch._parse_response(response)
|
||||
result = websearch._parse_response(response, "test query")
|
||||
assert result.query == "test query"
|
||||
assert result.answer == "Hello world"
|
||||
assert result.sources == []
|
||||
|
||||
|
||||
def test_parse_plain_string_content(websearch):
|
||||
# Short answers come back as a plain string, not a list of chunks.
|
||||
response = _make_response(outputs=[MessageOutputEntry(content="2 + 2 = 4.")])
|
||||
result = websearch._parse_response(response, "2 plus 2")
|
||||
assert result.answer == "2 + 2 = 4."
|
||||
assert result.sources == []
|
||||
|
||||
|
||||
def test_parse_sources_deduped(websearch):
|
||||
response = _make_response(
|
||||
content=[
|
||||
|
|
@ -97,7 +112,7 @@ def test_parse_sources_deduped(websearch):
|
|||
ToolReferenceChunk(tool="web_search", title="Site B", url="https://b.com"),
|
||||
]
|
||||
)
|
||||
result = websearch._parse_response(response)
|
||||
result = websearch._parse_response(response, "test query")
|
||||
assert result.answer == "Answer"
|
||||
assert len(result.sources) == 2
|
||||
assert result.sources[0].url == "https://a.com"
|
||||
|
|
@ -112,27 +127,27 @@ def test_parse_skips_source_without_url(websearch):
|
|||
ToolReferenceChunk(tool="web_search", title="No URL"),
|
||||
]
|
||||
)
|
||||
result = websearch._parse_response(response)
|
||||
result = websearch._parse_response(response, "test query")
|
||||
assert result.sources == []
|
||||
|
||||
|
||||
def test_parse_empty_text_raises(websearch):
|
||||
response = _make_response(content=[])
|
||||
with pytest.raises(ToolError, match="No text in agent response"):
|
||||
websearch._parse_response(response)
|
||||
websearch._parse_response(response, "test query")
|
||||
|
||||
|
||||
def test_parse_whitespace_only_raises(websearch):
|
||||
response = _make_response(content=[TextChunk(text=" ")])
|
||||
with pytest.raises(ToolError, match="No text in agent response"):
|
||||
websearch._parse_response(response)
|
||||
websearch._parse_response(response, "test query")
|
||||
|
||||
|
||||
def test_parse_skips_non_message_entries(websearch):
|
||||
response = _make_response(
|
||||
outputs=[MessageOutputEntry(content=[TextChunk(text="Answer")])]
|
||||
)
|
||||
result = websearch._parse_response(response)
|
||||
result = websearch._parse_response(response, "test query")
|
||||
assert result.answer == "Answer"
|
||||
|
||||
|
||||
|
|
@ -227,6 +242,7 @@ async def test_run_returns_parsed_result(websearch):
|
|||
websearch.run(WebSearchArgs(query="test query"))
|
||||
)
|
||||
|
||||
assert result.query == "test query"
|
||||
assert result.answer == "The answer"
|
||||
assert len(result.sources) == 1
|
||||
assert result.sources[0].url == "https://example.com"
|
||||
|
|
@ -376,3 +392,36 @@ def test_tool_manager_websearch_availability_falls_back_without_mistral_provider
|
|||
|
||||
def test_get_status_text():
|
||||
assert WebSearch.get_status_text() == "Searching the web"
|
||||
|
||||
|
||||
def test_get_result_display_includes_query_and_pluralizes_sources():
|
||||
result = WebSearchResult(
|
||||
query="python async",
|
||||
answer="answer",
|
||||
sources=[
|
||||
WebSearchSource(title="Docs", url="https://docs.python.org"),
|
||||
WebSearchSource(title="Blog", url="https://blog.example.com"),
|
||||
],
|
||||
)
|
||||
event = ToolResultEvent(
|
||||
tool_name="web_search", tool_call_id="t1", tool_class=WebSearch, result=result
|
||||
)
|
||||
|
||||
display = WebSearch.get_result_display(event)
|
||||
|
||||
assert display.success is True
|
||||
assert "python async" in display.message
|
||||
assert "2 sources" in display.message
|
||||
|
||||
|
||||
def test_get_result_display_uses_singular_for_one_source():
|
||||
result = WebSearchResult(
|
||||
query="python",
|
||||
answer="answer",
|
||||
sources=[WebSearchSource(title="Docs", url="https://docs.python.org")],
|
||||
)
|
||||
event = ToolResultEvent(
|
||||
tool_name="web_search", tool_call_id="t1", tool_class=WebSearch, result=result
|
||||
)
|
||||
|
||||
assert "1 source)" in WebSearch.get_result_display(event).message
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue