v2.15.0 (#773)
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>
This commit is contained in:
parent
702d0f412e
commit
cafb6d4147
247 changed files with 12401 additions and 3029 deletions
|
|
@ -268,8 +268,16 @@ def test_installed_bundle_launches(binary_dir: Path, binary_name: str) -> None:
|
|||
env = _isolated_env(vibe_home)
|
||||
env["PATH"] = f"{install_dir}{os.pathsep}{env.get('PATH', '')}"
|
||||
|
||||
command = binary_name
|
||||
if platform.system() == "Windows":
|
||||
# subprocess on Windows does not resolve executables through a PATH
|
||||
# value supplied only via env, so resolve it against that PATH first.
|
||||
if (resolved := shutil.which(binary_name, path=env["PATH"])) is None:
|
||||
_fail(f"installed binary not found on PATH: {binary_name}")
|
||||
command = resolved
|
||||
|
||||
result = subprocess.run(
|
||||
[binary_name, "--version"],
|
||||
[command, "--version"],
|
||||
capture_output=True,
|
||||
cwd=workdir,
|
||||
env=env,
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ class TestCommandRegistry:
|
|||
assert result is not None
|
||||
_, cmd, _ = result
|
||||
assert cmd.handler == "_show_session_picker"
|
||||
assert cmd.description == "Browse, resume, or delete saved sessions"
|
||||
|
||||
def test_rename_command_registration(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
|
|
@ -143,3 +144,47 @@ class TestCommandRegistry:
|
|||
assert cmd_name == "loop"
|
||||
assert cmd.handler == "_loop_command"
|
||||
assert cmd_args == "30s ping"
|
||||
|
||||
def test_exit_command_accepts_bare_synonyms(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
for alias in ["/exit", "exit", "quit", ":q", ":quit"]:
|
||||
assert registry.get_command_name(alias) == "exit", alias
|
||||
result = registry.parse_command(alias)
|
||||
assert result is not None, alias
|
||||
cmd_name, cmd, _ = result
|
||||
assert cmd_name == "exit"
|
||||
assert cmd.handler == "_exit_app"
|
||||
assert cmd.exits is True
|
||||
|
||||
def test_bare_exit_synonym_with_trailing_text_is_not_a_command(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
assert registry.parse_command("exit the function early") is None
|
||||
assert registry.parse_command("quit your job") is None
|
||||
|
||||
def test_bare_exit_synonym_in_multiline_message_is_not_a_command(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
assert registry.parse_command("exit\nplease refactor this module") is None
|
||||
|
||||
def test_slash_exit_still_parses_with_trailing_text(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
result = registry.parse_command("/exit now")
|
||||
assert result is not None
|
||||
cmd_name, _, cmd_args = result
|
||||
assert cmd_name == "exit"
|
||||
assert cmd_args == "now"
|
||||
|
||||
def test_exit_command_synonyms_are_case_insensitive(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
for alias in ["EXIT", "Quit", " exit ", ":Q"]:
|
||||
assert registry.get_command_name(alias) == "exit", alias
|
||||
|
||||
def test_exit_synonyms_excluded_when_command_disabled(self) -> None:
|
||||
registry = CommandRegistry(excluded_commands=["exit"])
|
||||
for alias in ["/exit", "exit", "quit", ":q", ":quit"]:
|
||||
assert registry.get_command_name(alias) is None, alias
|
||||
|
||||
def test_help_text_lists_exit_synonyms(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
help_text = registry.get_help_text()
|
||||
for alias in ["`/exit`", "`exit`", "`quit`", "`:q`", "`:quit`"]:
|
||||
assert alias in help_text, alias
|
||||
|
|
|
|||
31
tests/cli/test_help.py
Normal file
31
tests/cli/test_help.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.cli.entrypoint import parse_arguments
|
||||
|
||||
|
||||
def test_help_shows_auto_approve_flag(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setattr("sys.argv", ["vibe", "--help"])
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
parse_arguments()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "--auto-approve" in output
|
||||
assert "Shortcut for --agent auto-approve" in output
|
||||
|
||||
|
||||
def test_auto_approve_conflicts_with_agent(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setattr("sys.argv", ["vibe", "--agent", "plan", "--auto-approve"])
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
parse_arguments()
|
||||
|
||||
assert exc_info.value.code == 2
|
||||
assert "not allowed with argument --agent" in capsys.readouterr().err
|
||||
|
|
@ -7,8 +7,10 @@ from vibe.core.agents.models import BuiltinAgentName
|
|||
from vibe.core.config import VibeConfig
|
||||
|
||||
|
||||
def _make_args(*, agent: str | None, prompt: str | None) -> argparse.Namespace:
|
||||
return argparse.Namespace(agent=agent, prompt=prompt)
|
||||
def _make_args(
|
||||
*, agent: str | None, prompt: str | None, auto_approve: bool = False
|
||||
) -> argparse.Namespace:
|
||||
return argparse.Namespace(agent=agent, prompt=prompt, auto_approve=auto_approve)
|
||||
|
||||
|
||||
def test_uses_args_agent_when_provided() -> None:
|
||||
|
|
@ -51,3 +53,10 @@ def test_programmatic_mode_keeps_explicit_agent_arg() -> None:
|
|||
args = _make_args(agent="accept-edits", prompt="hello")
|
||||
|
||||
assert get_initial_agent_name(args, config) == "accept-edits"
|
||||
|
||||
|
||||
def test_auto_approve_flag_selects_auto_approve_agent() -> None:
|
||||
config = VibeConfig.model_construct(default_agent=BuiltinAgentName.PLAN)
|
||||
args = _make_args(agent=None, prompt="hello", auto_approve=True)
|
||||
|
||||
assert get_initial_agent_name(args, config) == BuiltinAgentName.AUTO_APPROVE
|
||||
|
|
|
|||
230
tests/cli/test_session_delete.py
Normal file
230
tests/cli/test_session_delete.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
||||
from vibe.cli.textual_ui.widgets.messages import ErrorMessage, UserCommandMessage
|
||||
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
|
||||
from vibe.core.config import SessionLoggingConfig
|
||||
from vibe.core.session.resume_sessions import short_session_id
|
||||
|
||||
|
||||
def _enabled_session_config(save_dir: Path) -> SessionLoggingConfig:
|
||||
return SessionLoggingConfig(enabled=True, save_dir=str(save_dir))
|
||||
|
||||
|
||||
class FakeSessionPicker:
|
||||
def __init__(self, *, has_sessions_after_remove: bool = True) -> None:
|
||||
self.has_sessions = True
|
||||
self.removed_option_ids: list[str] = []
|
||||
self.cleared_pending_option_ids: list[str] = []
|
||||
self._has_sessions_after_remove = has_sessions_after_remove
|
||||
|
||||
def remove_session(self, option_id: str) -> bool:
|
||||
self.removed_option_ids.append(option_id)
|
||||
self.has_sessions = self._has_sessions_after_remove
|
||||
return True
|
||||
|
||||
def clear_pending_delete(self, option_id: str) -> bool:
|
||||
self.cleared_pending_option_ids.append(option_id)
|
||||
return True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_request_deletes_local_session(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = build_test_vibe_config(
|
||||
session_logging=_enabled_session_config(tmp_path), enable_connectors=False
|
||||
)
|
||||
app = build_test_vibe_app(config=config)
|
||||
picker = FakeSessionPicker()
|
||||
mounted_widgets: list[object] = []
|
||||
deleted_sessions: list[tuple[str, SessionLoggingConfig]] = []
|
||||
|
||||
async def delete_session(
|
||||
session_id: str, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
deleted_sessions.append((session_id, session_config))
|
||||
|
||||
async def mount_and_scroll(widget: object, after: object | None = None) -> None:
|
||||
mounted_widgets.append(widget)
|
||||
|
||||
monkeypatch.setattr("vibe.cli.textual_ui.app.delete_saved_session", delete_session)
|
||||
monkeypatch.setattr(app, "query_one", lambda _selector: picker)
|
||||
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
|
||||
|
||||
await app.on_session_picker_app_session_delete_requested(
|
||||
SessionPickerApp.SessionDeleteRequested(
|
||||
"local:deleted-session", "local", "deleted-session"
|
||||
)
|
||||
)
|
||||
|
||||
assert deleted_sessions == [("deleted-session", config.session_logging)]
|
||||
assert picker.removed_option_ids == ["local:deleted-session"]
|
||||
assert any(
|
||||
isinstance(widget, UserCommandMessage)
|
||||
and widget._content
|
||||
== f"Deleted session `{short_session_id('deleted-session')}`."
|
||||
for widget in mounted_widgets
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_request_keeps_picker_on_delete_error(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = build_test_vibe_config(
|
||||
session_logging=_enabled_session_config(tmp_path), enable_connectors=False
|
||||
)
|
||||
app = build_test_vibe_app(config=config)
|
||||
picker = FakeSessionPicker()
|
||||
mounted_widgets: list[object] = []
|
||||
|
||||
async def delete_session(
|
||||
session_id: str, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
raise RuntimeError("disk said no")
|
||||
|
||||
async def mount_and_scroll(widget: object, after: object | None = None) -> None:
|
||||
mounted_widgets.append(widget)
|
||||
|
||||
monkeypatch.setattr("vibe.cli.textual_ui.app.delete_saved_session", delete_session)
|
||||
monkeypatch.setattr(app, "query_one", lambda _selector: picker)
|
||||
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
|
||||
|
||||
await app.on_session_picker_app_session_delete_requested(
|
||||
SessionPickerApp.SessionDeleteRequested(
|
||||
"local:deleted-session", "local", "deleted-session"
|
||||
)
|
||||
)
|
||||
|
||||
assert picker.removed_option_ids == []
|
||||
assert picker.cleared_pending_option_ids == ["local:deleted-session"]
|
||||
assert any(
|
||||
isinstance(widget, ErrorMessage)
|
||||
and widget._error == "Failed to delete session: disk said no"
|
||||
for widget in mounted_widgets
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_request_returns_to_input_when_picker_becomes_empty(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = build_test_vibe_config(
|
||||
session_logging=_enabled_session_config(tmp_path), enable_connectors=False
|
||||
)
|
||||
app = build_test_vibe_app(config=config)
|
||||
picker = FakeSessionPicker(has_sessions_after_remove=False)
|
||||
mounted_widgets: list[object] = []
|
||||
switched_to_input = False
|
||||
|
||||
async def delete_session(
|
||||
session_id: str, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
async def mount_and_scroll(widget: object, after: object | None = None) -> None:
|
||||
mounted_widgets.append(widget)
|
||||
|
||||
async def switch_to_input() -> None:
|
||||
nonlocal switched_to_input
|
||||
switched_to_input = True
|
||||
|
||||
monkeypatch.setattr("vibe.cli.textual_ui.app.delete_saved_session", delete_session)
|
||||
monkeypatch.setattr(app, "query_one", lambda _selector: picker)
|
||||
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
|
||||
monkeypatch.setattr(app, "_switch_to_input_app", switch_to_input)
|
||||
|
||||
await app.on_session_picker_app_session_delete_requested(
|
||||
SessionPickerApp.SessionDeleteRequested(
|
||||
"local:deleted-session", "local", "deleted-session"
|
||||
)
|
||||
)
|
||||
|
||||
assert switched_to_input is True
|
||||
assert picker.removed_option_ids == ["local:deleted-session"]
|
||||
assert [
|
||||
widget._content
|
||||
for widget in mounted_widgets
|
||||
if isinstance(widget, UserCommandMessage)
|
||||
] == [
|
||||
f"Deleted session `{short_session_id('deleted-session')}`.",
|
||||
"No saved sessions left for this directory.",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_request_rejects_remote_sessions(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = build_test_vibe_config(
|
||||
session_logging=_enabled_session_config(tmp_path), enable_connectors=False
|
||||
)
|
||||
app = build_test_vibe_app(config=config)
|
||||
mounted_widgets: list[object] = []
|
||||
|
||||
async def delete_session(
|
||||
session_id: str, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
pytest.fail("remote sessions should not be deleted")
|
||||
|
||||
async def mount_and_scroll(widget: object, after: object | None = None) -> None:
|
||||
mounted_widgets.append(widget)
|
||||
|
||||
monkeypatch.setattr("vibe.cli.textual_ui.app.delete_saved_session", delete_session)
|
||||
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
|
||||
|
||||
await app.on_session_picker_app_session_delete_requested(
|
||||
SessionPickerApp.SessionDeleteRequested(
|
||||
"remote:remote-session", "remote", "remote-session"
|
||||
)
|
||||
)
|
||||
|
||||
assert any(
|
||||
isinstance(widget, ErrorMessage)
|
||||
and widget._error == "Deleting remote sessions is not supported."
|
||||
for widget in mounted_widgets
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_request_rejects_current_session(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = build_test_vibe_config(
|
||||
session_logging=_enabled_session_config(tmp_path), enable_connectors=False
|
||||
)
|
||||
app = build_test_vibe_app(config=config)
|
||||
picker = FakeSessionPicker()
|
||||
mounted_widgets: list[object] = []
|
||||
deleted_sessions: list[str] = []
|
||||
|
||||
async def delete_session(
|
||||
session_id: str, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
deleted_sessions.append(session_id)
|
||||
|
||||
async def mount_and_scroll(widget: object, after: object | None = None) -> None:
|
||||
mounted_widgets.append(widget)
|
||||
|
||||
monkeypatch.setattr("vibe.cli.textual_ui.app.delete_saved_session", delete_session)
|
||||
monkeypatch.setattr(app, "query_one", lambda _selector: picker)
|
||||
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
|
||||
|
||||
await app.on_session_picker_app_session_delete_requested(
|
||||
SessionPickerApp.SessionDeleteRequested(
|
||||
f"local:{app.agent_loop.session_id}", "local", app.agent_loop.session_id
|
||||
)
|
||||
)
|
||||
|
||||
assert deleted_sessions == []
|
||||
assert picker.cleared_pending_option_ids == [f"local:{app.agent_loop.session_id}"]
|
||||
assert any(
|
||||
isinstance(widget, ErrorMessage)
|
||||
and widget._error == "Deleting the current session is not supported."
|
||||
for widget in mounted_widgets
|
||||
)
|
||||
|
|
@ -145,6 +145,35 @@ async def test_ui_displays_multiple_user_assistant_turns(
|
|||
assert assistant_messages[1]._content == "Second answer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_displays_messages_when_resuming_in_dangerous_directory(
|
||||
monkeypatch: pytest.MonkeyPatch, vibe_config: VibeConfig
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"vibe.cli.textual_ui.app.is_dangerous_directory",
|
||||
lambda: (True, "You are in the home directory"),
|
||||
)
|
||||
|
||||
agent_loop = build_test_agent_loop(config=vibe_config)
|
||||
agent_loop.messages.extend([
|
||||
LLMMessage(role=Role.user, content="Hello from a previous run"),
|
||||
LLMMessage(role=Role.assistant, content="Welcome back!"),
|
||||
])
|
||||
|
||||
app = build_test_vibe_app(agent_loop=agent_loop)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.5)
|
||||
|
||||
user_messages = app.query(UserMessage)
|
||||
assistant_messages = app.query(AssistantMessage)
|
||||
|
||||
assert len(user_messages) == 1
|
||||
assert user_messages[0]._content == "Hello from a previous run"
|
||||
assert len(assistant_messages) == 1
|
||||
assert assistant_messages[0]._content == "Welcome back!"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_rebuilds_history_when_whats_new_is_shown(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
|
|
|
|||
|
|
@ -129,3 +129,30 @@ async def test_skill_without_args_does_not_prepend_invocation_line(
|
|||
vibe_app_with_skills, pilot, "Do the thing."
|
||||
)
|
||||
assert "/my-skill" not in message._content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_popped_queued_skill_does_not_fire_telemetry(
|
||||
vibe_app_with_skills: VibeApp, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async with vibe_app_with_skills.run_test() as pilot:
|
||||
events: list[tuple[str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
vibe_app_with_skills.agent_loop.telemetry_client,
|
||||
"send_slash_command_used",
|
||||
lambda name, kind: events.append((name, kind)),
|
||||
)
|
||||
|
||||
chat_input = vibe_app_with_skills.query_one(ChatInputContainer)
|
||||
vibe_app_with_skills._agent_running = True
|
||||
try:
|
||||
chat_input.post_message(ChatInputContainer.Submitted("/my-skill"))
|
||||
await pilot.pause(0.1)
|
||||
assert len(vibe_app_with_skills._input_queue) == 1
|
||||
|
||||
await pilot.press("ctrl+c")
|
||||
await pilot.pause(0.1)
|
||||
assert len(vibe_app_with_skills._input_queue) == 0
|
||||
assert events == []
|
||||
finally:
|
||||
vibe_app_with_skills._agent_running = False
|
||||
|
|
|
|||
|
|
@ -4,38 +4,55 @@ 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()
|
||||
|
||||
async def add_message(self, _widget: object) -> None:
|
||||
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(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
hook_container_factory: list[FakeHookRunContainer],
|
||||
) -> None:
|
||||
created_containers: list[FakeHookRunContainer] = []
|
||||
|
||||
def make_container() -> FakeHookRunContainer:
|
||||
container = FakeHookRunContainer()
|
||||
created_containers.append(container)
|
||||
return container
|
||||
|
||||
monkeypatch.setattr(event_handler_module, "HookRunContainer", make_container)
|
||||
|
||||
mount_callback = AsyncMock()
|
||||
handler = EventHandler(
|
||||
mount_callback=mount_callback, get_tools_collapsed=lambda: False
|
||||
|
|
@ -44,30 +61,22 @@ async def test_hook_run_end_removes_empty_container(
|
|||
await handler.handle_event(HookRunStartEvent())
|
||||
await handler.handle_event(HookRunEndEvent())
|
||||
|
||||
assert len(created_containers) == 1
|
||||
created_containers[0].remove.assert_awaited_once()
|
||||
assert handler._hook_run_container is None
|
||||
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(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
hook_container_factory: list[FakeHookRunContainer],
|
||||
) -> None:
|
||||
created_containers: list[FakeHookRunContainer] = []
|
||||
|
||||
def make_container() -> FakeHookRunContainer:
|
||||
container = FakeHookRunContainer()
|
||||
created_containers.append(container)
|
||||
return container
|
||||
|
||||
monkeypatch.setattr(event_handler_module, "HookRunContainer", make_container)
|
||||
|
||||
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"
|
||||
|
|
@ -75,7 +84,279 @@ async def test_hook_run_end_keeps_container_with_messages(
|
|||
)
|
||||
await handler.handle_event(HookRunEndEvent())
|
||||
|
||||
assert len(created_containers) == 1
|
||||
created_containers[0].remove.assert_not_awaited()
|
||||
assert created_containers[0].display is True
|
||||
assert handler._hook_run_container is None
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import pytest
|
||||
|
||||
from vibe.cli.textual_ui.message_queue import MessageQueue, QueuedItemKind
|
||||
from vibe.cli.textual_ui.message_queue import MessageQueue, QueuedItem, QueuedItemKind
|
||||
|
||||
|
||||
def test_empty_queue_is_falsy() -> None:
|
||||
|
|
@ -38,6 +38,17 @@ def test_pop_last_returns_newest() -> None:
|
|||
assert [item.content for item in queue.items] == ["a", "b"]
|
||||
|
||||
|
||||
def test_pop_last_resumes_when_queue_becomes_empty() -> None:
|
||||
queue = MessageQueue()
|
||||
queue.append_prompt("a")
|
||||
queue.pause()
|
||||
|
||||
queue.pop_last()
|
||||
|
||||
assert not queue
|
||||
assert not queue.paused
|
||||
|
||||
|
||||
def test_pop_first_returns_oldest() -> None:
|
||||
queue = MessageQueue()
|
||||
queue.append_prompt("a")
|
||||
|
|
@ -74,11 +85,9 @@ def test_pause_and_resume() -> None:
|
|||
|
||||
def test_pause_is_idempotent() -> None:
|
||||
queue = MessageQueue()
|
||||
calls = []
|
||||
queue.set_change_listener(lambda: calls.append(None))
|
||||
queue.pause()
|
||||
queue.pause()
|
||||
assert len(calls) == 1
|
||||
assert queue.paused
|
||||
|
||||
|
||||
def test_clear_resets_state() -> None:
|
||||
|
|
@ -90,34 +99,30 @@ def test_clear_resets_state() -> None:
|
|||
assert not queue.paused
|
||||
|
||||
|
||||
def test_change_listener_fires_on_mutations() -> None:
|
||||
def test_prepend_prompts_inserts_at_head_preserving_order() -> None:
|
||||
queue = MessageQueue()
|
||||
calls: list[None] = []
|
||||
queue.set_change_listener(lambda: calls.append(None))
|
||||
|
||||
queue.append_prompt("a")
|
||||
assert len(calls) == 1
|
||||
|
||||
queue.append_bash("ls")
|
||||
assert len(calls) == 2
|
||||
|
||||
queue.pop_last()
|
||||
assert len(calls) == 3
|
||||
|
||||
queue.pause()
|
||||
assert len(calls) == 4
|
||||
|
||||
queue.resume()
|
||||
assert len(calls) == 5
|
||||
queue.append_prompt("x")
|
||||
queue.append_prompt("y")
|
||||
queue.prepend_prompts([
|
||||
QueuedItem(QueuedItemKind.PROMPT, "a"),
|
||||
QueuedItem(QueuedItemKind.PROMPT, "b"),
|
||||
])
|
||||
assert [item.content for item in queue.items] == ["a", "b", "x", "y"]
|
||||
|
||||
|
||||
def test_change_listener_can_be_cleared() -> None:
|
||||
def test_prepend_prompts_empty_is_noop() -> None:
|
||||
queue = MessageQueue()
|
||||
calls: list[None] = []
|
||||
queue.set_change_listener(lambda: calls.append(None))
|
||||
queue.set_change_listener(None)
|
||||
queue.append_prompt("a")
|
||||
assert calls == []
|
||||
queue.append_prompt("x")
|
||||
queue.prepend_prompts([])
|
||||
assert [item.content for item in queue.items] == ["x"]
|
||||
|
||||
|
||||
def test_append_prompt_with_skill_name() -> None:
|
||||
queue = MessageQueue()
|
||||
queue.append_prompt("expanded prompt", skill_name="my-skill")
|
||||
item = queue.items[0]
|
||||
assert item.skill_name == "my-skill"
|
||||
assert item.content == "expanded prompt"
|
||||
|
||||
|
||||
def test_items_returns_copy() -> None:
|
||||
|
|
|
|||
200
tests/cli/textual_ui/test_message_queue_ui.py
Normal file
200
tests/cli/textual_ui/test_message_queue_ui.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_app
|
||||
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,
|
||||
QueueHeaderMessage,
|
||||
WarningMessage,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vibe_app() -> VibeApp:
|
||||
return build_test_vibe_app()
|
||||
|
||||
|
||||
async def _wait_until(pilot, predicate, timeout: float = 2.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
await pilot.pause(0.05)
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_queue_header_when_empty(vibe_app: VibeApp) -> None:
|
||||
async with vibe_app.run_test():
|
||||
headers = list(vibe_app.query(QueueHeaderMessage))
|
||||
assert headers == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bash_submitted_during_running_bash_is_queued(vibe_app: VibeApp) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
chat_input.value = "!sleep 0.3"
|
||||
await pilot.press("enter")
|
||||
|
||||
await _wait_until(pilot, lambda: vibe_app._bash_task is not None, timeout=1.0)
|
||||
|
||||
chat_input.value = "!echo queued"
|
||||
await pilot.press("enter")
|
||||
|
||||
assert len(vibe_app._input_queue) == 1
|
||||
assert vibe_app._input_queue.items[0].content == "echo queued"
|
||||
|
||||
headers = list(vibe_app.query(QueueHeaderMessage))
|
||||
assert len(headers) == 1
|
||||
|
||||
queued_bashes = [w for w in vibe_app.query(BashOutputMessage) if w._queued]
|
||||
assert len(queued_bashes) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_command_rejected_with_warning_when_busy(vibe_app: VibeApp) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
chat_input.value = "!sleep 0.3"
|
||||
await pilot.press("enter")
|
||||
|
||||
await _wait_until(pilot, lambda: vibe_app._bash_task is not None, timeout=1.0)
|
||||
|
||||
chat_input.value = "/help"
|
||||
await pilot.press("enter")
|
||||
|
||||
assert not list(vibe_app.query(WarningMessage))
|
||||
assert any(
|
||||
"Slash commands cannot be queued" in notification.message
|
||||
for notification in vibe_app._notifications
|
||||
)
|
||||
assert len(vibe_app._input_queue) == 0
|
||||
assert chat_input.value.startswith("/help")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ctrl_c_pops_last_queued_item_lifo(vibe_app: VibeApp) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
chat_input.value = "!sleep 2"
|
||||
await pilot.press("enter")
|
||||
|
||||
await _wait_until(pilot, lambda: vibe_app._bash_task is not None, timeout=2.0)
|
||||
|
||||
chat_input.value = "!echo first"
|
||||
await pilot.press("enter")
|
||||
chat_input.value = "!echo second"
|
||||
await pilot.press("enter")
|
||||
|
||||
assert len(vibe_app._input_queue) == 2
|
||||
|
||||
await pilot.press("ctrl+c")
|
||||
assert len(vibe_app._input_queue) == 1
|
||||
assert vibe_app._input_queue.items[0].content == "echo first"
|
||||
|
||||
await pilot.press("escape")
|
||||
await _wait_until(pilot, lambda: vibe_app._bash_task is None, timeout=5.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escape_pauses_queue_when_job_running(vibe_app: VibeApp) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
chat_input.value = "!sleep 2"
|
||||
await pilot.press("enter")
|
||||
|
||||
await _wait_until(pilot, lambda: vibe_app._bash_task is not None, timeout=2.0)
|
||||
|
||||
chat_input.value = "!echo queued"
|
||||
await pilot.press("enter")
|
||||
assert len(vibe_app._input_queue) == 1
|
||||
|
||||
await pilot.press("escape")
|
||||
assert vibe_app._input_queue.paused
|
||||
assert len(vibe_app._input_queue) == 1
|
||||
|
||||
await _wait_until(pilot, lambda: vibe_app._bash_task is None, timeout=5.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_runs_queued_bashes_in_fifo_order(vibe_app: VibeApp) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
chat_input.value = "!sleep 0.2"
|
||||
await pilot.press("enter")
|
||||
|
||||
await _wait_until(pilot, lambda: vibe_app._bash_task is not None, timeout=1.0)
|
||||
|
||||
chat_input.value = "!echo first"
|
||||
await pilot.press("enter")
|
||||
chat_input.value = "!echo second"
|
||||
await pilot.press("enter")
|
||||
|
||||
await _wait_until(
|
||||
pilot,
|
||||
lambda: (
|
||||
len(list(vibe_app.query(BashOutputMessage))) == 3
|
||||
and all(not m._pending for m in vibe_app.query(BashOutputMessage))
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
msgs = list(vibe_app.query(BashOutputMessage))
|
||||
assert len(msgs) == 3
|
||||
assert vibe_app._input_queue.paused is False
|
||||
assert len(vibe_app._input_queue) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enter_on_empty_input_flushes_paused_queue(vibe_app: VibeApp) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
chat_input.value = "!sleep 2"
|
||||
await pilot.press("enter")
|
||||
|
||||
await _wait_until(pilot, lambda: vibe_app._bash_task is not None, timeout=2.0)
|
||||
|
||||
chat_input.value = "!echo queued"
|
||||
await pilot.press("enter")
|
||||
assert len(vibe_app._input_queue) == 1
|
||||
|
||||
await pilot.press("escape")
|
||||
assert vibe_app._input_queue.paused
|
||||
|
||||
await _wait_until(pilot, lambda: vibe_app._bash_task is None, timeout=10.0)
|
||||
|
||||
chat_input.value = ""
|
||||
await pilot.press("enter")
|
||||
|
||||
await _wait_until(
|
||||
pilot,
|
||||
lambda: (
|
||||
not vibe_app._input_queue.paused and len(vibe_app._input_queue) == 0
|
||||
),
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
assert not vibe_app._input_queue.paused
|
||||
assert len(vibe_app._input_queue) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quit_warning_shows_queue_count(vibe_app: VibeApp) -> None:
|
||||
async with vibe_app.run_test():
|
||||
vibe_app._input_queue.append_prompt("a")
|
||||
vibe_app._input_queue.append_prompt("b")
|
||||
warning = vibe_app._queue.quit_warning_extra()
|
||||
assert warning == "2 queued messages will be discarded"
|
||||
|
||||
vibe_app._input_queue.pop_last()
|
||||
warning = vibe_app._queue.quit_warning_extra()
|
||||
assert warning == "1 queued message will be discarded"
|
||||
|
||||
vibe_app._input_queue.pop_last()
|
||||
assert vibe_app._queue.quit_warning_extra() == ""
|
||||
|
|
@ -92,11 +92,12 @@ class TestActionInterruptOrQuit:
|
|||
mock_container.value = ""
|
||||
with (
|
||||
patch.object(app, "_get_chat_input", return_value=mock_container),
|
||||
patch.object(app, "_try_interrupt", return_value=False),
|
||||
patch.object(app, "_try_interrupt_no_job_steps", return_value=False),
|
||||
patch.object(app, "_try_interrupt_running_job", return_value=False),
|
||||
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
|
||||
):
|
||||
app.action_interrupt_or_quit()
|
||||
mock_confirm.assert_called_once_with("Ctrl+C")
|
||||
mock_confirm.assert_called_once_with("Ctrl+C", "")
|
||||
|
||||
def test_quits_on_confirmed(self, app: VibeApp) -> None:
|
||||
app._quit_manager._confirm_time = time.monotonic()
|
||||
|
|
@ -111,7 +112,9 @@ class TestActionInterruptOrQuit:
|
|||
def test_interrupts_before_requesting_confirmation(self, app: VibeApp) -> None:
|
||||
with (
|
||||
patch.object(app, "_get_chat_input", return_value=None),
|
||||
patch.object(app, "_try_interrupt", return_value=True) as mock_interrupt,
|
||||
patch.object(
|
||||
app, "_try_interrupt_no_job_steps", return_value=True
|
||||
) as mock_interrupt,
|
||||
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
|
||||
):
|
||||
app.action_interrupt_or_quit()
|
||||
|
|
@ -123,11 +126,12 @@ class TestActionInterruptOrQuit:
|
|||
) -> None:
|
||||
with (
|
||||
patch.object(app, "_get_chat_input", return_value=None),
|
||||
patch.object(app, "_try_interrupt", return_value=False),
|
||||
patch.object(app, "_try_interrupt_no_job_steps", return_value=False),
|
||||
patch.object(app, "_try_interrupt_running_job", return_value=False),
|
||||
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
|
||||
):
|
||||
app.action_interrupt_or_quit()
|
||||
mock_confirm.assert_called_once_with("Ctrl+C")
|
||||
mock_confirm.assert_called_once_with("Ctrl+C", "")
|
||||
|
||||
|
||||
class TestActionDeleteRightOrQuit:
|
||||
|
|
@ -148,7 +152,7 @@ class TestActionDeleteRightOrQuit:
|
|||
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
|
||||
):
|
||||
app.action_delete_right_or_quit()
|
||||
mock_confirm.assert_called_once_with("Ctrl+D")
|
||||
mock_confirm.assert_called_once_with("Ctrl+D", "")
|
||||
|
||||
def test_quits_on_confirmed(self, app: VibeApp) -> None:
|
||||
app._quit_manager._confirm_time = time.monotonic()
|
||||
|
|
@ -166,4 +170,15 @@ class TestActionDeleteRightOrQuit:
|
|||
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
|
||||
):
|
||||
app.action_delete_right_or_quit()
|
||||
mock_confirm.assert_called_once_with("Ctrl+D")
|
||||
mock_confirm.assert_called_once_with("Ctrl+D", "")
|
||||
|
||||
def test_shows_queue_warning_when_queue_non_empty(self, app: VibeApp) -> None:
|
||||
app._input_queue.append_prompt("queued")
|
||||
with (
|
||||
patch.object(app, "_get_chat_input", return_value=None),
|
||||
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
|
||||
):
|
||||
app.action_delete_right_or_quit()
|
||||
mock_confirm.assert_called_once_with(
|
||||
"Ctrl+D", "1 queued message will be discarded"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.widgets.tool_widgets import _strip_line_numbers
|
||||
from pydantic import BaseModel
|
||||
|
||||
from vibe.cli.textual_ui.widgets.collapsible import CollapsibleSection
|
||||
from vibe.cli.textual_ui.widgets.tool_widgets import (
|
||||
ToolResultWidget,
|
||||
_fenced_code_block,
|
||||
_strip_line_numbers,
|
||||
get_result_widget,
|
||||
)
|
||||
|
||||
|
||||
def test_strips_numbered_prefixes() -> None:
|
||||
|
|
@ -16,3 +24,57 @@ def test_leaves_warning_lines_untouched() -> None:
|
|||
def test_preserves_arrows_inside_content() -> None:
|
||||
content = " 1→a → b → c"
|
||||
assert _strip_line_numbers(content) == "a → b → c"
|
||||
|
||||
|
||||
def test_fence_uses_three_backticks_for_plain_content() -> None:
|
||||
block = _fenced_code_block("hello\nworld", "py")
|
||||
assert block == "```py\nhello\nworld\n```"
|
||||
|
||||
|
||||
def test_fence_outgrows_embedded_triple_backticks() -> None:
|
||||
content = "before\n```\n[click me](http://evil)\n```\nafter"
|
||||
block = _fenced_code_block(content, "md")
|
||||
fence = "````"
|
||||
assert block == f"{fence}md\n{content}\n{fence}"
|
||||
assert block.startswith(fence)
|
||||
assert block.endswith(fence)
|
||||
|
||||
|
||||
def test_fence_outgrows_longest_backtick_run() -> None:
|
||||
content = "a ```` b ``` c"
|
||||
block = _fenced_code_block(content, "")
|
||||
assert block.startswith("`````")
|
||||
assert block.endswith("`````")
|
||||
|
||||
|
||||
def test_fence_strips_newlines_from_ext() -> None:
|
||||
block = _fenced_code_block("safe", "x\n[click](http://evil)")
|
||||
assert block == "```xclickhttpevil\nsafe\n```"
|
||||
assert "\n[click]" not in block
|
||||
|
||||
|
||||
def test_fence_strips_backticks_from_ext() -> None:
|
||||
block = _fenced_code_block("safe", "py`\n```md")
|
||||
first_line = block.split("\n", 1)[0]
|
||||
assert first_line == "```pymd"
|
||||
|
||||
|
||||
def test_fence_caps_ext_length() -> None:
|
||||
block = _fenced_code_block("safe", "a" * 500)
|
||||
first_line = block.split("\n", 1)[0]
|
||||
assert first_line == "```" + "a" * 32
|
||||
|
||||
|
||||
def test_unknown_tool_uses_default_widget() -> None:
|
||||
widget = get_result_widget("unknown_tool", None, True, "done")
|
||||
assert type(widget) is ToolResultWidget
|
||||
|
||||
|
||||
def test_default_widget_renders_fields_collapsibly() -> None:
|
||||
class _Result(BaseModel):
|
||||
server: str
|
||||
text: str
|
||||
|
||||
widget = ToolResultWidget(_Result(server="s", text="hello"), True, "ok")
|
||||
children = list(widget.compose())
|
||||
assert any(isinstance(child, CollapsibleSection) for child in children)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from rich.text import Text
|
||||
from textual.widgets import OptionList
|
||||
|
||||
from vibe.cli.textual_ui.widgets.session_picker import (
|
||||
SessionPickerApp,
|
||||
|
|
@ -48,6 +51,12 @@ def sample_latest_messages() -> dict[str, str]:
|
|||
}
|
||||
|
||||
|
||||
def assert_delete_state(picker: SessionPickerApp, *, kind: str, option_id: str) -> None:
|
||||
assert picker._delete_state is not None
|
||||
assert picker._delete_state.kind == kind
|
||||
assert picker._delete_state.option_id == option_id
|
||||
|
||||
|
||||
class TestFormatRelativeTime:
|
||||
def test_just_now(self) -> None:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
|
|
@ -99,6 +108,7 @@ class TestSessionPickerAppInit:
|
|||
)
|
||||
assert picker._sessions == sample_sessions
|
||||
assert picker._latest_messages == sample_latest_messages
|
||||
assert picker._current_session_id is None
|
||||
|
||||
def test_id_is_sessionpicker_app(self) -> None:
|
||||
picker = SessionPickerApp(sessions=[], latest_messages={})
|
||||
|
|
@ -107,6 +117,29 @@ class TestSessionPickerAppInit:
|
|||
def test_can_focus_children_is_true(self) -> None:
|
||||
assert SessionPickerApp.can_focus_children is True
|
||||
|
||||
def test_delete_confirmation_state_starts_empty(
|
||||
self,
|
||||
sample_sessions: list[ResumeSessionInfo],
|
||||
sample_latest_messages: dict[str, str],
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
assert picker._delete_state is None
|
||||
|
||||
def test_has_sessions_tracks_session_list(
|
||||
self,
|
||||
sample_sessions: list[ResumeSessionInfo],
|
||||
sample_latest_messages: dict[str, str],
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
assert picker.has_sessions is True
|
||||
|
||||
empty_picker = SessionPickerApp(sessions=[], latest_messages={})
|
||||
assert empty_picker.has_sessions is False
|
||||
|
||||
|
||||
class TestSessionPickerMessages:
|
||||
def test_session_selected_stores_option_id(self) -> None:
|
||||
|
|
@ -129,16 +162,377 @@ class TestSessionPickerMessages:
|
|||
msg = SessionPickerApp.Cancelled()
|
||||
assert isinstance(msg, SessionPickerApp.Cancelled)
|
||||
|
||||
def test_session_delete_requested_stores_session_info(self) -> None:
|
||||
msg = SessionPickerApp.SessionDeleteRequested(
|
||||
"local:test-session-id", "local", "test-session-id"
|
||||
)
|
||||
assert msg.option_id == "local:test-session-id"
|
||||
assert msg.source == "local"
|
||||
assert msg.session_id == "test-session-id"
|
||||
|
||||
|
||||
class TestSessionPickerAppBindings:
|
||||
def _get_binding_keys(self) -> list[str]:
|
||||
keys = []
|
||||
for binding in SessionPickerApp.BINDINGS:
|
||||
if isinstance(binding, tuple) and len(binding) >= 1:
|
||||
keys.append(binding[0])
|
||||
keys.extend(binding[0].split(","))
|
||||
else:
|
||||
keys.append(binding.key)
|
||||
keys.extend(binding.key.split(","))
|
||||
return keys
|
||||
|
||||
def test_has_escape_binding(self) -> None:
|
||||
assert "escape" in self._get_binding_keys()
|
||||
|
||||
def test_has_delete_binding(self) -> None:
|
||||
assert "d" in self._get_binding_keys()
|
||||
assert "D" in self._get_binding_keys()
|
||||
|
||||
|
||||
class TestSessionPickerSessionRemoval:
|
||||
def test_first_delete_request_enters_confirmation(
|
||||
self,
|
||||
sample_sessions: list[ResumeSessionInfo],
|
||||
sample_latest_messages: dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
posted_messages: list[object] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
||||
picker.action_request_delete()
|
||||
|
||||
assert_delete_state(picker, kind="confirmation", option_id="local:session-a")
|
||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
||||
assert (
|
||||
"Press D again to delete" in option_list.replaced_prompts[-1].prompt.plain
|
||||
)
|
||||
assert posted_messages == []
|
||||
|
||||
def test_second_delete_request_posts_delete_message(
|
||||
self,
|
||||
sample_sessions: list[ResumeSessionInfo],
|
||||
sample_latest_messages: dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
posted_messages: list[object] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
||||
picker.action_request_delete()
|
||||
picker.action_request_delete()
|
||||
|
||||
assert_delete_state(picker, kind="pending", option_id="local:session-a")
|
||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
||||
assert "Deleting..." in option_list.replaced_prompts[-1].prompt.plain
|
||||
assert len(posted_messages) == 1
|
||||
message = posted_messages[0]
|
||||
assert isinstance(message, SessionPickerApp.SessionDeleteRequested)
|
||||
assert message.option_id == "local:session-a"
|
||||
assert message.source == "local"
|
||||
assert message.session_id == "session-a"
|
||||
|
||||
def test_delete_confirmation_is_consumed_after_request(
|
||||
self,
|
||||
sample_sessions: list[ResumeSessionInfo],
|
||||
sample_latest_messages: dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
posted_messages: list[object] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
||||
picker.action_request_delete()
|
||||
picker.action_request_delete()
|
||||
picker.action_request_delete()
|
||||
|
||||
assert len(posted_messages) == 1
|
||||
assert_delete_state(picker, kind="pending", option_id="local:session-a")
|
||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
||||
assert "Deleting..." in option_list.replaced_prompts[-1].prompt.plain
|
||||
|
||||
def test_delete_request_shows_feedback_for_remote_sessions(
|
||||
self,
|
||||
sample_sessions: list[ResumeSessionInfo],
|
||||
sample_latest_messages: dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="remote:session-c")
|
||||
posted_messages: list[object] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
||||
picker.action_request_delete()
|
||||
|
||||
assert_delete_state(picker, kind="feedback", option_id="remote:session-c")
|
||||
assert option_list.replaced_prompts[-1].option_id == "remote:session-c"
|
||||
assert (
|
||||
"Can't delete remote session"
|
||||
in option_list.replaced_prompts[-1].prompt.plain
|
||||
)
|
||||
assert posted_messages == []
|
||||
|
||||
def test_delete_request_shows_feedback_for_current_session(
|
||||
self,
|
||||
sample_sessions: list[ResumeSessionInfo],
|
||||
sample_latest_messages: dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions,
|
||||
latest_messages=sample_latest_messages,
|
||||
current_session_id="session-a",
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
posted_messages: list[object] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
||||
picker.action_request_delete()
|
||||
|
||||
assert_delete_state(picker, kind="feedback", option_id="local:session-a")
|
||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
||||
assert (
|
||||
"Can't delete current session"
|
||||
in option_list.replaced_prompts[-1].prompt.plain
|
||||
)
|
||||
assert posted_messages == []
|
||||
|
||||
picker.action_request_delete()
|
||||
|
||||
assert_delete_state(picker, kind="feedback", option_id="local:session-a")
|
||||
assert posted_messages == []
|
||||
|
||||
def test_pending_delete_blocks_resume_selection(
|
||||
self,
|
||||
sample_sessions: list[ResumeSessionInfo],
|
||||
sample_latest_messages: dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
posted_messages: list[object] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
||||
picker.action_request_delete()
|
||||
picker.action_request_delete()
|
||||
picker.on_option_list_option_selected(
|
||||
cast(OptionList.OptionSelected, FakeOptionEvent("local:session-a"))
|
||||
)
|
||||
picker.on_option_list_option_selected(
|
||||
cast(OptionList.OptionSelected, FakeOptionEvent("local:session-b"))
|
||||
)
|
||||
|
||||
assert len(posted_messages) == 1
|
||||
assert isinstance(posted_messages[0], SessionPickerApp.SessionDeleteRequested)
|
||||
assert_delete_state(picker, kind="pending", option_id="local:session-a")
|
||||
|
||||
def test_clear_pending_delete_restores_session_option(
|
||||
self,
|
||||
sample_sessions: list[ResumeSessionInfo],
|
||||
sample_latest_messages: dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
posted_messages: list[object] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
||||
picker.action_request_delete()
|
||||
picker.action_request_delete()
|
||||
|
||||
assert picker.clear_pending_delete("local:session-a") is True
|
||||
assert picker._delete_state is None
|
||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
||||
assert "Help me fix this bug" in option_list.replaced_prompts[-1].prompt.plain
|
||||
|
||||
def test_highlighting_another_session_clears_confirmation(
|
||||
self,
|
||||
sample_sessions: list[ResumeSessionInfo],
|
||||
sample_latest_messages: dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
picker.action_request_delete()
|
||||
|
||||
picker.on_option_list_option_highlighted(
|
||||
cast(OptionList.OptionHighlighted, FakeOptionEvent("local:session-b"))
|
||||
)
|
||||
|
||||
assert picker._delete_state is None
|
||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
||||
assert "Help me fix this bug" in option_list.replaced_prompts[-1].prompt.plain
|
||||
|
||||
def test_highlighting_another_session_clears_delete_feedback(
|
||||
self,
|
||||
sample_sessions: list[ResumeSessionInfo],
|
||||
sample_latest_messages: dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="remote:session-c")
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
picker.action_request_delete()
|
||||
|
||||
picker.on_option_list_option_highlighted(
|
||||
cast(OptionList.OptionHighlighted, FakeOptionEvent("local:session-b"))
|
||||
)
|
||||
|
||||
assert picker._delete_state is None
|
||||
assert option_list.replaced_prompts[-1].option_id == "remote:session-c"
|
||||
assert (
|
||||
"Add unit tests for the API"
|
||||
in option_list.replaced_prompts[-1].prompt.plain
|
||||
)
|
||||
|
||||
def test_escape_clears_confirmation_before_cancelling(
|
||||
self,
|
||||
sample_sessions: list[ResumeSessionInfo],
|
||||
sample_latest_messages: dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
posted_messages: list[object] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
picker.action_request_delete()
|
||||
|
||||
picker.action_cancel()
|
||||
|
||||
assert picker._delete_state is None
|
||||
assert posted_messages == []
|
||||
|
||||
picker.action_cancel()
|
||||
|
||||
assert len(posted_messages) == 1
|
||||
assert isinstance(posted_messages[0], SessionPickerApp.Cancelled)
|
||||
|
||||
def test_escape_clears_delete_feedback_before_cancelling(
|
||||
self,
|
||||
sample_sessions: list[ResumeSessionInfo],
|
||||
sample_latest_messages: dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="remote:session-c")
|
||||
posted_messages: list[object] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
picker.action_request_delete()
|
||||
|
||||
picker.action_cancel()
|
||||
|
||||
assert picker._delete_state is None
|
||||
assert posted_messages == []
|
||||
|
||||
picker.action_cancel()
|
||||
|
||||
assert len(posted_messages) == 1
|
||||
assert isinstance(posted_messages[0], SessionPickerApp.Cancelled)
|
||||
|
||||
def test_remove_session_updates_picker_state(
|
||||
self,
|
||||
sample_sessions: list[ResumeSessionInfo],
|
||||
sample_latest_messages: dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
picker.action_request_delete()
|
||||
assert_delete_state(picker, kind="confirmation", option_id="local:session-a")
|
||||
|
||||
assert picker.remove_session("local:session-a") is True
|
||||
|
||||
assert [session.option_id for session in picker._sessions] == [
|
||||
"local:session-b",
|
||||
"remote:session-c",
|
||||
]
|
||||
assert "local:session-a" not in picker._latest_messages
|
||||
assert picker._delete_state is None
|
||||
assert option_list.removed_option_ids == ["local:session-a"]
|
||||
|
||||
def test_remove_missing_session_returns_false(
|
||||
self,
|
||||
sample_sessions: list[ResumeSessionInfo],
|
||||
sample_latest_messages: dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList()
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
|
||||
assert picker.remove_session("local:missing") is False
|
||||
|
||||
assert picker._sessions == sample_sessions
|
||||
assert picker._latest_messages == sample_latest_messages
|
||||
assert option_list.removed_option_ids == []
|
||||
|
||||
|
||||
class FakeOption:
|
||||
def __init__(self, option_id: str) -> None:
|
||||
self.id = option_id
|
||||
|
||||
|
||||
class FakeOptionEvent:
|
||||
def __init__(self, option_id: str) -> None:
|
||||
self.option = FakeOption(option_id)
|
||||
|
||||
|
||||
class ReplacedPrompt:
|
||||
def __init__(self, option_id: str, prompt: Text) -> None:
|
||||
self.option_id = option_id
|
||||
self.prompt = prompt
|
||||
|
||||
|
||||
class FakeOptionList:
|
||||
def __init__(self, highlighted_option_id: str | None = None) -> None:
|
||||
self.highlighted_option = (
|
||||
FakeOption(highlighted_option_id)
|
||||
if highlighted_option_id is not None
|
||||
else None
|
||||
)
|
||||
self.removed_option_ids: list[str] = []
|
||||
self.replaced_prompts: list[ReplacedPrompt] = []
|
||||
|
||||
def remove_option(self, option_id: str) -> None:
|
||||
self.removed_option_ids.append(option_id)
|
||||
|
||||
def replace_option_prompt(self, option_id: str, prompt: Text) -> None:
|
||||
self.replaced_prompts.append(ReplacedPrompt(option_id, prompt))
|
||||
|
|
|
|||
75
tests/cli/textual_ui/test_terminal_input_filter.py
Normal file
75
tests/cli/textual_ui/test_terminal_input_filter.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from textual import events
|
||||
|
||||
from vibe.cli.textual_ui.terminal_input_filter import (
|
||||
FilteringXTermParser,
|
||||
patch_driver_parser,
|
||||
strip_malformed_mouse,
|
||||
)
|
||||
|
||||
NOISE = [
|
||||
"\x1b[<32;NaN;NaNM", # malformed SGR mouse (extended button on focus change)
|
||||
"\x1b[<35;NaN;NaNm",
|
||||
]
|
||||
|
||||
PRESERVED = [
|
||||
"\x1b[A", # arrow up
|
||||
"\x1b[15~", # F5
|
||||
"\x1b[97u", # plain kitty key 'a'
|
||||
"\x1b[<0;5;5M", # valid mouse down
|
||||
"\x1b[<0;5;5m", # valid mouse up
|
||||
"\x1b[<128;10;10M", # valid extended-button mouse
|
||||
"\x1b[<0;-1;-1M", # negative SGR-Pixels coords (Textual handles these)
|
||||
"\x1b[24;80R", # cursor position report
|
||||
"\x1b[I", # focus in
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seq", NOISE)
|
||||
def test_strip_removes_malformed_mouse(seq: str) -> None:
|
||||
assert strip_malformed_mouse(seq) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seq", PRESERVED)
|
||||
def test_strip_preserves_real_input(seq: str) -> None:
|
||||
assert strip_malformed_mouse(seq) == seq
|
||||
|
||||
|
||||
def test_strip_keeps_real_key_after_noise() -> None:
|
||||
assert strip_malformed_mouse("\x1b[<32;NaN;NaNM\x1b[A") == "\x1b[A"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seq", NOISE)
|
||||
def test_parser_emits_no_keys_for_noise(seq: str) -> None:
|
||||
tokens = list(FilteringXTermParser().feed(seq))
|
||||
assert [t for t in tokens if isinstance(t, events.Key) and t.character] == []
|
||||
|
||||
|
||||
def test_parser_still_emits_key_for_arrow() -> None:
|
||||
tokens = list(FilteringXTermParser().feed("\x1b[A"))
|
||||
assert any(isinstance(t, events.Key) and t.key == "up" for t in tokens)
|
||||
|
||||
|
||||
def test_all_noise_chunk_does_not_trip_eof() -> None:
|
||||
parser = FilteringXTermParser()
|
||||
assert list(parser.feed("\x1b[<32;NaN;NaNM")) == []
|
||||
# The parser must still be alive for the next real chunk.
|
||||
assert any(
|
||||
isinstance(t, events.Key) and t.key == "up" for t in parser.feed("\x1b[A")
|
||||
)
|
||||
|
||||
|
||||
def test_patch_driver_parser_rebinds_module_global() -> None:
|
||||
import sys
|
||||
|
||||
from textual.drivers.linux_driver import LinuxDriver
|
||||
|
||||
namespace = sys.modules[LinuxDriver.__module__].__dict__
|
||||
original = namespace["XTermParser"]
|
||||
try:
|
||||
patch_driver_parser(LinuxDriver)
|
||||
assert namespace["XTermParser"] is FilteringXTermParser
|
||||
finally:
|
||||
namespace["XTermParser"] = original
|
||||
|
|
@ -52,7 +52,6 @@ def test_resumed_user_message_with_images_renders_footer(tmp_path: Path) -> None
|
|||
[msg],
|
||||
tool_call_map={},
|
||||
start_index=0,
|
||||
tools_collapsed=False,
|
||||
history_widget_indices=WeakKeyDictionary(),
|
||||
)
|
||||
|
||||
|
|
@ -70,7 +69,6 @@ def test_resumed_user_message_with_images_only_still_mounts(tmp_path: Path) -> N
|
|||
[msg],
|
||||
tool_call_map={},
|
||||
start_index=0,
|
||||
tools_collapsed=False,
|
||||
history_widget_indices=WeakKeyDictionary(),
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue