v2.17.0 (#822)
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Hdandria <henri.dandria@mistral.ai> Co-authored-by: Ivana Dunisijevic <ivana.dunisijevic@mistral.ai> Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Mert Unsal <mert.unsal@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@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: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
564a14365e
commit
6bedf271ce
223 changed files with 10533 additions and 6947 deletions
|
|
@ -176,6 +176,27 @@ def test_resolve_api_key_for_plan_with_missing_env_var() -> None:
|
|||
environ["MISTRAL_API_KEY"] = previous_api_key
|
||||
|
||||
|
||||
def test_resolve_api_key_for_plan_falls_back_to_keyring(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
keyring_api_key = "keyring_mistral_api_key"
|
||||
monkeypatch.setattr(
|
||||
"vibe.core.config._settings.keyring.get_password",
|
||||
lambda service, username: keyring_api_key,
|
||||
)
|
||||
|
||||
provider = ProviderConfig(
|
||||
name="test_mistral",
|
||||
api_base="https://api.mistral.ai",
|
||||
backend=Backend.MISTRAL,
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
)
|
||||
|
||||
result = resolve_api_key_for_plan(provider)
|
||||
assert result == keyring_api_key
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("plan_info", "expected_cta"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
from vibe.cli.cache import read_cache, write_cache
|
||||
|
||||
|
||||
class TestReadCache:
|
||||
def test_reads_valid_toml(self, tmp_path: Path) -> None:
|
||||
cache_path = tmp_path / "cache.toml"
|
||||
cache_path.write_text('[update_cache]\nlatest_version = "1.0.0"\n')
|
||||
|
||||
result = read_cache(cache_path)
|
||||
|
||||
assert result["update_cache"]["latest_version"] == "1.0.0"
|
||||
|
||||
def test_returns_empty_dict_when_missing(self, tmp_path: Path) -> None:
|
||||
assert read_cache(tmp_path / "missing.toml") == {}
|
||||
|
||||
def test_returns_empty_dict_when_corrupted(self, tmp_path: Path) -> None:
|
||||
cache_path = tmp_path / "cache.toml"
|
||||
cache_path.write_text("{bad toml")
|
||||
|
||||
assert read_cache(cache_path) == {}
|
||||
|
||||
|
||||
class TestWriteCache:
|
||||
def test_writes_new_file(self, tmp_path: Path) -> None:
|
||||
cache_path = tmp_path / "cache.toml"
|
||||
|
||||
write_cache(cache_path, "feedback", {"last_shown_at": 100.0})
|
||||
|
||||
with cache_path.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert data["feedback"]["last_shown_at"] == 100.0
|
||||
|
||||
def test_merges_with_existing(self, tmp_path: Path) -> None:
|
||||
cache_path = tmp_path / "cache.toml"
|
||||
cache_path.write_text('[update_cache]\nlatest_version = "1.0.0"\n')
|
||||
|
||||
write_cache(cache_path, "feedback", {"last_shown_at": 200.0})
|
||||
|
||||
with cache_path.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert data["update_cache"]["latest_version"] == "1.0.0"
|
||||
assert data["feedback"]["last_shown_at"] == 200.0
|
||||
|
||||
def test_merges_within_section_and_leaves_other_sections_alone(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
cache_path = tmp_path / "cache.toml"
|
||||
cache_path.write_text(
|
||||
"[update_cache]\n"
|
||||
'latest_version = "1.0.0"\n'
|
||||
"stored_at_timestamp = 1\n"
|
||||
'seen_whats_new_version = "1.0.0"\n\n'
|
||||
"[feedback]\n"
|
||||
"last_shown_at = 100.0\n"
|
||||
)
|
||||
|
||||
write_cache(
|
||||
cache_path,
|
||||
"update_cache",
|
||||
{"latest_version": "2.0.0", "stored_at_timestamp": 2},
|
||||
)
|
||||
|
||||
with cache_path.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert data["update_cache"]["latest_version"] == "2.0.0"
|
||||
assert data["update_cache"]["stored_at_timestamp"] == 2
|
||||
assert data["update_cache"]["seen_whats_new_version"] == "1.0.0"
|
||||
assert data["feedback"]["last_shown_at"] == 100.0
|
||||
|
|
@ -128,6 +128,14 @@ class TestCommandRegistry:
|
|||
result = registry.parse_command("/connectors filesystem")
|
||||
assert result == ("mcp", registry.commands["mcp"], "filesystem")
|
||||
|
||||
def test_mcp_command_description_surfaces_auth_subcommands(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
command = registry.commands["mcp"]
|
||||
|
||||
assert "status" in command.description
|
||||
assert "login <alias>" in command.description
|
||||
assert "logout <alias>" in command.description
|
||||
|
||||
def test_data_retention_command_registration(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
result = registry.parse_command("/data-retention")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import tomllib
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from vibe.cli.textual_ui.widgets.feedback_bar_manager import FeedbackBarManager
|
||||
from vibe.core.cache_store import FileSystemVibeCodeCacheStore
|
||||
from vibe.core.feedback import (
|
||||
_CACHE_SECTION,
|
||||
_LAST_SHOWN_KEY,
|
||||
|
|
@ -15,24 +16,18 @@ from vibe.core.feedback import (
|
|||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
|
||||
def _patch_cache_file(tmp_path: Path):
|
||||
from vibe.core.paths._vibe_home import GlobalPath
|
||||
|
||||
return patch(
|
||||
"vibe.core.feedback.CACHE_FILE", GlobalPath(lambda: tmp_path / "cache.toml")
|
||||
)
|
||||
|
||||
|
||||
def _patch_probability(value: float):
|
||||
return patch("vibe.core.feedback.FEEDBACK_PROBABILITY", value)
|
||||
|
||||
|
||||
def _make_agent_loop(
|
||||
cache_path: Path,
|
||||
user_message_count: int = MIN_USER_MESSAGES_FOR_FEEDBACK,
|
||||
telemetry_active: bool = True,
|
||||
) -> MagicMock:
|
||||
loop = MagicMock()
|
||||
loop.telemetry_client.is_active.return_value = telemetry_active
|
||||
loop.cache_store = FileSystemVibeCodeCacheStore(cache_path)
|
||||
messages = [
|
||||
LLMMessage(role=Role.user, content=f"msg {i}")
|
||||
for i in range(user_message_count)
|
||||
|
|
@ -45,20 +40,22 @@ class TestShouldShow:
|
|||
def test_shows_when_conditions_met(self, tmp_path: Path) -> None:
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch("vibe.core.feedback.random.random", return_value=0.0),
|
||||
):
|
||||
assert manager.should_show(_make_agent_loop()) is True
|
||||
assert (
|
||||
manager.should_show(_make_agent_loop(tmp_path / "cache.toml")) is True
|
||||
)
|
||||
|
||||
def test_does_not_show_when_random_misses(self, tmp_path: Path) -> None:
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch("vibe.core.feedback.random.random", return_value=1.0),
|
||||
):
|
||||
assert manager.should_show(_make_agent_loop()) is False
|
||||
assert (
|
||||
manager.should_show(_make_agent_loop(tmp_path / "cache.toml")) is False
|
||||
)
|
||||
|
||||
def test_does_not_show_within_cooldown(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "cache.toml").write_text(
|
||||
|
|
@ -66,11 +63,12 @@ class TestShouldShow:
|
|||
)
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch("vibe.core.feedback.random.random", return_value=0.0),
|
||||
):
|
||||
assert manager.should_show(_make_agent_loop()) is False
|
||||
assert (
|
||||
manager.should_show(_make_agent_loop(tmp_path / "cache.toml")) is False
|
||||
)
|
||||
|
||||
def test_shows_after_cooldown_expires(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "cache.toml").write_text(
|
||||
|
|
@ -78,30 +76,38 @@ class TestShouldShow:
|
|||
)
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch("vibe.core.feedback.random.random", return_value=0.0),
|
||||
):
|
||||
assert manager.should_show(_make_agent_loop()) is True
|
||||
assert (
|
||||
manager.should_show(_make_agent_loop(tmp_path / "cache.toml")) is True
|
||||
)
|
||||
|
||||
def test_does_not_show_when_telemetry_inactive(self, tmp_path: Path) -> None:
|
||||
manager = FeedbackBarManager()
|
||||
with _patch_cache_file(tmp_path), _patch_probability(0.2):
|
||||
with _patch_probability(0.2):
|
||||
assert (
|
||||
manager.should_show(_make_agent_loop(telemetry_active=False)) is False
|
||||
manager.should_show(
|
||||
_make_agent_loop(tmp_path / "cache.toml", telemetry_active=False)
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_does_not_show_when_too_few_user_messages(self, tmp_path: Path) -> None:
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch("vibe.core.feedback.random.random", return_value=0.0),
|
||||
):
|
||||
assert manager.should_show(_make_agent_loop(user_message_count=1)) is False
|
||||
assert (
|
||||
manager.should_show(
|
||||
_make_agent_loop(tmp_path / "cache.toml", user_message_count=1)
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_skips_injected_messages_in_count(self, tmp_path: Path) -> None:
|
||||
loop = _make_agent_loop(user_message_count=0)
|
||||
loop = _make_agent_loop(tmp_path / "cache.toml", user_message_count=0)
|
||||
loop.messages = [
|
||||
LLMMessage(role=Role.user, content="real"),
|
||||
LLMMessage(role=Role.user, content="injected", injected=True),
|
||||
|
|
@ -109,7 +115,6 @@ class TestShouldShow:
|
|||
]
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch("vibe.core.feedback.random.random", return_value=0.0),
|
||||
):
|
||||
|
|
@ -121,8 +126,7 @@ class TestRecordFeedbackAsked:
|
|||
def test_writes_timestamp_to_cache(self, tmp_path: Path) -> None:
|
||||
manager = FeedbackBarManager()
|
||||
before = int(time.time())
|
||||
with _patch_cache_file(tmp_path):
|
||||
manager.record_feedback_asked()
|
||||
with (tmp_path / "cache.toml").open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
manager.record_feedback_asked(_make_agent_loop(tmp_path / "cache.toml"))
|
||||
with (tmp_path / "cache.toml").open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert data[_CACHE_SECTION][_LAST_SHOWN_KEY] >= before
|
||||
|
|
|
|||
|
|
@ -16,13 +16,41 @@ def test_help_shows_auto_approve_flag(
|
|||
assert exc_info.value.code == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "--auto-approve" in output
|
||||
assert "--yolo" in output
|
||||
assert "Shortcut for --agent auto-approve" in output
|
||||
|
||||
|
||||
def test_auto_approve_conflicts_with_agent(
|
||||
def test_help_shows_check_upgrade_flag(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setattr("sys.argv", ["vibe", "--agent", "plan", "--auto-approve"])
|
||||
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 "--check-upgrade" in output
|
||||
assert "Check for a Vibe update now" in output
|
||||
|
||||
|
||||
# def test_auto_approve_conflicts_with_agent(
|
||||
# monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
|
||||
|
||||
def test_yolo_alias_selects_auto_approve(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("sys.argv", ["vibe", "--yolo"])
|
||||
|
||||
args = parse_arguments()
|
||||
|
||||
assert args.auto_approve is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["--auto-approve", "--yolo"])
|
||||
def test_auto_approve_aliases_conflict_with_agent(
|
||||
flag: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setattr("sys.argv", ["vibe", "--agent", "plan", flag])
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
parse_arguments()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -21,6 +22,7 @@ def _make_args(**overrides: object) -> argparse.Namespace:
|
|||
"enabled_tools": None,
|
||||
"output": "text",
|
||||
"agent": "default",
|
||||
"check_upgrade": False,
|
||||
"setup": False,
|
||||
"workdir": None,
|
||||
"add_dir": [],
|
||||
|
|
@ -134,16 +136,15 @@ def test_trust_flag_trusts_cwd_for_session_only(
|
|||
|
||||
args = _make_args(trust=True, prompt=None)
|
||||
monkeypatch.setattr(entrypoint_mod, "parse_arguments", lambda: args)
|
||||
monkeypatch.setattr(
|
||||
entrypoint_mod, "check_and_resolve_trusted_folder", lambda _cwd: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None
|
||||
)
|
||||
|
||||
# Stop main() before it runs the actual CLI.
|
||||
monkeypatch.setattr(
|
||||
"vibe.cli.cli.run_cli", lambda _args: (_ for _ in ()).throw(SystemExit(0))
|
||||
)
|
||||
def fake_run_cli(_args: argparse.Namespace, **_kwargs: object) -> None:
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr("vibe.cli.cli.run_cli", fake_run_cli)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
entrypoint_mod.main()
|
||||
|
|
@ -172,9 +173,11 @@ def test_trust_flag_works_in_programmatic_mode(
|
|||
monkeypatch.setattr(
|
||||
entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"vibe.cli.cli.run_cli", lambda _args: (_ for _ in ()).throw(SystemExit(0))
|
||||
)
|
||||
|
||||
def fake_run_cli(_args: argparse.Namespace, **_kwargs: object) -> None:
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr("vibe.cli.cli.run_cli", fake_run_cli)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
entrypoint_mod.main()
|
||||
|
|
@ -183,6 +186,78 @@ def test_trust_flag_works_in_programmatic_mode(
|
|||
assert trusted_folders_manager._trusted == []
|
||||
|
||||
|
||||
def test_check_upgrade_does_not_pass_trust_resolver(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / "AGENTS.md").write_text("hello", encoding="utf-8")
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
args = _make_args(prompt=None, check_upgrade=True)
|
||||
monkeypatch.setattr(entrypoint_mod, "parse_arguments", lambda: args)
|
||||
monkeypatch.setattr(
|
||||
entrypoint_mod,
|
||||
"check_and_resolve_trusted_folder",
|
||||
lambda _cwd: pytest.fail("check-upgrade must not prompt for trust"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None
|
||||
)
|
||||
|
||||
def fake_run_cli(
|
||||
_args: argparse.Namespace,
|
||||
*,
|
||||
resolve_trusted_folder: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
assert resolve_trusted_folder is None
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr("vibe.cli.cli.run_cli", fake_run_cli)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
entrypoint_mod.main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
|
||||
def test_interactive_start_passes_trust_resolver_to_cli(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
args = _make_args(prompt=None)
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(entrypoint_mod, "parse_arguments", lambda: args)
|
||||
monkeypatch.setattr(
|
||||
entrypoint_mod,
|
||||
"check_and_resolve_trusted_folder",
|
||||
lambda _cwd: calls.append("trust"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None
|
||||
)
|
||||
|
||||
def fake_run_cli(
|
||||
_args: argparse.Namespace,
|
||||
*,
|
||||
resolve_trusted_folder: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
assert callable(resolve_trusted_folder)
|
||||
resolve_trusted_folder()
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr("vibe.cli.cli.run_cli", fake_run_cli)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
entrypoint_mod.main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert calls == ["trust"]
|
||||
|
||||
|
||||
def test_session_trust_does_not_write_to_disk(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
|
@ -224,3 +299,60 @@ def test_run_cli_passes_max_tokens_to_run_programmatic(
|
|||
|
||||
assert exc_info.value.code == 0
|
||||
assert call["max_session_tokens"] == 123
|
||||
|
||||
|
||||
def test_run_cli_runs_update_prompt_before_trust_resolver(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
args = _make_args(prompt=None)
|
||||
config = build_test_vibe_config()
|
||||
calls: list[str] = []
|
||||
|
||||
monkeypatch.setattr(cli_mod, "bootstrap_config_files", lambda: None)
|
||||
monkeypatch.setattr(cli_mod, "load_config_or_exit", lambda interactive: config)
|
||||
monkeypatch.setattr(
|
||||
cli_mod,
|
||||
"_maybe_run_startup_update_prompt",
|
||||
lambda _config, _repository: calls.append("update"),
|
||||
)
|
||||
|
||||
def resolve_trusted_folder() -> None:
|
||||
calls.append("trust")
|
||||
raise SystemExit(0)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cli_mod.run_cli(args, resolve_trusted_folder=resolve_trusted_folder)
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert calls == ["update", "trust"]
|
||||
|
||||
|
||||
def test_run_cli_check_upgrade_exits_before_loading_config(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
args = _make_args(prompt=None, check_upgrade=True)
|
||||
call: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr(cli_mod, "bootstrap_config_files", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
cli_mod,
|
||||
"load_config_or_exit",
|
||||
lambda interactive: pytest.fail("check-upgrade should not load config"),
|
||||
)
|
||||
monkeypatch.setattr(cli_mod, "load_update_prompt_theme", lambda: "dracula")
|
||||
|
||||
def fake_run_check_upgrade(_repository: object, *, theme: str | None) -> None:
|
||||
call["theme"] = theme
|
||||
|
||||
monkeypatch.setattr(cli_mod, "_run_check_upgrade", fake_run_check_upgrade)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cli_mod.run_cli(
|
||||
args,
|
||||
resolve_trusted_folder=lambda: pytest.fail(
|
||||
"check-upgrade should not prompt for trust"
|
||||
),
|
||||
)
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert call["theme"] == "dracula"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -114,7 +113,7 @@ async def test_resume_picker_shows_renamed_session_title(
|
|||
|
||||
assert handled is True
|
||||
assert captured_picker is not None
|
||||
assert captured_picker._latest_messages[f"local:{logger.session_id}"] == "New title"
|
||||
assert captured_picker._latest_messages[logger.session_id] == "New title"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -129,34 +128,3 @@ async def test_rename_command_requires_title(tmp_path: Path) -> None:
|
|||
assert any(error._error == "Usage: /rename <title>" for error in errors)
|
||||
|
||||
assert handled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_command_is_intercepted_for_remote_sessions(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config = build_test_vibe_config(session_logging=_enabled_session_config(tmp_path))
|
||||
app = build_test_vibe_app(config=config)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
||||
) as MockSource:
|
||||
remote_source = MagicMock()
|
||||
remote_source.session_id = "remote-session-id"
|
||||
remote_source.is_terminated = False
|
||||
remote_source.is_waiting_for_input = False
|
||||
MockSource.return_value = remote_source
|
||||
|
||||
await app._remote_manager.attach(
|
||||
session_id="remote-session-id", config=config
|
||||
)
|
||||
handled = await app._handle_command("/rename Remote title")
|
||||
await pilot.pause()
|
||||
errors = app.query(ErrorMessage)
|
||||
assert any(
|
||||
error._error == "Renaming is only supported for local sessions."
|
||||
for error in errors
|
||||
)
|
||||
|
||||
assert handled is True
|
||||
|
|
|
|||
|
|
@ -57,13 +57,11 @@ async def test_session_delete_request_deletes_local_session(
|
|||
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"
|
||||
)
|
||||
SessionPickerApp.SessionDeleteRequested("deleted-session", "deleted-session")
|
||||
)
|
||||
|
||||
assert deleted_sessions == [("deleted-session", config.session_logging)]
|
||||
assert picker.removed_option_ids == ["local:deleted-session"]
|
||||
assert picker.removed_option_ids == ["deleted-session"]
|
||||
assert any(
|
||||
isinstance(widget, UserCommandMessage)
|
||||
and widget._content
|
||||
|
|
@ -96,13 +94,11 @@ async def test_session_delete_request_keeps_picker_on_delete_error(
|
|||
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"
|
||||
)
|
||||
SessionPickerApp.SessionDeleteRequested("deleted-session", "deleted-session")
|
||||
)
|
||||
|
||||
assert picker.removed_option_ids == []
|
||||
assert picker.cleared_pending_option_ids == ["local:deleted-session"]
|
||||
assert picker.cleared_pending_option_ids == ["deleted-session"]
|
||||
assert any(
|
||||
isinstance(widget, ErrorMessage)
|
||||
and widget._error == "Failed to delete session: disk said no"
|
||||
|
|
@ -140,13 +136,11 @@ async def test_session_delete_request_returns_to_input_when_picker_becomes_empty
|
|||
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"
|
||||
)
|
||||
SessionPickerApp.SessionDeleteRequested("deleted-session", "deleted-session")
|
||||
)
|
||||
|
||||
assert switched_to_input is True
|
||||
assert picker.removed_option_ids == ["local:deleted-session"]
|
||||
assert picker.removed_option_ids == ["deleted-session"]
|
||||
assert [
|
||||
widget._content
|
||||
for widget in mounted_widgets
|
||||
|
|
@ -157,40 +151,6 @@ async def test_session_delete_request_returns_to_input_when_picker_becomes_empty
|
|||
]
|
||||
|
||||
|
||||
@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
|
||||
|
|
@ -217,12 +177,12 @@ async def test_session_delete_request_rejects_current_session(
|
|||
|
||||
await app.on_session_picker_app_session_delete_requested(
|
||||
SessionPickerApp.SessionDeleteRequested(
|
||||
f"local:{app.agent_loop.session_id}", "local", app.agent_loop.session_id
|
||||
app.agent_loop.session_id, app.agent_loop.session_id
|
||||
)
|
||||
)
|
||||
|
||||
assert deleted_sessions == []
|
||||
assert picker.cleared_pending_option_ids == [f"local:{app.agent_loop.session_id}"]
|
||||
assert picker.cleared_pending_option_ids == [app.agent_loop.session_id]
|
||||
assert any(
|
||||
isinstance(widget, ErrorMessage)
|
||||
and widget._error == "Deleting the current session is not supported."
|
||||
|
|
|
|||
|
|
@ -8,9 +8,20 @@ from unittest.mock import patch
|
|||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.cli.cli import _maybe_run_startup_update_prompt
|
||||
from vibe.cli.update_notifier import FileSystemUpdateCacheRepository, UpdateCache
|
||||
from vibe.setup.update_prompt.update_prompt_dialog import UpdatePromptResult
|
||||
from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
|
||||
from vibe import __version__
|
||||
from vibe.cli.cli import _maybe_run_startup_update_prompt, _run_check_upgrade
|
||||
from vibe.cli.update_notifier import (
|
||||
FileSystemUpdateCacheRepository,
|
||||
Update,
|
||||
UpdateCache,
|
||||
UpdateGatewayCause,
|
||||
UpdateGatewayError,
|
||||
)
|
||||
from vibe.setup.update_prompt.update_prompt_dialog import (
|
||||
UpdatePromptMode,
|
||||
UpdatePromptResult,
|
||||
)
|
||||
|
||||
|
||||
class _BrokenRepository:
|
||||
|
|
@ -129,6 +140,76 @@ def test_no_op_when_cache_read_raises_oserror() -> None:
|
|||
mock_ask.assert_not_called()
|
||||
|
||||
|
||||
def test_check_upgrade_fetches_and_prompts_when_update_is_available(
|
||||
repository: FileSystemUpdateCacheRepository,
|
||||
) -> None:
|
||||
notifier = FakeUpdateGateway(update=Update(latest_version="999.0.0"))
|
||||
|
||||
with patch(
|
||||
"vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE
|
||||
) as mock_ask:
|
||||
_run_check_upgrade(repository, update_notifier=notifier, theme="textual-light")
|
||||
|
||||
mock_ask.assert_called_once_with(
|
||||
__version__,
|
||||
"999.0.0",
|
||||
theme="textual-light",
|
||||
prompt_mode=UpdatePromptMode.CHECK_UPGRADE,
|
||||
)
|
||||
assert notifier.fetch_update_calls == 1
|
||||
|
||||
|
||||
def test_check_upgrade_cancel_does_not_dismiss_update(
|
||||
repository: FileSystemUpdateCacheRepository,
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_update_checks=True)
|
||||
notifier = FakeUpdateGateway(update=Update(latest_version="999.0.0"))
|
||||
|
||||
with patch(
|
||||
"vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE
|
||||
):
|
||||
_run_check_upgrade(repository, update_notifier=notifier)
|
||||
|
||||
cache = asyncio.run(repository.get())
|
||||
assert cache is not None
|
||||
assert cache.dismissed_version is None
|
||||
|
||||
with patch(
|
||||
"vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE
|
||||
) as mock_ask:
|
||||
_maybe_run_startup_update_prompt(config, repository)
|
||||
|
||||
mock_ask.assert_called_once()
|
||||
|
||||
|
||||
def test_check_upgrade_prints_up_to_date_when_no_update_exists(
|
||||
repository: FileSystemUpdateCacheRepository, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
notifier = FakeUpdateGateway(update=None)
|
||||
|
||||
with patch("vibe.cli.cli.ask_update_prompt") as mock_ask:
|
||||
_run_check_upgrade(repository, update_notifier=notifier)
|
||||
|
||||
mock_ask.assert_not_called()
|
||||
out = capsys.readouterr().out
|
||||
assert "already up to date" in out
|
||||
assert __version__ in out
|
||||
|
||||
|
||||
def test_check_upgrade_exits_one_when_gateway_errors(
|
||||
repository: FileSystemUpdateCacheRepository, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
notifier = FakeUpdateGateway(
|
||||
error=UpdateGatewayError(cause=UpdateGatewayCause.REQUEST_FAILED)
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_run_check_upgrade(repository, update_notifier=notifier)
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert "Update check failed" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_continue_marks_version_as_dismissed_and_prevents_reprompt(
|
||||
repository: FileSystemUpdateCacheRepository,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -8,10 +8,11 @@ import pytest
|
|||
|
||||
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
|
||||
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
||||
from tests.constants import OPENAI_BASE_URL
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.types import Backend, LLMMessage, Role
|
||||
from vibe.core.types import Backend
|
||||
|
||||
|
||||
def _chat_plan_gateway(*, prompt_switching_to_pro_plan: bool) -> FakeWhoAmIGateway:
|
||||
|
|
@ -100,46 +101,6 @@ async def test_teleport_command_without_history_sends_early_failure_telemetry(
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teleport_command_in_remote_session_sends_early_failure_telemetry(
|
||||
telemetry_events: list[dict[str, Any]],
|
||||
) -> None:
|
||||
app = build_test_vibe_app(
|
||||
config=_vibe_code_enabled_config(),
|
||||
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=False),
|
||||
)
|
||||
app.agent_loop.messages.append(LLMMessage(role=Role.user, content="hello"))
|
||||
app.agent_loop.messages.append(LLMMessage(role=Role.assistant, content="hi"))
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await _wait_until(
|
||||
pilot.pause,
|
||||
lambda: app.commands.get_command_name("/teleport") == "teleport",
|
||||
)
|
||||
|
||||
await app._remote_manager.attach(session_id="remote-session", config=app.config)
|
||||
await app.on_chat_input_container_submitted(
|
||||
ChatInputContainer.Submitted("/teleport")
|
||||
)
|
||||
await _wait_until(
|
||||
pilot.pause, lambda: len(_teleport_failed_events(telemetry_events)) == 1
|
||||
)
|
||||
await app._remote_manager.detach()
|
||||
|
||||
assert _teleport_failed_events(telemetry_events) == [
|
||||
{
|
||||
"event_name": "vibe.teleport_failed",
|
||||
"properties": {
|
||||
"stage": "remote_session",
|
||||
"error_class": "TeleportRemoteSessionError",
|
||||
"push_required": False,
|
||||
"nb_session_messages": 2,
|
||||
"session_id": app.agent_loop.session_id,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teleport_command_hidden_when_current_key_is_not_eligible() -> None:
|
||||
app = build_test_vibe_app(
|
||||
|
|
@ -215,7 +176,7 @@ async def test_teleport_command_hides_after_switching_to_non_mistral_model(
|
|||
),
|
||||
ProviderConfig(
|
||||
name="openai",
|
||||
api_base="https://api.openai.com/v1",
|
||||
api_base=f"{OPENAI_BASE_URL}/v1",
|
||||
api_key_env_var="OPENAI_API_KEY",
|
||||
backend=Backend.GENERIC,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
|
||||
|
||||
|
||||
def test_rendered_text_length_uses_terminal_cell_width() -> None:
|
||||
# "你" and "🙂" both occupy 2 terminal cells in Rich (+2 for separator).
|
||||
assert CompletionPopup.rendered_text_length("@你", "🙂") == 6
|
||||
|
||||
|
||||
def test_rendered_text_length_keeps_description_separator() -> None:
|
||||
assert CompletionPopup.rendered_text_length("@abc", "def") == 8
|
||||
|
|
@ -22,6 +22,11 @@ def _build(
|
|||
|
||||
def _render(*args, **kwargs):
|
||||
kwargs.setdefault("dark", True)
|
||||
args = list(args)
|
||||
# Tests pass a single start line as an int for readability; the renderer
|
||||
# expects a list of occurrences.
|
||||
if len(args) >= 4 and isinstance(args[3], int):
|
||||
args[3] = [args[3]]
|
||||
return render_edit_diff(*args, **kwargs)
|
||||
|
||||
|
||||
|
|
@ -156,6 +161,32 @@ class TestRenderEditDiff:
|
|||
assert any(_plain(w).rstrip().endswith("d") for w in removed)
|
||||
assert any(_plain(w).rstrip().endswith("Z") for w in added)
|
||||
|
||||
def test_replace_all_renders_each_occurrence(self) -> None:
|
||||
widgets = render_edit_diff(
|
||||
"foo", "bar", "py", [3, 10, 25], ansi=False, dark=True
|
||||
)
|
||||
removed = [w for w in widgets if "diff-removed" in w.classes]
|
||||
added = [w for w in widgets if "diff-added" in w.classes]
|
||||
assert len(removed) == 3
|
||||
assert len(added) == 3
|
||||
|
||||
def test_replace_all_uses_each_start_line(self) -> None:
|
||||
widgets = render_edit_diff(
|
||||
"foo", "bar", "py", [3, 10, 25], ansi=False, dark=True
|
||||
)
|
||||
joined = "\n".join(_plain(w) for w in widgets)
|
||||
assert "3" in joined
|
||||
assert "10" in joined
|
||||
assert "25" in joined
|
||||
|
||||
def test_replace_all_separates_occurrences_with_gap(self) -> None:
|
||||
widgets = render_edit_diff("foo", "bar", "py", [3, 10], ansi=False, dark=True)
|
||||
assert sum("diff-gap" in w.classes for w in widgets) == 1
|
||||
|
||||
def test_single_occurrence_has_no_gap(self) -> None:
|
||||
widgets = render_edit_diff("foo", "bar", "py", [3], ansi=False, dark=True)
|
||||
assert all("diff-gap" not in w.classes for w in widgets)
|
||||
|
||||
|
||||
class TestBorderColors:
|
||||
def test_keys_index_into_widgets(self) -> None:
|
||||
|
|
|
|||
|
|
@ -39,10 +39,12 @@ async def test_no_queue_header_when_empty(vibe_app: VibeApp) -> None:
|
|||
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"
|
||||
chat_input.value = "!sleep 1"
|
||||
await pilot.press("enter")
|
||||
|
||||
await _wait_until(pilot, lambda: vibe_app._bash_task is not None, timeout=1.0)
|
||||
assert await _wait_until(
|
||||
pilot, lambda: vibe_app._bash_task is not None, timeout=2.0
|
||||
)
|
||||
|
||||
chat_input.value = "!echo queued"
|
||||
await pilot.press("enter")
|
||||
|
|
@ -56,6 +58,15 @@ async def test_bash_submitted_during_running_bash_is_queued(vibe_app: VibeApp) -
|
|||
queued_bashes = [w for w in vibe_app.query(BashOutputMessage) if w._queued]
|
||||
assert len(queued_bashes) == 1
|
||||
|
||||
await pilot.press("ctrl+c")
|
||||
assert await _wait_until(
|
||||
pilot, lambda: len(vibe_app._input_queue) == 0, timeout=2.0
|
||||
)
|
||||
await pilot.press("escape")
|
||||
assert await _wait_until(
|
||||
pilot, lambda: vibe_app._bash_task is None, timeout=5.0
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_command_rejected_with_warning_when_busy(vibe_app: VibeApp) -> None:
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_app
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
from vibe.cli.textual_ui.app import VibeApp, _run_app_with_cleanup
|
||||
from vibe.cli.textual_ui.quit_manager import QUIT_CONFIRM_DELAY, QuitManager
|
||||
|
||||
|
||||
|
|
@ -182,3 +183,69 @@ class TestActionDeleteRightOrQuit:
|
|||
mock_confirm.assert_called_once_with(
|
||||
"Ctrl+D", "1 queued message will be discarded"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_cleanup_cancels_in_flight_tasks(app: VibeApp) -> None:
|
||||
async def _pending() -> None:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
agent_task = asyncio.create_task(_pending())
|
||||
bash_task = asyncio.create_task(_pending())
|
||||
app._agent_task = agent_task
|
||||
app._bash_task = bash_task
|
||||
|
||||
await asyncio.wait_for(app.shutdown_cleanup(), timeout=1.0)
|
||||
|
||||
assert agent_task.cancelled()
|
||||
assert bash_task.cancelled()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_disables_future_queue_drains(app: VibeApp) -> None:
|
||||
app._input_queue.append_prompt("queued")
|
||||
|
||||
await app._begin_shutdown()
|
||||
|
||||
with patch("vibe.cli.textual_ui.message_queue.asyncio.create_task") as create_task:
|
||||
app._queue.start_drain_if_needed()
|
||||
|
||||
create_task.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_begin_shutdown_stops_scheduled_loop_runner(app: VibeApp) -> None:
|
||||
with (
|
||||
patch.object(app._queue, "shutdown", new_callable=AsyncMock) as queue_shutdown,
|
||||
patch.object(app._loop_runner, "stop", new_callable=AsyncMock) as loop_stop,
|
||||
):
|
||||
await app._begin_shutdown()
|
||||
|
||||
queue_shutdown.assert_awaited_once()
|
||||
loop_stop.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_cleanup_flushes_telemetry(app: VibeApp) -> None:
|
||||
with patch.object(
|
||||
app.agent_loop.telemetry_client, "aclose", new_callable=AsyncMock
|
||||
) as telemetry_aclose:
|
||||
await app.shutdown_cleanup()
|
||||
|
||||
telemetry_aclose.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_app_with_cleanup_runs_cleanup_when_run_async_raises(
|
||||
app: VibeApp,
|
||||
) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
app, "run_async", new_callable=AsyncMock, side_effect=RuntimeError("boom")
|
||||
),
|
||||
patch.object(app, "shutdown_cleanup", new_callable=AsyncMock) as cleanup,
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
await _run_app_with_cleanup(app)
|
||||
|
||||
cleanup.assert_awaited_once()
|
||||
|
|
|
|||
|
|
@ -1,286 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.cli.textual_ui.remote.remote_session_manager import RemoteSessionManager
|
||||
from vibe.core.tools.builtins.ask_user_question import AskUserQuestionArgs
|
||||
from vibe.core.types import WaitingForInputEvent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager() -> RemoteSessionManager:
|
||||
return RemoteSessionManager()
|
||||
|
||||
|
||||
class TestProperties:
|
||||
def test_is_active_false_by_default(self, manager: RemoteSessionManager) -> None:
|
||||
assert manager.is_active is False
|
||||
|
||||
def test_is_terminated_false_when_inactive(
|
||||
self, manager: RemoteSessionManager
|
||||
) -> None:
|
||||
assert manager.is_terminated is False
|
||||
|
||||
def test_is_waiting_for_input_false_when_inactive(
|
||||
self, manager: RemoteSessionManager
|
||||
) -> None:
|
||||
assert manager.is_waiting_for_input is False
|
||||
|
||||
def test_has_pending_input_false_by_default(
|
||||
self, manager: RemoteSessionManager
|
||||
) -> None:
|
||||
assert manager.has_pending_input is False
|
||||
|
||||
def test_session_id_none_when_inactive(self, manager: RemoteSessionManager) -> None:
|
||||
assert manager.session_id is None
|
||||
|
||||
|
||||
class TestAttachDetach:
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_activates_manager(
|
||||
self, manager: RemoteSessionManager
|
||||
) -> None:
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
||||
) as MockSource:
|
||||
mock_source = MagicMock()
|
||||
mock_source.session_id = "test-session-id"
|
||||
MockSource.return_value = mock_source
|
||||
|
||||
config = MagicMock()
|
||||
await manager.attach(session_id="test-session-id", config=config)
|
||||
|
||||
assert manager.is_active is True
|
||||
assert manager.session_id == "test-session-id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detach_cleans_up(self, manager: RemoteSessionManager) -> None:
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
||||
) as MockSource:
|
||||
mock_source = AsyncMock()
|
||||
mock_source.session_id = "test-id"
|
||||
MockSource.return_value = mock_source
|
||||
|
||||
config = MagicMock()
|
||||
await manager.attach(session_id="test-id", config=config)
|
||||
await manager.detach()
|
||||
|
||||
assert manager.is_active is False
|
||||
assert manager.session_id is None
|
||||
assert manager.has_pending_input is False
|
||||
mock_source.close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_detaches_previous_session(
|
||||
self, manager: RemoteSessionManager
|
||||
) -> None:
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
||||
) as MockSource:
|
||||
first_source = AsyncMock()
|
||||
second_source = MagicMock()
|
||||
second_source.session_id = "second-id"
|
||||
MockSource.side_effect = [first_source, second_source]
|
||||
|
||||
config = MagicMock()
|
||||
await manager.attach(session_id="first-id", config=config)
|
||||
await manager.attach(session_id="second-id", config=config)
|
||||
|
||||
first_source.close.assert_called_once()
|
||||
assert manager.session_id == "second-id"
|
||||
|
||||
|
||||
class TestValidateInput:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_waiting_for_input(
|
||||
self, manager: RemoteSessionManager
|
||||
) -> None:
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
||||
) as MockSource:
|
||||
mock_source = MagicMock()
|
||||
mock_source.is_terminated = False
|
||||
mock_source.is_waiting_for_input = True
|
||||
MockSource.return_value = mock_source
|
||||
|
||||
config = MagicMock()
|
||||
await manager.attach(session_id="id", config=config)
|
||||
|
||||
assert manager.validate_input() is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_warning_when_terminated(
|
||||
self, manager: RemoteSessionManager
|
||||
) -> None:
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
||||
) as MockSource:
|
||||
mock_source = MagicMock()
|
||||
mock_source.is_terminated = True
|
||||
MockSource.return_value = mock_source
|
||||
|
||||
config = MagicMock()
|
||||
await manager.attach(session_id="id", config=config)
|
||||
|
||||
result = manager.validate_input()
|
||||
assert result is not None
|
||||
assert "ended" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_warning_when_not_waiting_for_input(
|
||||
self, manager: RemoteSessionManager
|
||||
) -> None:
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
||||
) as MockSource:
|
||||
mock_source = MagicMock()
|
||||
mock_source.is_terminated = False
|
||||
mock_source.is_waiting_for_input = False
|
||||
MockSource.return_value = mock_source
|
||||
|
||||
config = MagicMock()
|
||||
await manager.attach(session_id="id", config=config)
|
||||
|
||||
result = manager.validate_input()
|
||||
assert result is not None
|
||||
assert "not waiting" in result
|
||||
|
||||
|
||||
class TestSendPrompt:
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_when_inactive_and_required(
|
||||
self, manager: RemoteSessionManager
|
||||
) -> None:
|
||||
with pytest.raises(RuntimeError, match="No active remote session"):
|
||||
await manager.send_prompt("hello")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_silently_when_inactive_and_not_required(
|
||||
self, manager: RemoteSessionManager
|
||||
) -> None:
|
||||
await manager.send_prompt("hello", require_source=False)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restores_pending_on_error(
|
||||
self, manager: RemoteSessionManager
|
||||
) -> None:
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
||||
) as MockSource:
|
||||
mock_source = AsyncMock()
|
||||
mock_source.send_prompt.side_effect = Exception("connection error")
|
||||
MockSource.return_value = mock_source
|
||||
|
||||
config = MagicMock()
|
||||
await manager.attach(session_id="id", config=config)
|
||||
|
||||
event = WaitingForInputEvent(task_id="t1", label="test")
|
||||
manager.set_pending_input(event)
|
||||
|
||||
with pytest.raises(Exception, match="connection error"):
|
||||
await manager.send_prompt("hello")
|
||||
|
||||
assert manager.has_pending_input is True
|
||||
|
||||
|
||||
class TestPendingInput:
|
||||
def test_set_and_cancel_pending_input(self, manager: RemoteSessionManager) -> None:
|
||||
event = WaitingForInputEvent(task_id="t1", label="test")
|
||||
manager.set_pending_input(event)
|
||||
assert manager.has_pending_input is True
|
||||
|
||||
manager.cancel_pending_input()
|
||||
assert manager.has_pending_input is False
|
||||
|
||||
|
||||
class TestBuildQuestionArgs:
|
||||
def test_returns_none_with_no_predefined_answers(
|
||||
self, manager: RemoteSessionManager
|
||||
) -> None:
|
||||
event = WaitingForInputEvent(task_id="t1", label="test")
|
||||
assert manager.build_question_args(event) is None
|
||||
|
||||
def test_returns_none_with_one_predefined_answer(
|
||||
self, manager: RemoteSessionManager
|
||||
) -> None:
|
||||
event = WaitingForInputEvent(
|
||||
task_id="t1", label="test", predefined_answers=["only one"]
|
||||
)
|
||||
assert manager.build_question_args(event) is None
|
||||
|
||||
def test_returns_args_with_two_predefined_answers(
|
||||
self, manager: RemoteSessionManager
|
||||
) -> None:
|
||||
event = WaitingForInputEvent(
|
||||
task_id="t1", label="Pick one", predefined_answers=["yes", "no"]
|
||||
)
|
||||
result = manager.build_question_args(event)
|
||||
assert result is not None
|
||||
assert isinstance(result, AskUserQuestionArgs)
|
||||
assert len(result.questions) == 1
|
||||
assert result.questions[0].question == "Pick one"
|
||||
assert len(result.questions[0].options) == 2
|
||||
|
||||
def test_caps_at_four_predefined_answers(
|
||||
self, manager: RemoteSessionManager
|
||||
) -> None:
|
||||
event = WaitingForInputEvent(
|
||||
task_id="t1",
|
||||
label="Pick",
|
||||
predefined_answers=["a", "b", "c", "d", "e", "f"],
|
||||
)
|
||||
result = manager.build_question_args(event)
|
||||
assert result is not None
|
||||
assert len(result.questions[0].options) == 4
|
||||
|
||||
def test_uses_default_question_when_no_label(
|
||||
self, manager: RemoteSessionManager
|
||||
) -> None:
|
||||
event = WaitingForInputEvent(task_id="t1", predefined_answers=["a", "b"])
|
||||
result = manager.build_question_args(event)
|
||||
assert result is not None
|
||||
assert result.questions[0].question == "Choose an answer"
|
||||
|
||||
|
||||
class TestBuildTerminalMessage:
|
||||
def test_completed_when_no_source(self, manager: RemoteSessionManager) -> None:
|
||||
msg_type, text = manager.build_terminal_message()
|
||||
assert msg_type == "info"
|
||||
assert "completed" in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_state(self, manager: RemoteSessionManager) -> None:
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
||||
) as MockSource:
|
||||
mock_source = MagicMock()
|
||||
mock_source.is_failed = True
|
||||
mock_source.is_canceled = False
|
||||
MockSource.return_value = mock_source
|
||||
|
||||
config = MagicMock()
|
||||
await manager.attach(session_id="id", config=config)
|
||||
|
||||
msg_type, text = manager.build_terminal_message()
|
||||
assert msg_type == "error"
|
||||
assert "failed" in text.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canceled_state(self, manager: RemoteSessionManager) -> None:
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
||||
) as MockSource:
|
||||
mock_source = MagicMock()
|
||||
mock_source.is_failed = False
|
||||
mock_source.is_canceled = True
|
||||
MockSource.return_value = mock_source
|
||||
|
||||
config = MagicMock()
|
||||
await manager.attach(session_id="id", config=config)
|
||||
|
||||
msg_type, text = manager.build_terminal_message()
|
||||
assert msg_type == "warning"
|
||||
assert "canceled" in text.lower()
|
||||
|
|
@ -19,25 +19,21 @@ def sample_sessions() -> list[ResumeSessionInfo]:
|
|||
return [
|
||||
ResumeSessionInfo(
|
||||
session_id="session-a",
|
||||
source="local",
|
||||
cwd="/test",
|
||||
title="Session A",
|
||||
end_time=(datetime.now(UTC) - timedelta(minutes=5)).isoformat(),
|
||||
),
|
||||
ResumeSessionInfo(
|
||||
session_id="session-b",
|
||||
source="local",
|
||||
cwd="/test",
|
||||
title="Session B",
|
||||
end_time=(datetime.now(UTC) - timedelta(hours=1)).isoformat(),
|
||||
),
|
||||
ResumeSessionInfo(
|
||||
session_id="session-c",
|
||||
source="remote",
|
||||
cwd="/test",
|
||||
title="Session C",
|
||||
end_time=(datetime.now(UTC) - timedelta(days=1)).isoformat(),
|
||||
status="RUNNING",
|
||||
),
|
||||
]
|
||||
|
||||
|
|
@ -45,9 +41,9 @@ def sample_sessions() -> list[ResumeSessionInfo]:
|
|||
@pytest.fixture
|
||||
def sample_latest_messages() -> dict[str, str]:
|
||||
return {
|
||||
"local:session-a": "Help me fix this bug",
|
||||
"local:session-b": "Refactor the authentication module",
|
||||
"remote:session-c": "Add unit tests for the API",
|
||||
"session-a": "Help me fix this bug",
|
||||
"session-b": "Refactor the authentication module",
|
||||
"session-c": "Add unit tests for the API",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -143,31 +139,19 @@ class TestSessionPickerAppInit:
|
|||
|
||||
class TestSessionPickerMessages:
|
||||
def test_session_selected_stores_option_id(self) -> None:
|
||||
msg = SessionPickerApp.SessionSelected(
|
||||
"local:test-session-id", "local", "test-session-id"
|
||||
)
|
||||
assert msg.option_id == "local:test-session-id"
|
||||
assert msg.source == "local"
|
||||
msg = SessionPickerApp.SessionSelected("test-session-id", "test-session-id")
|
||||
assert msg.option_id == "test-session-id"
|
||||
assert msg.session_id == "test-session-id"
|
||||
|
||||
def test_session_selected_with_full_uuid(self) -> None:
|
||||
session_id = "abc12345-6789-0123-4567-89abcdef0123"
|
||||
option_id = f"remote:{session_id}"
|
||||
msg = SessionPickerApp.SessionSelected(option_id, "remote", session_id)
|
||||
assert msg.option_id == option_id
|
||||
assert msg.source == "remote"
|
||||
assert msg.session_id == session_id
|
||||
|
||||
def test_cancelled_can_be_instantiated(self) -> None:
|
||||
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"
|
||||
"test-session-id", "test-session-id"
|
||||
)
|
||||
assert msg.option_id == "local:test-session-id"
|
||||
assert msg.source == "local"
|
||||
assert msg.option_id == "test-session-id"
|
||||
assert msg.session_id == "test-session-id"
|
||||
|
||||
|
||||
|
|
@ -199,15 +183,15 @@ class TestSessionPickerSessionRemoval:
|
|||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
option_list = FakeOptionList(highlighted_option_id="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_delete_state(picker, kind="confirmation", option_id="session-a")
|
||||
assert option_list.replaced_prompts[-1].option_id == "session-a"
|
||||
assert (
|
||||
"Press D again to delete" in option_list.replaced_prompts[-1].prompt.plain
|
||||
)
|
||||
|
|
@ -222,7 +206,7 @@ class TestSessionPickerSessionRemoval:
|
|||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
option_list = FakeOptionList(highlighted_option_id="session-a")
|
||||
posted_messages: list[object] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
|
@ -230,14 +214,13 @@ class TestSessionPickerSessionRemoval:
|
|||
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_delete_state(picker, kind="pending", option_id="session-a")
|
||||
assert option_list.replaced_prompts[-1].option_id == "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.option_id == "session-a"
|
||||
assert message.session_id == "session-a"
|
||||
|
||||
def test_delete_confirmation_is_consumed_after_request(
|
||||
|
|
@ -249,7 +232,7 @@ class TestSessionPickerSessionRemoval:
|
|||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
option_list = FakeOptionList(highlighted_option_id="session-a")
|
||||
posted_messages: list[object] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
|
@ -259,34 +242,10 @@ class TestSessionPickerSessionRemoval:
|
|||
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_delete_state(picker, kind="pending", option_id="session-a")
|
||||
assert option_list.replaced_prompts[-1].option_id == "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],
|
||||
|
|
@ -298,15 +257,15 @@ class TestSessionPickerSessionRemoval:
|
|||
latest_messages=sample_latest_messages,
|
||||
current_session_id="session-a",
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
option_list = FakeOptionList(highlighted_option_id="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_delete_state(picker, kind="feedback", option_id="session-a")
|
||||
assert option_list.replaced_prompts[-1].option_id == "session-a"
|
||||
assert (
|
||||
"Can't delete current session"
|
||||
in option_list.replaced_prompts[-1].prompt.plain
|
||||
|
|
@ -315,7 +274,7 @@ class TestSessionPickerSessionRemoval:
|
|||
|
||||
picker.action_request_delete()
|
||||
|
||||
assert_delete_state(picker, kind="feedback", option_id="local:session-a")
|
||||
assert_delete_state(picker, kind="feedback", option_id="session-a")
|
||||
assert posted_messages == []
|
||||
|
||||
def test_pending_delete_blocks_resume_selection(
|
||||
|
|
@ -327,7 +286,7 @@ class TestSessionPickerSessionRemoval:
|
|||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
option_list = FakeOptionList(highlighted_option_id="session-a")
|
||||
posted_messages: list[object] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
|
@ -335,15 +294,15 @@ class TestSessionPickerSessionRemoval:
|
|||
picker.action_request_delete()
|
||||
picker.action_request_delete()
|
||||
picker.on_option_list_option_selected(
|
||||
cast(OptionList.OptionSelected, FakeOptionEvent("local:session-a"))
|
||||
cast(OptionList.OptionSelected, FakeOptionEvent("session-a"))
|
||||
)
|
||||
picker.on_option_list_option_selected(
|
||||
cast(OptionList.OptionSelected, FakeOptionEvent("local:session-b"))
|
||||
cast(OptionList.OptionSelected, FakeOptionEvent("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")
|
||||
assert_delete_state(picker, kind="pending", option_id="session-a")
|
||||
|
||||
def test_clear_pending_delete_restores_session_option(
|
||||
self,
|
||||
|
|
@ -354,7 +313,7 @@ class TestSessionPickerSessionRemoval:
|
|||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
option_list = FakeOptionList(highlighted_option_id="session-a")
|
||||
posted_messages: list[object] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
|
@ -362,9 +321,9 @@ class TestSessionPickerSessionRemoval:
|
|||
picker.action_request_delete()
|
||||
picker.action_request_delete()
|
||||
|
||||
assert picker.clear_pending_delete("local:session-a") is True
|
||||
assert picker.clear_pending_delete("session-a") is True
|
||||
assert picker._delete_state is None
|
||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
||||
assert option_list.replaced_prompts[-1].option_id == "session-a"
|
||||
assert "Help me fix this bug" in option_list.replaced_prompts[-1].prompt.plain
|
||||
|
||||
def test_highlighting_another_session_clears_confirmation(
|
||||
|
|
@ -376,16 +335,16 @@ class TestSessionPickerSessionRemoval:
|
|||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
option_list = FakeOptionList(highlighted_option_id="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"))
|
||||
cast(OptionList.OptionHighlighted, FakeOptionEvent("session-b"))
|
||||
)
|
||||
|
||||
assert picker._delete_state is None
|
||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
||||
assert option_list.replaced_prompts[-1].option_id == "session-a"
|
||||
assert "Help me fix this bug" in option_list.replaced_prompts[-1].prompt.plain
|
||||
|
||||
def test_highlighting_another_session_clears_delete_feedback(
|
||||
|
|
@ -395,18 +354,20 @@ class TestSessionPickerSessionRemoval:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
sessions=sample_sessions,
|
||||
latest_messages=sample_latest_messages,
|
||||
current_session_id="session-c",
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="remote:session-c")
|
||||
option_list = FakeOptionList(highlighted_option_id="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"))
|
||||
cast(OptionList.OptionHighlighted, FakeOptionEvent("session-b"))
|
||||
)
|
||||
|
||||
assert picker._delete_state is None
|
||||
assert option_list.replaced_prompts[-1].option_id == "remote:session-c"
|
||||
assert option_list.replaced_prompts[-1].option_id == "session-c"
|
||||
assert (
|
||||
"Add unit tests for the API"
|
||||
in option_list.replaced_prompts[-1].prompt.plain
|
||||
|
|
@ -421,7 +382,7 @@ class TestSessionPickerSessionRemoval:
|
|||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
option_list = FakeOptionList(highlighted_option_id="session-a")
|
||||
posted_messages: list[object] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
|
@ -444,9 +405,11 @@ class TestSessionPickerSessionRemoval:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
sessions=sample_sessions,
|
||||
latest_messages=sample_latest_messages,
|
||||
current_session_id="session-c",
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="remote:session-c")
|
||||
option_list = FakeOptionList(highlighted_option_id="session-c")
|
||||
posted_messages: list[object] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
|
@ -471,20 +434,20 @@ class TestSessionPickerSessionRemoval:
|
|||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
||||
option_list = FakeOptionList(highlighted_option_id="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_delete_state(picker, kind="confirmation", option_id="session-a")
|
||||
|
||||
assert picker.remove_session("local:session-a") is True
|
||||
assert picker.remove_session("session-a") is True
|
||||
|
||||
assert [session.option_id for session in picker._sessions] == [
|
||||
"local:session-b",
|
||||
"remote:session-c",
|
||||
"session-b",
|
||||
"session-c",
|
||||
]
|
||||
assert "local:session-a" not in picker._latest_messages
|
||||
assert "session-a" not in picker._latest_messages
|
||||
assert picker._delete_state is None
|
||||
assert option_list.removed_option_ids == ["local:session-a"]
|
||||
assert option_list.removed_option_ids == ["session-a"]
|
||||
|
||||
def test_remove_missing_session_returns_false(
|
||||
self,
|
||||
|
|
@ -498,7 +461,7 @@ class TestSessionPickerSessionRemoval:
|
|||
option_list = FakeOptionList()
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
|
||||
assert picker.remove_session("local:missing") is False
|
||||
assert picker.remove_session("missing") is False
|
||||
|
||||
assert picker._sessions == sample_sessions
|
||||
assert picker._latest_messages == sample_latest_messages
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ from weakref import WeakKeyDictionary
|
|||
|
||||
from vibe.cli.textual_ui.widgets.messages import UserMessage
|
||||
from vibe.cli.textual_ui.windowing.history import build_history_widgets
|
||||
from vibe.core.types import ImageAttachment, LLMMessage, Role
|
||||
from vibe.core.types import FileImageSource, ImageAttachment, LLMMessage, Role
|
||||
|
||||
|
||||
def _att(path: Path, alias: str) -> ImageAttachment:
|
||||
path.write_bytes(b"\x89PNG")
|
||||
return ImageAttachment(path=path, alias=alias, mime_type="image/png")
|
||||
return ImageAttachment(
|
||||
source=FileImageSource(path=path), alias=alias, mime_type="image/png"
|
||||
)
|
||||
|
||||
|
||||
def test_attachments_footer_singular(tmp_path: Path) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue