Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai> Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com> Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
362 lines
12 KiB
Python
362 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from tests.stubs.fake_tool import FakeTool, FakeToolArgs
|
|
import vibe.cli.textual_ui.handlers.event_handler as event_handler_module
|
|
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
|
|
from vibe.cli.textual_ui.widgets.messages import HookSystemMessageLine
|
|
from vibe.core.hooks.models import (
|
|
HookEndEvent,
|
|
HookMessageSeverity,
|
|
HookRunEndEvent,
|
|
HookRunStartEvent,
|
|
HookStartEvent,
|
|
HookType,
|
|
)
|
|
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
|
|
|
|
|
class FakeHookRunContainer:
|
|
def __init__(self) -> None:
|
|
self.display = False
|
|
self.remove = AsyncMock()
|
|
self.messages: list[HookSystemMessageLine] = []
|
|
self.classes: set[str] = set()
|
|
|
|
def add_class(self, cls: str) -> None:
|
|
self.classes.add(cls)
|
|
|
|
async def add_message(self, widget: HookSystemMessageLine) -> None:
|
|
self.display = True
|
|
self.messages.append(widget)
|
|
|
|
|
|
@pytest.fixture
|
|
def hook_container_factory(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> list[FakeHookRunContainer]:
|
|
created: list[FakeHookRunContainer] = []
|
|
|
|
def make_container() -> FakeHookRunContainer:
|
|
container = FakeHookRunContainer()
|
|
created.append(container)
|
|
return container
|
|
|
|
monkeypatch.setattr(event_handler_module, "HookRunContainer", make_container)
|
|
return created
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hook_run_end_removes_empty_container(
|
|
hook_container_factory: list[FakeHookRunContainer],
|
|
) -> None:
|
|
mount_callback = AsyncMock()
|
|
handler = EventHandler(
|
|
mount_callback=mount_callback, get_tools_collapsed=lambda: False
|
|
)
|
|
|
|
await handler.handle_event(HookRunStartEvent())
|
|
await handler.handle_event(HookRunEndEvent())
|
|
|
|
assert len(hook_container_factory) == 1
|
|
hook_container_factory[0].remove.assert_awaited_once()
|
|
assert "agent_turn" not in handler._hook_containers
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hook_run_end_keeps_container_with_messages(
|
|
hook_container_factory: list[FakeHookRunContainer],
|
|
) -> None:
|
|
mount_callback = AsyncMock()
|
|
handler = EventHandler(
|
|
mount_callback=mount_callback, get_tools_collapsed=lambda: False
|
|
)
|
|
|
|
await handler.handle_event(HookRunStartEvent())
|
|
await handler.handle_event(HookStartEvent(hook_name="post-turn"))
|
|
await handler.handle_event(
|
|
HookEndEvent(
|
|
hook_name="post-turn", status=HookMessageSeverity.OK, content="Hook output"
|
|
)
|
|
)
|
|
await handler.handle_event(HookRunEndEvent())
|
|
|
|
assert len(hook_container_factory) == 1
|
|
hook_container_factory[0].remove.assert_not_awaited()
|
|
assert hook_container_factory[0].display is True
|
|
assert "agent_turn" not in handler._hook_containers
|
|
|
|
|
|
def _tool_call_event(call_id: str) -> ToolCallEvent:
|
|
return ToolCallEvent(
|
|
tool_name="stub_tool",
|
|
tool_class=FakeTool,
|
|
args=FakeToolArgs(),
|
|
tool_call_id=call_id,
|
|
)
|
|
|
|
|
|
def _tool_result_event(call_id: str) -> ToolResultEvent:
|
|
return ToolResultEvent(
|
|
tool_name="stub_tool",
|
|
tool_class=FakeTool,
|
|
result=None,
|
|
cancelled=False,
|
|
tool_call_id=call_id,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_before_tool_container_scoped_to_tool_call_id(
|
|
hook_container_factory: list[FakeHookRunContainer],
|
|
) -> None:
|
|
"""Two concurrent tool calls each get their own before_tool container."""
|
|
mount_callback = AsyncMock()
|
|
handler = EventHandler(
|
|
mount_callback=mount_callback, get_tools_collapsed=lambda: False
|
|
)
|
|
|
|
# Two tool calls arrive
|
|
await handler.handle_event(_tool_call_event("call_A"))
|
|
await handler.handle_event(_tool_call_event("call_B"))
|
|
|
|
# before_tool starts for both, interleaved
|
|
await handler.handle_event(
|
|
HookRunStartEvent(
|
|
scope=HookType.BEFORE_TOOL, tool_name="stub_tool", tool_call_id="call_A"
|
|
)
|
|
)
|
|
await handler.handle_event(
|
|
HookRunStartEvent(
|
|
scope=HookType.BEFORE_TOOL, tool_name="stub_tool", tool_call_id="call_B"
|
|
)
|
|
)
|
|
|
|
# Two distinct containers were created.
|
|
assert len(hook_container_factory) == 2
|
|
assert "before_tool:call_A" in handler._hook_containers
|
|
assert "before_tool:call_B" in handler._hook_containers
|
|
assert (
|
|
handler._hook_containers["before_tool:call_A"]
|
|
is not handler._hook_containers["before_tool:call_B"]
|
|
)
|
|
|
|
# End them — both are empty, both should be removed.
|
|
await handler.handle_event(
|
|
HookRunEndEvent(scope=HookType.BEFORE_TOOL, tool_call_id="call_A")
|
|
)
|
|
await handler.handle_event(
|
|
HookRunEndEvent(scope=HookType.BEFORE_TOOL, tool_call_id="call_B")
|
|
)
|
|
assert "before_tool:call_A" not in handler._hook_containers
|
|
assert "before_tool:call_B" not in handler._hook_containers
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_tool_container_anchors_after_tool_result(
|
|
hook_container_factory: list[FakeHookRunContainer],
|
|
) -> None:
|
|
mount_callback = AsyncMock()
|
|
handler = EventHandler(
|
|
mount_callback=mount_callback, get_tools_collapsed=lambda: False
|
|
)
|
|
|
|
await handler.handle_event(_tool_call_event("call_X"))
|
|
await handler.handle_event(_tool_result_event("call_X"))
|
|
# After-tool start mounts after the tool result, which is the current
|
|
# anchor for this tool_call_id.
|
|
await handler.handle_event(
|
|
HookRunStartEvent(
|
|
scope=HookType.AFTER_TOOL, tool_name="stub_tool", tool_call_id="call_X"
|
|
)
|
|
)
|
|
assert "after_tool:call_X" in handler._hook_containers
|
|
|
|
# mount_callback was called with after=<tool_result_widget> for the after_tool
|
|
# container. Inspect the last call's kwargs.
|
|
last_call = mount_callback.call_args_list[-1]
|
|
assert last_call.kwargs.get("after") is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_before_tool_container_for_unknown_tool_call_id(
|
|
hook_container_factory: list[FakeHookRunContainer],
|
|
) -> None:
|
|
mount_callback = AsyncMock()
|
|
handler = EventHandler(
|
|
mount_callback=mount_callback, get_tools_collapsed=lambda: False
|
|
)
|
|
|
|
await handler.handle_event(
|
|
HookRunStartEvent(
|
|
scope=HookType.BEFORE_TOOL, tool_name="bash", tool_call_id="unknown"
|
|
)
|
|
)
|
|
last_call = mount_callback.call_args_list[-1]
|
|
assert last_call.kwargs.get("after") is None
|
|
assert last_call.kwargs.get("before") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_before_tool_container_mounts_before_tool_call_widget(
|
|
hook_container_factory: list[FakeHookRunContainer],
|
|
) -> None:
|
|
mount_callback = AsyncMock()
|
|
handler = EventHandler(
|
|
mount_callback=mount_callback, get_tools_collapsed=lambda: False
|
|
)
|
|
|
|
await handler.handle_event(_tool_call_event("call_Y"))
|
|
tool_call_widget = handler._tool_call_anchors["call_Y"]
|
|
|
|
await handler.handle_event(
|
|
HookRunStartEvent(
|
|
scope=HookType.BEFORE_TOOL, tool_name="stub_tool", tool_call_id="call_Y"
|
|
)
|
|
)
|
|
|
|
last_call = mount_callback.call_args_list[-1]
|
|
assert last_call.kwargs.get("before") is tool_call_widget
|
|
assert last_call.kwargs.get("after") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_before_tool_run_end_keeps_tool_call_widget_as_anchor(
|
|
hook_container_factory: list[FakeHookRunContainer],
|
|
) -> None:
|
|
mount_callback = AsyncMock()
|
|
handler = EventHandler(
|
|
mount_callback=mount_callback, get_tools_collapsed=lambda: False
|
|
)
|
|
|
|
await handler.handle_event(_tool_call_event("call_Z"))
|
|
tool_call_widget = handler._tool_call_anchors["call_Z"]
|
|
|
|
await handler.handle_event(
|
|
HookRunStartEvent(
|
|
scope=HookType.BEFORE_TOOL, tool_name="stub_tool", tool_call_id="call_Z"
|
|
)
|
|
)
|
|
await handler.handle_event(HookStartEvent(hook_name="before"))
|
|
await handler.handle_event(
|
|
HookEndEvent(hook_name="before", status=HookMessageSeverity.OK, content="ok")
|
|
)
|
|
await handler.handle_event(
|
|
HookRunEndEvent(scope=HookType.BEFORE_TOOL, tool_call_id="call_Z")
|
|
)
|
|
|
|
# Even though the before_tool container has content and stays in the DOM,
|
|
# the anchor for the next widget (the tool result) must remain the call
|
|
# widget — the container lives *above* the call, not below it.
|
|
assert handler._tool_call_anchors["call_Z"] is tool_call_widget
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hook_end_routes_to_matching_container_when_chains_interleave(
|
|
hook_container_factory: list[FakeHookRunContainer],
|
|
) -> None:
|
|
mount_callback = AsyncMock()
|
|
handler = EventHandler(
|
|
mount_callback=mount_callback, get_tools_collapsed=lambda: False
|
|
)
|
|
|
|
await handler.handle_event(_tool_call_event("call_A"))
|
|
await handler.handle_event(_tool_call_event("call_B"))
|
|
|
|
await handler.handle_event(
|
|
HookRunStartEvent(
|
|
scope=HookType.BEFORE_TOOL, tool_name="stub_tool", tool_call_id="call_A"
|
|
)
|
|
)
|
|
assert len(hook_container_factory) == 1
|
|
container_a = hook_container_factory[-1]
|
|
|
|
await handler.handle_event(HookStartEvent(hook_name="hook_a"))
|
|
await handler.handle_event(
|
|
HookRunStartEvent(
|
|
scope=HookType.BEFORE_TOOL, tool_name="stub_tool", tool_call_id="call_B"
|
|
)
|
|
)
|
|
assert len(hook_container_factory) == 2
|
|
container_b = hook_container_factory[-1]
|
|
assert container_a is not container_b
|
|
|
|
await handler.handle_event(
|
|
HookEndEvent(
|
|
hook_name="hook_a",
|
|
status=HookMessageSeverity.OK,
|
|
content="from A",
|
|
scope=HookType.BEFORE_TOOL,
|
|
tool_call_id="call_A",
|
|
)
|
|
)
|
|
await handler.handle_event(HookStartEvent(hook_name="hook_b"))
|
|
await handler.handle_event(
|
|
HookEndEvent(
|
|
hook_name="hook_b",
|
|
status=HookMessageSeverity.OK,
|
|
content="from B",
|
|
scope=HookType.BEFORE_TOOL,
|
|
tool_call_id="call_B",
|
|
)
|
|
)
|
|
|
|
assert len(container_a.messages) == 1
|
|
assert len(container_b.messages) == 1
|
|
assert container_a.messages[0]._content == "from A"
|
|
assert container_b.messages[0]._content == "from B"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hook_end_after_other_chain_ended_still_routes_correctly(
|
|
hook_container_factory: list[FakeHookRunContainer],
|
|
) -> None:
|
|
mount_callback = AsyncMock()
|
|
handler = EventHandler(
|
|
mount_callback=mount_callback, get_tools_collapsed=lambda: False
|
|
)
|
|
|
|
await handler.handle_event(_tool_call_event("call_A"))
|
|
await handler.handle_event(_tool_call_event("call_B"))
|
|
await handler.handle_event(
|
|
HookRunStartEvent(
|
|
scope=HookType.BEFORE_TOOL, tool_name="stub_tool", tool_call_id="call_A"
|
|
)
|
|
)
|
|
container_a = hook_container_factory[-1]
|
|
await handler.handle_event(HookStartEvent(hook_name="hook_a"))
|
|
|
|
await handler.handle_event(
|
|
HookRunStartEvent(
|
|
scope=HookType.BEFORE_TOOL, tool_name="stub_tool", tool_call_id="call_B"
|
|
)
|
|
)
|
|
await handler.handle_event(HookStartEvent(hook_name="hook_b"))
|
|
await handler.handle_event(
|
|
HookEndEvent(
|
|
hook_name="hook_b",
|
|
status=HookMessageSeverity.OK,
|
|
content="from B",
|
|
scope=HookType.BEFORE_TOOL,
|
|
tool_call_id="call_B",
|
|
)
|
|
)
|
|
await handler.handle_event(
|
|
HookRunEndEvent(scope=HookType.BEFORE_TOOL, tool_call_id="call_B")
|
|
)
|
|
|
|
await handler.handle_event(
|
|
HookEndEvent(
|
|
hook_name="hook_a",
|
|
status=HookMessageSeverity.OK,
|
|
content="from A",
|
|
scope=HookType.BEFORE_TOOL,
|
|
tool_call_id="call_A",
|
|
)
|
|
)
|
|
|
|
assert len(container_a.messages) == 1
|
|
assert container_a.messages[0]._content == "from A"
|