From 725d3a56ce99aa69157f33cabdf28f23a69320da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Drouin?= Date: Fri, 19 Jun 2026 17:39:44 +0200 Subject: [PATCH] v2.17.1 (#823) Co-authored-by: Jean Burellier Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com> Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com> Co-authored-by: Mistral Vibe --- CHANGELOG.md | 8 + distribution/zed/extension.toml | 12 +- pyproject.toml | 8 +- tests/acp/test_commands.py | 60 ++++++ tests/acp/test_initialize.py | 4 +- tests/acp/test_load_session.py | 2 +- tests/acp/test_new_session.py | 11 +- tests/acp/test_workspace_trust.py | 84 +++++++- .../cli/plan_offer/test_decide_plan_offer.py | 57 +++++ tests/cli/test_commands.py | 35 ++-- tests/cli/test_ui_teleport_availability.py | 91 ++++++-- tests/cli/textual_ui/test_diff_rendering.py | 6 +- tests/skills/registry/test_client.py | 195 ++++++++++++++++++ tests/skills/registry/test_models.py | 169 +++++++++++++++ ...leport_command_visible_for_pro_account.svg | 50 ++--- tests/snapshots/test_ui_snapshot_teleport.py | 26 --- uv.lock | 38 ++-- vibe/__init__.py | 2 +- vibe/acp/acp_agent_loop.py | 59 ++++-- vibe/acp/commands/__init__.py | 4 +- vibe/acp/commands/registry.py | 20 +- vibe/acp/teleport.py | 16 ++ vibe/cli/commands.py | 46 ++--- vibe/cli/plan_offer/decide_plan_offer.py | 27 ++- vibe/cli/textual_ui/app.py | 78 ++++--- vibe/cli/textual_ui/app.tcss | 23 ++- vibe/cli/textual_ui/widgets/diff_rendering.py | 100 +++++---- vibe/cli/textual_ui/widgets/tool_widgets.py | 2 +- vibe/core/config/_settings.py | 9 + vibe/core/config/vibe_schema.py | 9 + vibe/core/skills/models.py | 43 +++- vibe/core/skills/registry/__init__.py | 0 vibe/core/skills/registry/_client.py | 135 ++++++++++++ vibe/core/skills/registry/models.py | 187 +++++++++++++++++ vibe/core/telemetry/types.py | 2 +- 35 files changed, 1330 insertions(+), 288 deletions(-) create mode 100644 tests/skills/registry/test_client.py create mode 100644 tests/skills/registry/test_models.py create mode 100644 vibe/core/skills/registry/__init__.py create mode 100644 vibe/core/skills/registry/_client.py create mode 100644 vibe/core/skills/registry/models.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6449832..edce982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.17.1] - 2026-06-19 + +### Changed + +- Commands in `/help` are now listed alphabetically in both the CLI and ACP +- `/teleport` is now always available and shows an explicit error when its prerequisites aren't met, instead of being hidden + + ## [2.17.0] - 2026-06-19 ### Added diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 9082bd7..4b4e81a 100644 --- a/distribution/zed/extension.toml +++ b/distribution/zed/extension.toml @@ -1,7 +1,7 @@ id = "mistral-vibe" name = "Mistral Vibe" description = "Mistral's open-source coding assistant" -version = "2.17.0" +version = "2.17.1" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,21 +11,21 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-darwin-aarch64-2.17.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-darwin-aarch64-2.17.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-darwin-x86_64-2.17.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-darwin-x86_64-2.17.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-linux-aarch64-2.17.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-linux-aarch64-2.17.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-linux-x86_64-2.17.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-linux-x86_64-2.17.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-windows-x86_64-2.17.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-windows-x86_64-2.17.1.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index c6a2088..52213d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.17.0" +version = "2.17.1" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" @@ -50,7 +50,7 @@ dependencies = [ "httpx==0.28.1", "httpx-sse==0.4.3", "humanize==4.15.0", - "idna==3.13", + "idna==3.18", "importlib-metadata==8.7.1", "jaraco-classes==3.4.0", "jaraco-context==6.1.2", @@ -65,10 +65,10 @@ dependencies = [ "linkify-it-py==2.1.0", "markdown-it-py==4.0.0", "markdownify==1.2.2", - "mcp==1.27.2", + "mcp==1.28.0", "mdit-py-plugins==0.5.0", "mdurl==0.1.2", - "mistralai==2.4.9", + "mistralai==2.4.11", "more-itertools==11.0.2", "opentelemetry-api==1.39.1", "opentelemetry-exporter-otlp-proto-common==1.39.1", diff --git a/tests/acp/test_commands.py b/tests/acp/test_commands.py index 136a564..962a922 100644 --- a/tests/acp/test_commands.py +++ b/tests/acp/test_commands.py @@ -165,6 +165,25 @@ class TestHandleHelp: for cmd in main_commands: assert f"/{cmd}" in content + @pytest.mark.asyncio + async def test_lists_registered_commands_alphabetically( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_id = await _new_session_and_clear(acp_agent_loop) + await _prompt(acp_agent_loop, session_id, "/help") + + content = _get_message_texts(acp_agent_loop)[0] + commands_section = content.split("### Available Commands\n\n", maxsplit=1)[ + 1 + ].split("\n\n", maxsplit=1)[0] + command_names = [ + line.split("`", maxsplit=2)[1].removeprefix("/") + for line in commands_section.splitlines() + if line.startswith("- ") + ] + + assert command_names == sorted(command_names) + @pytest.mark.asyncio async def test_includes_user_invocable_skills( self, acp_agent_loop_with_skills: VibeAcpAgentLoop, skills_dir: Path @@ -267,6 +286,28 @@ class TestHandleTeleport: ] assert _get_tool_updates(acp_agent_loop) == [] + @pytest.mark.asyncio + async def test_teleport_replies_with_error_when_model_not_mistral( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_id = await _new_session_and_clear(acp_agent_loop) + + with patch( + "vibe.core.config._settings.VibeConfig.is_active_model_mistral", + return_value=False, + ): + response = await _prompt(acp_agent_loop, session_id, "/teleport") + + assert response.stop_reason == "end_turn" + assert response.field_meta == { + "tool_name": "teleport", + "teleport": {"status": "unavailable"}, + } + texts = _get_message_texts(acp_agent_loop) + assert len(texts) == 1 + assert "active Mistral model" in texts[0] + assert _get_tool_updates(acp_agent_loop) == [] + @pytest.mark.asyncio async def test_teleport_sends_tool_updates_and_structured_url( self, acp_agent_loop: VibeAcpAgentLoop @@ -542,6 +583,25 @@ class TestCommandFallthrough: class TestAvailableCommandsWithSkills: + @pytest.mark.asyncio + async def test_available_commands_are_alphabetical( + self, acp_agent_loop_with_skills: VibeAcpAgentLoop, skills_dir: Path + ) -> None: + create_skill(skills_dir, "alpha-skill", "First skill") + + await acp_agent_loop_with_skills.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + await _wait_for_available_commands(acp_agent_loop_with_skills) + + updates = _get_client(acp_agent_loop_with_skills)._session_updates + available = [ + u for u in updates if isinstance(u.update, AvailableCommandsUpdate) + ] + cmd_names = [c.name for c in available[0].update.available_commands] + + assert cmd_names == sorted(cmd_names) + @pytest.mark.asyncio async def test_skills_appear_in_available_commands( self, acp_agent_loop_with_skills: VibeAcpAgentLoop, skills_dir: Path diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index 2aa9c97..bc3dbbb 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -72,7 +72,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.1" ) assert response.auth_methods is not None @@ -172,7 +172,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.1" ) assert response.auth_methods is not None diff --git a/tests/acp/test_load_session.py b/tests/acp/test_load_session.py index a0d6726..9127266 100644 --- a/tests/acp/test_load_session.py +++ b/tests/acp/test_load_session.py @@ -152,7 +152,7 @@ class TestLoadSession: "cwd": str(tmp_working_directory.resolve()), "repoRoot": None, "ignoredFiles": ["AGENTS.md"], - "availableDecisions": ["trust_cwd", "trust_session", "decline"], + "availableDecisions": ["trust_cwd", "decline"], }, } } diff --git a/tests/acp/test_new_session.py b/tests/acp/test_new_session.py index bfd7c36..a73c621 100644 --- a/tests/acp/test_new_session.py +++ b/tests/acp/test_new_session.py @@ -200,7 +200,7 @@ class TestACPNewSession: "cwd": str(tmp_working_directory.resolve()), "repoRoot": None, "ignoredFiles": ["AGENTS.md"], - "availableDecisions": ["trust_cwd", "trust_session", "decline"], + "availableDecisions": ["trust_cwd", "decline"], }, } assert trusted_folders_manager.is_trusted(tmp_working_directory) is None @@ -230,12 +230,7 @@ class TestACPNewSession: "cwd": str(cwd.resolve()), "repoRoot": str(repo.resolve()), "ignoredFiles": ["AGENTS.md"], - "availableDecisions": [ - "trust_repo", - "trust_cwd", - "trust_session", - "decline", - ], + "availableDecisions": ["trust_repo", "trust_cwd", "decline"], }, } assert trusted_folders_manager.is_trusted(repo) is None @@ -264,7 +259,7 @@ class TestACPNewSession: "cwd": str(tmp_working_directory.resolve()), "repoRoot": None, "ignoredFiles": ["AGENTS.md"], - "availableDecisions": ["trust_cwd", "trust_session", "decline"], + "availableDecisions": ["trust_cwd", "decline"], }, } assert trusted_folders_manager.is_trusted(tmp_working_directory) is False diff --git a/tests/acp/test_workspace_trust.py b/tests/acp/test_workspace_trust.py index 28b17f3..af5f56e 100644 --- a/tests/acp/test_workspace_trust.py +++ b/tests/acp/test_workspace_trust.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from pathlib import Path import pytest @@ -20,6 +21,14 @@ async def _system_prompt(acp_agent_loop: VibeAcpAgentLoop, session_id: str) -> s return session.agent_loop.messages[0].content or "" +async def _wait_for_background_tasks( + acp_agent_loop: VibeAcpAgentLoop, session_id: str +) -> None: + session = acp_agent_loop.sessions[session_id] + while session._tasks: + await asyncio.gather(*list(session._tasks)) + + @pytest.fixture def acp_agent_loop( backend: FakeBackend, monkeypatch: pytest.MonkeyPatch @@ -66,12 +75,12 @@ class TestWorkspaceTrustExtMethods: "cwd": str(tmp_working_directory.resolve()), "repoRoot": None, "ignoredFiles": ["AGENTS.md"], - "availableDecisions": ["trust_cwd", "trust_session", "decline"], + "availableDecisions": ["trust_cwd", "decline"], }, } @pytest.mark.asyncio - async def test_workspace_trust_decision_trusts_session_and_reloads_session( + async def test_workspace_trust_decision_trusts_cwd_and_reloads_session( self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path ) -> None: (tmp_working_directory / "AGENTS.md").write_text( @@ -90,19 +99,74 @@ class TestWorkspaceTrustExtMethods: "trust/decision", { "cwd": str(tmp_working_directory), - "decision": "trust_session", + "decision": "trust_cwd", "session_id": session_response.session_id, }, ) - assert response == {"trust_status": "session", "details": None} + assert response == {"trust_status": "trusted", "details": None} normalized = str(tmp_working_directory.resolve()) - assert normalized in trusted_folders_manager._session_trusted - assert normalized not in trusted_folders_manager._trusted + assert normalized not in trusted_folders_manager._session_trusted + assert normalized in trusted_folders_manager._trusted + await _wait_for_background_tasks(acp_agent_loop, session_response.session_id) assert "Reloaded session instructions" in await _system_prompt( acp_agent_loop, session_response.session_id ) + @pytest.mark.asyncio + async def test_workspace_trust_decision_returns_before_reload_completes( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_working_directory: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + (tmp_working_directory / "AGENTS.md").write_text( + "Slow reload instructions", encoding="utf-8" + ) + + session_response = await acp_agent_loop.new_session( + cwd=str(tmp_working_directory), mcp_servers=[] + ) + assert session_response.session_id is not None + + reload_started = asyncio.Event() + release_reload = asyncio.Event() + + async def slow_reload_session_config(session) -> None: + reload_started.set() + await release_reload.wait() + + monkeypatch.setattr( + acp_agent_loop, "_reload_session_config", slow_reload_session_config + ) + + decision_task = asyncio.create_task( + acp_agent_loop.ext_method( + "trust/decision", + { + "cwd": str(tmp_working_directory), + "decision": "trust_cwd", + "session_id": session_response.session_id, + }, + ) + ) + + try: + await asyncio.wait_for(reload_started.wait(), timeout=1) + await asyncio.sleep(0) + + assert decision_task.done() + assert decision_task.result() == { + "trust_status": "trusted", + "details": None, + } + finally: + release_reload.set() + await decision_task + await _wait_for_background_tasks( + acp_agent_loop, session_response.session_id + ) + @pytest.mark.asyncio async def test_workspace_trust_decision_rejects_unavailable_decision( self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path @@ -117,6 +181,12 @@ class TestWorkspaceTrustExtMethods: {"cwd": str(tmp_working_directory), "decision": "trust_repo"}, ) + with pytest.raises(InvalidRequestError): + await acp_agent_loop.ext_method( + "trust/decision", + {"cwd": str(tmp_working_directory), "decision": "trust_session"}, + ) + assert trusted_folders_manager.is_trusted(tmp_working_directory) is None @pytest.mark.asyncio @@ -132,7 +202,7 @@ class TestWorkspaceTrustExtMethods: "trust/decision", { "cwd": str(tmp_working_directory), - "decision": "trust_session", + "decision": "trust_cwd", "session_id": "missing-session", }, ) diff --git a/tests/cli/plan_offer/test_decide_plan_offer.py b/tests/cli/plan_offer/test_decide_plan_offer.py index e3e86b8..47953a6 100644 --- a/tests/cli/plan_offer/test_decide_plan_offer.py +++ b/tests/cli/plan_offer/test_decide_plan_offer.py @@ -10,6 +10,7 @@ from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway from vibe.cli.plan_offer.decide_plan_offer import ( PlanInfo, WhoAmIPlanType, + check_teleport_eligibility, decide_plan_offer, plan_offer_cta, plan_title, @@ -250,6 +251,62 @@ def test_plan_offer_cta_uses_configured_vibe_url() -> None: ) +def test_check_teleport_eligibility_returns_none_for_eligible_key() -> None: + plan_info = PlanInfo( + plan_type=WhoAmIPlanType.CHAT, + plan_name="INDIVIDUAL", + prompt_switching_to_pro_plan=False, + ) + + assert check_teleport_eligibility(plan_info) is None + + +@pytest.mark.parametrize( + "plan_info", + [ + PlanInfo( + plan_type=WhoAmIPlanType.CHAT, + plan_name="INDIVIDUAL", + prompt_switching_to_pro_plan=True, + ), + PlanInfo( + plan_type=WhoAmIPlanType.API, + plan_name="FREE", + prompt_switching_to_pro_plan=False, + ), + PlanInfo( + plan_type=WhoAmIPlanType.CHAT, + plan_name="FREE", + prompt_switching_to_pro_plan=False, + ), + None, + ], + ids=["pro-plan-wrong-key", "api-free", "chat-free", "unresolved"], +) +def test_check_teleport_eligibility_points_ineligible_keys_to_api_key_url( + plan_info: PlanInfo | None, +) -> None: + message = check_teleport_eligibility(plan_info) + + assert message is not None + assert "https://chat.mistral.ai/code/extensions?focus=key" in message + + +def test_check_teleport_eligibility_uses_configured_vibe_url() -> None: + plan_info = PlanInfo( + plan_type=WhoAmIPlanType.CHAT, + plan_name="INDIVIDUAL", + prompt_switching_to_pro_plan=True, + ) + + message = check_teleport_eligibility( + plan_info, vibe_base_url="https://vibe.example.com/" + ) + + assert message is not None + assert "https://vibe.example.com/code/extensions?focus=key" in message + + @pytest.mark.parametrize( ("response", "expected"), [ diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index ac90326..c1f0a0f 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -1,20 +1,6 @@ from __future__ import annotations -from vibe.cli.commands import Command, CommandAvailabilityContext, CommandRegistry -from vibe.cli.plan_offer.decide_plan_offer import PlanInfo -from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType - - -def _eligible_teleport_context() -> CommandAvailabilityContext: - return CommandAvailabilityContext( - vibe_code_enabled=True, - is_active_model_mistral=True, - plan_info=PlanInfo( - plan_type=WhoAmIPlanType.CHAT, - plan_name="INDIVIDUAL", - prompt_switching_to_pro_plan=False, - ), - ) +from vibe.cli.commands import Command, CommandRegistry class TestCommandRegistry: @@ -79,7 +65,7 @@ class TestCommandRegistry: assert registry.parse_command("/teleport") is None def test_teleport_command_registration_uses_resolved_context(self) -> None: - registry = CommandRegistry(availability_context=_eligible_teleport_context()) + registry = CommandRegistry(vibe_code_enabled=True) assert registry.get_command_name("/teleport") == "teleport" assert registry.has_command("teleport") @@ -87,12 +73,23 @@ class TestCommandRegistry: registry = CommandRegistry() assert "/teleport" not in registry.get_help_text() - eligible_registry = CommandRegistry( - availability_context=_eligible_teleport_context() - ) + eligible_registry = CommandRegistry(vibe_code_enabled=True) assert eligible_registry.get("teleport") is not None assert "/teleport" in eligible_registry.get_help_text() + def test_help_text_lists_commands_alphabetically(self) -> None: + registry = CommandRegistry() + commands_section = registry.get_help_text().split( + "### Commands\n\n", maxsplit=1 + )[1] + command_names = [ + line.split("`", maxsplit=2)[1].removeprefix("/") + for line in commands_section.splitlines() + if line.startswith("- ") + ] + + assert command_names == sorted(command_names) + def test_resume_command_registration(self) -> None: registry = CommandRegistry() assert registry.get_command_name("/resume") == "resume" diff --git a/tests/cli/test_ui_teleport_availability.py b/tests/cli/test_ui_teleport_availability.py index 13fcac5..b8fe67e 100644 --- a/tests/cli/test_ui_teleport_availability.py +++ b/tests/cli/test_ui_teleport_availability.py @@ -11,6 +11,7 @@ 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.cli.textual_ui.widgets.messages import ErrorMessage from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig from vibe.core.types import Backend @@ -48,6 +49,10 @@ def _teleport_failed_events( ] +def _error_messages(app) -> list[str]: + return [error._error for error in app.query(ErrorMessage)] + + @pytest.mark.asyncio async def test_teleport_command_visible_for_paid_chat_users() -> None: app = build_test_vibe_app( @@ -102,66 +107,101 @@ async def test_teleport_command_without_history_sends_early_failure_telemetry( @pytest.mark.asyncio -async def test_teleport_command_hidden_when_current_key_is_not_eligible() -> None: +async def test_teleport_command_visible_but_errors_when_key_not_eligible( + 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=True), ) async with app.run_test() as pilot: - await pilot.pause(0.2) + await _wait_until( + pilot.pause, + lambda: app.commands.get_command_name("/teleport") == "teleport", + ) - assert app.commands.get_command_name("/teleport") is None - assert "/teleport" not in app.commands.get_help_text() + assert "/teleport" in app.commands.get_help_text() input_widget = app.query_one(ChatInputContainer).input_widget assert input_widget is not None - assert "&" not in input_widget.mode_characters + assert "&" in input_widget.mode_characters + + await app.on_chat_input_container_submitted( + ChatInputContainer.Submitted("/teleport") + ) + await _wait_until( + pilot.pause, + lambda: any("Vibe Pro API key" in error for error in _error_messages(app)), + ) + + assert _teleport_failed_events(telemetry_events) == [ + { + "event_name": "vibe.teleport_failed", + "properties": { + "stage": "ineligible", + "error_class": "TeleportIneligibleError", + "push_required": False, + "nb_session_messages": 0, + "session_id": app.agent_loop.session_id, + }, + } + ] @pytest.mark.asyncio -async def test_hidden_teleport_command_falls_through_as_user_text() -> None: +async def test_teleport_command_errors_instead_of_user_text_when_not_eligible() -> None: app = build_test_vibe_app( config=_vibe_code_enabled_config(), plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True), ) async with app.run_test() as pilot: - await pilot.pause(0.2) + await _wait_until( + pilot.pause, + lambda: app.commands.get_command_name("/teleport") == "teleport", + ) - app._handle_teleport_command = AsyncMock() app._handle_user_message = AsyncMock() await app.on_chat_input_container_submitted( ChatInputContainer.Submitted("/teleport") ) + await _wait_until( + pilot.pause, + lambda: any("Vibe Pro API key" in error for error in _error_messages(app)), + ) - app._handle_teleport_command.assert_not_awaited() - app._handle_user_message.assert_awaited_once_with("/teleport") + app._handle_user_message.assert_not_awaited() @pytest.mark.asyncio -async def test_hidden_ampersand_teleport_shortcut_falls_through_as_user_text() -> None: +async def test_ampersand_teleport_shortcut_errors_when_not_eligible() -> None: app = build_test_vibe_app( config=_vibe_code_enabled_config(), plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True), ) async with app.run_test() as pilot: - await pilot.pause(0.2) + await _wait_until( + pilot.pause, + lambda: app.commands.get_command_name("/teleport") == "teleport", + ) - app._handle_teleport_command = AsyncMock() app._handle_user_message = AsyncMock() await app.on_chat_input_container_submitted( ChatInputContainer.Submitted("&continue") ) + await _wait_until( + pilot.pause, + lambda: any("Vibe Pro API key" in error for error in _error_messages(app)), + ) - app._handle_teleport_command.assert_not_awaited() - app._handle_user_message.assert_awaited_once_with("&continue") + app._handle_user_message.assert_not_awaited() @pytest.mark.asyncio -async def test_teleport_command_hides_after_switching_to_non_mistral_model( +async def test_teleport_command_errors_after_switching_to_non_mistral_model( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("OPENAI_API_KEY", "mock-openai-key") @@ -223,11 +263,18 @@ async def test_teleport_command_hides_after_switching_to_non_mistral_model( ): await app._reload_config() - await _wait_until( - pilot.pause, lambda: app.commands.get_command_name("/teleport") is None - ) - - assert app.commands.get_command_name("/teleport") is None + await _wait_until(pilot.pause, lambda: not app.config.is_active_model_mistral()) + assert app.commands.get_command_name("/teleport") == "teleport" input_widget = app.query_one(ChatInputContainer).input_widget assert input_widget is not None - assert "&" not in input_widget.mode_characters + assert "&" in input_widget.mode_characters + + await app.on_chat_input_container_submitted( + ChatInputContainer.Submitted("/teleport") + ) + await _wait_until( + pilot.pause, + lambda: any( + "active Mistral model" in error for error in _error_messages(app) + ), + ) diff --git a/tests/cli/textual_ui/test_diff_rendering.py b/tests/cli/textual_ui/test_diff_rendering.py index fb5405a..527b6a5 100644 --- a/tests/cli/textual_ui/test_diff_rendering.py +++ b/tests/cli/textual_ui/test_diff_rendering.py @@ -2,7 +2,7 @@ from __future__ import annotations from textual.content import Content from textual.highlight import HighlightTheme -from textual.widgets import Static +from textual.widget import Widget from vibe.cli.textual_ui.widgets.diff_rendering import ( _build_diff_line, @@ -35,7 +35,9 @@ def _render_with_colors(*args, **kwargs): return widgets, diff_border_colors(widgets) -def _plain(widget: Static) -> str: +def _plain(widget: Widget) -> str: + if plain := getattr(widget, "plain", None): + return plain visual = widget.render() return visual.plain if isinstance(visual, Content) else str(visual) diff --git a/tests/skills/registry/test_client.py b/tests/skills/registry/test_client.py new file mode 100644 index 0000000..1a1bf6e --- /dev/null +++ b/tests/skills/registry/test_client.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import httpx +import pytest +import respx + +from vibe.core.skills.registry._client import RegistrySkillsClient, RegistrySkillsError + +_URL = "https://api.mistral.ai/v1/skills" + + +def _page(skill_id: str, *, next_token: str = "") -> dict[str, object]: + return { + "data": [ + {"skillId": skill_id, "skill": {"skillName": skill_id, "skillBody": "b"}} + ], + "nextPageToken": next_token, + } + + +@pytest.mark.asyncio +@respx.mock +async def test_list_catalog_paginates() -> None: + route = respx.get(_URL) + route.side_effect = [ + httpx.Response(200, json=_page("a", next_token="p2")), + httpx.Response(200, json=_page("b")), + ] + + async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client: + items = await client.list_catalog(page_size=50) + + assert [item.skill_id for item in items] == ["a", "b"] + assert route.calls[0].request.url.params["pageSize"] == "50" + assert "pageToken" not in route.calls[0].request.url.params + assert route.calls[1].request.url.params["pageToken"] == "p2" + assert route.calls[0].request.headers["Authorization"] == "Bearer key" + + +@pytest.mark.asyncio +@respx.mock +async def test_list_catalog_single_page() -> None: + respx.get(_URL).mock(return_value=httpx.Response(200, json=_page("only"))) + async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client: + items = await client.list_catalog(page_size=100) + assert [item.skill_id for item in items] == ["only"] + + +@pytest.mark.asyncio +@respx.mock +async def test_unauthorized_raises() -> None: + respx.get(_URL).mock(return_value=httpx.Response(401, json={"message": "no"})) + async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client: + with pytest.raises(RegistrySkillsError, match="unauthorized"): + await client.list_catalog(page_size=10) + + +@pytest.mark.asyncio +@respx.mock +async def test_server_error_raises() -> None: + respx.get(_URL).mock(return_value=httpx.Response(503)) + async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client: + with pytest.raises(RegistrySkillsError, match="unexpected status"): + await client.list_catalog(page_size=10) + + +@pytest.mark.asyncio +@respx.mock +async def test_non_json_raises() -> None: + respx.get(_URL).mock(return_value=httpx.Response(200, text="not json")) + async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client: + with pytest.raises(RegistrySkillsError, match="valid JSON"): + await client.list_catalog(page_size=10) + + +@pytest.mark.asyncio +@respx.mock +async def test_network_error_raises() -> None: + respx.get(_URL).mock(side_effect=httpx.ConnectError("boom")) + async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client: + with pytest.raises(RegistrySkillsError, match="request failed"): + await client.list_catalog(page_size=10) + + +@pytest.mark.asyncio +@respx.mock +async def test_list_catalog_sends_fields_mask() -> None: + route = respx.get(_URL).mock(return_value=httpx.Response(200, json=_page("a"))) + async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client: + await client.list_catalog(page_size=10) + params = route.calls[0].request.url.params + assert params["pageSize"] == "10" + assert "skillBody" not in params["fields"] + assert "skillName" in params["fields"] + + +@pytest.mark.asyncio +@respx.mock +async def test_list_versions_parses_aliases_and_sorts_desc() -> None: + respx.get(f"{_URL}/sid/versions").mock( + return_value=httpx.Response( + 200, + json={ + "items": [ + {"version": 1, "versionAttributes": {"aliases": ["old"]}}, + { + "version": 3, + "versionAttributes": {"aliases": ["stable", "main"]}, + }, + {"version": 2}, + ] + }, + ) + ) + async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client: + versions = await client.list_versions("sid") + + assert [v.version for v in versions] == [3, 2, 1] + assert versions[0].aliases == ["stable", "main"] + assert versions[1].aliases == [] + assert versions[2].aliases == ["old"] + + +@pytest.mark.asyncio +@respx.mock +async def test_list_versions_invalid_payload_raises() -> None: + respx.get(f"{_URL}/sid/versions").mock( + return_value=httpx.Response(200, json={"items": [{"noVersion": 1}]}) + ) + async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client: + with pytest.raises(RegistrySkillsError, match="invalid versions response"): + await client.list_versions("sid") + + +@pytest.mark.asyncio +@respx.mock +async def test_get_skill_by_id() -> None: + respx.get(f"{_URL}/sid").mock( + return_value=httpx.Response( + 200, + json={ + "skillId": "sid", + "skill": {"skillName": "n", "skillBody": "b"}, + "version": 3, + }, + ) + ) + async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client: + item = await client.get_skill("sid") + assert item.skill_id == "sid" + assert item.version == 3 + + +@pytest.mark.asyncio +@respx.mock +async def test_get_skill_not_found_raises() -> None: + respx.get(f"{_URL}/missing").mock(return_value=httpx.Response(404)) + async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client: + with pytest.raises(RegistrySkillsError, match="not found"): + await client.get_skill("missing") + + +@pytest.mark.asyncio +@respx.mock +async def test_get_skill_invalid_payload_raises() -> None: + # A 200 whose body isn't a valid skill object must surface as a registry + # error, not a bare pydantic ValidationError. + respx.get(f"{_URL}/sid").mock( + return_value=httpx.Response(200, json="not-an-object") + ) + async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client: + with pytest.raises(RegistrySkillsError, match="invalid skill response"): + await client.get_skill("sid") + + +@pytest.mark.asyncio +@respx.mock +async def test_list_catalog_invalid_payload_raises() -> None: + respx.get(_URL).mock(return_value=httpx.Response(200, json="not-an-object")) + async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client: + with pytest.raises(RegistrySkillsError, match="invalid catalog response"): + await client.list_catalog(page_size=10) + + +@pytest.mark.asyncio +@respx.mock +async def test_list_catalog_raises_when_page_cap_exceeded() -> None: + # Every page reports more pages -> the cap is hit with data remaining, which + # must raise rather than silently return a truncated catalog. + respx.get(_URL).mock( + return_value=httpx.Response(200, json=_page("a", next_token="more")) + ) + async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client: + with pytest.raises(RegistrySkillsError, match="maximum number of pages"): + await client.list_catalog(page_size=10) diff --git a/tests/skills/registry/test_models.py b/tests/skills/registry/test_models.py new file mode 100644 index 0000000..9268758 --- /dev/null +++ b/tests/skills/registry/test_models.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import base64 +import re + +import pytest + +from vibe.core.skills.registry.models import ( + ListSkillsResponse, + RegistryAssetContent, + RegistrySkillItem, + sanitize_skill_name, +) + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("my_skill", "my-skill"), + ("My Skill", "my-skill"), + (" weird__name--ok ", "weird-name-ok"), + ("grill-me", "grill-me"), + ("___", None), + ("", None), + ], +) +def test_sanitize_skill_name(raw: str, expected: str | None) -> None: + assert sanitize_skill_name(raw) == expected + + +def test_asset_to_bytes_text() -> None: + asset = RegistryAssetContent(text_content="hello") + assert asset.to_bytes() == b"hello" + + +def test_asset_to_bytes_raw_base64() -> None: + encoded = base64.b64encode(b"\x00\x01binary").decode("ascii") + asset = RegistryAssetContent(raw_content=encoded, is_executable=True) + assert asset.to_bytes() == b"\x00\x01binary" + assert asset.is_executable is True + + +def test_asset_to_bytes_invalid_base64_returns_none() -> None: + assert RegistryAssetContent(raw_content="not!base64!").to_bytes() is None + + +def test_asset_to_bytes_empty_returns_none() -> None: + assert RegistryAssetContent().to_bytes() is None + + +def test_item_parses_camel_case() -> None: + item = RegistrySkillItem.model_validate({ + "skillId": "abc", + "skill": { + "skillName": "Free Form", + "skillDescription": "does things", + "skillBody": "# body", + "skillAssets": {"ref.txt": {"textContent": "x", "isExecutable": False}}, + }, + "metadata": {"name": "my_skill", "latestVersion": 3}, + "version": 3, + }) + assert item.skill_id == "abc" + assert item.resolved_name == "my-skill" + assert item.resolved_description == "does things" + assert item.skill.skill_assets["ref.txt"].text_content == "x" + assert item.version == 3 + + +def test_item_parses_snake_case_fallback() -> None: + item = RegistrySkillItem.model_validate({ + "skill_id": "abc", + "skill": {"skill_name": "n", "skill_body": "b"}, + "metadata": {"name": "snake_name"}, + }) + assert item.resolved_name == "snake-name" + + +def test_item_name_falls_back_to_skill_name() -> None: + item = RegistrySkillItem.model_validate({ + "skill": {"skillName": "Fallback Name", "skillBody": "b"} + }) + assert item.resolved_name == "fallback-name" + + +def test_item_name_falls_back_to_title() -> None: + item = RegistrySkillItem.model_validate({ + "skillId": "abc", + "skill": {"skillBody": "b"}, + "attributes": {"title": "My Cool Skill"}, + }) + assert item.resolved_name == "my-cool-skill" + + +def test_item_name_falls_back_to_skill_id() -> None: + item = RegistrySkillItem.model_validate({ + "skillId": "019de91a-84f5-76af-84a2-5f4389f372c7", + "skill": {"skillBody": "b"}, + }) + assert item.resolved_name == "skill-019de91a84f576af84a25f4389f372c7" + + +def test_item_name_none_without_any_identifier() -> None: + item = RegistrySkillItem.model_validate({"skill": {"skillBody": "b"}}) + assert item.resolved_name is None + + +def test_item_description_falls_back_to_attributes() -> None: + item = RegistrySkillItem.model_validate({ + "skill": {"skillName": "n", "skillBody": "b"}, + "attributes": {"title": "T", "description": "attr desc"}, + }) + assert item.resolved_description == "attr desc" + + +def test_list_response_parses_pagination() -> None: + response = ListSkillsResponse.model_validate({ + "data": [{"skillId": "1", "skill": {"skillName": "a", "skillBody": "b"}}], + "nextPageToken": "next", + }) + assert len(response.data) == 1 + assert response.next_page_token == "next" + + +def test_list_response_defaults_empty() -> None: + response = ListSkillsResponse.model_validate({}) + assert response.data == [] + assert response.next_page_token == "" + + +# Resolved/sanitized names must always satisfy SkillMetadata.name's pattern. +_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") + + +def test_sanitize_skill_name_caps_at_64_chars() -> None: + name = sanitize_skill_name("x" * 200) + assert name is not None + assert len(name) <= 64 + assert _NAME_RE.match(name) + + +def test_resolved_name_id_fallback_is_lowercase_and_valid() -> None: + # No usable name anywhere -> falls back to the skill id, which may be + # uppercase; it must still be a valid, lowercase skill name. + item = RegistrySkillItem.model_validate({ + "skillId": "AB12CD34-EF56-7890-ABCD-EF1234567890" + }) + assert item.resolved_name == "skill-ab12cd34ef567890abcdef1234567890" + assert _NAME_RE.match(item.resolved_name or "") + + +def test_resolved_name_id_fallback_uses_full_id_to_avoid_collisions() -> None: + # Ids sharing a prefix (common for time-ordered UUIDv7) must not collapse + # to the same fallback name. + a = RegistrySkillItem.model_validate({ + "skillId": "ab12cd34-0000-0000-0000-000000000001" + }) + b = RegistrySkillItem.model_validate({ + "skillId": "ab12cd34-0000-0000-0000-000000000002" + }) + assert a.resolved_name != b.resolved_name + + +def test_resolved_name_id_fallback_handles_degenerate_id() -> None: + # A hyphen-only id must collapse to a valid name, not a trailing-hyphen + # "skill-" that would fail validation. + item = RegistrySkillItem.model_validate({"skillId": "----"}) + assert item.resolved_name == "skill" + assert _NAME_RE.match(item.resolved_name or "") diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg index 26f6f5b..3099ad9 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg @@ -212,31 +212,31 @@ Commands -• /help: Show help message -• /config: Edit config settings -• /model: Select active model -• /thinking: Select thinking level -• /reload: Reload configuration, agent instructions, and skills from disk -• /clear: Clear conversation history -• /copy: Copy the last agent message to the clipboard -• /log: Show path to current interaction log file -• /debug: Toggle debug console -• /compact: Compact conversation history by summarizing. Optionally pass instructions to guide the summary -• /exit:q:quitexitquit: Exit the application -• /status: Display agent statistics -• /teleport: Teleport session to Vibe Code Web -• /proxy-setup: Configure proxy and SSL certificate settings -• /continue/resume: Browse, resume, or delete saved sessions -• /rename: Rename the current session -• /connectors/mcp: Display available MCP servers and connectors. Pass a name to list tools; subcommands: -status, login , logout  -• /voice: Configure voice settings -• /leanstall: Install the Lean 4 agent (leanstral) -• /unleanstall: Uninstall the Lean 4 agent -• /rewind: Rewind to a previous message -• /loop: Schedule a recurring prompt. Use /loop <interval> <prompt>/loop list, or /loop cancel <id|all> -• /data-retention: Show data retention information -• /theme: Select theme +• /clear: Clear conversation history +• /compact: Compact conversation history by summarizing. Optionally pass instructions to guide the summary +• /config: Edit config settings +• /copy: Copy the last agent message to the clipboard +• /data-retention: Show data retention information +• /debug: Toggle debug console +• /exit:q:quitexitquit: Exit the application +• /help: Show help message +• /leanstall: Install the Lean 4 agent (leanstral) +• /log: Show path to current interaction log file +• /loop: Schedule a recurring prompt. Use /loop <interval> <prompt>/loop list, or /loop cancel <id|all> +• /mcp/connectors: Display available MCP servers and connectors. Pass a name to list tools; subcommands: +status, login , logout  +• /model: Select active model +• /proxy-setup: Configure proxy and SSL certificate settings +• /reload: Reload configuration, agent instructions, and skills from disk +• /rename: Rename the current session +• /resume/continue: Browse, resume, or delete saved sessions +• /rewind: Rewind to a previous message +• /status: Display agent statistics +• /teleport: Teleport session to Vibe Code Web +• /theme: Select theme +• /thinking: Select thinking level +• /unleanstall: Uninstall the Lean 4 agent +• /voice: Configure voice settings ────────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─ diff --git a/tests/snapshots/test_ui_snapshot_teleport.py b/tests/snapshots/test_ui_snapshot_teleport.py index 0bfb90f..e45f44e 100644 --- a/tests/snapshots/test_ui_snapshot_teleport.py +++ b/tests/snapshots/test_ui_snapshot_teleport.py @@ -149,19 +149,6 @@ class TeleportCommandHelpProApp(TeleportCommandHelpSnapshotApp): ) -class TeleportCommandHelpFreeApp(TeleportCommandHelpSnapshotApp): - def __init__(self): - super().__init__( - FakeWhoAmIGateway( - WhoAmIResponse( - plan_type=WhoAmIPlanType.API, - plan_name="FREE", - prompt_switching_to_pro_plan=False, - ) - ) - ) - - def test_snapshot_teleport_status_checking_git(snap_compare: SnapCompare) -> None: async def run_before(pilot: Pilot) -> None: await pilot.pause(0.2) @@ -302,16 +289,3 @@ def test_snapshot_teleport_command_visible_for_pro_account( terminal_size=(120, 48), run_before=run_before, ) - - -def test_snapshot_teleport_command_hidden_for_non_pro_account( - snap_compare: SnapCompare, -) -> None: - async def run_before(pilot: Pilot) -> None: - await pilot.pause(0.2) - - assert snap_compare( - "test_ui_snapshot_teleport.py:TeleportCommandHelpFreeApp", - terminal_size=(120, 48), - run_before=run_before, - ) diff --git a/uv.lock b/uv.lock index a1aeba9..150c5ec 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,12 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", +] [options] exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. @@ -504,11 +510,11 @@ wheels = [ [[package]] name = "idna" -version = "3.13" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -677,7 +683,7 @@ name = "macholib" version = "1.16.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "altgraph" }, + { name = "altgraph", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } wheels = [ @@ -779,7 +785,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.27.2" +version = "1.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -797,9 +803,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" }, ] [[package]] @@ -825,7 +831,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.17.0" +version = "2.17.1" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, @@ -980,7 +986,7 @@ requires-dist = [ { name = "httpx", specifier = "==0.28.1" }, { name = "httpx-sse", specifier = "==0.4.3" }, { name = "humanize", specifier = "==4.15.0" }, - { name = "idna", specifier = "==3.13" }, + { name = "idna", specifier = "==3.18" }, { name = "importlib-metadata", specifier = "==8.7.1" }, { name = "jaraco-classes", specifier = "==3.4.0" }, { name = "jaraco-context", specifier = "==6.1.2" }, @@ -995,10 +1001,10 @@ requires-dist = [ { name = "linkify-it-py", specifier = "==2.1.0" }, { name = "markdown-it-py", specifier = "==4.0.0" }, { name = "markdownify", specifier = "==1.2.2" }, - { name = "mcp", specifier = "==1.27.2" }, + { name = "mcp", specifier = "==1.28.0" }, { name = "mdit-py-plugins", specifier = "==0.5.0" }, { name = "mdurl", specifier = "==0.1.2" }, - { name = "mistralai", specifier = "==2.4.9" }, + { name = "mistralai", specifier = "==2.4.11" }, { name = "more-itertools", specifier = "==11.0.2" }, { name = "opentelemetry-api", specifier = "==1.39.1" }, { name = "opentelemetry-exporter-otlp-proto-common", specifier = "==1.39.1" }, @@ -1086,7 +1092,7 @@ dev = [ [[package]] name = "mistralai" -version = "2.4.9" +version = "2.4.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "eval-type-backport" }, @@ -1098,9 +1104,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/f1/f22fa0fac767279df95e0796f37520eae0678a75b62d7bca3e85c46b684b/mistralai-2.4.9.tar.gz", hash = "sha256:9a6465b8bddf825d76cf80ab54d55bb8089fdf5aafc0a7943ae74c825df97939", size = 471000, upload-time = "2026-06-03T13:04:22.565Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/de/afe647785ba603d6473dd407edfda917c31e182522381930b43bb6aa25ef/mistralai-2.4.11.tar.gz", hash = "sha256:bc2bb5cae59ce4b07928f5c824eeb5c9a33f6a2a12049725be14619969036f82", size = 488149, upload-time = "2026-06-16T15:41:18.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/bc/977b65cddce185dce0414d14c783040a9f9d647bbfe9b0f5c74ff9262816/mistralai-2.4.9-py3-none-any.whl", hash = "sha256:8e2f8a58697455f2113b4f6d0d1bd28d66d3eb51f94ff24b9d22a21dea531520", size = 1121336, upload-time = "2026-06-03T13:04:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/21/6d/0529d895ca381cfa636857259c1fec91d6d7c985b77bf72b9cf5a626d431/mistralai-2.4.11-py3-none-any.whl", hash = "sha256:479f8007ef522e674fbf617e65f8dcdf5d702fc1468c470ae40b1ee0aa0f8136", size = 1170945, upload-time = "2026-06-16T15:41:17.11Z" }, ] [[package]] @@ -1969,8 +1975,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "sys_platform != 'win32'" }, + { name = "jeepney", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ diff --git a/vibe/__init__.py b/vibe/__init__.py index af8f90d..fedf2cc 100644 --- a/vibe/__init__.py +++ b/vibe/__init__.py @@ -3,4 +3,4 @@ from __future__ import annotations from pathlib import Path VIBE_ROOT = Path(__file__).parent -__version__ = "2.17.0" +__version__ = "2.17.1" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index ae98164..e46e8bd 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -73,7 +73,7 @@ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError from vibe import VIBE_ROOT, __version__ from vibe.acp.acp_logger import acp_message_observer -from vibe.acp.commands import AcpCommandAvailabilityContext, AcpCommandRegistry +from vibe.acp.commands import AcpCommandRegistry from vibe.acp.exceptions import ( CompactionError, ConfigurationError, @@ -224,7 +224,6 @@ WORKSPACE_TRUST_META_KEY = "workspace_trust" TRUST_GRANT_DECISIONS = { WorkspaceTrustDecision.TRUST_REPO, WorkspaceTrustDecision.TRUST_CWD, - WorkspaceTrustDecision.TRUST_SESSION, } @@ -711,9 +710,7 @@ class VibeAcpAgentLoop(AcpAgent): self, session_id: str, agent_loop: AgentLoop ) -> AcpSessionLoop: command_registry = AcpCommandRegistry( - availability_context=AcpCommandAvailabilityContext( - vibe_code_enabled=agent_loop.base_config.vibe_code_enabled - ) + vibe_code_enabled=agent_loop.base_config.vibe_code_enabled ) session = AcpSessionLoop( id=session_id, agent_loop=agent_loop, command_registry=command_registry @@ -824,7 +821,7 @@ class VibeAcpAgentLoop(AcpAgent): repoRoot=str(prompt.repo_root.resolve()) if prompt.repo_root else None, ignoredFiles=self._workspace_trust_ignored_files(prompt), availableDecisions=available_workspace_trust_decisions( - prompt, include_session=True + prompt, include_session=False ), ) @@ -873,7 +870,7 @@ class VibeAcpAgentLoop(AcpAgent): raise InvalidRequestError("No workspace trust decision is available.") available_decisions = available_workspace_trust_decisions( - prompt, include_session=True + prompt, include_session=False ) if decision not in available_decisions: raise InvalidRequestError(f"Unsupported trust decision: {decision}") @@ -914,9 +911,7 @@ class VibeAcpAgentLoop(AcpAgent): raise InvalidRequestError(str(exc)) from exc if session is not None and request.decision in TRUST_GRANT_DECISIONS: - os.chdir(cwd) - await self._reload_session_config(session) - await session.command_registry.notify_changed() + session.spawn(self._reload_trusted_workspace_session(session, cwd)) return self._workspace_trust_response(cwd).model_dump( mode="json", by_alias=True @@ -1201,6 +1196,7 @@ class VibeAcpAgentLoop(AcpAgent): ) ) + commands.sort(key=lambda command: command.name) await self.client.session_update( session_id=session.id, update=update_available_commands(commands) ) @@ -1277,10 +1273,7 @@ class VibeAcpAgentLoop(AcpAgent): return True async def _reload_config(self, session: AcpSessionLoop) -> None: - new_config = VibeConfig.load(tool_paths=session.agent_loop.config.tool_paths) - self._apply_client_project_name(new_config) - _merge_non_interactive_disabled_tools(new_config) - await session.agent_loop.reload_with_initial_messages(base_config=new_config) + await self._reload_session_config(session) async def _apply_model_change(self, session: AcpSessionLoop, model_id: str) -> bool: model_aliases = [model.alias for model in session.agent_loop.config.models] @@ -2037,20 +2030,25 @@ class VibeAcpAgentLoop(AcpAgent): self, session: AcpSessionLoop, text_prompt: str, message_id: str ) -> PromptResponse: lines = ["### Available Commands", ""] - for cmd in session.command_registry.commands.values(): + for cmd in sorted( + session.command_registry.commands.values(), key=lambda command: command.name + ): hint = f" `<{cmd.input_hint}>`" if cmd.input_hint else "" lines.append(f"- `/{cmd.name}`{hint}: {cmd.description}") builtin_names = set(session.command_registry.commands) - invocable = { - n: s - for n, s in session.agent_loop.skill_manager.available_skills.items() - if s.user_invocable and n not in builtin_names - } + invocable = sorted( + ( + skill + for skill in session.agent_loop.skill_manager.available_skills.values() + if skill.user_invocable and skill.name not in builtin_names + ), + key=lambda skill: skill.name, + ) if invocable: lines.extend(["", "### Available Skills", ""]) - for name, info in invocable.items(): - lines.append(f"- `/{name}`: {info.description}") + for skill in invocable: + lines.append(f"- `/{skill.name}`: {skill.description}") return await self._command_reply(session, "\n".join(lines), message_id) @@ -2186,6 +2184,23 @@ class VibeAcpAgentLoop(AcpAgent): _merge_non_interactive_disabled_tools(new_config) await session.agent_loop.reload_with_initial_messages(base_config=new_config) + async def _reload_trusted_workspace_session( + self, session: AcpSessionLoop, cwd: Path + ) -> None: + try: + os.chdir(cwd) + await self._reload_session_config(session) + await session.command_registry.notify_changed() + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "Failed to reload trusted workspace session config: session_id=%s cwd=%s", + session.id, + cwd, + exc_info=exc, + ) + async def _handle_reload( self, session: AcpSessionLoop, text_prompt: str, message_id: str ) -> PromptResponse: diff --git a/vibe/acp/commands/__init__.py b/vibe/acp/commands/__init__.py index d28826d..5d08b7a 100644 --- a/vibe/acp/commands/__init__.py +++ b/vibe/acp/commands/__init__.py @@ -1,5 +1,5 @@ from __future__ import annotations -from vibe.acp.commands.registry import AcpCommandAvailabilityContext, AcpCommandRegistry +from vibe.acp.commands.registry import AcpCommandRegistry -__all__ = ["AcpCommandAvailabilityContext", "AcpCommandRegistry"] +__all__ = ["AcpCommandRegistry"] diff --git a/vibe/acp/commands/registry.py b/vibe/acp/commands/registry.py index ad403f4..5970659 100644 --- a/vibe/acp/commands/registry.py +++ b/vibe/acp/commands/registry.py @@ -6,17 +6,7 @@ from dataclasses import dataclass, field type OnCommandsChanged = Callable[[], Awaitable[None]] -@dataclass(frozen=True) -class AcpCommandAvailabilityContext: - """Context used to decide whether a command should be advertised.""" - - vibe_code_enabled: bool = False - - def is_teleport_available(self) -> bool: - return self.vibe_code_enabled - - -type CommandAvailability = Callable[[AcpCommandAvailabilityContext], bool] +type CommandAvailability = Callable[[bool], bool] @dataclass(frozen=True) @@ -34,9 +24,7 @@ class AcpCommand: class AcpCommandRegistry: """Registry of ACP commands. Notifies listeners when commands change.""" - availability_context: AcpCommandAvailabilityContext = field( - default_factory=AcpCommandAvailabilityContext - ) + vibe_code_enabled: bool = False _commands: dict[str, AcpCommand] = field(default_factory=dict) _on_changed: OnCommandsChanged | None = None @@ -51,7 +39,7 @@ class AcpCommandRegistry: def _is_available(self, command: AcpCommand) -> bool: if command.is_available is None: return True - return command.is_available(self.availability_context) + return command.is_available(self.vibe_code_enabled) def set_on_changed(self, callback: OnCommandsChanged) -> None: self._on_changed = callback @@ -101,7 +89,7 @@ def _build_commands() -> dict[str, AcpCommand]: name="teleport", description="Teleport session to Vibe Code Web", handler="_handle_teleport", - is_available=AcpCommandAvailabilityContext.is_teleport_available, + is_available=lambda vibe_code_enabled: vibe_code_enabled, ), "proxy-setup": AcpCommand( name="proxy-setup", diff --git a/vibe/acp/teleport.py b/vibe/acp/teleport.py index ddfdb96..e55c372 100644 --- a/vibe/acp/teleport.py +++ b/vibe/acp/teleport.py @@ -194,6 +194,22 @@ async def handle_teleport_command( field_meta=_teleport_field_meta("unavailable"), ) + if not session.agent_loop.config.is_active_model_mistral(): + send_teleport_early_failure_telemetry( + session.agent_loop.telemetry_client, + stage="ineligible", + error_class="TeleportIneligibleError", + nb_session_messages=len(session.agent_loop.messages[1:]), + ) + return await _teleport_command_reply( + client, + session, + "Teleport requires an active Mistral model. Switch to a Mistral " + "model, then try again.", + message_id, + field_meta=_teleport_field_meta("unavailable"), + ) + last_user_message = next( ( msg diff --git a/vibe/cli/commands.py b/vibe/cli/commands.py index a3cfeb5..f777773 100644 --- a/vibe/cli/commands.py +++ b/vibe/cli/commands.py @@ -4,27 +4,10 @@ from collections.abc import Callable from dataclasses import dataclass import sys -from vibe.cli.plan_offer.decide_plan_offer import PlanInfo - ALT_KEY = "⌥" if sys.platform == "darwin" else "Alt" -@dataclass(frozen=True) -class CommandAvailabilityContext: - vibe_code_enabled: bool = False - is_active_model_mistral: bool = False - plan_info: PlanInfo | None = None - - def is_teleport_available(self) -> bool: - return ( - self.vibe_code_enabled - and self.is_active_model_mistral - and self.plan_info is not None - and self.plan_info.is_teleport_eligible() - ) - - -CommandAvailability = Callable[[CommandAvailabilityContext], bool] +CommandAvailability = Callable[[bool], bool] @dataclass @@ -40,14 +23,13 @@ class CommandRegistry: def __init__( self, excluded_commands: list[str] | None = None, - availability_context: CommandAvailabilityContext | None = None, + vibe_code_enabled: bool = False, ) -> None: if excluded_commands is None: excluded_commands = [] self._disabled_commands = set(excluded_commands) - self._availability_context = CommandAvailabilityContext() self._commands: dict[str, Command] = {} - self.refresh(availability_context) + self.refresh(vibe_code_enabled) def _build_commands(self) -> dict[str, Command]: return { @@ -116,7 +98,7 @@ class CommandRegistry: aliases=frozenset(["/teleport"]), description="Teleport session to Vibe Code Web", handler="_teleport_command", - is_available=CommandAvailabilityContext.is_teleport_available, + is_available=lambda vibe_code_enabled: vibe_code_enabled, ), "proxy-setup": Command( aliases=frozenset(["/proxy-setup"]), @@ -186,12 +168,8 @@ class CommandRegistry: def commands(self) -> dict[str, Command]: return self._commands - def refresh( - self, availability_context: CommandAvailabilityContext | None = None - ) -> None: - self._availability_context = ( - availability_context or CommandAvailabilityContext() - ) + def refresh(self, vibe_code_enabled: bool = False) -> None: + self._vibe_code_enabled = vibe_code_enabled self._commands = { name: command for name, command in self._build_commands().items() @@ -202,7 +180,7 @@ class CommandRegistry: def _is_command_available(self, command: Command) -> bool: if command.is_available is None: return True - return command.is_available(self._availability_context) + return command.is_available(self._vibe_code_enabled) def _alias_map(self) -> dict[str, str]: return { @@ -261,7 +239,13 @@ class CommandRegistry: "", ] - for cmd in self.commands.values(): - aliases = ", ".join(f"`{alias}`" for alias in sorted(cmd.aliases)) + for name, cmd in sorted(self.commands.items()): + canonical_alias = f"/{name}" + aliases = ", ".join( + f"`{alias}`" + for alias in sorted( + cmd.aliases, key=lambda alias: (alias != canonical_alias, alias) + ) + ) lines.append(f"- {aliases}: {cmd.description}") return "\n".join(lines) diff --git a/vibe/cli/plan_offer/decide_plan_offer.py b/vibe/cli/plan_offer/decide_plan_offer.py index caaed7c..c3b0331 100644 --- a/vibe/cli/plan_offer/decide_plan_offer.py +++ b/vibe/cli/plan_offer/decide_plan_offer.py @@ -113,20 +113,41 @@ def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None: return resolve_api_key(api_env_key) +def vibe_api_key_url(vibe_base_url: str = DEFAULT_VIBE_BASE_URL) -> str: + return f"{vibe_base_url.rstrip('/')}/code/extensions?focus=key" + + def plan_offer_cta( payload: PlanInfo | None, *, vibe_base_url: str = DEFAULT_VIBE_BASE_URL ) -> str | None: if not payload: return - vibe_api_key_url = f"{vibe_base_url.rstrip('/')}/code/extensions?focus=key" + key_url = vibe_api_key_url(vibe_base_url) if payload.prompt_switching_to_pro_plan: - return f"### Switch to your [Vibe Pro API key]({vibe_api_key_url})" + return f"### Switch to your [Vibe Pro API key]({key_url})" if ( payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED} or payload.is_free_chat_plan() or payload.is_free_mistral_code_plan() ): - return f"### Unlock more with Vibe - [Upgrade to Vibe Pro]({vibe_api_key_url})" + return f"### Unlock more with Vibe - [Upgrade to Vibe Pro]({key_url})" + + +def check_teleport_eligibility( + payload: PlanInfo | None, *, vibe_base_url: str = DEFAULT_VIBE_BASE_URL +) -> str | None: + if payload is not None and payload.is_teleport_eligible(): + return None + key_url = vibe_api_key_url(vibe_base_url) + if payload is not None and payload.prompt_switching_to_pro_plan: + return ( + "Teleport requires a Vibe Pro API key, but the current key is on a " + f"different plan. Switch to your Vibe Pro API key: {key_url}" + ) + return ( + "Teleport requires a Vibe Pro subscription. Your current API key isn't " + f"eligible. Upgrade to Vibe Pro: {key_url}" + ) def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911 diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index bbcea3a..2fce0f1 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -31,7 +31,7 @@ from textual.widgets import Static from vibe import __version__ as CORE_VERSION from vibe.cli.clipboard import copy_selection_to_clipboard, copy_text_to_clipboard -from vibe.cli.commands import CommandAvailabilityContext, CommandRegistry +from vibe.cli.commands import CommandRegistry from vibe.cli.narrator_manager import ( NarratorManager, NarratorManagerPort, @@ -40,6 +40,7 @@ from vibe.cli.narrator_manager import ( from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway from vibe.cli.plan_offer.decide_plan_offer import ( PlanInfo, + check_teleport_eligibility, decide_plan_offer, plan_offer_cta, plan_title, @@ -184,6 +185,7 @@ from vibe.core.session.saved_sessions import ( from vibe.core.session.session_loader import SessionLoader from vibe.core.session.title_format import format_session_title from vibe.core.skills.manager import SkillManager +from vibe.core.telemetry.types import TeleportFailureStage from vibe.core.teleport.telemetry import send_teleport_early_failure_telemetry from vibe.core.teleport.types import ( TeleportCheckingGitEvent, @@ -588,20 +590,13 @@ class VibeApp(App): # noqa: PLR0904 def _connectors_enabled(self) -> bool: return self.agent_loop.connector_registry is not None - def _get_command_availability_context(self) -> CommandAvailabilityContext: - return CommandAvailabilityContext( - vibe_code_enabled=self.agent_loop.base_config.vibe_code_enabled, - is_active_model_mistral=self.config.is_active_model_mistral(), - plan_info=self._plan_info, - ) - def _build_command_registry(self) -> CommandRegistry: return CommandRegistry( - availability_context=self._get_command_availability_context() + vibe_code_enabled=self.agent_loop.base_config.vibe_code_enabled ) def _refresh_command_registry(self) -> None: - self.commands.refresh(self._get_command_availability_context()) + self.commands.refresh(self.agent_loop.base_config.vibe_code_enabled) def compose(self) -> ComposeResult: with ChatScroll(id="chat"): @@ -1881,29 +1876,54 @@ class VibeApp(App): # noqa: PLR0904 async def _teleport_command(self, **kwargs: Any) -> None: await self._handle_teleport_command(show_message=False) + def _teleport_unavailable_reason(self) -> str | None: + if not self.config.is_active_model_mistral(): + return ( + "Teleport requires an active Mistral model. Use /model to switch to " + "a Mistral model, then try again." + ) + return check_teleport_eligibility( + self._plan_info, vibe_base_url=self.config.vibe_base_url + ) + + async def _fail_teleport_early( + self, *, stage: TeleportFailureStage, error_class: str, message: str + ) -> None: + send_teleport_early_failure_telemetry( + self.agent_loop.telemetry_client, + stage=stage, + error_class=error_class, + nb_session_messages=len(self.agent_loop.messages[1:]), + ) + await self._mount_and_scroll( + ErrorMessage(message, collapsed=self._tools_collapsed) + ) + async def _handle_teleport_command( self, value: str | None = None, show_message: bool = True ) -> None: has_history = any(msg.role != Role.system for msg in self.agent_loop.messages) - if not value: - if show_message: - await self._mount_and_scroll(SlashCommandMessage("teleport")) - if not has_history: - send_teleport_early_failure_telemetry( - self.agent_loop.telemetry_client, - stage="no_history", - error_class="TeleportNoHistoryError", - nb_session_messages=len(self.agent_loop.messages[1:]), - ) - await self._mount_and_scroll( - ErrorMessage( - "No conversation history to teleport.", - collapsed=self._tools_collapsed, - ) - ) - return - elif show_message: - await self._mount_and_scroll(TeleportUserMessage(value)) + if show_message: + await self._mount_and_scroll( + TeleportUserMessage(value) if value else SlashCommandMessage("teleport") + ) + + if reason := self._teleport_unavailable_reason(): + await self._fail_teleport_early( + stage="ineligible", + error_class="TeleportIneligibleError", + message=reason, + ) + return + + if not value and not has_history: + await self._fail_teleport_early( + stage="no_history", + error_class="TeleportNoHistoryError", + message="No conversation history to teleport.", + ) + return + self.run_worker(self._teleport(value), exclusive=False) async def _teleport(self, prompt: str | None = None) -> None: diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss index 0483e4b..18d3ed0 100644 --- a/vibe/cli/textual_ui/app.tcss +++ b/vibe/cli/textual_ui/app.tcss @@ -842,9 +842,24 @@ StatusMessage { color: $warning; } -.diff-removed { +.diff-removed, +.diff-added, +.diff-context { + width: 100%; height: auto; + .diff-gutter { + width: auto; + height: auto; + } + + .diff-body { + width: 1fr; + height: auto; + } +} + +.diff-removed { &:dark { background: $error 10%; } @@ -859,8 +874,6 @@ StatusMessage { } .diff-added { - height: auto; - &:dark { background: $success 10%; } @@ -883,10 +896,6 @@ StatusMessage { } } -.diff-context { - height: auto; -} - .todo-empty, .todo-cancelled { height: auto; diff --git a/vibe/cli/textual_ui/widgets/diff_rendering.py b/vibe/cli/textual_ui/widgets/diff_rendering.py index 2030ff7..7c0801a 100644 --- a/vibe/cli/textual_ui/widgets/diff_rendering.py +++ b/vibe/cli/textual_ui/widgets/diff_rendering.py @@ -5,6 +5,8 @@ import difflib from pathlib import Path import re +from textual.app import ComposeResult +from textual.containers import Horizontal from textual.content import Content from textual.highlight import ( ANSIDarkHighlightTheme, @@ -12,9 +14,13 @@ from textual.highlight import ( HighlightTheme, highlight as highlight_code, ) +from textual.widget import Widget from textual.widgets import Static -from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic +from vibe.cli.textual_ui.widgets.no_markup_static import ( + NoMarkupStatic, + NonSelectableStatic, +) from vibe.core.utils.io import read_safe from vibe.core.utils.text import snippet_start_lines @@ -60,6 +66,38 @@ def _highlight_line(code: str, language: str, theme: type[HighlightTheme]) -> Co return lines[0] if lines else Content(code) +def _gutter_styles(prefix_char: str, *, ansi: bool) -> tuple[str, str]: + if prefix_char == "-": + if ansi: + return f"bold {_REMOVED_STYLE}", f"bold {_REMOVED_STYLE}" + return _REMOVED_STYLE, _DIM_MUTED_STYLE + if prefix_char == "+": + sign_style = _ADDED_STYLE + return sign_style, _ADDED_STYLE if ansi else _DIM_MUTED_STYLE + return _MUTED_STYLE, _DIM_MUTED_STYLE + + +def _build_diff_gutter(prefix_char: str, lineno: int | None, *, ansi: bool) -> Content: + sign_style, lineno_style = _gutter_styles(prefix_char, ansi=ansi) + lineno_str = f"{lineno:>4} " if lineno is not None else "" + prefix = f"{prefix_char} " + return Content.styled(lineno_str, lineno_style) + Content.styled(prefix, sign_style) + + +def _build_diff_body( + code: str, + prefix_char: str, + language: str, + *, + ansi: bool, + theme: type[HighlightTheme], +) -> Content: + body = _highlight_line(code, language, theme) + if prefix_char == "-" and ansi: + body = body.stylize("dim") + return body + + def _build_diff_line( code: str, prefix_char: str, @@ -69,31 +107,23 @@ def _build_diff_line( ansi: bool, theme: type[HighlightTheme], ) -> Content: - # ANSI themes lack row backgrounds; the gutter carries the diff color instead. - body = _highlight_line(code, language, theme) - - if prefix_char == "-": - if ansi: - sign_style = lineno_style = f"bold {_REMOVED_STYLE}" - body = body.stylize("dim") - else: - sign_style, lineno_style = _REMOVED_STYLE, _DIM_MUTED_STYLE - elif prefix_char == "+": - sign_style = _ADDED_STYLE - lineno_style = _ADDED_STYLE if ansi else _DIM_MUTED_STYLE - else: - sign_style, lineno_style = _MUTED_STYLE, _DIM_MUTED_STYLE - - lineno_str = f"{lineno:>4} " if lineno is not None else "" - prefix = f"{prefix_char} " - - return ( - Content.styled(lineno_str, lineno_style) - + Content.styled(prefix, sign_style) - + body + return _build_diff_gutter(prefix_char, lineno, ansi=ansi) + _build_diff_body( + code, prefix_char, language, ansi=ansi, theme=theme ) +class _DiffRow(Horizontal): + def __init__(self, gutter: Content, body: Content, *, classes: str) -> None: + self._gutter = gutter + self._body = body + self.plain = gutter.plain + body.plain + super().__init__(classes=classes) + + def compose(self) -> ComposeResult: + yield NonSelectableStatic(self._gutter, classes="diff-gutter") + yield Static(self._body, classes="diff-body") + + def render_edit_diff( old_string: str, new_string: str, @@ -102,7 +132,7 @@ def render_edit_diff( *, ansi: bool, dark: bool, -) -> list[Static]: +) -> list[Widget]: theme = _pick_theme(ansi=ansi, dark=dark) diff_lines = list( difflib.unified_diff( @@ -119,7 +149,7 @@ def render_edit_diff( # replace_all repeats the same change at each match; render one block per # occurrence, anchored at its own line number, with a gap in between. - widgets: list[Static] = [] + widgets: list[Widget] = [] for index, start_line in enumerate(start_lines): if index > 0: widgets.append(NoMarkupStatic("⋯", classes="diff-gap")) @@ -136,10 +166,10 @@ def _render_occurrence( *, ansi: bool, theme: type[HighlightTheme], -) -> list[Static]: +) -> list[Widget]: offset = (start_line - 1) if start_line else 0 old_lineno = new_lineno = 0 # overwritten by the first @@ header - widgets: list[Static] = [] + widgets: list[Widget] = [] first_hunk = True for line in diff_lines: @@ -167,20 +197,18 @@ def _render_occurrence( old_lineno += 1 new_lineno += 1 - content = _build_diff_line( - code, - prefix_char, - lineno if start_line else None, - language, - ansi=ansi, - theme=theme, + lineno_val = lineno if start_line else None + gutter = _build_diff_gutter(prefix_char, lineno_val, ansi=ansi) + body = _build_diff_body(code, prefix_char, language, ansi=ansi, theme=theme) + + widgets.append( + _DiffRow(gutter, body, classes=_DIFF_CSS_CLASS_BY_PREFIX[prefix_char]) ) - widgets.append(Static(content, classes=_DIFF_CSS_CLASS_BY_PREFIX[prefix_char])) return widgets -def diff_border_colors(rows: Iterable[Static]) -> dict[int, str]: +def diff_border_colors(rows: Iterable[Widget]) -> dict[int, str]: return { i: color for i, row in enumerate(rows) diff --git a/vibe/cli/textual_ui/widgets/tool_widgets.py b/vibe/cli/textual_ui/widgets/tool_widgets.py index 50581c0..20c9f84 100644 --- a/vibe/cli/textual_ui/widgets/tool_widgets.py +++ b/vibe/cli/textual_ui/widgets/tool_widgets.py @@ -245,7 +245,7 @@ class EditResultWidget(ToolResultWidget[EditResult]): if not self.result: yield from self._footer() return - rows: list[Static] = [ + rows: list[Widget] = [ NoMarkupStatic(f"⚠ {w}", classes="tool-result-warning") for w in self.warnings ] diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py index 1ef3eb9..26c1a7f 100644 --- a/vibe/core/config/_settings.py +++ b/vibe/core/config/_settings.py @@ -785,6 +785,15 @@ class VibeConfig(BaseSettings): " is set. Supports glob patterns and regex with 're:' prefix." ), ) + experimental_enable_registry_skills: bool = Field( + default=False, + description=( + "Experimental: pull workspace skills from the Mistral AI Registry" + " (api.mistral.ai) and make them available alongside local skills." + " Requires a Mistral provider and API key. Local and builtin skills take" + " precedence on name collision." + ), + ) model_config = SettingsConfigDict( env_prefix="VIBE_", case_sensitive=False, extra="ignore" diff --git a/vibe/core/config/vibe_schema.py b/vibe/core/config/vibe_schema.py index 892083c..c27762c 100644 --- a/vibe/core/config/vibe_schema.py +++ b/vibe/core/config/vibe_schema.py @@ -177,6 +177,15 @@ class VibeConfigSchema(ConfigSchema): " is set. Supports glob patterns and regex with 're:' prefix." ), ) + experimental_enable_registry_skills: Annotated[bool, WithReplaceMerge()] = Field( + default=False, + description=( + "Experimental: pull workspace skills from the Mistral AI Registry" + " (api.mistral.ai) and make them available alongside local skills." + " Requires a Mistral provider and API key. Local and builtin skills take" + " precedence on name collision." + ), + ) # Internal vibe_code_enabled: Annotated[bool, WithReplaceMerge()] = True diff --git a/vibe/core/skills/models.py b/vibe/core/skills/models.py index aa744a1..e48b078 100644 --- a/vibe/core/skills/models.py +++ b/vibe/core/skills/models.py @@ -1,11 +1,39 @@ from __future__ import annotations +from enum import StrEnum, auto from pathlib import Path from typing import Any from pydantic import BaseModel, Field, field_validator +class SkillSource(StrEnum): + BUILTIN = auto() + LOCAL = auto() + REGISTRY = auto() + + +class SkillScope(StrEnum): + BUILTIN = auto() + GLOBAL = auto() + PROJECT = auto() + + +# The registry's reserved alias that always resolves to the newest version, +# server-side (see ai-registry versioning.RESERVED_ALIAS). A pin set to this +# alias auto-resolves to latest and never reports an "update available". +REGISTRY_LATEST_ALIAS = "latest" + + +class RegistryRef(BaseModel): + skill_id: str + # The concrete, materialized version currently on disk (for display/loading). + version: int + # The pinned alias (e.g. "latest") when this is an alias pin; None when the + # pin is frozen to a specific version number. + alias: str | None = None + + class SkillMetadata(BaseModel): model_config = {"populate_by_name": True} @@ -72,6 +100,9 @@ class SkillInfo(BaseModel): user_invocable: bool = True skill_path: Path | None = None prompt: str + source: SkillSource = SkillSource.LOCAL + scope: SkillScope = SkillScope.GLOBAL + registry: RegistryRef | None = None model_config = {"arbitrary_types_allowed": True} @@ -83,7 +114,14 @@ class SkillInfo(BaseModel): @classmethod def from_metadata( - cls, meta: SkillMetadata, skill_path: Path, prompt: str + cls, + meta: SkillMetadata, + skill_path: Path, + prompt: str, + *, + source: SkillSource = SkillSource.LOCAL, + scope: SkillScope = SkillScope.GLOBAL, + registry: RegistryRef | None = None, ) -> SkillInfo: return cls( name=meta.name, @@ -95,6 +133,9 @@ class SkillInfo(BaseModel): user_invocable=meta.user_invocable, skill_path=skill_path.resolve(), prompt=prompt, + source=source, + scope=scope, + registry=registry, ) diff --git a/vibe/core/skills/registry/__init__.py b/vibe/core/skills/registry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vibe/core/skills/registry/_client.py b/vibe/core/skills/registry/_client.py new file mode 100644 index 0000000..6ba4ece --- /dev/null +++ b/vibe/core/skills/registry/_client.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from types import TracebackType +from typing import Any + +import httpx +from pydantic import BaseModel, ValidationError + +from vibe.core.skills.registry.models import ( + ListSkillsResponse, + ListVersionsResponse, + RegistrySkillItem, + SkillVersionInfo, +) +from vibe.core.utils.http import build_ssl_context + +_MAX_PAGES = 50 + + +class RegistrySkillsError(Exception): + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason + + +def _parse[M: BaseModel](model: type[M], payload: Any, what: str) -> M: + """Validate a registry payload, normalizing failures to RegistrySkillsError. + + Keeps every response-parsing path consistent: a malformed 200 surfaces as a + registry error, not a bare ValidationError that callers wouldn't catch. + """ + try: + return model.model_validate(payload) + except ValidationError as exc: + raise RegistrySkillsError(f"invalid {what} response") from exc + + +class RegistrySkillsClient: + _CATALOG_FIELDS = ( + "skillId,skill.skillName,skill.skillDescription,attributes,metadata,version" + ) + + def __init__(self, base_url: str, api_key: str, *, timeout: float = 30.0) -> None: + self._skills_url = f"{base_url.rstrip('/')}/skills" + self._api_key = api_key + self._timeout = timeout + self._client: httpx.AsyncClient | None = None + + async def __aenter__(self) -> RegistrySkillsClient: + self._client = httpx.AsyncClient( + timeout=self._timeout, + headers={ + "Authorization": f"Bearer {self._api_key}", + "Accept": "application/json", + }, + verify=build_ssl_context(), + ) + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if self._client is not None: + await self._client.aclose() + self._client = None + + async def list_catalog(self, *, page_size: int) -> list[RegistrySkillItem]: + return await self._list(page_size=page_size, fields=self._CATALOG_FIELDS) + + async def list_versions(self, skill_id: str) -> list[SkillVersionInfo]: + payload = await self._get_json(f"{self._skills_url}/{skill_id}/versions", {}) + parsed = _parse(ListVersionsResponse, payload, "versions") + infos = [row.to_info() for row in parsed.items] + infos.sort(key=lambda v: v.version, reverse=True) + return infos + + async def get_skill( + self, skill_id: str, *, version: int | None = None, alias: str | None = None + ) -> RegistrySkillItem: + # version_selector is a oneof: version XOR alias (omit both = latest). + params: dict[str, Any] = {} + if version is not None: + params["version"] = version + elif alias is not None: + params["alias"] = alias + url = f"{self._skills_url}/{skill_id}" + payload = await self._get_json(url, params) + return _parse(RegistrySkillItem, payload, "skill") + + async def _list( + self, *, page_size: int, fields: str | None + ) -> list[RegistrySkillItem]: + items: list[RegistrySkillItem] = [] + page_token = "" + for _ in range(_MAX_PAGES): + params: dict[str, Any] = {"pageSize": page_size} + if fields: + params["fields"] = fields + if page_token: + params["pageToken"] = page_token + page = _parse( + ListSkillsResponse, + await self._get_json(self._skills_url, params), + "catalog", + ) + items.extend(page.data) + if not page.next_page_token: + return items + page_token = page.next_page_token + # Cap reached with pages still remaining: fail loudly rather than return + # a silently-truncated catalog that a caller would treat as complete. + raise RegistrySkillsError("catalog exceeds the maximum number of pages") + + async def _get_json(self, url: str, params: dict[str, Any]) -> Any: + if self._client is None: + raise RegistrySkillsError("client used outside of an async context") + try: + response = await self._client.get(url, params=params) + except httpx.RequestError as exc: + raise RegistrySkillsError(f"request failed: {exc}") from exc + + if response.status_code in {httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN}: + raise RegistrySkillsError(f"unauthorized ({response.status_code})") + if response.status_code == httpx.codes.NOT_FOUND: + raise RegistrySkillsError("not found (404)") + if not response.is_success: + raise RegistrySkillsError(f"unexpected status {response.status_code}") + + try: + return response.json() + except ValueError as exc: + raise RegistrySkillsError("response was not valid JSON") from exc diff --git a/vibe/core/skills/registry/models.py b/vibe/core/skills/registry/models.py new file mode 100644 index 0000000..3b593bc --- /dev/null +++ b/vibe/core/skills/registry/models.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import base64 +import binascii +import re + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field + +_NON_NAME_CHARS = re.compile(r"[^a-z0-9]+") +# Mirror SkillMetadata.name's max_length so a sanitized name always validates. +_MAX_NAME_LEN = 64 + + +def sanitize_skill_name(raw: str) -> str | None: + collapsed = _NON_NAME_CHARS.sub("-", raw.strip().lower()).strip("-") + collapsed = collapsed[:_MAX_NAME_LEN].rstrip("-") + return collapsed or None + + +class RegistryAssetContent(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + text_content: str | None = Field( + default=None, validation_alias=AliasChoices("textContent", "text_content") + ) + raw_content: str | None = Field( + default=None, validation_alias=AliasChoices("rawContent", "raw_content") + ) + is_executable: bool = Field( + default=False, validation_alias=AliasChoices("isExecutable", "is_executable") + ) + + def to_bytes(self) -> bytes | None: + if self.text_content is not None: + return self.text_content.encode("utf-8") + if self.raw_content is not None: + try: + return base64.b64decode(self.raw_content, validate=True) + except (binascii.Error, ValueError): + return None + return None + + +class RegistrySkillPayload(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + skill_name: str = Field( + default="", validation_alias=AliasChoices("skillName", "skill_name") + ) + skill_description: str = Field( + default="", + validation_alias=AliasChoices("skillDescription", "skill_description"), + ) + skill_body: str = Field( + default="", validation_alias=AliasChoices("skillBody", "skill_body") + ) + skill_assets: dict[str, RegistryAssetContent] = Field( + default_factory=dict, + validation_alias=AliasChoices("skillAssets", "skill_assets"), + ) + + +class RegistryAttributes(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + title: str = "" + description: str | None = None + + +class RegistryMetadata(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str = "" + latest_version: int = Field( + default=0, validation_alias=AliasChoices("latestVersion", "latest_version") + ) + sharing_scope: str = Field( + default="", validation_alias=AliasChoices("sharingScope", "sharing_scope") + ) + created_at: str = Field( + default="", validation_alias=AliasChoices("createdAt", "created_at") + ) + last_modified_at: str = Field( + default="", validation_alias=AliasChoices("lastModifiedAt", "last_modified_at") + ) + created_by: str = Field( + default="", validation_alias=AliasChoices("createdBy", "created_by") + ) + + +class RegistryVersionMetadata(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + created_at: str = Field( + default="", validation_alias=AliasChoices("createdAt", "created_at") + ) + + +class RegistryVersionAttributes(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + notes: str = "" + aliases: list[str] = Field(default_factory=list) + + +class RegistrySkillItem(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + skill_id: str = Field( + default="", validation_alias=AliasChoices("skillId", "skill_id") + ) + skill: RegistrySkillPayload = Field(default_factory=RegistrySkillPayload) + attributes: RegistryAttributes = Field(default_factory=RegistryAttributes) + metadata: RegistryMetadata = Field(default_factory=RegistryMetadata) + version: int = 0 + version_metadata: RegistryVersionMetadata = Field( + default_factory=RegistryVersionMetadata, + validation_alias=AliasChoices("versionMetadata", "version_metadata"), + ) + version_attributes: RegistryVersionAttributes = Field( + default_factory=RegistryVersionAttributes, + validation_alias=AliasChoices("versionAttributes", "version_attributes"), + ) + + @property + def resolved_name(self) -> str | None: + for candidate in ( + self.metadata.name, + self.skill.skill_name, + self.attributes.title, + ): + if (name := sanitize_skill_name(candidate)) is not None: + return name + if self.skill_id: + # Run the id-based fallback through the same sanitizer so it obeys + # the charset / length / hyphen rules SkillMetadata.name enforces. + # The full id (not a prefix) keeps unnamed skills collision-free. + return sanitize_skill_name(f"skill-{self.skill_id.replace('-', '')}") + return None + + @property + def resolved_description(self) -> str: + if self.skill.skill_description.strip(): + return self.skill.skill_description.strip() + if self.attributes.description and self.attributes.description.strip(): + return self.attributes.description.strip() + return "" + + +class ListSkillsResponse(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + data: list[RegistrySkillItem] = Field(default_factory=list) + next_page_token: str = Field( + default="", validation_alias=AliasChoices("nextPageToken", "next_page_token") + ) + + +class SkillVersionInfo(BaseModel): + """One row from the versions endpoint: a concrete version and its author + aliases (e.g. 'main', 'stable'). The reserved 'latest' alias is dynamic and + not part of this list. + """ + + version: int + aliases: list[str] = Field(default_factory=list) + + +class RegistryVersionRow(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + version: int + version_attributes: RegistryVersionAttributes = Field( + default_factory=RegistryVersionAttributes, + validation_alias=AliasChoices("versionAttributes", "version_attributes"), + ) + + def to_info(self) -> SkillVersionInfo: + return SkillVersionInfo( + version=self.version, aliases=list(self.version_attributes.aliases) + ) + + +class ListVersionsResponse(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + items: list[RegistryVersionRow] = Field(default_factory=list) diff --git a/vibe/core/telemetry/types.py b/vibe/core/telemetry/types.py index 4a7629f..c6daa47 100644 --- a/vibe/core/telemetry/types.py +++ b/vibe/core/telemetry/types.py @@ -60,7 +60,7 @@ class TelemetryRequestMetadata(TelemetryBaseMetadata): TeleportFailureStage = Literal[ - "no_history", "git_check", "push", "workflow_start", "cancelled" + "no_history", "ineligible", "git_check", "push", "workflow_start", "cancelled" ]