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: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-Authored-By: Clément Siriex <clement.sirieix@mistral.ai>
Co-Authored-By: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-Authored-By: Thaddee Tyl <thaddee.tyl@gmail.com>
Co-Authored-By: David Brochart <david.brochart@gmail.com>
Co-Authored-By: Joseph Guhlin <joseph.guhlin@gmail.com>
Co-Authored-By: Thomas Kenbeek <thomaskenbeek@gmail.com>
Co-Authored-By: Remenby31 <baptiste.cruvellier31@gmail.com>
This commit is contained in:
Mathias Gesbert 2026-01-27 16:39:30 +01:00 committed by Mathias Gesbert
parent 79f215d91c
commit d33db9fff8
217 changed files with 16911 additions and 4305 deletions

View file

@ -0,0 +1,188 @@
from __future__ import annotations
import pytest
from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.ask_user_question import (
Answer,
AskUserQuestion,
AskUserQuestionArgs,
AskUserQuestionConfig,
AskUserQuestionResult,
Choice,
Question,
)
from vibe.core.types import ToolCallEvent, ToolResultEvent
@pytest.fixture
def tool():
config = AskUserQuestionConfig()
return AskUserQuestion(config=config, state=BaseToolState())
@pytest.fixture
def single_question_args():
return AskUserQuestionArgs(
questions=[
Question(
question="Which database?",
header="DB",
options=[
Choice(label="PostgreSQL", description="Relational DB"),
Choice(label="MongoDB", description="Document DB"),
],
)
]
)
@pytest.fixture
def multi_question_args():
return AskUserQuestionArgs(
questions=[
Question(
question="Which database?",
header="DB",
options=[Choice(label="PostgreSQL"), Choice(label="MongoDB")],
),
Question(
question="Which framework?",
header="Framework",
options=[Choice(label="FastAPI"), Choice(label="Django")],
),
]
)
async def run_tool_with_callback(tool, args, callback):
from vibe.core.tools.base import InvokeContext
ctx = InvokeContext(user_input_callback=callback, tool_call_id="123")
result = None
async for item in tool.run(args, ctx):
result = item
return result
@pytest.mark.asyncio
async def test_raises_error_without_callback(tool, single_question_args):
with pytest.raises(ToolError) as err:
async for _ in tool.run(single_question_args, ctx=None):
pass
assert "interactive UI" in str(err.value)
@pytest.mark.asyncio
async def test_calls_callback_and_returns_result(tool, single_question_args):
expected_result = AskUserQuestionResult(
answers=[
Answer(question="Which database?", answer="PostgreSQL", is_other=False)
],
cancelled=False,
)
async def mock_callback(args):
assert args == single_question_args
return expected_result
result = await run_tool_with_callback(tool, single_question_args, mock_callback)
assert result is not None
assert result == expected_result
assert result.answers[0].answer == "PostgreSQL"
@pytest.mark.asyncio
async def test_handles_cancelled_result(tool, single_question_args):
expected_result = AskUserQuestionResult(answers=[], cancelled=True)
async def mock_callback(args):
return expected_result
result = await run_tool_with_callback(tool, single_question_args, mock_callback)
assert result is not None
assert result.cancelled is True
assert len(result.answers) == 0
@pytest.mark.asyncio
async def test_handles_other_response(tool, single_question_args):
expected_result = AskUserQuestionResult(
answers=[Answer(question="Which database?", answer="SQLite", is_other=True)],
cancelled=False,
)
async def mock_callback(args):
return expected_result
result = await run_tool_with_callback(tool, single_question_args, mock_callback)
assert result is not None
assert result.answers[0].is_other is True
assert result.answers[0].answer == "SQLite"
class TestToolUIDisplay:
def test_get_call_display_single_question(self, single_question_args):
event = ToolCallEvent(
tool_name="ask_user_question",
tool_class=AskUserQuestion,
args=single_question_args,
tool_call_id="123",
)
display = AskUserQuestion.get_call_display(event)
assert "Which database?" in display.summary
def test_get_call_display_multiple_questions(self, multi_question_args):
event = ToolCallEvent(
tool_name="ask_user_question",
tool_class=AskUserQuestion,
args=multi_question_args,
tool_call_id="123",
)
display = AskUserQuestion.get_call_display(event)
assert "2 questions" in display.summary
def test_get_result_display_success(self):
result = AskUserQuestionResult(
answers=[Answer(question="Q?", answer="A", is_other=False)], cancelled=False
)
event = ToolResultEvent(
tool_name="ask_user_question",
tool_class=AskUserQuestion,
result=result,
tool_call_id="123",
)
display = AskUserQuestion.get_result_display(event)
assert display.success is True
assert "A" in display.message
def test_get_result_display_cancelled(self):
result = AskUserQuestionResult(answers=[], cancelled=True)
event = ToolResultEvent(
tool_name="ask_user_question",
tool_class=AskUserQuestion,
result=result,
tool_call_id="123",
)
display = AskUserQuestion.get_result_display(event)
assert display.success is False
assert "cancelled" in display.message.lower()
def test_get_result_display_other(self):
result = AskUserQuestionResult(
answers=[Answer(question="Q?", answer="Custom", is_other=True)],
cancelled=False,
)
event = ToolResultEvent(
tool_name="ask_user_question",
tool_class=AskUserQuestion,
result=result,
tool_call_id="123",
)
display = AskUserQuestion.get_result_display(event)
assert display.success is True
assert "(Other)" in display.message

View file

@ -2,19 +2,21 @@ from __future__ import annotations
import pytest
from tests.mock.utils import collect_result
from vibe.core.tools.base import BaseToolState, ToolError, ToolPermission
from vibe.core.tools.builtins.bash import Bash, BashArgs, BashToolConfig
@pytest.fixture
def bash(tmp_path):
config = BashToolConfig(workdir=tmp_path)
def bash(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = BashToolConfig()
return Bash(config=config, state=BaseToolState())
@pytest.mark.asyncio
async def test_runs_echo_successfully(bash):
result = await bash.run(BashArgs(command="echo hello"))
result = await collect_result(bash.run(BashArgs(command="echo hello")))
assert result.returncode == 0
assert result.stdout == "hello\n"
@ -24,7 +26,7 @@ async def test_runs_echo_successfully(bash):
@pytest.mark.asyncio
async def test_fails_cat_command_with_missing_file(bash):
with pytest.raises(ToolError) as err:
await bash.run(BashArgs(command="cat missing_file.txt"))
await collect_result(bash.run(BashArgs(command="cat missing_file.txt")))
message = str(err.value)
assert "Command failed" in message
@ -33,11 +35,12 @@ async def test_fails_cat_command_with_missing_file(bash):
@pytest.mark.asyncio
async def test_uses_effective_workdir(tmp_path):
config = BashToolConfig(workdir=tmp_path)
async def test_uses_effective_workdir(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = BashToolConfig()
bash_tool = Bash(config=config, state=BaseToolState())
result = await bash_tool.run(BashArgs(command="pwd"))
result = await collect_result(bash_tool.run(BashArgs(command="pwd")))
assert result.stdout.strip() == str(tmp_path)
@ -45,17 +48,19 @@ async def test_uses_effective_workdir(tmp_path):
@pytest.mark.asyncio
async def test_handles_timeout(bash):
with pytest.raises(ToolError) as err:
await bash.run(BashArgs(command="sleep 2", timeout=1))
await collect_result(bash.run(BashArgs(command="sleep 2", timeout=1)))
assert "Command timed out after 1s" in str(err.value)
@pytest.mark.asyncio
async def test_truncates_output_to_max_bytes(bash):
config = BashToolConfig(workdir=None, max_output_bytes=5)
config = BashToolConfig(max_output_bytes=5)
bash_tool = Bash(config=config, state=BaseToolState())
result = await bash_tool.run(BashArgs(command="printf 'abcdefghij'"))
result = await collect_result(
bash_tool.run(BashArgs(command="printf 'abcdefghij'"))
)
assert result.stdout == "abcde"
assert result.stderr == ""
@ -64,7 +69,7 @@ async def test_truncates_output_to_max_bytes(bash):
@pytest.mark.asyncio
async def test_decodes_non_utf8_bytes(bash):
result = await bash.run(BashArgs(command="printf '\\xff\\xfe'"))
result = await collect_result(bash.run(BashArgs(command="printf '\\xff\\xfe'")))
# accept both possible encodings, as some shells emit escaped bytes as literal strings
assert result.stdout in {"<EFBFBD><EFBFBD>", "\xff\xfe", r"\xff\xfe"}

View file

@ -4,6 +4,7 @@ import shutil
import pytest
from tests.mock.utils import collect_result
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.grep import (
Grep,
@ -15,13 +16,15 @@ from vibe.core.tools.builtins.grep import (
@pytest.fixture
def grep(tmp_path):
config = GrepToolConfig(workdir=tmp_path)
def grep(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = GrepToolConfig()
return Grep(config=config, state=GrepState())
@pytest.fixture
def grep_gnu_only(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
original_which = shutil.which
def mock_which(cmd):
@ -30,7 +33,7 @@ def grep_gnu_only(tmp_path, monkeypatch):
return original_which(cmd)
monkeypatch.setattr("shutil.which", mock_which)
config = GrepToolConfig(workdir=tmp_path)
config = GrepToolConfig()
return Grep(config=config, state=GrepState())
@ -66,7 +69,7 @@ def test_raises_error_if_no_grep_available(grep, monkeypatch):
async def test_finds_pattern_in_file(grep, tmp_path):
(tmp_path / "test.py").write_text("def hello():\n print('world')\n")
result = await grep.run(GrepArgs(pattern="hello"))
result = await collect_result(grep.run(GrepArgs(pattern="hello")))
assert result.match_count == 1
assert "hello" in result.matches
@ -78,7 +81,7 @@ async def test_finds_pattern_in_file(grep, tmp_path):
async def test_finds_multiple_matches(grep, tmp_path):
(tmp_path / "test.py").write_text("foo\nbar\nfoo\nbaz\nfoo\n")
result = await grep.run(GrepArgs(pattern="foo"))
result = await collect_result(grep.run(GrepArgs(pattern="foo")))
assert result.match_count == 3
assert result.matches.count("foo") == 3
@ -89,7 +92,7 @@ async def test_finds_multiple_matches(grep, tmp_path):
async def test_returns_empty_on_no_matches(grep, tmp_path):
(tmp_path / "test.py").write_text("def hello():\n pass\n")
result = await grep.run(GrepArgs(pattern="nonexistent"))
result = await collect_result(grep.run(GrepArgs(pattern="nonexistent")))
assert result.match_count == 0
assert result.matches == ""
@ -99,7 +102,7 @@ async def test_returns_empty_on_no_matches(grep, tmp_path):
@pytest.mark.asyncio
async def test_fails_with_empty_pattern(grep):
with pytest.raises(ToolError) as err:
await grep.run(GrepArgs(pattern=""))
await collect_result(grep.run(GrepArgs(pattern="")))
assert "Empty search pattern" in str(err.value)
@ -107,7 +110,7 @@ async def test_fails_with_empty_pattern(grep):
@pytest.mark.asyncio
async def test_fails_with_nonexistent_path(grep):
with pytest.raises(ToolError) as err:
await grep.run(GrepArgs(pattern="test", path="nonexistent"))
await collect_result(grep.run(GrepArgs(pattern="test", path="nonexistent")))
assert "Path does not exist" in str(err.value)
@ -119,7 +122,7 @@ async def test_searches_in_specific_path(grep, tmp_path):
(subdir / "test.py").write_text("match here\n")
(tmp_path / "other.py").write_text("match here too\n")
result = await grep.run(GrepArgs(pattern="match", path="subdir"))
result = await collect_result(grep.run(GrepArgs(pattern="match", path="subdir")))
assert result.match_count == 1
assert "subdir" in result.matches and "test.py" in result.matches
@ -130,19 +133,20 @@ async def test_searches_in_specific_path(grep, tmp_path):
async def test_truncates_to_max_matches(grep, tmp_path):
(tmp_path / "test.py").write_text("\n".join(f"line {i}" for i in range(200)))
result = await grep.run(GrepArgs(pattern="line", max_matches=50))
result = await collect_result(grep.run(GrepArgs(pattern="line", max_matches=50)))
assert result.match_count == 50
assert result.was_truncated
@pytest.mark.asyncio
async def test_truncates_to_max_output_bytes(grep, tmp_path):
config = GrepToolConfig(workdir=tmp_path, max_output_bytes=100)
async def test_truncates_to_max_output_bytes(grep, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = GrepToolConfig(max_output_bytes=100)
grep_tool = Grep(config=config, state=GrepState())
(tmp_path / "test.py").write_text("\n".join("x" * 100 for _ in range(10)))
result = await grep_tool.run(GrepArgs(pattern="x"))
result = await collect_result(grep_tool.run(GrepArgs(pattern="x")))
assert len(result.matches) <= 100
assert result.was_truncated
@ -155,7 +159,7 @@ async def test_respects_default_ignore_patterns(grep, tmp_path):
node_modules.mkdir()
(node_modules / "excluded.js").write_text("match\n")
result = await grep.run(GrepArgs(pattern="match"))
result = await collect_result(grep.run(GrepArgs(pattern="match")))
assert "included.py" in result.matches
assert "excluded.js" not in result.matches
@ -170,7 +174,7 @@ async def test_respects_vibeignore_file(grep, tmp_path):
(tmp_path / "excluded.tmp").write_text("match\n")
(tmp_path / "included.py").write_text("match\n")
result = await grep.run(GrepArgs(pattern="match"))
result = await collect_result(grep.run(GrepArgs(pattern="match")))
assert "included.py" in result.matches
assert "excluded.py" not in result.matches
@ -182,7 +186,7 @@ async def test_ignores_comments_in_vibeignore(grep, tmp_path):
(tmp_path / ".vibeignore").write_text("# comment\npattern/\n# another comment\n")
(tmp_path / "file.py").write_text("match\n")
result = await grep.run(GrepArgs(pattern="match"))
result = await collect_result(grep.run(GrepArgs(pattern="match")))
assert result.match_count >= 1
@ -191,20 +195,21 @@ async def test_ignores_comments_in_vibeignore(grep, tmp_path):
async def test_tracks_search_history(grep, tmp_path):
(tmp_path / "test.py").write_text("content\n")
await grep.run(GrepArgs(pattern="first"))
await grep.run(GrepArgs(pattern="second"))
await grep.run(GrepArgs(pattern="third"))
await collect_result(grep.run(GrepArgs(pattern="first")))
await collect_result(grep.run(GrepArgs(pattern="second")))
await collect_result(grep.run(GrepArgs(pattern="third")))
assert grep.state.search_history == ["first", "second", "third"]
@pytest.mark.asyncio
async def test_uses_effective_workdir(tmp_path):
config = GrepToolConfig(workdir=tmp_path)
async def test_uses_effective_workdir(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = GrepToolConfig()
grep_tool = Grep(config=config, state=GrepState())
(tmp_path / "test.py").write_text("match\n")
result = await grep_tool.run(GrepArgs(pattern="match", path="."))
result = await collect_result(grep_tool.run(GrepArgs(pattern="match", path=".")))
assert result.match_count == 1
assert "test.py" in result.matches
@ -216,7 +221,7 @@ class TestGnuGrepBackend:
async def test_finds_pattern_in_file(self, grep_gnu_only, tmp_path):
(tmp_path / "test.py").write_text("def hello():\n print('world')\n")
result = await grep_gnu_only.run(GrepArgs(pattern="hello"))
result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="hello")))
assert result.match_count == 1
assert "hello" in result.matches
@ -226,7 +231,7 @@ class TestGnuGrepBackend:
async def test_finds_multiple_matches(self, grep_gnu_only, tmp_path):
(tmp_path / "test.py").write_text("foo\nbar\nfoo\nbaz\nfoo\n")
result = await grep_gnu_only.run(GrepArgs(pattern="foo"))
result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="foo")))
assert result.match_count == 3
assert result.matches.count("foo") == 3
@ -235,7 +240,9 @@ class TestGnuGrepBackend:
async def test_returns_empty_on_no_matches(self, grep_gnu_only, tmp_path):
(tmp_path / "test.py").write_text("def hello():\n pass\n")
result = await grep_gnu_only.run(GrepArgs(pattern="nonexistent"))
result = await collect_result(
grep_gnu_only.run(GrepArgs(pattern="nonexistent"))
)
assert result.match_count == 0
assert result.matches == ""
@ -246,7 +253,7 @@ class TestGnuGrepBackend:
):
(tmp_path / "test.py").write_text("Hello\nHELLO\nhello\n")
result = await grep_gnu_only.run(GrepArgs(pattern="hello"))
result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="hello")))
assert result.match_count == 3
@ -254,7 +261,7 @@ class TestGnuGrepBackend:
async def test_case_sensitive_for_mixed_case_pattern(self, grep_gnu_only, tmp_path):
(tmp_path / "test.py").write_text("Hello\nHELLO\nhello\n")
result = await grep_gnu_only.run(GrepArgs(pattern="Hello"))
result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="Hello")))
assert result.match_count == 1
@ -265,7 +272,7 @@ class TestGnuGrepBackend:
node_modules.mkdir()
(node_modules / "excluded.js").write_text("match\n")
result = await grep_gnu_only.run(GrepArgs(pattern="match"))
result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="match")))
assert "included.py" in result.matches
assert "excluded.js" not in result.matches
@ -277,7 +284,9 @@ class TestGnuGrepBackend:
(subdir / "test.py").write_text("match here\n")
(tmp_path / "other.py").write_text("match here too\n")
result = await grep_gnu_only.run(GrepArgs(pattern="match", path="subdir"))
result = await collect_result(
grep_gnu_only.run(GrepArgs(pattern="match", path="subdir"))
)
assert result.match_count == 1
assert "other.py" not in result.matches
@ -291,7 +300,7 @@ class TestGnuGrepBackend:
(tmp_path / "excluded.tmp").write_text("match\n")
(tmp_path / "included.py").write_text("match\n")
result = await grep_gnu_only.run(GrepArgs(pattern="match"))
result = await collect_result(grep_gnu_only.run(GrepArgs(pattern="match")))
assert "included.py" in result.matches
assert "excluded.py" not in result.matches
@ -301,7 +310,9 @@ class TestGnuGrepBackend:
async def test_truncates_to_max_matches(self, grep_gnu_only, tmp_path):
(tmp_path / "test.py").write_text("\n".join(f"line {i}" for i in range(200)))
result = await grep_gnu_only.run(GrepArgs(pattern="line", max_matches=50))
result = await collect_result(
grep_gnu_only.run(GrepArgs(pattern="line", max_matches=50))
)
assert result.match_count == 50
assert result.was_truncated
@ -313,7 +324,7 @@ class TestRipgrepBackend:
async def test_smart_case_lowercase_pattern(self, grep, tmp_path):
(tmp_path / "test.py").write_text("Hello\nHELLO\nhello\n")
result = await grep.run(GrepArgs(pattern="hello"))
result = await collect_result(grep.run(GrepArgs(pattern="hello")))
assert result.match_count == 3
@ -321,7 +332,7 @@ class TestRipgrepBackend:
async def test_smart_case_mixed_case_pattern(self, grep, tmp_path):
(tmp_path / "test.py").write_text("Hello\nHELLO\nhello\n")
result = await grep.run(GrepArgs(pattern="Hello"))
result = await collect_result(grep.run(GrepArgs(pattern="Hello")))
assert result.match_count == 1
@ -336,12 +347,12 @@ class TestRipgrepBackend:
(ignored_dir / "file.py").write_text("match\n")
(tmp_path / "included.py").write_text("match\n")
result_with_ignore = await grep.run(GrepArgs(pattern="match"))
result_with_ignore = await collect_result(grep.run(GrepArgs(pattern="match")))
assert "included.py" in result_with_ignore.matches
assert "ignored_by_rg" not in result_with_ignore.matches
result_without_ignore = await grep.run(
GrepArgs(pattern="match", use_default_ignore=False)
result_without_ignore = await collect_result(
grep.run(GrepArgs(pattern="match", use_default_ignore=False))
)
assert "included.py" in result_without_ignore.matches
assert "ignored_by_rg/file.py" in result_without_ignore.matches

View file

@ -0,0 +1,106 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from pydantic import BaseModel
import pytest
from tests.mock.utils import collect_result
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState, InvokeContext
from vibe.core.types import ApprovalCallback, ApprovalResponse, ToolStreamEvent
class SimpleArgs(BaseModel):
value: str
class SimpleResult(BaseModel):
output: str
had_context: bool
approval_callback_present: bool
class SimpleTool(BaseTool[SimpleArgs, SimpleResult, BaseToolConfig, BaseToolState]):
description = "A simple test tool"
async def run(
self, args: SimpleArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | SimpleResult, None]:
yield SimpleResult(
output=f"processed: {args.value}",
had_context=ctx is not None,
approval_callback_present=ctx is not None
and ctx.approval_callback is not None,
)
@pytest.fixture
def simple_tool() -> SimpleTool:
return SimpleTool(config=BaseToolConfig(), state=BaseToolState())
class TestInvokeContext:
def test_default_approval_callback_is_none(self) -> None:
ctx = InvokeContext(tool_call_id="test-call-id")
assert ctx.approval_callback is None
def test_approval_callback_can_be_set(self) -> None:
def dummy_callback(
_tool_name: str, _args: BaseModel, _tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
return ApprovalResponse.YES, None
callback: ApprovalCallback = dummy_callback
ctx = InvokeContext(tool_call_id="test-call-id", approval_callback=callback)
assert ctx.approval_callback is callback
class TestToolInvokeWithContext:
@pytest.mark.asyncio
async def test_invoke_without_context(self, simple_tool: SimpleTool) -> None:
result = await collect_result(simple_tool.invoke(value="test"))
assert result.output == "processed: test"
assert result.had_context is False
assert result.approval_callback_present is False
@pytest.mark.asyncio
async def test_invoke_with_empty_context(self, simple_tool: SimpleTool) -> None:
ctx = InvokeContext(tool_call_id="test-call-id")
result = await collect_result(simple_tool.invoke(ctx=ctx, value="test"))
assert result.output == "processed: test"
assert result.had_context is True
assert result.approval_callback_present is False
@pytest.mark.asyncio
async def test_invoke_with_approval_callback(self, simple_tool: SimpleTool) -> None:
def dummy_callback(
_tool_name: str, _args: BaseModel, _tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
return ApprovalResponse.YES, None
callback: ApprovalCallback = dummy_callback
ctx = InvokeContext(tool_call_id="test-call-id", approval_callback=callback)
result = await collect_result(simple_tool.invoke(ctx=ctx, value="test"))
assert result.output == "processed: test"
assert result.had_context is True
assert result.approval_callback_present is True
@pytest.mark.asyncio
async def test_run_receives_context(self, simple_tool: SimpleTool) -> None:
ctx = InvokeContext(tool_call_id="test-call-id")
result = await collect_result(
simple_tool.run(SimpleArgs(value="direct"), ctx=ctx)
)
assert result.had_context is True
@pytest.mark.asyncio
async def test_run_without_context_defaults_to_none(
self, simple_tool: SimpleTool
) -> None:
result = await collect_result(simple_tool.run(SimpleArgs(value="direct")))
assert result.had_context is False

View file

@ -1,5 +1,7 @@
from __future__ import annotations
from pathlib import Path
import pytest
from vibe.core.config import SessionLoggingConfig, VibeConfig
@ -27,7 +29,7 @@ def test_returns_default_config_when_no_overrides(tool_manager):
assert (
type(config).__name__ == "BashToolConfig"
) # due to vibe's discover system isinstance would fail
assert config.default_timeout == 30 # type: ignore[attr-defined]
assert config.default_timeout == 300 # type: ignore[attr-defined]
assert config.max_output_bytes == 16000 # type: ignore[attr-defined]
assert config.permission == ToolPermission.ASK
@ -47,7 +49,7 @@ def test_merges_user_overrides_with_defaults():
type(config).__name__ == "BashToolConfig"
) # due to vibe's discover system isinstance would fail
assert config.permission == ToolPermission.ALWAYS
assert config.default_timeout == 30 # type: ignore[attr-defined]
assert config.default_timeout == 300 # type: ignore[attr-defined]
def test_preserves_tool_specific_fields_from_overrides():
@ -73,16 +75,364 @@ def test_falls_back_to_base_config_for_unknown_tool(tool_manager):
assert config.permission == ToolPermission.ASK
def test_applies_workdir_from_vibe_config(tmp_path):
vibe_config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
workdir=tmp_path,
)
manager = ToolManager(lambda: vibe_config)
class TestToolManagerFiltering:
def test_enabled_tools_filters_to_only_enabled(self):
vibe_config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
enabled_tools=["bash", "grep"],
)
manager = ToolManager(lambda: vibe_config)
config = manager.get_tool_config("bash")
tools = manager.available_tools
assert len(tools) < len(manager._available)
assert "bash" in tools
assert "grep" in tools
assert "read_file" not in tools
assert "write_file" not in tools
assert config.workdir == tmp_path
assert config.effective_workdir == tmp_path
def test_disabled_tools_excludes_disabled(self):
vibe_config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
disabled_tools=["bash", "write_file"],
)
manager = ToolManager(lambda: vibe_config)
tools = manager.available_tools
assert len(tools) < len(manager._available)
assert "bash" not in tools
assert "write_file" not in tools
assert "grep" in tools
assert "read_file" in tools
def test_enabled_tools_takes_precedence_over_disabled(self):
vibe_config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
enabled_tools=["bash"],
disabled_tools=["bash"], # Should be ignored
)
manager = ToolManager(lambda: vibe_config)
tools = manager.available_tools
assert len(tools) == 1
assert "bash" in tools
def test_glob_pattern_matching(self):
vibe_config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
disabled_tools=["*_file"], # Matches read_file, write_file
)
manager = ToolManager(lambda: vibe_config)
tools = manager.available_tools
assert "read_file" not in tools
assert "write_file" not in tools
assert "bash" in tools
assert "grep" in tools
def test_regex_pattern_matching(self):
vibe_config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
enabled_tools=["re:^(bash|grep)$"],
)
manager = ToolManager(lambda: vibe_config)
tools = manager.available_tools
assert len(tools) == 2
assert "bash" in tools
assert "grep" in tools
def test_case_insensitive_matching(self):
vibe_config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
enabled_tools=["BASH", "GREP"],
)
manager = ToolManager(lambda: vibe_config)
tools = manager.available_tools
assert "bash" in tools
assert "grep" in tools
def test_empty_enabled_tools_returns_all(self):
vibe_config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
enabled_tools=[],
)
manager = ToolManager(lambda: vibe_config)
tools = manager.available_tools
assert "bash" in tools
assert "grep" in tools
assert "read_file" in tools
def test_tool_paths_with_file_and_directory(self, tmp_path: Path):
"""Should handle a mix of file and directory paths in tool_paths."""
import sys
# Create a directory with a tool
tool_dir = tmp_path / "tools"
tool_dir.mkdir()
(tool_dir / "dir_tool.py").write_text("""
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
from pydantic import BaseModel
from collections.abc import AsyncGenerator
class DirToolArgs(BaseModel):
pass
class DirToolResult(BaseModel):
pass
class DirTool(BaseTool[DirToolArgs, DirToolResult, BaseToolConfig, BaseToolState]):
description = "Tool from directory"
async def run(self, args, ctx=None):
yield DirToolResult()
""")
# Create a standalone tool file
file_tool = tmp_path / "file_tool.py"
file_tool.write_text("""
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
from pydantic import BaseModel
from collections.abc import AsyncGenerator
class FileToolArgs(BaseModel):
pass
class FileToolResult(BaseModel):
pass
class FileTool(BaseTool[FileToolArgs, FileToolResult, BaseToolConfig, BaseToolState]):
description = "Tool from file path"
async def run(self, args, ctx=None):
yield FileToolResult()
""")
# Clean up any previously loaded modules
to_remove = [k for k in sys.modules if "dir_tool" in k or "file_tool" in k]
for k in to_remove:
del sys.modules[k]
vibe_config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
tool_paths=[tool_dir, file_tool],
)
manager = ToolManager(lambda: vibe_config)
tools = manager.available_tools
assert "dir_tool" in tools
assert "file_tool" in tools
class TestToolManagerModuleReuse:
"""Tests for module reuse across ToolManager instances.
When multiple ToolManager instances are created (e.g., main agent + subagent),
they should reuse the same tool modules from sys.modules to preserve class identity.
This prevents Pydantic validation errors when tool results from one agent
are validated against types from another.
"""
def test_multiple_managers_share_tool_classes(self):
"""Tool classes should be identical across multiple ToolManager instances."""
vibe_config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
)
manager1 = ToolManager(lambda: vibe_config)
manager2 = ToolManager(lambda: vibe_config)
# Get the same tool class from both managers
todo_class1 = manager1.available_tools.get("todo")
todo_class2 = manager2.available_tools.get("todo")
assert todo_class1 is not None
assert todo_class2 is not None
# Class objects should be identical (same id), not just equal
assert todo_class1 is todo_class2
def test_tool_state_classes_are_identical(self):
"""Tool state classes should be identical across managers."""
vibe_config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
)
manager1 = ToolManager(lambda: vibe_config)
manager2 = ToolManager(lambda: vibe_config)
todo_class1 = manager1.available_tools["todo"]
todo_class2 = manager2.available_tools["todo"]
state_class1 = todo_class1._get_tool_state_class()
state_class2 = todo_class2._get_tool_state_class()
assert state_class1 is state_class2
def test_tool_args_results_classes_are_identical(self):
"""Tool args and result classes should be identical across managers."""
vibe_config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
)
manager1 = ToolManager(lambda: vibe_config)
manager2 = ToolManager(lambda: vibe_config)
todo_class1 = manager1.available_tools["todo"]
todo_class2 = manager2.available_tools["todo"]
args1, result1 = todo_class1._get_tool_args_results()
args2, result2 = todo_class2._get_tool_args_results()
assert args1 is args2
assert result1 is result2
def test_tool_instances_are_isolated(self):
"""Tool instances should be separate even though classes are shared.
This ensures subagents have isolated state (e.g., separate todo lists)
while still sharing class definitions for Pydantic validation.
"""
vibe_config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
)
manager1 = ToolManager(lambda: vibe_config)
manager2 = ToolManager(lambda: vibe_config)
# Get tool instances from each manager
tool1 = manager1.get("todo")
tool2 = manager2.get("todo")
# Instances should be different objects
assert tool1 is not tool2
# State should be different objects
assert tool1.state is not tool2.state
# Verify state is truly isolated by modifying one
from vibe.core.tools.builtins.todo import TodoItem
tool1.state.todos = [TodoItem(id="1", content="test")]
assert len(tool1.state.todos) == 1
assert len(tool2.state.todos) == 0 # Unaffected!
def test_class_shared_but_instances_isolated(self):
"""Classes must be shared (for validation) but instances isolated (for state)."""
vibe_config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
)
manager1 = ToolManager(lambda: vibe_config)
manager2 = ToolManager(lambda: vibe_config)
tool1 = manager1.get("todo")
tool2 = manager2.get("todo")
# Classes are shared (same object)
assert type(tool1) is type(tool2)
assert type(tool1.state) is type(tool2.state)
# But instances are different
assert tool1 is not tool2
assert tool1.state is not tool2.state
def test_different_files_same_stem_get_different_modules(self, tmp_path: Path):
"""Tools with same stem but different paths should be separate modules.
This ensures user tools can override builtins - a custom todo.py in
~/.config/vibe/tools/ should override the builtin todo.py.
"""
import sys
# Create two tool files with the same stem but different content
dir1 = tmp_path / "tools1"
dir2 = tmp_path / "tools2"
dir1.mkdir()
dir2.mkdir()
tool_code_v1 = """
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
from pydantic import BaseModel
from collections.abc import AsyncGenerator
class DummyArgs(BaseModel):
value: str
class DummyResult(BaseModel):
version: str = "v1"
class DummyTool(BaseTool[DummyArgs, DummyResult, BaseToolConfig, BaseToolState]):
description = "Dummy tool v1"
async def run(self, args: DummyArgs, ctx=None) -> AsyncGenerator[DummyResult, None]:
yield DummyResult(version="v1")
"""
tool_code_v2 = """
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
from pydantic import BaseModel
from collections.abc import AsyncGenerator
class DummyArgs(BaseModel):
value: str
class DummyResult(BaseModel):
version: str = "v2"
class DummyTool(BaseTool[DummyArgs, DummyResult, BaseToolConfig, BaseToolState]):
description = "Dummy tool v2"
async def run(self, args: DummyArgs, ctx=None) -> AsyncGenerator[DummyResult, None]:
yield DummyResult(version="v2")
"""
(dir1 / "dummy.py").write_text(tool_code_v1)
(dir2 / "dummy.py").write_text(tool_code_v2)
# Clean up any previously loaded dummy modules
to_remove = [k for k in sys.modules if "dummy" in k]
for k in to_remove:
del sys.modules[k]
# Load tools from both directories (dir2 comes after, should override)
classes = list(ToolManager._iter_tool_classes([dir1, dir2]))
dummy_classes = [c for c in classes if "dummy" in c.get_name().lower()]
# Should have 2 separate classes (from different modules)
assert len(dummy_classes) == 2
# They should be different class objects
assert dummy_classes[0] is not dummy_classes[1]
# When put in a dict (like _available), the second one wins
available = {c.get_name(): c for c in classes}
final_class = available.get("dummy_tool")
assert final_class is not None
assert final_class.description == "Dummy tool v2"

308
tests/tools/test_mcp.py Normal file
View file

@ -0,0 +1,308 @@
from __future__ import annotations
from unittest.mock import MagicMock
from pydantic import ValidationError
import pytest
from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp
from vibe.core.tools.mcp import (
MCPToolResult,
RemoteTool,
_parse_call_result,
create_mcp_http_proxy_tool_class,
create_mcp_stdio_proxy_tool_class,
)
class TestRemoteTool:
def test_creates_remote_tool_with_valid_data(self):
tool = RemoteTool.model_validate({
"name": "test_tool",
"description": "A test tool",
"inputSchema": {
"type": "object",
"properties": {"arg": {"type": "string"}},
},
})
assert tool.name == "test_tool"
assert tool.description == "A test tool"
assert tool.input_schema == {
"type": "object",
"properties": {"arg": {"type": "string"}},
}
def test_uses_default_schema_when_none_provided(self):
tool = RemoteTool(name="test_tool")
assert tool.input_schema == {"type": "object", "properties": {}}
def test_rejects_empty_name(self):
with pytest.raises(ValueError, match="MCP tool missing valid 'name'"):
RemoteTool(name="")
def test_rejects_whitespace_only_name(self):
with pytest.raises(ValueError, match="MCP tool missing valid 'name'"):
RemoteTool(name=" ")
def test_normalizes_schema_from_object_with_model_dump(self):
mock_schema = MagicMock()
mock_schema.model_dump.return_value = {"type": "string"}
tool = RemoteTool.model_validate({"name": "test", "inputSchema": mock_schema})
assert tool.input_schema == {"type": "string"}
def test_rejects_invalid_input_schema(self):
with pytest.raises(ValueError, match="inputSchema must be a dict"):
RemoteTool.model_validate({"name": "test", "inputSchema": 12345})
class TestMCPToolResult:
def test_creates_result_with_text(self):
result = MCPToolResult(server="test_server", tool="test_tool", text="output")
assert result.ok is True
assert result.server == "test_server"
assert result.tool == "test_tool"
assert result.text == "output"
assert result.structured is None
def test_creates_result_with_structured_content(self):
result = MCPToolResult(
server="test_server", tool="test_tool", structured={"key": "value"}
)
assert result.structured == {"key": "value"}
assert result.text is None
class TestParseCallResult:
def test_parses_text_content(self):
mock_result = MagicMock()
mock_result.structuredContent = None
mock_result.content = [MagicMock(text="Hello world")]
result = _parse_call_result("server", "tool", mock_result)
assert result.server == "server"
assert result.tool == "tool"
assert result.text == "Hello world"
assert result.structured is None
def test_parses_structured_content(self):
mock_result = MagicMock()
mock_result.structuredContent = {"data": "value"}
mock_result.content = None
result = _parse_call_result("server", "tool", mock_result)
assert result.structured == {"data": "value"}
assert result.text is None
def test_prefers_structured_over_text(self):
mock_result = MagicMock()
mock_result.structuredContent = {"data": "value"}
mock_result.content = [MagicMock(text="text content")]
result = _parse_call_result("server", "tool", mock_result)
assert result.structured == {"data": "value"}
assert result.text is None
def test_joins_multiple_text_blocks(self):
mock_result = MagicMock()
mock_result.structuredContent = None
mock_result.content = [MagicMock(text="line1"), MagicMock(text="line2")]
result = _parse_call_result("server", "tool", mock_result)
assert result.text == "line1\nline2"
class TestCreateMCPHttpProxyToolClass:
def test_creates_tool_class_with_correct_name(self):
remote = RemoteTool(name="my_tool", description="Test tool")
tool_cls = create_mcp_http_proxy_tool_class(
url="http://localhost:8080", remote=remote, alias="test_server"
)
assert tool_cls.get_name() == "test_server_my_tool"
def test_creates_tool_class_with_url_based_alias(self):
remote = RemoteTool(name="my_tool")
tool_cls = create_mcp_http_proxy_tool_class(
url="http://localhost:8080", remote=remote
)
assert tool_cls.get_name() == "localhost_8080_my_tool"
def test_includes_description_with_hint(self):
remote = RemoteTool(name="my_tool", description="Base description")
tool_cls = create_mcp_http_proxy_tool_class(
url="http://localhost:8080",
remote=remote,
alias="test",
server_hint="Use this for testing",
)
assert "[test]" in tool_cls.description
assert "Base description" in tool_cls.description
assert "Hint: Use this for testing" in tool_cls.description
def test_stores_timeout_settings(self):
remote = RemoteTool(name="my_tool")
tool_cls = create_mcp_http_proxy_tool_class(
url="http://localhost:8080",
remote=remote,
startup_timeout_sec=30.0,
tool_timeout_sec=120.0,
)
assert tool_cls._startup_timeout_sec == 30.0 # type: ignore[attr-defined]
assert tool_cls._tool_timeout_sec == 120.0 # type: ignore[attr-defined]
def test_returns_correct_parameters(self):
remote = RemoteTool.model_validate({
"name": "my_tool",
"inputSchema": {
"type": "object",
"properties": {"arg": {"type": "string"}},
},
})
tool_cls = create_mcp_http_proxy_tool_class(
url="http://localhost:8080", remote=remote
)
params = tool_cls.get_parameters()
assert params == {"type": "object", "properties": {"arg": {"type": "string"}}}
class TestCreateMCPStdioProxyToolClass:
def test_creates_tool_class_with_alias(self):
remote = RemoteTool(name="my_tool")
tool_cls = create_mcp_stdio_proxy_tool_class(
command=["python", "-m", "mcp_server"], remote=remote, alias="my_server"
)
assert tool_cls.get_name() == "my_server_my_tool"
def test_creates_tool_class_with_command_based_alias(self):
remote = RemoteTool(name="my_tool")
tool_cls = create_mcp_stdio_proxy_tool_class(
command=["python", "-m", "mcp_server"], remote=remote
)
name = tool_cls.get_name()
assert name.startswith("python_")
assert name.endswith("_my_tool")
def test_stores_env_settings(self):
remote = RemoteTool(name="my_tool")
tool_cls = create_mcp_stdio_proxy_tool_class(
command=["python", "-m", "mcp_server"],
remote=remote,
env={"API_KEY": "secret"},
)
assert tool_cls._env == {"API_KEY": "secret"} # type: ignore[attr-defined]
def test_stores_timeout_settings(self):
remote = RemoteTool(name="my_tool")
tool_cls = create_mcp_stdio_proxy_tool_class(
command=["python", "-m", "mcp_server"],
remote=remote,
startup_timeout_sec=15.0,
tool_timeout_sec=90.0,
)
assert tool_cls._startup_timeout_sec == 15.0 # type: ignore[attr-defined]
assert tool_cls._tool_timeout_sec == 90.0 # type: ignore[attr-defined]
def test_includes_hint_in_description(self):
remote = RemoteTool(name="my_tool", description="Base description")
tool_cls = create_mcp_stdio_proxy_tool_class(
command=["python"],
remote=remote,
alias="test",
server_hint="For testing only",
)
assert "Hint: For testing only" in tool_cls.description
class TestMCPConfigModels:
def test_mcp_base_default_timeouts(self):
config = MCPStdio(
name="test", transport="stdio", command="python -m test_server"
)
assert config.startup_timeout_sec == 10.0
assert config.tool_timeout_sec == 60.0
def test_mcp_base_custom_timeouts(self):
config = MCPStdio(
name="test",
transport="stdio",
command="python -m test_server",
startup_timeout_sec=30.0,
tool_timeout_sec=120.0,
)
assert config.startup_timeout_sec == 30.0
assert config.tool_timeout_sec == 120.0
def test_mcp_base_rejects_non_positive_timeout(self):
with pytest.raises(ValidationError):
MCPStdio(
name="test", transport="stdio", command="python", startup_timeout_sec=0
)
def test_mcp_stdio_with_env(self):
config = MCPStdio(
name="test",
transport="stdio",
command="python -m server",
env={"API_KEY": "secret", "DEBUG": "1"},
)
assert config.env == {"API_KEY": "secret", "DEBUG": "1"}
def test_mcp_stdio_argv_with_string_command(self):
config = MCPStdio(
name="test", transport="stdio", command="python -m server --port 8080"
)
assert config.argv() == ["python", "-m", "server", "--port", "8080"]
def test_mcp_stdio_argv_with_list_command(self):
config = MCPStdio(
name="test",
transport="stdio",
command=["python", "-m", "server"],
args=["--port", "8080"],
)
assert config.argv() == ["python", "-m", "server", "--port", "8080"]
def test_mcp_http_default_timeouts(self):
config = MCPHttp(name="test", transport="http", url="http://localhost:8080")
assert config.startup_timeout_sec == 10.0
assert config.tool_timeout_sec == 60.0
def test_mcp_streamable_http_default_timeouts(self):
config = MCPStreamableHttp(
name="test", transport="streamable-http", url="http://localhost:8080"
)
assert config.startup_timeout_sec == 10.0
assert config.tool_timeout_sec == 60.0
def test_mcp_name_normalization(self):
config = MCPStdio(name="my server!@#$%", transport="stdio", command="python")
# Trailing special chars become underscores which are then stripped
assert config.name == "my_server"

164
tests/tools/test_task.py Normal file
View file

@ -0,0 +1,164 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from tests.mock.utils import collect_result
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import BUILTIN_AGENTS, AgentType
from vibe.core.config import VibeConfig
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
from vibe.core.tools.builtins.task import Task, TaskArgs, TaskResult, TaskToolConfig
from vibe.core.types import AssistantEvent, LLMMessage, Role
@pytest.fixture
def task_tool() -> Task:
return Task(config=TaskToolConfig(), state=BaseToolState())
class TestTaskArgs:
def test_default_agent_is_explore(self) -> None:
args = TaskArgs(task="do something")
assert args.agent == "explore"
def test_custom_values(self) -> None:
args = TaskArgs(task="do something", agent="explore")
assert args.task == "do something"
assert args.agent == "explore"
class TestTaskToolValidation:
@pytest.fixture
def ctx(self) -> InvokeContext:
config = VibeConfig(include_project_context=False, include_prompt_detail=False)
manager = AgentManager(lambda: config)
return InvokeContext(tool_call_id="test-call-id", agent_manager=manager)
@pytest.mark.asyncio
async def test_rejects_primary_agent(
self, task_tool: Task, ctx: InvokeContext
) -> None:
args = TaskArgs(task="do something", agent="default")
with pytest.raises(ToolError) as exc_info:
await collect_result(task_tool.run(args, ctx))
assert "agent" in str(exc_info.value).lower()
assert "subagent" in str(exc_info.value).lower()
@pytest.mark.asyncio
async def test_rejects_nonexistent_agent(
self, task_tool: Task, ctx: InvokeContext
) -> None:
args = TaskArgs(task="do something", agent="nonexistent")
with pytest.raises(ToolError) as exc_info:
await collect_result(task_tool.run(args, ctx))
assert "Unknown agent" in str(exc_info.value)
@pytest.mark.asyncio
async def test_requires_agent_manager_in_context(self, task_tool: Task) -> None:
args = TaskArgs(task="do something", agent="explore")
ctx = InvokeContext(tool_call_id="test-call-id") # No agent_manager
with pytest.raises(ToolError) as exc_info:
await collect_result(task_tool.run(args, ctx))
assert "agent_manager" in str(exc_info.value).lower()
def test_explore_agent_is_valid_subagent(self) -> None:
agent = BUILTIN_AGENTS["explore"]
assert agent.agent_type == AgentType.SUBAGENT
class TestTaskToolExecution:
@pytest.fixture
def ctx(self) -> InvokeContext:
config = VibeConfig(include_project_context=False, include_prompt_detail=False)
manager = AgentManager(lambda: config)
return InvokeContext(tool_call_id="test-call-id", agent_manager=manager)
@pytest.mark.asyncio
async def test_happy_path_returns_subagent_response(
self, task_tool: Task, ctx: InvokeContext
) -> None:
"""Test that task tool successfully runs a subagent and returns its response."""
mock_messages = [
LLMMessage(role=Role.system, content="system"),
LLMMessage(role=Role.user, content="task"),
LLMMessage(role=Role.assistant, content="response 1"),
LLMMessage(role=Role.assistant, content="response 2"),
]
async def mock_act(task: str):
yield AssistantEvent(content="Hello from subagent!")
yield AssistantEvent(content=" More content.")
with patch("vibe.core.tools.builtins.task.AgentLoop") as mock_agent_loop_class:
mock_agent_loop = MagicMock()
mock_agent_loop.act = mock_act
mock_agent_loop.messages = mock_messages
mock_agent_loop.set_approval_callback = MagicMock()
mock_agent_loop_class.return_value = mock_agent_loop
args = TaskArgs(task="explore the codebase", agent="explore")
result = await collect_result(task_tool.run(args, ctx))
assert isinstance(result, TaskResult)
assert result.response == "Hello from subagent! More content."
assert result.turns_used == 2 # 2 assistant messages in mock_messages
assert result.completed is True
@pytest.mark.asyncio
async def test_handles_stopped_by_middleware(
self, task_tool: Task, ctx: InvokeContext
) -> None:
"""Test that task tool reports incomplete when stopped by middleware."""
mock_messages = [
LLMMessage(role=Role.system, content="system"),
LLMMessage(role=Role.assistant, content="partial"),
]
async def mock_act(task: str):
yield AssistantEvent(content="Partial response", stopped_by_middleware=True)
with patch("vibe.core.tools.builtins.task.AgentLoop") as mock_agent_loop_class:
mock_agent_loop = MagicMock()
mock_agent_loop.act = mock_act
mock_agent_loop.messages = mock_messages
mock_agent_loop.set_approval_callback = MagicMock()
mock_agent_loop_class.return_value = mock_agent_loop
args = TaskArgs(task="do something", agent="explore")
result = await collect_result(task_tool.run(args, ctx))
assert isinstance(result, TaskResult)
assert result.completed is False
@pytest.mark.asyncio
async def test_handles_subagent_exception(
self, task_tool: Task, ctx: InvokeContext
) -> None:
"""Test that task tool gracefully handles exceptions from subagent."""
mock_messages = [LLMMessage(role=Role.system, content="system")]
async def mock_act(task: str):
yield AssistantEvent(content="Starting...")
raise RuntimeError("Simulated error")
with patch("vibe.core.tools.builtins.task.AgentLoop") as mock_agent_loop_class:
mock_agent_loop = MagicMock()
mock_agent_loop.act = mock_act
mock_agent_loop.messages = mock_messages
mock_agent_loop.set_approval_callback = MagicMock()
mock_agent_loop_class.return_value = mock_agent_loop
args = TaskArgs(task="do something", agent="explore")
result = await collect_result(task_tool.run(args, ctx))
assert isinstance(result, TaskResult)
assert result.completed is False
assert "Simulated error" in result.response

View file

@ -9,19 +9,20 @@ from textual.widgets import Static
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.messages import BashOutputMessage, ErrorMessage
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
@pytest.fixture
def vibe_config(tmp_path: Path) -> VibeConfig:
return VibeConfig(
session_logging=SessionLoggingConfig(enabled=False), workdir=tmp_path
)
def vibe_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> VibeConfig:
monkeypatch.chdir(tmp_path)
return VibeConfig(session_logging=SessionLoggingConfig(enabled=False))
@pytest.fixture
def vibe_app(vibe_config: VibeConfig) -> VibeApp:
return VibeApp(config=vibe_config)
agent_loop = AgentLoop(vibe_config)
return VibeApp(agent_loop=agent_loop)
async def _wait_for_bash_output_message(