Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai>
Co-Authored-By: Clément Drouin <clement.drouin@mistral.ai>
Co-Authored-by: Thiago Padilha <thiago@coplane.com>
This commit is contained in:
Mathias Gesbert 2025-12-23 19:00:46 +01:00 committed by Mathias Gesbert
parent 2e1e15120d
commit 078693fc64
67 changed files with 3959 additions and 819 deletions

View file

@ -41,7 +41,7 @@ class TestACPInitialize:
),
)
assert response.agentInfo == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.2.2"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.0"
)
assert response.authMethods == []
@ -63,7 +63,7 @@ class TestACPInitialize:
),
)
assert response.agentInfo == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.2.2"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.0"
)
assert response.authMethods is not None

View file

@ -5,10 +5,17 @@ from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, mock_open, patch
import pyperclip
import pytest
from textual.app import App
from vibe.cli.clipboard import _copy_osc52, copy_selection_to_clipboard
from vibe.cli.clipboard import (
_copy_osc52,
_copy_wayland_clipboard,
_copy_x11_clipboard,
_get_copy_fns,
copy_selection_to_clipboard,
)
class MockWidget:
@ -79,88 +86,69 @@ def test_copy_selection_to_clipboard_no_notification(
mock_app.notify.assert_not_called()
@patch("vibe.cli.clipboard._copy_osc52")
@patch("vibe.cli.clipboard.pyperclip.copy")
def test_copy_selection_to_clipboard_success_with_osc52(
mock_pyperclip_copy: MagicMock, mock_osc52_copy: MagicMock, mock_app: MagicMock
@patch("vibe.cli.clipboard._get_copy_fns")
def test_copy_selection_to_clipboard_success(
mock_get_copy_fns: MagicMock, mock_app: MagicMock
) -> None:
widget = MockWidget(
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
)
mock_app.query.return_value = [widget]
mock_copy_fn = MagicMock()
mock_get_copy_fns.return_value = [mock_copy_fn]
copy_selection_to_clipboard(mock_app)
mock_osc52_copy.assert_called_once_with("selected text")
mock_pyperclip_copy.assert_not_called()
mock_app.copy_to_clipboard.assert_not_called()
mock_copy_fn.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
'"selected text" copied to clipboard', severity="information", timeout=2
)
@patch("vibe.cli.clipboard._copy_osc52")
@patch("vibe.cli.clipboard.pyperclip.copy")
def test_copy_selection_to_clipboard_osc52_fails_success_with_pyperclip(
mock_pyperclip_copy: MagicMock, mock_osc52_copy: MagicMock, mock_app: MagicMock
) -> None:
widget = MockWidget(
text_selection=SimpleNamespace(),
get_selection_result=(" selected text ", None),
)
mock_app.query.return_value = [widget]
mock_osc52_copy.side_effect = Exception("osc52 failed")
copy_selection_to_clipboard(mock_app)
mock_osc52_copy.assert_called_once_with(" selected text ")
mock_pyperclip_copy.assert_called_once_with(" selected text ")
mock_app.notify.assert_called_once_with(
'" selected text " copied to clipboard', severity="information", timeout=2
)
mock_app.copy_to_clipboard.assert_not_called()
@patch("vibe.cli.clipboard._copy_osc52")
@patch("vibe.cli.clipboard.pyperclip.copy")
def test_copy_selection_to_clipboard_osc52_and_pyperclip_fail_success_with_app_copy(
mock_pyperclip_copy: MagicMock, mock_osc52_copy: MagicMock, mock_app: MagicMock
@patch("vibe.cli.clipboard._get_copy_fns")
def test_copy_selection_to_clipboard_tries_all(
mock_get_copy_fns: MagicMock, mock_app: MagicMock
) -> None:
widget = MockWidget(
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
)
mock_app.query.return_value = [widget]
mock_osc52_copy.side_effect = Exception("osc52 failed")
mock_pyperclip_copy.side_effect = Exception("pyperclip failed")
fn_1 = MagicMock(side_effect=Exception("failed"))
fn_2 = MagicMock()
fn_3 = MagicMock()
mock_get_copy_fns.return_value = [fn_1, fn_2, fn_3]
copy_selection_to_clipboard(mock_app)
mock_osc52_copy.assert_called_once_with("selected text")
mock_pyperclip_copy.assert_called_once_with("selected text")
mock_app.copy_to_clipboard.assert_called_once_with("selected text")
fn_1.assert_called_once_with("selected text")
fn_2.assert_called_once_with("selected text")
fn_3.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
'"selected text" copied to clipboard', severity="information", timeout=2
)
@patch("vibe.cli.clipboard._copy_osc52")
@patch("vibe.cli.clipboard.pyperclip.copy")
@patch("vibe.cli.clipboard._get_copy_fns")
def test_copy_selection_to_clipboard_all_methods_fail(
mock_pyperclip_copy: MagicMock, mock_osc52_copy: MagicMock, mock_app: MagicMock
mock_get_copy_fns: MagicMock, mock_app: MagicMock
) -> None:
widget = MockWidget(
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
)
mock_app.query.return_value = [widget]
mock_osc52_copy.side_effect = Exception("osc52 failed")
mock_pyperclip_copy.side_effect = Exception("pyperclip failed")
mock_app.copy_to_clipboard.side_effect = Exception("app copy failed")
failing_fn1 = MagicMock(side_effect=Exception("failed 1"))
failing_fn2 = MagicMock(side_effect=Exception("failed 2"))
failing_fn3 = MagicMock(side_effect=Exception("failed 3"))
mock_get_copy_fns.return_value = [failing_fn1, failing_fn2, failing_fn3]
copy_selection_to_clipboard(mock_app)
mock_osc52_copy.assert_called_once_with("selected text")
mock_pyperclip_copy.assert_called_once_with("selected text")
mock_app.copy_to_clipboard.assert_called_once_with("selected text")
failing_fn1.assert_called_once_with("selected text")
failing_fn2.assert_called_once_with("selected text")
failing_fn3.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
"Failed to copy - no clipboard method available", severity="warning", timeout=3
)
@ -177,10 +165,12 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No
widget3 = MockWidget(text_selection=None)
mock_app.query.return_value = [widget1, widget2, widget3]
with patch("vibe.cli.clipboard._copy_osc52") as mock_osc52_copy:
with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns:
mock_copy_fn = MagicMock()
mock_get_copy_fns.return_value = [mock_copy_fn]
copy_selection_to_clipboard(mock_app)
mock_osc52_copy.assert_called_once_with("first selection\nsecond selection")
mock_copy_fn.assert_called_once_with("first selection\nsecond selection")
mock_app.notify.assert_called_once_with(
'"first selection⏎second selection" copied to clipboard',
severity="information",
@ -195,15 +185,12 @@ def test_copy_selection_to_clipboard_preview_shortening(mock_app: MagicMock) ->
)
mock_app.query.return_value = [widget]
with (
patch("vibe.cli.clipboard._copy_osc52") as mock_osc52_copy,
patch("vibe.cli.clipboard.pyperclip.copy") as mock_pyperclip_copy,
):
mock_osc52_copy.side_effect = Exception("osc52 failed")
with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns:
mock_copy_fn = MagicMock()
mock_get_copy_fns.return_value = [mock_copy_fn]
copy_selection_to_clipboard(mock_app)
mock_osc52_copy.assert_called_once_with(long_text)
mock_pyperclip_copy.assert_called_once_with(long_text)
mock_copy_fn.assert_called_once_with(long_text)
notification_call = mock_app.notify.call_args
assert notification_call is not None
assert '"' in notification_call[0][0]
@ -226,3 +213,113 @@ def test_copy_osc52_writes_correct_sequence(
handle = mock_file()
handle.write.assert_called_once_with(expected_seq)
handle.flush.assert_called_once()
@patch("builtins.open", new_callable=mock_open)
def test_copy_osc52_with_tmux(
mock_file: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("TMUX", "1")
test_text = "test text"
_copy_osc52(test_text)
encoded = base64.b64encode(test_text.encode("utf-8")).decode("ascii")
expected_seq = f"\033Ptmux;\033\033]52;c;{encoded}\a\033\\"
handle = mock_file()
handle.write.assert_called_once_with(expected_seq)
@patch("vibe.cli.clipboard.subprocess.run")
def test_copy_x11_clipboard(mock_subprocess: MagicMock) -> None:
test_text = "test text"
_copy_x11_clipboard(test_text)
mock_subprocess.assert_called_once_with(
["xclip", "-selection", "clipboard"],
input=test_text.encode("utf-8"),
check=True,
)
@patch("vibe.cli.clipboard.subprocess.run")
def test_copy_wayland_clipboard(mock_subprocess: MagicMock) -> None:
test_text = "test text"
_copy_wayland_clipboard(test_text)
mock_subprocess.assert_called_once_with(
["wl-copy"], input=test_text.encode("utf-8"), check=True
)
@patch("vibe.cli.clipboard.shutil.which")
def test_get_copy_fns_no_system_tools(mock_which: MagicMock, mock_app: App) -> None:
mock_which.return_value = None
copy_fns = _get_copy_fns(mock_app)
assert len(copy_fns) == 3
assert copy_fns[0] == _copy_osc52
assert copy_fns[1] == pyperclip.copy
assert copy_fns[2] == mock_app.copy_to_clipboard
@patch("vibe.cli.clipboard.shutil.which")
def test_get_copy_fns_with_xclip(mock_which: MagicMock, mock_app: App) -> None:
def which_side_effect(cmd: str) -> str | None:
return "/usr/bin/xclip" if cmd == "xclip" else None
mock_which.side_effect = which_side_effect
copy_fns = _get_copy_fns(mock_app)
assert len(copy_fns) == 4
assert copy_fns[0] == _copy_x11_clipboard
assert copy_fns[1] == _copy_osc52
assert copy_fns[2] == pyperclip.copy
assert copy_fns[3] == mock_app.copy_to_clipboard
@patch("vibe.cli.clipboard.shutil.which")
def test_get_copy_fns_with_wl_copy(mock_which: MagicMock, mock_app: App) -> None:
def which_side_effect(cmd: str) -> str | None:
return "/usr/bin/wl-copy" if cmd == "wl-copy" else None
mock_which.side_effect = which_side_effect
copy_fns = _get_copy_fns(mock_app)
assert len(copy_fns) == 4
assert copy_fns[0] == _copy_wayland_clipboard
assert copy_fns[1] == _copy_osc52
assert copy_fns[2] == pyperclip.copy
assert copy_fns[3] == mock_app.copy_to_clipboard
@patch("vibe.cli.clipboard.shutil.which")
def test_get_copy_fns_with_both_system_tools(
mock_which: MagicMock, mock_app: App
) -> None:
def which_side_effect(cmd: str) -> str | None:
match cmd:
case "wl-copy":
return "/usr/bin/wl-copy"
case "xclip":
return "/usr/bin/xclip"
case _:
return None
mock_which.side_effect = which_side_effect
copy_fns = _get_copy_fns(mock_app)
assert len(copy_fns) == 5
# xclip is checked last, so it's added last and ends up first in the list
assert copy_fns[0] == _copy_x11_clipboard
# wl-copy is checked first, so it's added before xclip
assert copy_fns[1] == _copy_wayland_clipboard
assert copy_fns[2] == _copy_osc52
assert copy_fns[3] == pyperclip.copy
assert copy_fns[4] == mock_app.copy_to_clipboard

View file

@ -9,6 +9,7 @@ MOCK_DATA_ENV_VAR = "VIBE_MOCK_LLM_DATA"
def mock_llm_chunk(
content: str = "Hello!",
reasoning_content: str | None = None,
role: Role = Role.assistant,
tool_calls: list[ToolCall] | None = None,
name: str | None = None,
@ -19,6 +20,7 @@ def mock_llm_chunk(
message = LLMMessage(
role=role,
content=content,
reasoning_content=reasoning_content,
tool_calls=tool_calls,
name=name,
tool_call_id=tool_call_id,

View file

@ -13,7 +13,7 @@ from textual.widgets import Input
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
from vibe.setup.onboarding.screens.theme_selection import ThemeSelectionScreen
async def _wait_for(
@ -78,16 +78,19 @@ async def test_ui_can_pick_a_theme_and_saves_selection(config_dir: Path) -> None
await pass_welcome_screen(pilot)
theme_screen = app.screen
assert isinstance(theme_screen, ThemeSelectionScreen)
app.post_message(
Resize(Size(40, 10), Size(40, 10))
) # trigger the resize event handler
preview = theme_screen.query_one("#preview")
assert preview.styles.max_height is not None
target_theme = "gruvbox"
assert target_theme in THEMES
start_index = THEMES.index(app.theme)
target_index = THEMES.index(target_theme)
steps_down = (target_index - start_index) % len(THEMES)
# Use the screen's available themes which accounts for terminal theme availability
available_themes = theme_screen._available_themes
assert target_theme in available_themes
start_index = theme_screen._theme_index
target_index = available_themes.index(target_theme)
steps_down = (target_index - start_index) % len(available_themes)
await pilot.press(*["down"] * steps_down)
assert app.theme == target_theme
await pilot.press("enter")

59
tests/skills/conftest.py Normal file
View file

@ -0,0 +1,59 @@
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from vibe.core.config import SessionLoggingConfig, VibeConfig
@pytest.fixture
def skills_dir(tmp_path: Path) -> Path:
"""Create a temporary skills directory."""
skills = tmp_path / "skills"
skills.mkdir()
return skills
@pytest.fixture
def skill_config(skills_dir: Path) -> VibeConfig:
return VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
)
def create_skill(
skills_dir: Path,
name: str,
description: str = "A test skill",
*,
license: str | None = None,
compatibility: str | None = None,
metadata: dict[str, str] | None = None,
allowed_tools: str | None = None,
body: str = "## Instructions\n\nTest instructions here.",
) -> Path:
skill_dir = skills_dir / name
skill_dir.mkdir(parents=True, exist_ok=True)
frontmatter: dict[str, object] = {"name": name, "description": description}
if license:
frontmatter["license"] = license
if compatibility:
frontmatter["compatibility"] = compatibility
if metadata:
frontmatter["metadata"] = metadata
if allowed_tools:
frontmatter["allowed-tools"] = allowed_tools
yaml_str = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True)
content = f"---\n{yaml_str}---\n\n{body}"
skill_file = skill_dir / "SKILL.md"
skill_file.write_text(content, encoding="utf-8")
return skill_dir

View file

@ -0,0 +1,275 @@
from __future__ import annotations
from pathlib import Path
import pytest
from tests.skills.conftest import create_skill
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.skills.manager import SkillManager
@pytest.fixture
def config() -> VibeConfig:
return VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
)
@pytest.fixture
def skill_manager(config: VibeConfig) -> SkillManager:
return SkillManager(config)
class TestSkillManagerDiscovery:
def test_discovers_no_skills_when_directory_empty(
self, skill_manager: SkillManager
) -> None:
assert skill_manager.available_skills == {}
def test_discovers_skill_from_skill_paths(self, skills_dir: Path) -> None:
create_skill(skills_dir, "test-skill", "A test skill")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
)
manager = SkillManager(config)
assert "test-skill" in manager.available_skills
assert manager.available_skills["test-skill"].description == "A test skill"
def test_discovers_multiple_skills(self, skills_dir: Path) -> None:
create_skill(skills_dir, "skill-one", "First skill")
create_skill(skills_dir, "skill-two", "Second skill")
create_skill(skills_dir, "skill-three", "Third skill")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
)
manager = SkillManager(config)
assert len(manager.available_skills) == 3
assert "skill-one" in manager.available_skills
assert "skill-two" in manager.available_skills
assert "skill-three" in manager.available_skills
def test_ignores_directories_without_skill_md(self, skills_dir: Path) -> None:
# Create a directory that's not a skill
not_a_skill = skills_dir / "not-a-skill"
not_a_skill.mkdir()
(not_a_skill / "README.md").write_text("Not a skill")
# Create a valid skill
create_skill(skills_dir, "valid-skill", "A valid skill")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
)
manager = SkillManager(config)
skills = manager.available_skills
assert len(skills) == 1
assert "valid-skill" in skills
assert "not-a-skill" not in skills
def test_ignores_files_in_skills_directory(self, skills_dir: Path) -> None:
# Create a file in the skills directory (not a directory)
(skills_dir / "not-a-directory.md").write_text("Just a file")
# Create a valid skill
create_skill(skills_dir, "valid-skill", "A valid skill")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
)
manager = SkillManager(config)
skills = manager.available_skills
assert len(skills) == 1
assert "valid-skill" in skills
class TestSkillManagerParsing:
def test_parses_all_skill_fields(self, skills_dir: Path) -> None:
create_skill(
skills_dir,
"full-skill",
"A skill with all fields",
license="MIT",
compatibility="Requires git",
metadata={"author": "Test Author", "version": "1.0"},
allowed_tools="bash read_file",
)
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
)
manager = SkillManager(config)
skill = manager.get_skill("full-skill")
assert skill is not None
assert skill.name == "full-skill"
assert skill.description == "A skill with all fields"
assert skill.license == "MIT"
assert skill.compatibility == "Requires git"
assert skill.metadata == {"author": "Test Author", "version": "1.0"}
assert skill.allowed_tools == ["bash", "read_file"]
def test_sets_correct_skill_path(self, skills_dir: Path) -> None:
create_skill(skills_dir, "test-skill", "A test skill")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
)
manager = SkillManager(config)
skill = manager.get_skill("test-skill")
assert skill is not None
assert skill.skill_path == skills_dir / "test-skill" / "SKILL.md"
assert skill.skill_dir == skills_dir / "test-skill"
def test_skips_skill_with_invalid_frontmatter(self, skills_dir: Path) -> None:
# Create an invalid skill
invalid_skill_dir = skills_dir / "invalid-skill"
invalid_skill_dir.mkdir()
(invalid_skill_dir / "SKILL.md").write_text("No frontmatter here")
# Create a valid skill
create_skill(skills_dir, "valid-skill", "A valid skill")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
)
manager = SkillManager(config)
skills = manager.available_skills
assert len(skills) == 1
assert "valid-skill" in skills
assert "invalid-skill" not in skills
def test_skips_skill_with_missing_required_fields(self, skills_dir: Path) -> None:
# Create skill missing description
missing_desc_dir = skills_dir / "missing-desc"
missing_desc_dir.mkdir()
(missing_desc_dir / "SKILL.md").write_text("---\nname: missing-desc\n---\n")
# Create a valid skill
create_skill(skills_dir, "valid-skill", "A valid skill")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
)
manager = SkillManager(config)
skills = manager.available_skills
assert len(skills) == 1
assert "valid-skill" in skills
class TestSkillManagerSearchPaths:
def test_discovers_from_multiple_skill_paths(self, tmp_path: Path) -> None:
# Create two separate skill directories
skills_dir_1 = tmp_path / "skills1"
skills_dir_1.mkdir()
create_skill(skills_dir_1, "skill-from-dir1", "Skill from directory 1")
skills_dir_2 = tmp_path / "skills2"
skills_dir_2.mkdir()
create_skill(skills_dir_2, "skill-from-dir2", "Skill from directory 2")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir_1, skills_dir_2],
)
manager = SkillManager(config)
skills = manager.available_skills
assert len(skills) == 2
assert "skill-from-dir1" in skills
assert "skill-from-dir2" in skills
def test_first_discovered_wins_for_duplicates(self, tmp_path: Path) -> None:
# Create two directories with the same skill name
skills_dir_1 = tmp_path / "skills1"
skills_dir_1.mkdir()
create_skill(skills_dir_1, "duplicate-skill", "First version")
skills_dir_2 = tmp_path / "skills2"
skills_dir_2.mkdir()
create_skill(skills_dir_2, "duplicate-skill", "Second version")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir_1, skills_dir_2],
)
manager = SkillManager(config)
skills = manager.available_skills
assert len(skills) == 1
assert skills["duplicate-skill"].description == "First version"
def test_ignores_nonexistent_skill_paths(self, tmp_path: Path) -> None:
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
create_skill(skills_dir, "valid-skill", "A valid skill")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir, tmp_path / "nonexistent"],
)
manager = SkillManager(config)
assert len(manager.available_skills) == 1
assert "valid-skill" in manager.available_skills
class TestSkillManagerGetSkill:
def test_returns_skill_by_name(self, skills_dir: Path) -> None:
create_skill(skills_dir, "test-skill", "A test skill")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
)
manager = SkillManager(config)
skill = manager.get_skill("test-skill")
assert skill is not None
assert skill.name == "test-skill"
def test_returns_none_for_unknown_skill(self, skill_manager: SkillManager) -> None:
assert skill_manager.get_skill("nonexistent-skill") is None

188
tests/skills/test_models.py Normal file
View file

@ -0,0 +1,188 @@
from __future__ import annotations
from pathlib import Path
from pydantic import ValidationError
import pytest
from vibe.core.skills.models import SkillInfo, SkillMetadata
class TestSkillMetadata:
def test_creates_with_required_fields(self) -> None:
meta = SkillMetadata(name="test-skill", description="A test skill")
assert meta.name == "test-skill"
assert meta.description == "A test skill"
assert meta.license is None
assert meta.compatibility is None
assert meta.metadata == {}
assert meta.allowed_tools == []
def test_creates_with_all_fields(self) -> None:
meta = SkillMetadata(
name="full-skill",
description="A skill with all fields",
license="MIT",
compatibility="Requires git",
metadata={"author": "Test Author", "version": "1.0"},
allowed_tools=["bash", "read_file"],
)
assert meta.name == "full-skill"
assert meta.description == "A skill with all fields"
assert meta.license == "MIT"
assert meta.compatibility == "Requires git"
assert meta.metadata == {"author": "Test Author", "version": "1.0"}
assert meta.allowed_tools == ["bash", "read_file"]
def test_raises_error_for_uppercase_name(self) -> None:
with pytest.raises(ValidationError) as exc_info:
SkillMetadata(name="Test-SKILL", description="A test skill")
assert "name" in str(exc_info.value).lower()
def test_raises_error_for_invalid_chars_in_name(self) -> None:
with pytest.raises(ValidationError) as exc_info:
SkillMetadata(name="test_skill@v1.0", description="A test skill")
assert "name" in str(exc_info.value).lower()
def test_raises_error_for_consecutive_hyphens(self) -> None:
with pytest.raises(ValidationError) as exc_info:
SkillMetadata(name="test--skill", description="A test skill")
assert "name" in str(exc_info.value).lower()
def test_raises_error_for_leading_trailing_hyphens(self) -> None:
with pytest.raises(ValidationError) as exc_info:
SkillMetadata(name="-test-skill-", description="A test skill")
assert "name" in str(exc_info.value).lower()
def test_parses_allowed_tools_from_space_delimited_string(self) -> None:
meta = SkillMetadata(
name="test",
description="A test skill",
allowed_tools="bash read_file grep", # type: ignore[arg-type]
)
assert meta.allowed_tools == ["bash", "read_file", "grep"]
def test_parses_allowed_tools_from_list(self) -> None:
meta = SkillMetadata(
name="test", description="A test skill", allowed_tools=["bash", "read_file"]
)
assert meta.allowed_tools == ["bash", "read_file"]
def test_parses_allowed_tools_handles_none(self) -> None:
meta = SkillMetadata(
name="test",
description="A test skill",
allowed_tools=None, # type: ignore[arg-type]
)
assert meta.allowed_tools == []
def test_normalizes_metadata_values_to_strings(self) -> None:
meta = SkillMetadata(
name="test",
description="A test skill",
metadata={"version": 1.0, "count": 42}, # type: ignore[dict-item]
)
assert meta.metadata == {"version": "1.0", "count": "42"}
def test_raises_error_for_missing_name(self) -> None:
with pytest.raises(ValidationError) as exc_info:
SkillMetadata(description="A test skill") # type: ignore[call-arg]
assert "name" in str(exc_info.value)
def test_raises_error_for_missing_description(self) -> None:
with pytest.raises(ValidationError) as exc_info:
SkillMetadata(name="test") # type: ignore[call-arg]
assert "description" in str(exc_info.value)
def test_raises_error_for_empty_name(self) -> None:
with pytest.raises(ValidationError) as exc_info:
SkillMetadata(name="", description="A test skill")
assert "name" in str(exc_info.value).lower()
def test_raises_error_for_empty_description(self) -> None:
with pytest.raises(ValidationError) as exc_info:
SkillMetadata(name="test", description="")
assert "description" in str(exc_info.value).lower()
class TestSkillInfo:
def test_creates_from_metadata(self, tmp_path: Path) -> None:
skill_path = tmp_path / "test-skill" / "SKILL.md"
skill_path.parent.mkdir()
skill_path.touch()
meta = SkillMetadata(
name="test-skill", description="A test skill", license="MIT"
)
info = SkillInfo.from_metadata(meta, skill_path)
assert info.name == "test-skill"
assert info.description == "A test skill"
assert info.license == "MIT"
assert info.skill_path == skill_path.resolve()
assert info.skill_dir == skill_path.parent.resolve()
def test_creates_with_all_fields(self, tmp_path: Path) -> None:
skill_path = tmp_path / "full-skill" / "SKILL.md"
skill_path.parent.mkdir()
skill_path.touch()
info = SkillInfo(
name="full-skill",
description="A skill with all fields",
license="Apache-2.0",
compatibility="git, docker",
metadata={"author": "Test"},
allowed_tools=["bash"],
skill_path=skill_path,
)
assert info.name == "full-skill"
assert info.description == "A skill with all fields"
assert info.license == "Apache-2.0"
assert info.compatibility == "git, docker"
assert info.metadata == {"author": "Test"}
assert info.allowed_tools == ["bash"]
assert info.skill_path == skill_path
assert info.skill_dir == skill_path.parent.resolve()
def test_from_metadata_resolves_paths(self, tmp_path: Path) -> None:
skill_path = tmp_path / "test-skill" / "SKILL.md"
skill_path.parent.mkdir()
skill_path.touch()
meta = SkillMetadata(name="test-skill", description="A test skill")
info = SkillInfo.from_metadata(meta, skill_path)
assert info.skill_path.is_absolute()
assert info.skill_dir.is_absolute()
def test_inherits_all_metadata_fields(self, tmp_path: Path) -> None:
skill_path = tmp_path / "test-skill" / "SKILL.md"
skill_path.parent.mkdir()
skill_path.touch()
meta = SkillMetadata(
name="test-skill",
description="A test skill",
license="MIT",
compatibility="Requires Python 3.12",
metadata={"key": "value"},
allowed_tools=["bash", "grep"],
)
info = SkillInfo.from_metadata(meta, skill_path)
assert info.license == meta.license
assert info.compatibility == meta.compatibility
assert info.metadata == meta.metadata
assert info.allowed_tools == meta.allowed_tools

115
tests/skills/test_parser.py Normal file
View file

@ -0,0 +1,115 @@
from __future__ import annotations
import pytest
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
class TestParseFrontmatter:
def test_parses_valid_frontmatter(self) -> None:
content = """---
name: test-skill
description: A test skill
---
## Body content here
"""
frontmatter, body = parse_frontmatter(content)
assert frontmatter["name"] == "test-skill"
assert frontmatter["description"] == "A test skill"
assert "## Body content here" in body
def test_parses_frontmatter_with_all_fields(self) -> None:
content = """---
name: full-skill
description: A skill with all fields
license: MIT
compatibility: Requires git
metadata:
author: Test Author
version: "1.0"
allowed-tools: bash read_file
---
Instructions here.
"""
frontmatter, body = parse_frontmatter(content)
assert frontmatter["name"] == "full-skill"
assert frontmatter["description"] == "A skill with all fields"
assert frontmatter["license"] == "MIT"
assert frontmatter["compatibility"] == "Requires git"
assert frontmatter["metadata"]["author"] == "Test Author"
assert frontmatter["metadata"]["version"] == "1.0"
assert frontmatter["allowed-tools"] == "bash read_file"
assert "Instructions here." in body
def test_raises_error_for_missing_frontmatter(self) -> None:
content = "Just markdown content without frontmatter"
with pytest.raises(SkillParseError) as exc_info:
parse_frontmatter(content)
assert "Missing or invalid YAML frontmatter" in str(exc_info.value)
def test_raises_error_for_unclosed_frontmatter(self) -> None:
content = """---
name: incomplete
description: Missing closing delimiter
"""
with pytest.raises(SkillParseError) as exc_info:
parse_frontmatter(content)
assert "Missing or invalid YAML frontmatter" in str(exc_info.value)
def test_raises_error_for_invalid_yaml(self) -> None:
content = """---
name: [invalid yaml
description: broken
---
Body here.
"""
with pytest.raises(SkillParseError) as exc_info:
parse_frontmatter(content)
assert "Invalid YAML frontmatter" in str(exc_info.value)
def test_raises_error_for_non_dict_frontmatter(self) -> None:
content = """---
- item1
- item2
---
Body here.
"""
with pytest.raises(SkillParseError) as exc_info:
parse_frontmatter(content)
assert "must be a mapping" in str(exc_info.value)
def test_handles_empty_frontmatter(self) -> None:
content = """---
---
Body content.
"""
frontmatter, body = parse_frontmatter(content)
assert frontmatter == {}
assert "Body content." in body
def test_handles_frontmatter_with_no_body(self) -> None:
content = """---
name: minimal
description: No body
---
"""
frontmatter, body = parse_frontmatter(content)
assert frontmatter["name"] == "minimal"
assert body.strip() == ""

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -0,0 +1,146 @@
from __future__ import annotations
from textual.pilot import Pilot
from tests.mock.utils import mock_llm_chunk
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
from tests.snapshots.snap_compare import SnapCompare
from tests.stubs.fake_backend import FakeBackend
from vibe.cli.textual_ui.widgets.messages import ReasoningMessage
from vibe.core.agent import Agent
class SnapshotTestAppWithReasoningContent(BaseSnapshotTestApp):
def __init__(self) -> None:
config = default_config()
fake_backend = FakeBackend(
chunks=[
mock_llm_chunk(
content="",
reasoning_content="Let me think about this step by step...",
),
mock_llm_chunk(
content="",
reasoning_content=" First, I need to understand the question.",
),
mock_llm_chunk(
content="", reasoning_content=" Then I can formulate a response."
),
mock_llm_chunk(content="The answer to your question is 42."),
mock_llm_chunk(content=" This is the ultimate answer."),
]
)
super().__init__(config=config)
self.agent = Agent(
config,
mode=self._current_agent_mode,
enable_streaming=True,
backend=fake_backend,
)
class SnapshotTestAppWithInterleavedReasoning(BaseSnapshotTestApp):
def __init__(self) -> None:
config = default_config()
fake_backend = FakeBackend(
chunks=[
mock_llm_chunk(
content="", reasoning_content="Let me think about this..."
),
mock_llm_chunk(content="Here's "),
mock_llm_chunk(content="the "),
mock_llm_chunk(content="first "),
mock_llm_chunk(content="part "),
mock_llm_chunk(content="of the answer. "),
mock_llm_chunk(content="", reasoning_content="Now let me verify..."),
mock_llm_chunk(content="And here's the conclusion!"),
]
)
super().__init__(config=config)
self.agent = Agent(
config,
mode=self._current_agent_mode,
enable_streaming=True,
backend=fake_backend,
)
def test_snapshot_shows_reasoning_content(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.press(*"What is the answer?")
await pilot.press("enter")
await pilot.pause(0.5)
assert snap_compare(
"test_ui_snapshot_reasoning_content.py:SnapshotTestAppWithReasoningContent",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_shows_reasoning_content_expanded(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.press(*"What is the answer?")
await pilot.press("enter")
await pilot.pause(0.5)
reasoning_msg = pilot.app.query_one(ReasoningMessage)
await pilot.click(reasoning_msg)
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_reasoning_content.py:SnapshotTestAppWithReasoningContent",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_shows_interleaved_reasoning(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.press(*"Explain this to me")
await pilot.press("enter")
await pilot.pause(0.5)
assert snap_compare(
"test_ui_snapshot_reasoning_content.py:SnapshotTestAppWithInterleavedReasoning",
terminal_size=(120, 36),
run_before=run_before,
)
class SnapshotTestAppWithBufferedReasoningTransition(BaseSnapshotTestApp):
def __init__(self) -> None:
config = default_config()
fake_backend = FakeBackend(
chunks=[
mock_llm_chunk(
content="", reasoning_content="Analyzing the problem..."
),
mock_llm_chunk(content="", reasoning_content=" Considering options..."),
mock_llm_chunk(content="", reasoning_content=" Making decision."),
mock_llm_chunk(content="Here is my carefully considered answer."),
mock_llm_chunk(content=" I hope this helps!"),
]
)
super().__init__(config=config)
self.agent = Agent(
config,
mode=self._current_agent_mode,
enable_streaming=True,
backend=fake_backend,
)
def test_snapshot_buffered_reasoning_yields_before_content(
snap_compare: SnapCompare,
) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.press(*"Give me an answer")
await pilot.press("enter")
await pilot.pause(0.5)
assert snap_compare(
"test_ui_snapshot_reasoning_content.py:SnapshotTestAppWithBufferedReasoningTransition",
terminal_size=(120, 36),
run_before=run_before,
)

View file

@ -25,6 +25,7 @@ from vibe.core.types import (
AssistantEvent,
FunctionCall,
LLMMessage,
ReasoningEvent,
Role,
ToolCall,
ToolCallEvent,
@ -376,3 +377,150 @@ async def test_act_flushes_and_logs_when_streaming_errors(observer_capture) -> N
assert [role for role, _ in observed] == [Role.system, Role.user]
assert agent.interaction_logger.save_interaction.await_count == 1
def _snapshot_events(events: list) -> list[tuple[str, str]]:
return [
(type(e).__name__, e.content)
for e in events
if isinstance(e, (AssistantEvent, ReasoningEvent))
]
@pytest.mark.asyncio
async def test_reasoning_buffer_yields_before_content_on_transition() -> None:
backend = FakeBackend([
mock_llm_chunk(content="", reasoning_content="Let me think"),
mock_llm_chunk(content="", reasoning_content=" about this"),
mock_llm_chunk(content="", reasoning_content=" problem..."),
mock_llm_chunk(content="The answer is 42."),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("What's the answer?")]
assert _snapshot_events(events) == [
("ReasoningEvent", "Let me think about this problem..."),
("AssistantEvent", "The answer is 42."),
]
@pytest.mark.asyncio
async def test_reasoning_buffer_yields_before_content_with_batching() -> None:
backend = FakeBackend([
mock_llm_chunk(content="", reasoning_content="Step 1"),
mock_llm_chunk(content="", reasoning_content=", Step 2"),
mock_llm_chunk(content="", reasoning_content=", Step 3"),
mock_llm_chunk(content="", reasoning_content=", Step 4"),
mock_llm_chunk(content="", reasoning_content=", Step 5"), # Triggers batch
mock_llm_chunk(content="", reasoning_content=", Step 6"),
mock_llm_chunk(content="", reasoning_content=", Final"),
mock_llm_chunk(content="Done thinking!"),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Think step by step")]
assert _snapshot_events(events) == [
("ReasoningEvent", "Step 1, Step 2, Step 3, Step 4, Step 5"),
("ReasoningEvent", ", Step 6, Final"),
("AssistantEvent", "Done thinking!"),
]
@pytest.mark.asyncio
async def test_content_buffer_yields_before_reasoning_on_transition() -> None:
"""When content is buffered and reasoning arrives, content yields first."""
backend = FakeBackend([
mock_llm_chunk(content="Starting the response"),
mock_llm_chunk(content=" here..."),
mock_llm_chunk(content="", reasoning_content="Wait, let me reconsider"),
mock_llm_chunk(content="", reasoning_content=" this approach..."),
mock_llm_chunk(content="Actually, the final answer."),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Give me an answer")]
assert _snapshot_events(events) == [
("AssistantEvent", "Starting the response here..."),
("ReasoningEvent", "Wait, let me reconsider this approach..."),
("AssistantEvent", "Actually, the final answer."),
]
@pytest.mark.asyncio
async def test_interleaved_reasoning_content_preserves_order() -> None:
backend = FakeBackend([
mock_llm_chunk(content="", reasoning_content="Think 1"),
mock_llm_chunk(content="Answer 1 "),
mock_llm_chunk(content="", reasoning_content="Think 2"),
mock_llm_chunk(content="Answer 2 "),
mock_llm_chunk(content="", reasoning_content="Think 3"),
mock_llm_chunk(content="Answer 3"),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Interleaved test")]
assert _snapshot_events(events) == [
("ReasoningEvent", "Think 1"),
("AssistantEvent", "Answer 1 "),
("ReasoningEvent", "Think 2"),
("AssistantEvent", "Answer 2 "),
("ReasoningEvent", "Think 3"),
("AssistantEvent", "Answer 3"),
]
assistant_msg = next(m for m in agent.messages if m.role == Role.assistant)
assert assistant_msg.reasoning_content == "Think 1Think 2Think 3"
assert assistant_msg.content == "Answer 1 Answer 2 Answer 3"
@pytest.mark.asyncio
async def test_only_reasoning_chunks_yields_reasoning_event() -> None:
backend = FakeBackend([
mock_llm_chunk(content="", reasoning_content="Just thinking..."),
mock_llm_chunk(content="", reasoning_content=" nothing to say yet."),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Silent thinking")]
assert _snapshot_events(events) == [
("ReasoningEvent", "Just thinking... nothing to say yet.")
]
@pytest.mark.asyncio
async def test_final_buffers_flush_in_correct_order() -> None:
backend = FakeBackend([
mock_llm_chunk(content="", reasoning_content="Final thought"),
mock_llm_chunk(content="Final words"),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("End buffers test")]
assert _snapshot_events(events) == [
("ReasoningEvent", "Final thought"),
("AssistantEvent", "Final words"),
]
@pytest.mark.asyncio
async def test_empty_content_chunks_do_not_trigger_false_yields() -> None:
backend = FakeBackend([
mock_llm_chunk(content="", reasoning_content="Reasoning here"),
mock_llm_chunk(content=""), # Empty content shouldn't flush reasoning
mock_llm_chunk(content="", reasoning_content=" more reasoning"),
mock_llm_chunk(content="Actual content"),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Empty content test")]
assert _snapshot_events(events) == [
("ReasoningEvent", "Reasoning here more reasoning"),
("AssistantEvent", "Actual content"),
]

View file

@ -2,8 +2,8 @@ from __future__ import annotations
import asyncio
import json
from typing import Any
from pydantic import BaseModel
import pytest
from tests.mock.utils import mock_llm_chunk
@ -140,7 +140,7 @@ async def test_tool_call_requires_approval_if_not_auto_approved() -> None:
@pytest.mark.asyncio
async def test_tool_call_approved_by_callback() -> None:
def approval_callback(
_tool_name: str, _args: dict[str, Any], _tool_call_id: str
_tool_name: str, _args: BaseModel, _tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
return (ApprovalResponse.YES, None)
@ -177,7 +177,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
custom_feedback = "User declined tool execution"
def approval_callback(
_tool_name: str, _args: dict[str, Any], _tool_call_id: str
_tool_name: str, _args: BaseModel, _tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
return (ApprovalResponse.NO, custom_feedback)
@ -247,7 +247,7 @@ async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> No
agent_ref: Agent | None = None
def approval_callback(
tool_name: str, _args: dict[str, Any], _tool_call_id: str
tool_name: str, _args: BaseModel, _tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
callback_invocations.append(tool_name)
# Set permission to ALWAYS for this tool (simulating the new behavior)

View file

@ -0,0 +1,360 @@
from __future__ import annotations
from unittest.mock import MagicMock
import httpx
import mistralai
import pytest
import respx
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 (
ModelConfig,
ProviderConfig,
SessionLoggingConfig,
VibeConfig,
)
from vibe.core.llm.backend.generic import GenericBackend
from vibe.core.llm.backend.mistral import MistralMapper, ParsedContent
from vibe.core.llm.format import APIToolFormatHandler
from vibe.core.types import AssistantEvent, LLMMessage, ReasoningEvent, Role
def make_config() -> VibeConfig:
return VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
auto_compact_threshold=0,
system_prompt_id="tests",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
enabled_tools=[],
tools={},
)
class TestMistralMapperParseContent:
def test_parse_content_string_returns_content_only(self):
mapper = MistralMapper()
result = mapper.parse_content("Hello, world!")
assert result == ParsedContent(content="Hello, world!", reasoning_content=None)
def test_parse_content_text_chunk_returns_content_only(self):
mapper = MistralMapper()
content: list[mistralai.ContentChunk] = [
mistralai.TextChunk(type="text", text="Hello from text chunk")
]
result = mapper.parse_content(content)
assert result == ParsedContent(
content="Hello from text chunk", reasoning_content=None
)
def test_parse_content_thinking_chunk_extracts_reasoning(self):
mapper = MistralMapper()
content: list[mistralai.ContentChunk] = [
mistralai.ThinkChunk(
type="thinking",
thinking=[mistralai.TextChunk(type="text", text="Let me think...")],
),
mistralai.TextChunk(type="text", text="The answer is 42."),
]
result = mapper.parse_content(content)
assert result == ParsedContent(
content="The answer is 42.", reasoning_content="Let me think..."
)
def test_parse_content_multiple_thinking_chunks_concatenates(self):
mapper = MistralMapper()
content: list[mistralai.ContentChunk] = [
mistralai.ThinkChunk(
type="thinking",
thinking=[mistralai.TextChunk(type="text", text="First thought. ")],
),
mistralai.ThinkChunk(
type="thinking",
thinking=[mistralai.TextChunk(type="text", text="Second thought.")],
),
mistralai.TextChunk(type="text", text="Final answer."),
]
result = mapper.parse_content(content)
assert result == ParsedContent(
content="Final answer.", reasoning_content="First thought. Second thought."
)
def test_parse_content_thinking_only_returns_empty_content(self):
mapper = MistralMapper()
content: list[mistralai.ContentChunk] = [
mistralai.ThinkChunk(
type="thinking",
thinking=[mistralai.TextChunk(type="text", text="Just thinking...")],
)
]
result = mapper.parse_content(content)
assert result == ParsedContent(content="", reasoning_content="Just thinking...")
def test_parse_content_empty_list_returns_empty(self):
mapper = MistralMapper()
content: list[mistralai.ContentChunk] = []
result = mapper.parse_content(content)
assert result == ParsedContent(content="", reasoning_content=None)
class TestMistralMapperPrepareMessage:
def test_prepare_assistant_message_without_reasoning(self):
mapper = MistralMapper()
msg = LLMMessage(role=Role.assistant, content="Hello!")
result = mapper.prepare_message(msg)
assert isinstance(result, mistralai.AssistantMessage)
assert result.content == "Hello!"
def test_prepare_assistant_message_with_reasoning_creates_chunks(self):
mapper = MistralMapper()
msg = LLMMessage(
role=Role.assistant,
content="The answer is 42.",
reasoning_content="Let me calculate...",
)
result = mapper.prepare_message(msg)
assert isinstance(result, mistralai.AssistantMessage)
assert isinstance(result.content, list)
assert len(result.content) == 2
think_chunk = result.content[0]
assert isinstance(think_chunk, mistralai.ThinkChunk)
assert think_chunk.type == "thinking"
assert len(think_chunk.thinking) == 1
inner_chunk = think_chunk.thinking[0]
assert isinstance(inner_chunk, mistralai.TextChunk)
assert inner_chunk.text == "Let me calculate..."
text_chunk = result.content[1]
assert isinstance(text_chunk, mistralai.TextChunk)
assert text_chunk.type == "text"
assert text_chunk.text == "The answer is 42."
def test_prepare_assistant_message_with_reasoning_and_none_content(self):
mapper = MistralMapper()
msg = LLMMessage(
role=Role.assistant, content=None, reasoning_content="Just thinking..."
)
result = mapper.prepare_message(msg)
assert isinstance(result, mistralai.AssistantMessage)
assert isinstance(result.content, list)
assert len(result.content) == 2
think_chunk = result.content[0]
assert isinstance(think_chunk, mistralai.ThinkChunk)
assert think_chunk.type == "thinking"
assert len(think_chunk.thinking) == 1
inner_chunk = think_chunk.thinking[0]
assert isinstance(inner_chunk, mistralai.TextChunk)
assert inner_chunk.text == "Just thinking..."
text_chunk = result.content[1]
assert isinstance(text_chunk, mistralai.TextChunk)
assert text_chunk.text == ""
class TestGenericBackendReasoningContent:
@pytest.mark.asyncio
async def test_complete_extracts_reasoning_content(self):
base_url = "https://api.example.com"
json_response = {
"id": "fake_id",
"created": 1234567890,
"model": "test-model",
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
"object": "chat.completion",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "The answer is 42.",
"reasoning_content": "Let me think step by step...",
},
}
],
}
with respx.mock(base_url=base_url) as mock_api:
mock_api.post("/v1/chat/completions").mock(
return_value=httpx.Response(status_code=200, json=json_response)
)
provider = ProviderConfig(
name="test", api_base=f"{base_url}/v1", api_key_env_var="API_KEY"
)
backend = GenericBackend(provider=provider)
model = ModelConfig(name="test-model", provider="test", alias="test")
messages = [LLMMessage(role=Role.user, content="What is the answer?")]
result = await backend.complete(
model=model,
messages=messages,
temperature=0.2,
tools=None,
max_tokens=None,
tool_choice=None,
extra_headers=None,
)
assert result.message.content == "The answer is 42."
assert result.message.reasoning_content == "Let me think step by step..."
@pytest.mark.asyncio
async def test_complete_streaming_extracts_reasoning_content(self):
base_url = "https://api.example.com"
chunks = [
b'data: {"id":"id1","object":"chat.completion.chunk","created":123,"model":"test","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"Thinking..."},"finish_reason":null}]}',
b'data: {"id":"id1","object":"chat.completion.chunk","created":123,"model":"test","choices":[{"index":0,"delta":{"content":"Answer"},"finish_reason":null}]}',
b'data: {"id":"id1","object":"chat.completion.chunk","created":123,"model":"test","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}',
b"data: [DONE]",
]
with respx.mock(base_url=base_url) as mock_api:
mock_api.post("/v1/chat/completions").mock(
return_value=httpx.Response(
status_code=200,
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
headers={"Content-Type": "text/event-stream"},
)
)
provider = ProviderConfig(
name="test", api_base=f"{base_url}/v1", api_key_env_var="API_KEY"
)
backend = GenericBackend(provider=provider)
model = ModelConfig(name="test-model", provider="test", alias="test")
messages = [LLMMessage(role=Role.user, content="Stream please")]
results = []
async for chunk in backend.complete_streaming(
model=model,
messages=messages,
temperature=0.2,
tools=None,
max_tokens=None,
tool_choice=None,
extra_headers=None,
):
results.append(chunk)
assert results[0].message.reasoning_content == "Thinking..."
assert results[0].message.content == ""
assert results[1].message.content == "Answer"
assert results[1].message.reasoning_content is None
class TestAPIToolFormatHandlerReasoningContent:
def test_process_api_response_message_extracts_reasoning_content(self):
handler = APIToolFormatHandler()
mock_message = MagicMock()
mock_message.role = "assistant"
mock_message.content = "The answer is 42."
mock_message.reasoning_content = "Let me think..."
mock_message.tool_calls = None
result = handler.process_api_response_message(mock_message)
assert result.content == "The answer is 42."
assert result.reasoning_content == "Let me think..."
def test_process_api_response_message_handles_missing_reasoning_content(self):
handler = APIToolFormatHandler()
mock_message = MagicMock(spec=["role", "content", "tool_calls"])
mock_message.role = "assistant"
mock_message.content = "Hello"
mock_message.tool_calls = None
result = handler.process_api_response_message(mock_message)
assert result.content == "Hello"
assert result.reasoning_content is None
class TestAgentStreamingReasoningEvents:
@pytest.mark.asyncio
async def test_streaming_accumulates_reasoning_in_message(self):
backend = FakeBackend([
mock_llm_chunk(content="", reasoning_content="First thought. "),
mock_llm_chunk(content="", reasoning_content="Second thought."),
mock_llm_chunk(content="Final answer."),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
[_ async for _ in agent.act("Think and answer")]
assistant_msg = next(m for m in agent.messages if m.role == Role.assistant)
assert assistant_msg.reasoning_content == "First thought. Second thought."
assert assistant_msg.content == "Final answer."
@pytest.mark.asyncio
async def test_streaming_content_only_no_reasoning(self):
backend = FakeBackend([
mock_llm_chunk(content="Hello "),
mock_llm_chunk(content="world!"),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Say hello")]
reasoning_events = [e for e in events if isinstance(e, ReasoningEvent)]
assert len(reasoning_events) == 0
assistant_events = [e for e in events if isinstance(e, AssistantEvent)]
assert len(assistant_events) == 1
assistant_msg = next(m for m in agent.messages if m.role == Role.assistant)
assert assistant_msg.reasoning_content is None
assert assistant_msg.content == "Hello world!"
class TestLLMMessageReasoningContent:
def test_llm_message_from_dict_with_reasoning_content(self):
data = {
"role": "assistant",
"content": "Answer",
"reasoning_content": "Thinking...",
}
msg = LLMMessage.model_validate(data)
assert msg.reasoning_content == "Thinking..."
def test_llm_message_model_dump_includes_reasoning_content(self):
msg = LLMMessage(
role=Role.assistant, content="Answer", reasoning_content="Thinking..."
)
dumped = msg.model_dump(exclude_none=True)
assert dumped["reasoning_content"] == "Thinking..."
def test_llm_message_model_dump_excludes_none_reasoning_content(self):
msg = LLMMessage(role=Role.assistant, content="Answer")
dumped = msg.model_dump(exclude_none=True)
assert "reasoning_content" not in dumped