2.0.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: 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:
parent
79f215d91c
commit
d33db9fff8
217 changed files with 16911 additions and 4305 deletions
188
tests/tools/test_ask_user_question.py
Normal file
188
tests/tools/test_ask_user_question.py
Normal 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
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
106
tests/tools/test_invoke_context.py
Normal file
106
tests/tools/test_invoke_context.py
Normal 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
|
||||
|
|
@ -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
308
tests/tools/test_mcp.py
Normal 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
164
tests/tools/test_task.py
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue