v1.3.0
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:
parent
2e1e15120d
commit
078693fc64
67 changed files with 3959 additions and 819 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
59
tests/skills/conftest.py
Normal 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
|
||||
275
tests/skills/test_manager.py
Normal file
275
tests/skills/test_manager.py
Normal 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
188
tests/skills/test_models.py
Normal 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
115
tests/skills/test_parser.py
Normal 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 |
146
tests/snapshots/test_ui_snapshot_reasoning_content.py
Normal file
146
tests/snapshots/test_ui_snapshot_reasoning_content.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
360
tests/test_reasoning_content.py
Normal file
360
tests/test_reasoning_content.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue