From 95336528f68d5518158f71a6b85a439bd234f6cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20Rossin=C3=A8s?= Date: Thu, 16 Apr 2026 11:10:26 +0200 Subject: [PATCH] v2.7.6 (#600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Clément Sirieix Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert Co-authored-by: Maxime Dolores Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Paul Cacheux Co-authored-by: Quentin Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com> Co-authored-by: angelapopopo Co-authored-by: Mistral Vibe --- .vscode/launch.json | 2 +- AGENTS.md | 9 + CHANGELOG.md | 25 + distribution/zed/extension.toml | 14 +- pyproject.toml | 4 +- tests/acp/test_commands.py | 279 +++++++++++ tests/acp/test_initialize.py | 4 +- tests/cli/test_mcp_app.py | 8 +- tests/cli/test_question_app.py | 205 +++++++++ tests/core/test_merge.py | 163 +++++++ tests/core/test_telemetry_send.py | 2 +- tests/skills/test_manager.py | 74 +++ ...shot_mcp_backspace_returns_to_overview.svg | 30 +- .../test_snapshot_mcp_broken_server.svg | 32 +- ...napshot_mcp_connector_back_to_overview.svg | 206 +++++++++ .../test_snapshot_mcp_connectors_only.svg | 207 +++++++++ ...test_snapshot_mcp_drill_into_connector.svg | 203 ++++++++ .../test_snapshot_mcp_overview.svg | 30 +- ...st_snapshot_mcp_overview_navigate_down.svg | 30 +- ..._snapshot_mcp_with_connectors_overview.svg | 206 +++++++++ ...napshot_streaming_code_fence_preserved.svg | 203 ++++++++ .../snapshots/test_ui_snapshot_mcp_command.py | 117 +++++ .../test_ui_snapshot_streaming_code_fence.py | 61 +++ tests/stubs/fake_connector_registry.py | 46 ++ tests/stubs/fake_mcp_registry.py | 5 + tests/test_agent_backend.py | 2 + tests/test_deferred_init.py | 8 +- tests/test_ui_input_history.py | 6 +- tests/tools/test_connectors.py | 401 ++++++++++++++++ uv.lock | 12 +- vibe/__init__.py | 2 +- vibe/acp/acp_agent_loop.py | 433 ++++++++++++------ vibe/acp/commands/__init__.py | 5 + vibe/acp/commands/registry.py | 88 ++++ vibe/cli/commands.py | 5 + vibe/cli/textual_ui/app.py | 128 ++++-- vibe/cli/textual_ui/widgets/banner/banner.py | 25 +- .../widgets/chat_input/text_area.py | 2 - vibe/cli/textual_ui/widgets/mcp_app.py | 196 ++++++-- vibe/cli/textual_ui/widgets/messages.py | 4 +- vibe/cli/textual_ui/widgets/question_app.py | 47 +- vibe/core/agent_loop.py | 37 +- vibe/core/config/_settings.py | 2 +- vibe/core/llm/backend/anthropic.py | 4 +- vibe/core/llm/backend/mistral.py | 12 +- vibe/core/prompts/cli.md | 3 +- vibe/core/skills/manager.py | 31 +- vibe/core/skills/models.py | 6 + vibe/core/system_prompt.py | 127 +++-- vibe/core/telemetry/send.py | 2 +- vibe/core/tools/connectors/__init__.py | 14 + .../tools/connectors/connector_registry.py | 417 +++++++++++++++++ vibe/core/tools/manager.py | 84 +++- vibe/core/tools/mcp/registry.py | 8 +- vibe/core/tools/mcp/tools.py | 5 + vibe/core/utils/__init__.py | 3 + vibe/core/utils/http.py | 2 +- vibe/core/utils/merge.py | 104 +++++ vibe/whats_new.md | 5 +- 59 files changed, 3992 insertions(+), 403 deletions(-) create mode 100644 tests/acp/test_commands.py create mode 100644 tests/core/test_merge.py create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_streaming_code_fence/test_snapshot_streaming_code_fence_preserved.svg create mode 100644 tests/snapshots/test_ui_snapshot_streaming_code_fence.py create mode 100644 tests/stubs/fake_connector_registry.py create mode 100644 tests/tools/test_connectors.py create mode 100644 vibe/acp/commands/__init__.py create mode 100644 vibe/acp/commands/registry.py create mode 100644 vibe/core/tools/connectors/__init__.py create mode 100644 vibe/core/tools/connectors/connector_registry.py create mode 100644 vibe/core/utils/merge.py diff --git a/.vscode/launch.json b/.vscode/launch.json index 186480e..51586af 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,5 @@ { - "version": "2.7.5", + "version": "2.7.6", "configurations": [ { "name": "ACP Server", diff --git a/AGENTS.md b/AGENTS.md index 80ad6d5..245028f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,3 +154,12 @@ guidelines: - Use the workspace snippets: type the prefix (e.g. acpschema, acphelpers, vibetypes, vibeconfig) and accept the suggestion to insert the import line, then change the symbol name. - Or ask Cursor: select the undefined symbol, then Cmd+K and request "Add the missing import for ". - Or copy the import from an existing file in the repo (e.g. acp.schema, acp.helpers, vibe.core.*). + + - title: "No Docstrings in Tests" + description: > + Do not add docstrings to test functions, test methods, or test classes. + Test names should be descriptive enough to convey intent (e.g., + `test_create_user_returns_403_when_unauthorized`). Docstrings in tests add + noise, duplicate the function name, and can suppress pytest's default output + (pytest displays the docstring instead of the node id when one is present). + Use inline comments sparingly for non-obvious setup or assertions instead. diff --git a/CHANGELOG.md b/CHANGELOG.md index 65eaabd..16fd452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ 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.7.6] - 2026-04-16 + +### Added + +- `MergeStrategy` enum and merge logic for configuration +- `call_source=vibe_code` field in LLM request metadata +- "Other" task type for non-code requests in CLI prompt + +### Changed + +- Parallelized git subprocess calls during startup +- Extracted command registry and refactored skill resolution +- 1M context window and thinking budget max for opus +- Updated default telemetry URL to `api.mistral.ai` + +### Fixed + +- Markdown fence context loss causing streaming rendering problems +- Proxy chain URLs in `api_base` parsing + +### Removed + +- Alt+Left / Alt+Right key bindings from chat input + + ## [2.7.5] - 2026-04-14 ### Changed diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index c22ebb7..71ad511 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.7.5" +version = "2.7.6" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,25 +11,25 @@ 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.7.5/vibe-acp-darwin-aarch64-2.7.5.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.6/vibe-acp-darwin-aarch64-2.7.6.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-darwin-x86_64-2.7.5.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.6/vibe-acp-darwin-x86_64-2.7.6.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-linux-aarch64-2.7.5.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.6/vibe-acp-linux-aarch64-2.7.6.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-linux-x86_64-2.7.5.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.6/vibe-acp-linux-x86_64-2.7.6.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-windows-aarch64-2.7.5.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.6/vibe-acp-windows-aarch64-2.7.6.zip" cmd = "./vibe-acp.exe" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-windows-x86_64-2.7.5.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.6/vibe-acp-windows-x86_64-2.7.6.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index a0c3733..3eefaee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.7.5" +version = "2.7.6" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" @@ -40,7 +40,7 @@ dependencies = [ "keyring>=25.6.0", "markdownify>=1.2.2", "mcp>=1.14.0", - "mistralai==2.1.3", + "mistralai==2.3.2", "opentelemetry-api>=1.39.1", "opentelemetry-exporter-otlp-proto-http>=1.39.1", "opentelemetry-sdk>=1.39.1", diff --git a/tests/acp/test_commands.py b/tests/acp/test_commands.py new file mode 100644 index 0000000..92f75cb --- /dev/null +++ b/tests/acp/test_commands.py @@ -0,0 +1,279 @@ +"""Tests for ACP slash command handlers on VibeAcpAgentLoop.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, patch + +from acp.schema import AgentMessageChunk, AvailableCommandsUpdate, TextContentBlock +import pytest + +from tests.acp.conftest import _create_acp_agent +from tests.skills.conftest import create_skill +from tests.stubs.fake_backend import FakeBackend +from tests.stubs.fake_client import FakeClient +from vibe.acp.acp_agent_loop import VibeAcpAgentLoop +from vibe.core.agent_loop import AgentLoop +from vibe.core.config import SessionLoggingConfig + + +def _get_client(agent: VibeAcpAgentLoop) -> FakeClient: + assert isinstance(agent.client, FakeClient) + return agent.client + + +def _get_message_texts(agent: VibeAcpAgentLoop) -> list[str]: + """Extract text content from all AgentMessageChunk session updates.""" + return [ + u.update.content.text + for u in _get_client(agent)._session_updates + if isinstance(u.update, AgentMessageChunk) + ] + + +async def _new_session_and_clear(agent: VibeAcpAgentLoop) -> str: + """Create a new session, drain the startup updates, return session_id.""" + resp = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + await asyncio.sleep(0) # let background tasks (available_commands) complete + _get_client(agent)._session_updates.clear() + return resp.session_id + + +async def _prompt(agent: VibeAcpAgentLoop, session_id: str, text: str): + return await agent.prompt( + prompt=[TextContentBlock(type="text", text=text)], session_id=session_id + ) + + +def _make_patched_agent_loop( + backend: FakeBackend, + *, + skill_paths: list[Path] | None = None, + session_logging: SessionLoggingConfig | None = None, +) -> type[AgentLoop]: + """Create a PatchedAgentLoop class that injects config overrides.""" + config_updates: dict = {} + if skill_paths is not None: + config_updates["skill_paths"] = skill_paths + if session_logging is not None: + config_updates["session_logging"] = session_logging + + class PatchedAgentLoop(AgentLoop): + def __init__(self, *args, **kwargs) -> None: + if config_updates and "config" in kwargs and kwargs["config"] is not None: + kwargs["config"] = kwargs["config"].model_copy(update=config_updates) + super().__init__(*args, **{**kwargs, "backend": backend}) + + return PatchedAgentLoop + + +@pytest.fixture +def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop: + patched = _make_patched_agent_loop(backend) + patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=patched).start() + return _create_acp_agent() + + +@pytest.fixture +def skills_dir(tmp_path: Path) -> Path: + d = tmp_path / "skills" + d.mkdir() + return d + + +@pytest.fixture +def acp_agent_loop_with_skills( + backend: FakeBackend, skills_dir: Path +) -> VibeAcpAgentLoop: + # Skills must exist in skills_dir BEFORE new_session() is called. + patched = _make_patched_agent_loop(backend, skill_paths=[skills_dir]) + patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=patched).start() + return _create_acp_agent() + + +class TestHandleHelp: + @pytest.mark.asyncio + async def test_lists_all_registered_commands( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_id = await _new_session_and_clear(acp_agent_loop) + response = await _prompt(acp_agent_loop, session_id, "/help") + + assert response.stop_reason == "end_turn" + texts = _get_message_texts(acp_agent_loop) + assert len(texts) == 1 + content = texts[0] + + main_commands = ["help", "compact", "reload", "proxy-setup"] + for cmd in main_commands: + assert f"/{cmd}" in content + + @pytest.mark.asyncio + async def test_includes_user_invocable_skills( + self, acp_agent_loop_with_skills: VibeAcpAgentLoop, skills_dir: Path + ) -> None: + # Create skills before new_session so SkillManager discovers them + create_skill(skills_dir, "my-skill", "Does something useful") + create_skill(skills_dir, "hidden-skill", "Secret", user_invocable=False) + + session_id = await _new_session_and_clear(acp_agent_loop_with_skills) + await _prompt(acp_agent_loop_with_skills, session_id, "/help") + + content = _get_message_texts(acp_agent_loop_with_skills)[0] + assert "/my-skill" in content + assert "Does something useful" in content + assert "hidden-skill" not in content + + +class TestHandleCompact: + @pytest.mark.asyncio + async def test_empty_history_does_not_compact( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_id = await _new_session_and_clear(acp_agent_loop) + session = acp_agent_loop.sessions[session_id] + + with patch.object( + session.agent_loop, "compact", new_callable=AsyncMock + ) as mock_compact: + await _prompt(acp_agent_loop, session_id, "/compact") + mock_compact.assert_not_called() + + @pytest.mark.asyncio + async def test_compact_calls_agent_loop_compact( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_id = await _new_session_and_clear(acp_agent_loop) + + # Have a conversation first to create history + await _prompt(acp_agent_loop, session_id, "Hello, tell me something") + _get_client(acp_agent_loop)._session_updates.clear() + + session = acp_agent_loop.sessions[session_id] + with patch.object( + session.agent_loop, "compact", new_callable=AsyncMock + ) as mock_compact: + response = await _prompt(acp_agent_loop, session_id, "/compact") + assert response.stop_reason == "end_turn" + mock_compact.assert_called_once() + + +class TestHandleReload: + @pytest.mark.asyncio + async def test_reload_calls_reload_with_initial_messages( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_id = await _new_session_and_clear(acp_agent_loop) + session = acp_agent_loop.sessions[session_id] + + with patch.object( + session.agent_loop, "reload_with_initial_messages", new_callable=AsyncMock + ) as mock_reload: + response = await _prompt(acp_agent_loop, session_id, "/reload") + assert response.stop_reason == "end_turn" + mock_reload.assert_called_once() + + @pytest.mark.asyncio + async def test_reload_notifies_commands_changed( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_id = await _new_session_and_clear(acp_agent_loop) + session = acp_agent_loop.sessions[session_id] + + with patch.object( + session.command_registry, "notify_changed", new_callable=AsyncMock + ) as mock_notify: + await _prompt(acp_agent_loop, session_id, "/reload") + mock_notify.assert_called_once() + + +class TestCommandFallthrough: + @pytest.mark.asyncio + async def test_unknown_slash_command_reaches_agent( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_id = await _new_session_and_clear(acp_agent_loop) + response = await _prompt(acp_agent_loop, session_id, "/nonexistent") + + # The agent loop should have processed it (FakeBackend returns "Hi") + assert response.stop_reason == "end_turn" + texts = _get_message_texts(acp_agent_loop) + # Should contain the LLM response, not a command reply + assert any("Hi" in t for t in texts) + + @pytest.mark.asyncio + async def test_regular_message_reaches_agent( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_id = await _new_session_and_clear(acp_agent_loop) + response = await _prompt(acp_agent_loop, session_id, "Hello world") + + assert response.stop_reason == "end_turn" + texts = _get_message_texts(acp_agent_loop) + assert any("Hi" in t for t in texts) + + +class TestAvailableCommandsWithSkills: + @pytest.mark.asyncio + async def test_skills_appear_in_available_commands( + self, acp_agent_loop_with_skills: VibeAcpAgentLoop, skills_dir: Path + ) -> None: + create_skill(skills_dir, "my-skill", "A useful skill") + + await acp_agent_loop_with_skills.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + await asyncio.sleep(0) + + updates = _get_client(acp_agent_loop_with_skills)._session_updates + available = [ + u for u in updates if isinstance(u.update, AvailableCommandsUpdate) + ] + assert len(available) == 1 + + cmd_names = [c.name for c in available[0].update.available_commands] + assert "my-skill" in cmd_names + # Built-in commands should also be present + assert "help" in cmd_names + + @pytest.mark.asyncio + async def test_non_invocable_skills_excluded_from_available_commands( + self, acp_agent_loop_with_skills: VibeAcpAgentLoop, skills_dir: Path + ) -> None: + create_skill(skills_dir, "visible-skill", "Visible") + create_skill(skills_dir, "hidden-skill", "Hidden", user_invocable=False) + + await acp_agent_loop_with_skills.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + await asyncio.sleep(0) + + 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 "visible-skill" in cmd_names + assert "hidden-skill" not in cmd_names + + +class TestCommandCaseInsensitivity: + @pytest.mark.asyncio + async def test_uppercase_command(self, acp_agent_loop: VibeAcpAgentLoop) -> None: + session_id = await _new_session_and_clear(acp_agent_loop) + response = await _prompt(acp_agent_loop, session_id, "/HELP") + + assert response.stop_reason == "end_turn" + content = _get_message_texts(acp_agent_loop)[0] + assert "Available Commands" in content + + @pytest.mark.asyncio + async def test_mixed_case_command(self, acp_agent_loop: VibeAcpAgentLoop) -> None: + session_id = await _new_session_and_clear(acp_agent_loop) + response = await _prompt(acp_agent_loop, session_id, "/Help") + + assert response.stop_reason == "end_turn" + content = _get_message_texts(acp_agent_loop)[0] + assert "Available Commands" in content diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index e41a0b1..7e4944a 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -28,7 +28,7 @@ class TestACPInitialize: session_capabilities=SessionCapabilities(list=SessionListCapabilities()), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.5" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.6" ) assert response.auth_methods == [] @@ -52,7 +52,7 @@ class TestACPInitialize: session_capabilities=SessionCapabilities(list=SessionListCapabilities()), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.5" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.6" ) assert response.auth_methods is not None diff --git a/tests/cli/test_mcp_app.py b/tests/cli/test_mcp_app.py index 922a465..13d5cf3 100644 --- a/tests/cli/test_mcp_app.py +++ b/tests/cli/test_mcp_app.py @@ -142,7 +142,9 @@ class TestMCPAppInit: app = MCPApp(mcp_servers=servers, tool_manager=mgr) app._viewing_server = "srv" render_calls: list[str | None] = [] - app._refresh_view = lambda server_name: render_calls.append(server_name) + app._refresh_view = lambda server_name, *, kind=None: render_calls.append( + server_name + ) app.action_back() assert render_calls == [None] @@ -151,6 +153,8 @@ class TestMCPAppInit: app = MCPApp(mcp_servers=[], tool_manager=mgr) app._viewing_server = None render_calls: list[str | None] = [] - app._refresh_view = lambda server_name: render_calls.append(server_name) + app._refresh_view = lambda server_name, *, kind=None: render_calls.append( + server_name + ) app.action_back() assert render_calls == [] diff --git a/tests/cli/test_question_app.py b/tests/cli/test_question_app.py index 594d602..cd4fe71 100644 --- a/tests/cli/test_question_app.py +++ b/tests/cli/test_question_app.py @@ -278,6 +278,65 @@ class TestQuestionAppActions: assert app._get_other_text(0) == "Text for Q1" assert app._get_other_text(1) == "" + def test_switch_question_restores_cursor_to_answered_option( + self, multi_question_args + ): + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + app = QuestionApp(multi_question_args) + # Answer question 0 with MongoDB (index 1) + app.answers[0] = ("MongoDB", False) + + # Switch away then back + app._switch_question(1) + app._switch_question(0) + + assert app.selected_option == 1 + + def test_switch_question_restores_cursor_to_other(self, multi_question_args): + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + app = QuestionApp(multi_question_args) + # Answer question 0 with Other + app.answers[0] = ("Custom DB", True) + + app._switch_question(1) + app._switch_question(0) + + assert app.selected_option == len(app.questions[0].options) + assert app._is_other_selected + + def test_switch_question_defaults_to_zero_if_unanswered(self, multi_question_args): + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + app = QuestionApp(multi_question_args) + + app._switch_question(1) + assert app.selected_option == 0 + + def test_switch_question_restores_cursor_multi_select(self): + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + args = AskUserQuestionArgs( + questions=[ + Question( + question="Q1?", options=[Choice(label="A"), Choice(label="B")] + ), + Question( + question="Q2?", + options=[Choice(label="X"), Choice(label="Y"), Choice(label="Z")], + multi_select=True, + ), + ] + ) + app = QuestionApp(args) + # Select Y (1) and Z (2) for question 1 + app.multi_selections[1] = {1, 2} + + app._switch_question(1) + + assert app.selected_option == 1 # min of {1, 2} + class TestMultiSelectOtherBehavior: def test_multi_select_other_does_not_advance_on_save(self, multi_select_args): @@ -489,6 +548,152 @@ class TestMultiSelectAutoSelect: assert app._other_option_idx in app.multi_selections[0] +class TestNumberKeyShortcuts: + def test_number_key_selects_predefined_option(self, single_question_args): + from unittest.mock import patch + + from textual import events + + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + app = QuestionApp(single_question_args) + app.selected_option = 0 + + # Press "2" -> should select MongoDB (index 1) and save + event = events.Key("2", "2") + with patch.object(app, "_advance_or_submit"): + app._handle_number_key(event) + + assert app.selected_option == 1 + assert 0 in app.answers + answer_text, is_other = app.answers[0] + assert answer_text == "MongoDB" + assert is_other is False + + def test_number_key_first_option(self, single_question_args): + from unittest.mock import patch + + from textual import events + + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + app = QuestionApp(single_question_args) + + event = events.Key("1", "1") + with patch.object(app, "_advance_or_submit"): + app._handle_number_key(event) + + assert app.selected_option == 0 + assert 0 in app.answers + answer_text, _ = app.answers[0] + assert answer_text == "PostgreSQL" + + def test_number_key_other_empty_focuses(self, single_question_args): + from textual import events + + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + app = QuestionApp(single_question_args) + # Other is option index 2 -> key "3" + event = events.Key("3", "3") + app._handle_number_key(event) + + # Should navigate to Other but not validate (no text) + assert app.selected_option == 2 + assert app._is_other_selected is True + assert 0 not in app.answers + + def test_number_key_other_with_text_focuses_without_validating( + self, single_question_args + ): + from unittest.mock import MagicMock + + from textual import events + + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + app = QuestionApp(single_question_args) + app.other_texts[0] = "SQLite" + app.other_input = MagicMock() + app.other_input.has_focus = False + + event = events.Key("3", "3") + app._handle_number_key(event) + + # Should navigate to Other but not validate — just focus the input + assert app.selected_option == 2 + assert app._is_other_selected is True + assert 0 not in app.answers + + def test_number_key_out_of_range_ignored(self, single_question_args): + from textual import events + + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + app = QuestionApp(single_question_args) + # 3 options total (2 + Other), so "4" is out of range + event = events.Key("4", "4") + app._handle_number_key(event) + + assert app.selected_option == 0 + assert 0 not in app.answers + + def test_number_key_zero_ignored(self, single_question_args): + from textual import events + + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + app = QuestionApp(single_question_args) + event = events.Key("0", "0") + app._handle_number_key(event) + + assert app.selected_option == 0 + assert 0 not in app.answers + + def test_number_key_ignored_when_input_focused(self, single_question_args): + from unittest.mock import MagicMock + + from textual import events + + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + app = QuestionApp(single_question_args) + app.other_input = MagicMock() + app.other_input.has_focus = True + + event = events.Key("1", "1") + app._handle_number_key(event) + + assert app.selected_option == 0 + assert 0 not in app.answers + + def test_number_key_multi_select_toggles(self, multi_select_args): + from textual import events + + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + app = QuestionApp(multi_select_args) + + # Press "1" in multi-select -> should toggle Auth + event = events.Key("1", "1") + app._handle_number_key(event) + + assert 0 in app.multi_selections.get(0, set()) + + def test_number_key_multi_select_submit_ignored(self, multi_select_args): + from textual import events + + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + app = QuestionApp(multi_select_args) + # Submit is at index 4 -> key "5" + event = events.Key("5", "5") + app._handle_number_key(event) + + # Should not navigate to submit + assert app.selected_option == 0 + + class TestMultiSelectSubmit: def test_navigate_to_submit(self, multi_select_args): from vibe.cli.textual_ui.widgets.question_app import QuestionApp diff --git a/tests/core/test_merge.py b/tests/core/test_merge.py new file mode 100644 index 0000000..db9088e --- /dev/null +++ b/tests/core/test_merge.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from vibe.core.utils.merge import MergeConflictError, MergeStrategy + + +class TestMergeStrategyEnum: + def test_values_are_lowercase_strings(self) -> None: + assert MergeStrategy.REPLACE == "replace" + assert MergeStrategy.CONCAT == "concat" + assert MergeStrategy.UNION == "union" + assert MergeStrategy.MERGE == "merge" + assert MergeStrategy.CONFLICT == "conflict" + + def test_all_strategies_exist(self) -> None: + assert len(MergeStrategy) == 5 + + def test_unknown_strategy_raises_not_implemented(self) -> None: + fake = MagicMock(spec=MergeStrategy) + with pytest.raises(NotImplementedError, match="not implemented"): + MergeStrategy.apply(fake, "a", "b") + + +class TestReplace: + def test_override_wins(self) -> None: + assert MergeStrategy.REPLACE.apply(1, 2) == 2 + + def test_base_returned_when_override_none(self) -> None: + assert MergeStrategy.REPLACE.apply(1, None) == 1 + + def test_both_none_returns_none(self) -> None: + assert MergeStrategy.REPLACE.apply(None, None) is None + + def test_override_none_preserves_base_type(self) -> None: + assert MergeStrategy.REPLACE.apply([1, 2], None) == [1, 2] + assert MergeStrategy.REPLACE.apply({"a": 1}, None) == {"a": 1} + + def test_override_can_be_falsy(self) -> None: + assert MergeStrategy.REPLACE.apply(1, 0) == 0 + assert MergeStrategy.REPLACE.apply(1, "") == "" + assert MergeStrategy.REPLACE.apply(1, False) is False + + +class TestConcat: + def test_lists_concatenated(self) -> None: + assert MergeStrategy.CONCAT.apply([1, 2], [3, 4]) == [1, 2, 3, 4] + + def test_base_none_returns_override(self) -> None: + assert MergeStrategy.CONCAT.apply(None, [1]) == [1] + + def test_override_none_returns_base(self) -> None: + assert MergeStrategy.CONCAT.apply([1], None) == [1] + + def test_both_none_returns_none(self) -> None: + assert MergeStrategy.CONCAT.apply(None, None) is None + + def test_empty_lists(self) -> None: + assert MergeStrategy.CONCAT.apply([], []) == [] + + def test_raises_type_error_for_non_list(self) -> None: + with pytest.raises(TypeError, match="CONCAT requires list operands"): + MergeStrategy.CONCAT.apply("a", [1]) + with pytest.raises(TypeError, match="CONCAT requires list operands"): + MergeStrategy.CONCAT.apply([1], "b") + + +class TestUnion: + def test_merge_by_key_override_wins(self) -> None: + base = [{"name": "a", "v": 1}, {"name": "b", "v": 2}] + override = [{"name": "b", "v": 99}] + result = MergeStrategy.UNION.apply(base, override, key_fn=lambda x: x["name"]) + assert result == [{"name": "a", "v": 1}, {"name": "b", "v": 99}] + + def test_preserves_order_base_first(self) -> None: + base = [{"name": "a"}, {"name": "b"}] + override = [{"name": "c"}] + result = MergeStrategy.UNION.apply(base, override, key_fn=lambda x: x["name"]) + assert [x["name"] for x in result] == ["a", "b", "c"] + + def test_new_keys_from_override_appended(self) -> None: + base = [{"name": "a"}] + override = [{"name": "b"}, {"name": "c"}] + result = MergeStrategy.UNION.apply(base, override, key_fn=lambda x: x["name"]) + assert len(result) == 3 + + def test_base_none_returns_override(self) -> None: + assert MergeStrategy.UNION.apply(None, [1], key_fn=str) == [1] + + def test_override_none_returns_base(self) -> None: + assert MergeStrategy.UNION.apply([1], None, key_fn=str) == [1] + + def test_both_none_returns_none(self) -> None: + assert MergeStrategy.UNION.apply(None, None) is None + + def test_raises_without_key_fn(self) -> None: + with pytest.raises(ValueError, match="UNION strategy requires key_fn"): + MergeStrategy.UNION.apply([1], [2]) + + def test_raises_type_error_for_non_list(self) -> None: + with pytest.raises(TypeError, match="UNION requires list operands"): + MergeStrategy.UNION.apply("a", [1], key_fn=str) + + +class TestMerge: + def test_dicts_merged_one_level(self) -> None: + result = MergeStrategy.MERGE.apply({"a": 1, "b": 2}, {"b": 3, "c": 4}) + assert result == {"a": 1, "b": 3, "c": 4} + + def test_nested_dicts_not_recursed(self) -> None: + base = {"a": {"x": 1, "y": 2}} + override = {"a": {"y": 3, "z": 4}} + result = MergeStrategy.MERGE.apply(base, override) + assert result == {"a": {"y": 3, "z": 4}} + + def test_base_none_returns_override(self) -> None: + assert MergeStrategy.MERGE.apply(None, {"a": 1}) == {"a": 1} + + def test_override_none_returns_base(self) -> None: + assert MergeStrategy.MERGE.apply({"a": 1}, None) == {"a": 1} + + def test_both_none_returns_none(self) -> None: + assert MergeStrategy.MERGE.apply(None, None) is None + + def test_raises_type_error_for_non_dict(self) -> None: + with pytest.raises(TypeError, match="MERGE requires dict operands"): + MergeStrategy.MERGE.apply([1], {"a": 1}) + + def test_does_not_mutate_inputs(self) -> None: + base = {"a": 1} + override = {"b": 2} + MergeStrategy.MERGE.apply(base, override) + assert base == {"a": 1} + assert override == {"b": 2} + + +class TestConflict: + def test_raises_when_both_provided(self) -> None: + with pytest.raises(MergeConflictError): + MergeStrategy.CONFLICT.apply(1, 2) + + def test_base_only_returns_base(self) -> None: + assert MergeStrategy.CONFLICT.apply(1, None) == 1 + + def test_override_only_returns_override(self) -> None: + assert MergeStrategy.CONFLICT.apply(None, 2) == 2 + + def test_both_none_returns_none(self) -> None: + assert MergeStrategy.CONFLICT.apply(None, None) is None + + +class TestMergeConflictError: + def test_default_message(self) -> None: + err = MergeConflictError() + assert str(err) == "Merge conflict" + assert err.field_name == "" + + def test_message_with_field_name(self) -> None: + err = MergeConflictError("active_model") + assert str(err) == "Merge conflict on field 'active_model'" + assert err.field_name == "active_model" diff --git a/tests/core/test_telemetry_send.py b/tests/core/test_telemetry_send.py index 1015e91..686d1ac 100644 --- a/tests/core/test_telemetry_send.py +++ b/tests/core/test_telemetry_send.py @@ -489,7 +489,7 @@ class TestTelemetryClient: assert ( client._get_telemetry_url("not-a-valid-url") - == "https://codestral.mistral.ai/v1/datalake/events" + == "https://api.mistral.ai/v1/datalake/events" ) def test_is_active_false_when_mistral_provider_exists_but_no_api_key( diff --git a/tests/skills/test_manager.py b/tests/skills/test_manager.py index ec8f160..a5dfea9 100644 --- a/tests/skills/test_manager.py +++ b/tests/skills/test_manager.py @@ -518,3 +518,77 @@ class TestSkillUserInvocable: assert skills["visible-skill"].user_invocable is True assert skills["hidden-skill"].user_invocable is False assert skills["default-skill"].user_invocable is True + + +class TestParseSkillCommand: + def test_plain_text_returns_none(self, skill_manager: SkillManager) -> None: + assert skill_manager.parse_skill_command("hello world") is None + + def test_unknown_skill_returns_none(self, skill_manager: SkillManager) -> None: + assert skill_manager.parse_skill_command("/nonexistent") is None + + def test_slash_only_returns_none(self, skill_manager: SkillManager) -> None: + assert skill_manager.parse_skill_command("/") is None + + def test_parses_skill_without_args( + self, skills_dir: Path, skill_config: VibeConfig + ) -> None: + create_skill(skills_dir, "my-skill", body="Do the thing.") + manager = SkillManager(lambda: skill_config) + + parsed = manager.parse_skill_command("/my-skill") + assert parsed is not None + assert parsed.name == "my-skill" + assert "Do the thing." in parsed.content + assert parsed.extra_instructions is None + + def test_parses_skill_with_args( + self, skills_dir: Path, skill_config: VibeConfig + ) -> None: + create_skill(skills_dir, "my-skill", body="Do the thing.") + manager = SkillManager(lambda: skill_config) + + parsed = manager.parse_skill_command("/my-skill fix the bug") + assert parsed is not None + assert parsed.name == "my-skill" + assert parsed.extra_instructions == "fix the bug" + + def test_case_insensitive(self, skills_dir: Path, skill_config: VibeConfig) -> None: + create_skill(skills_dir, "my-skill", body="Do the thing.") + manager = SkillManager(lambda: skill_config) + + parsed = manager.parse_skill_command("/MY-SKILL") + assert parsed is not None + assert parsed.name == "my-skill" + + def test_raises_on_unreadable_skill( + self, skills_dir: Path, skill_config: VibeConfig + ) -> None: + create_skill(skills_dir, "bad-skill", body="content") + manager = SkillManager(lambda: skill_config) + (skills_dir / "bad-skill" / "SKILL.md").unlink() + + with pytest.raises(OSError): + manager.parse_skill_command("/bad-skill") + + +class TestBuildSkillPrompt: + def test_without_args(self, skills_dir: Path, skill_config: VibeConfig) -> None: + create_skill(skills_dir, "my-skill", body="Do the thing.") + manager = SkillManager(lambda: skill_config) + + parsed = manager.parse_skill_command("/my-skill") + assert parsed is not None + prompt = SkillManager.build_skill_prompt("/my-skill", parsed) + assert prompt == parsed.content + + def test_with_args(self, skills_dir: Path, skill_config: VibeConfig) -> None: + create_skill(skills_dir, "my-skill", body="Do the thing.") + manager = SkillManager(lambda: skill_config) + + text = "/my-skill fix the bug" + parsed = manager.parse_skill_command(text) + assert parsed is not None + prompt = SkillManager.build_skill_prompt(text, parsed) + assert prompt.startswith("/my-skill fix the bug") + assert "Do the thing." in prompt diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg index f867115..f389abc 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg @@ -39,6 +39,8 @@ .terminal-r5 { fill: #9a9b99 } .terminal-r6 { fill: #608ab1;font-weight: bold } .terminal-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-r8 { fill: #9cafbd;font-weight: bold } +.terminal-r9 { fill: #868887 } @@ -160,7 +162,7 @@ - + @@ -179,21 +181,21 @@ - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + - -/mcp filesystem -MCP servers opened... +/mcp filesystem +MCP servers opened... + - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -filesystem  [stdio]  1 tool enabled -search  [http]  1 tool enabled +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem  [stdio]  1 tool +  search      [http]   1 tool ↑↓ Navigate  Enter Show tools  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg index b0b4eb4..3fd3bce 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg @@ -39,6 +39,8 @@ .terminal-r5 { fill: #9a9b99 } .terminal-r6 { fill: #608ab1;font-weight: bold } .terminal-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-r8 { fill: #9cafbd;font-weight: bold } +.terminal-r9 { fill: #868887 } @@ -160,7 +162,7 @@ - + @@ -178,22 +180,22 @@ - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + - -/mcp -MCP servers opened... +/mcp +MCP servers opened... + - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -filesystem  [stdio]  1 tool enabled -broken-server  [stdio]  unavailable -search  [http]  1 tool enabled +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem     [stdio]  1 tool +  broken-server  [stdio]  no tools +  search         [http]   1 tool ↑↓ Navigate  Enter Show tools  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg new file mode 100644 index 0000000..ee3ceb9 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppWithConnectors + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers & Connectors + +Local MCP Servers +  filesystem  [stdio]  1 tool + +Workspace Connectors +  gmail  [connector]  3 tools connected +  slack  [connector]  2 tools connected + +↑↓ Navigate  Enter Show tools  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg new file mode 100644 index 0000000..9384cec --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppConnectorsOnly + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers & Connectors + +Workspace Connectors +  gmail  [connector]  3 tools connected +  slack  [connector]  2 tools connected + +↑↓ Navigate  Enter Show tools  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg new file mode 100644 index 0000000..37b0e29 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppWithConnectors + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +Connector: slack + +search_messages  -  Search Slack messages +send_message  -  Send a Slack message + +↑↓ Navigate  Backspace Back  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg index 36c307d..a289eb1 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg @@ -39,6 +39,8 @@ .terminal-r5 { fill: #9a9b99 } .terminal-r6 { fill: #608ab1;font-weight: bold } .terminal-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-r8 { fill: #9cafbd;font-weight: bold } +.terminal-r9 { fill: #868887 } @@ -160,7 +162,7 @@ - + @@ -179,21 +181,21 @@ - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + - -/mcp -MCP servers opened... +/mcp +MCP servers opened... + - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -filesystem  [stdio]  1 tool enabled -search  [http]  1 tool enabled +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem  [stdio]  1 tool +  search      [http]   1 tool ↑↓ Navigate  Enter Show tools  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg index 424481d..65bbfde 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg @@ -39,6 +39,8 @@ .terminal-r5 { fill: #9a9b99 } .terminal-r6 { fill: #608ab1;font-weight: bold } .terminal-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-r8 { fill: #868887 } +.terminal-r9 { fill: #9cafbd;font-weight: bold } @@ -160,7 +162,7 @@ - + @@ -179,21 +181,21 @@ - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + - -/mcp -MCP servers opened... +/mcp +MCP servers opened... + - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -filesystem  [stdio]  1 tool enabled -search  [http]  1 tool enabled +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem  [stdio]  1 tool +  search      [http]   1 tool ↑↓ Navigate  Enter Show tools  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg new file mode 100644 index 0000000..ee3ceb9 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppWithConnectors + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers & Connectors + +Local MCP Servers +  filesystem  [stdio]  1 tool + +Workspace Connectors +  gmail  [connector]  3 tools connected +  slack  [connector]  2 tools connected + +↑↓ Navigate  Enter Show tools  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_code_fence/test_snapshot_streaming_code_fence_preserved.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_code_fence/test_snapshot_streaming_code_fence_preserved.svg new file mode 100644 index 0000000..c270a9e --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_code_fence/test_snapshot_streaming_code_fence_preserved.svg @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + StreamingCodeFenceApp + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +show diagram + +  Client +    | +    |  POST /api/orders +    v ++---------+ +| Gateway | ++---------+ +    | +    v ++---------+ +| Service | ++---------+ +    | +    v ++----+----+ +|   DB    | ++---------+ + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/test_ui_snapshot_mcp_command.py b/tests/snapshots/test_ui_snapshot_mcp_command.py index fa303a0..3a432ba 100644 --- a/tests/snapshots/test_ui_snapshot_mcp_command.py +++ b/tests/snapshots/test_ui_snapshot_mcp_command.py @@ -6,14 +6,29 @@ from textual.pilot import Pilot from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config from tests.snapshots.snap_compare import SnapCompare +from tests.stubs.fake_connector_registry import FakeConnectorRegistry from tests.stubs.fake_mcp_registry import ( FakeMCPRegistry, FakeMCPRegistryWithBrokenServer, ) from vibe.core.config import MCPHttp, MCPStdio +from vibe.core.tools.connectors import CONNECTORS_ENV_VAR +from vibe.core.tools.mcp.tools import RemoteTool _MCP_PATCH = "vibe.core.agent_loop.MCPRegistry" +_FAKE_CONNECTORS = { + "gmail": [ + RemoteTool(name="gmail_search", description="Search emails in Gmail"), + RemoteTool(name="gmail_draft_create", description="Draft an email in Gmail"), + RemoteTool(name="gmail_open_message", description="Open a mail on Gmail"), + ], + "slack": [ + RemoteTool(name="search_messages", description="Search Slack messages"), + RemoteTool(name="send_message", description="Send a Slack message"), + ], +} + class SnapshotTestAppNoMcpServers(BaseSnapshotTestApp): def __init__(self) -> None: @@ -157,3 +172,105 @@ def test_snapshot_mcp_escape_closes(snap_compare: SnapCompare) -> None: terminal_size=(120, 36), run_before=run_before, ) + + +# --------------------------------------------------------------------------- +# Apps with connectors +# --------------------------------------------------------------------------- + + +class SnapshotTestAppWithConnectors(BaseSnapshotTestApp): + def __init__(self) -> None: + config = default_config() + config.mcp_servers = [ + MCPStdio(name="filesystem", transport="stdio", command="npx") + ] + super().__init__(config=config) + registry = FakeConnectorRegistry(connectors=_FAKE_CONNECTORS) + self.agent_loop.connector_registry = registry + self.agent_loop.tool_manager._connector_registry = registry + self.agent_loop.tool_manager.integrate_connectors() + + +class SnapshotTestAppConnectorsOnly(BaseSnapshotTestApp): + def __init__(self) -> None: + config = default_config() + config.mcp_servers = [] + super().__init__(config=config) + registry = FakeConnectorRegistry(connectors=_FAKE_CONNECTORS) + self.agent_loop.connector_registry = registry + self.agent_loop.tool_manager._connector_registry = registry + self.agent_loop.tool_manager.integrate_connectors() + + +# --------------------------------------------------------------------------- +# Connector snapshot tests +# --------------------------------------------------------------------------- + + +@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"}) +def test_snapshot_mcp_with_connectors_overview(snap_compare: SnapCompare) -> None: + + async def run_before(pilot: Pilot) -> None: + await _run_mcp_command(pilot, "/mcp") + + with patch(_MCP_PATCH, FakeMCPRegistry): + assert snap_compare( + "test_ui_snapshot_mcp_command.py:SnapshotTestAppWithConnectors", + terminal_size=(120, 36), + run_before=run_before, + ) + + +@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"}) +def test_snapshot_mcp_connectors_only(snap_compare: SnapCompare) -> None: + + async def run_before(pilot: Pilot) -> None: + await _run_mcp_command(pilot, "/mcp") + + assert snap_compare( + "test_ui_snapshot_mcp_command.py:SnapshotTestAppConnectorsOnly", + terminal_size=(120, 36), + run_before=run_before, + ) + + +@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"}) +def test_snapshot_mcp_drill_into_connector(snap_compare: SnapCompare) -> None: + + async def run_before(pilot: Pilot) -> None: + await _run_mcp_command(pilot, "/mcp") + # Navigate past the MCP server to the first connector + await pilot.press("down") # skip filesystem server + await pilot.pause(0.1) + await pilot.press("down") # first connector + await pilot.pause(0.1) + await pilot.press("enter") # drill in + await pilot.pause(0.1) + + with patch(_MCP_PATCH, FakeMCPRegistry): + assert snap_compare( + "test_ui_snapshot_mcp_command.py:SnapshotTestAppWithConnectors", + terminal_size=(120, 36), + run_before=run_before, + ) + + +@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"}) +def test_snapshot_mcp_connector_back_to_overview(snap_compare: SnapCompare) -> None: + + async def run_before(pilot: Pilot) -> None: + await _run_mcp_command(pilot, "/mcp") + await pilot.press("down", "down") + await pilot.pause(0.1) + await pilot.press("enter") + await pilot.pause(0.1) + await pilot.press("backspace") + await pilot.pause(0.1) + + with patch(_MCP_PATCH, FakeMCPRegistry): + assert snap_compare( + "test_ui_snapshot_mcp_command.py:SnapshotTestAppWithConnectors", + terminal_size=(120, 36), + run_before=run_before, + ) diff --git a/tests/snapshots/test_ui_snapshot_streaming_code_fence.py b/tests/snapshots/test_ui_snapshot_streaming_code_fence.py new file mode 100644 index 0000000..43991ec --- /dev/null +++ b/tests/snapshots/test_ui_snapshot_streaming_code_fence.py @@ -0,0 +1,61 @@ +"""Regression test for streaming code-fenced ASCII art rendering. + +Textual's Markdown.update() sets _last_parsed_line past fence openers, +causing append() to re-parse without fence context and collapse content +into paragraph text. This test streams a code-fenced diagram in small +chunks and verifies the rendered output preserves the fence block. +""" + +from __future__ import annotations + +from textual.pilot import Pilot + +from tests.conftest import build_test_agent_loop +from tests.mock.utils import mock_llm_chunk +from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config +from tests.snapshots.snap_compare import SnapCompare +from tests.stubs.fake_backend import FakeBackend + +# Chunks are sized so the first one contains the fence opener (```) +# plus content past the first newline — the exact pattern that triggers +# the bug. +_CHUNKS = [ + # First chunk: fence opener + content past newline (triggers the bug) + "```\n Client\n |\n | POST", + " /api/orders\n v\n+---------+\n| Gateway ", + "|\n+---------+\n |\n v\n+---------+\n| Service ", + "|\n+---------+\n |\n v\n+----+----+\n| DB ", + "|\n+---------+\n```", +] + + +class StreamingCodeFenceApp(BaseSnapshotTestApp): + def __init__(self) -> None: + config = default_config() + fake_backend = FakeBackend( + chunks=[mock_llm_chunk(content=chunk) for chunk in _CHUNKS] + ) + super().__init__(config=config) + self.agent_loop = build_test_agent_loop( + config=config, + agent_name=self._current_agent_name, + enable_streaming=True, + backend=fake_backend, + ) + + +def test_snapshot_streaming_code_fence_preserved(snap_compare: SnapCompare) -> None: + """Verify that a streamed code-fenced diagram renders as a code block, + not as collapsed paragraph text. + """ + + async def run_before(pilot: Pilot) -> None: + await pilot.press(*"show diagram") + await pilot.press("enter") + await pilot.pause(0.5) + + assert snap_compare( + "test_ui_snapshot_streaming_code_fence.py:StreamingCodeFenceApp", + terminal_size=(120, 36), + run_before=run_before, + ) diff --git a/tests/stubs/fake_connector_registry.py b/tests/stubs/fake_connector_registry.py new file mode 100644 index 0000000..4a18ad9 --- /dev/null +++ b/tests/stubs/fake_connector_registry.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from vibe.core.tools.base import BaseTool +from vibe.core.tools.connectors.connector_registry import ( + ConnectorRegistry, + RemoteTool, + _normalize_name, + create_connector_proxy_tool_class, +) + + +class FakeConnectorRegistry(ConnectorRegistry): + """Test double that returns canned connector tools without hitting the API.""" + + def __init__(self, connectors: dict[str, list[RemoteTool]] | None = None) -> None: + super().__init__(api_key="fake-key") + self._fake_connectors = connectors or {} + self._build_cache() + + def _build_cache(self) -> None: + self._cache = {} + self._connector_names = [] + self._connector_connected = {} + for connector_name, tools in self._fake_connectors.items(): + alias = _normalize_name(connector_name) + connector_id = f"fake-id-{connector_name}" + tool_map: dict[str, type[BaseTool]] = {} + for remote in tools: + proxy_cls = create_connector_proxy_tool_class( + connector_name=connector_name, + connector_alias=alias, + connector_id=connector_id, + remote=remote, + api_key="fake-key", + ) + tool_map[proxy_cls.get_name()] = proxy_cls + self._cache[connector_id] = tool_map + self._connector_names.append(alias) + self._connector_connected[alias] = bool(tool_map) + + def get_tools(self) -> dict[str, type[BaseTool]]: + result: dict[str, type[BaseTool]] = {} + if self._cache: + for tools in self._cache.values(): + result.update(tools) + return result diff --git a/tests/stubs/fake_mcp_registry.py b/tests/stubs/fake_mcp_registry.py index 7edad41..139a39a 100644 --- a/tests/stubs/fake_mcp_registry.py +++ b/tests/stubs/fake_mcp_registry.py @@ -18,6 +18,11 @@ _BROKEN_SERVER_NAME = "broken-server" class FakeMCPRegistry(MCPRegistry): + async def get_tools_async( + self, servers: list[MCPServer] + ) -> dict[str, type[BaseTool]]: + return self.get_tools(servers) + def get_tools(self, servers: list[MCPServer]) -> dict[str, type[BaseTool]]: result: dict[str, type[BaseTool]] = {} for srv in servers: diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py index 5b60c05..ef7bf01 100644 --- a/tests/test_agent_backend.py +++ b/tests/test_agent_backend.py @@ -131,6 +131,7 @@ async def test_passes_session_id_to_backend(vibe_config: VibeConfig): assert "message_id" in meta assert meta["is_user_prompt"] == "true" assert meta["call_type"] == "main_call" + assert meta["call_source"] == "vibe_code" @pytest.mark.asyncio @@ -162,6 +163,7 @@ async def test_passes_entrypoint_metadata_to_backend(vibe_config: VibeConfig): assert "message_id" in meta assert meta["is_user_prompt"] == "true" assert meta["call_type"] == "main_call" + assert meta["call_source"] == "vibe_code" @pytest.mark.asyncio diff --git a/tests/test_deferred_init.py b/tests/test_deferred_init.py index e3cc4a9..50c97e7 100644 --- a/tests/test_deferred_init.py +++ b/tests/test_deferred_init.py @@ -31,7 +31,7 @@ class TestCompleteInit: loop = build_test_agent_loop(defer_heavy_init=True) error = RuntimeError("mcp boom") - with patch.object(loop.tool_manager, "integrate_mcp", side_effect=error): + with patch.object(loop.tool_manager, "integrate_all", side_effect=error): loop._complete_init() assert loop.is_initialized @@ -44,7 +44,7 @@ class TestCompleteInit: with patch.object( loop.tool_manager._mcp_registry, - "get_tools", + "get_tools_async", side_effect=RuntimeError("mcp discovery boom"), ): loop._complete_init() @@ -84,7 +84,7 @@ class TestWaitForInit: loop = build_test_agent_loop(defer_heavy_init=True) error = RuntimeError("init failed") - with patch.object(loop.tool_manager, "integrate_mcp", side_effect=error): + with patch.object(loop.tool_manager, "integrate_all", side_effect=error): loop._complete_init() with pytest.raises(RuntimeError, match="init failed"): @@ -95,7 +95,7 @@ class TestWaitForInit: loop = build_test_agent_loop(defer_heavy_init=True) error = RuntimeError("once only") - with patch.object(loop.tool_manager, "integrate_mcp", side_effect=error): + with patch.object(loop.tool_manager, "integrate_all", side_effect=error): loop._complete_init() with pytest.raises(RuntimeError): diff --git a/tests/test_ui_input_history.py b/tests/test_ui_input_history.py index 9d0c36b..f5d66cb 100644 --- a/tests/test_ui_input_history.py +++ b/tests/test_ui_input_history.py @@ -123,13 +123,13 @@ async def test_ui_alt_left_and_alt_right_move_by_word(vibe_app: VibeApp) -> None await pilot.press(*"hello brave world") assert textarea.cursor_location == (0, len("hello brave world")) - await pilot.press("alt+left") + await pilot.press("ctrl+left") assert textarea.cursor_location == (0, len("hello brave ")) - await pilot.press("alt+left") + await pilot.press("ctrl+left") assert textarea.cursor_location == (0, len("hello ")) - await pilot.press("alt+right") + await pilot.press("ctrl+right") assert textarea.cursor_location == (0, len("hello brave")) assert chat_input.value == "hello brave world" diff --git a/tests/tools/test_connectors.py b/tests/tools/test_connectors.py new file mode 100644 index 0000000..c569f36 --- /dev/null +++ b/tests/tools/test_connectors.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +import os +from typing import cast +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from tests.stubs.fake_connector_registry import FakeConnectorRegistry +from tests.stubs.fake_mcp_registry import FakeMCPRegistry +from vibe.core.config import VibeConfig +from vibe.core.tools.base import BaseToolConfig, ToolError +from vibe.core.tools.connectors import CONNECTORS_ENV_VAR +from vibe.core.tools.connectors.connector_registry import ( + RemoteTool, + _connector_error_message, + _normalize_name, + _unwrap_http_status_error, + create_connector_proxy_tool_class, +) +from vibe.core.tools.manager import ToolManager +from vibe.core.tools.mcp.tools import MCPTool, MCPToolResult + +# --------------------------------------------------------------------------- +# Unit tests for helper functions +# --------------------------------------------------------------------------- + + +class TestNormalizeName: + def test_basic(self) -> None: + assert _normalize_name("my_connector") == "my_connector" + + def test_special_chars(self) -> None: + assert _normalize_name("my.connector!v2") == "my_connector_v2" + + def test_strip_edges(self) -> None: + assert _normalize_name("--hello--") == "hello" + + +# --------------------------------------------------------------------------- +# Unit tests for proxy tool class creation +# --------------------------------------------------------------------------- + + +class TestCreateConnectorProxyTool: + def test_tool_name(self) -> None: + remote = RemoteTool(name="search", description="Search docs") + cls = create_connector_proxy_tool_class( + connector_name="deepwiki", + connector_alias="deepwiki", + connector_id="abc-123", + remote=remote, + api_key="key", + ) + assert cls.get_name() == "connector_deepwiki_search" + + def test_is_connector_flag(self) -> None: + remote = RemoteTool(name="read", description="Read file") + cls = create_connector_proxy_tool_class( + connector_name="myconn", + connector_alias="myconn", + connector_id="id-1", + remote=remote, + api_key="key", + ) + assert issubclass(cls, MCPTool) + assert cls._is_connector is True + assert cls.is_connector() is True + + def test_description_includes_alias(self) -> None: + remote = RemoteTool(name="fetch", description="Fetch page") + cls = create_connector_proxy_tool_class( + connector_name="web_tool", + connector_alias="web_tool", + connector_id="id-2", + remote=remote, + api_key="key", + ) + assert cls.description.startswith("[web_tool]") + assert "Fetch page" in cls.description + + def test_parameters_from_schema(self) -> None: + schema = { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + } + remote = RemoteTool.model_validate({ + "name": "search", + "description": "Search", + "inputSchema": schema, + }) + cls = create_connector_proxy_tool_class( + connector_name="conn", + connector_alias="conn", + connector_id="id-3", + remote=remote, + api_key="key", + ) + params = cls.get_parameters() + assert params["properties"]["query"]["type"] == "string" + + def test_server_name_is_connector_alias(self) -> None: + remote = RemoteTool(name="tool1", description="A tool") + cls = cast( + type[MCPTool], + create_connector_proxy_tool_class( + connector_name="my-connector", + connector_alias="my-connector", + connector_id="id-4", + remote=remote, + api_key="key", + ), + ) + assert cls.get_server_name() == "my-connector" + + +# --------------------------------------------------------------------------- +# FakeConnectorRegistry tests +# --------------------------------------------------------------------------- + + +class TestFakeConnectorRegistry: + def test_get_tools(self) -> None: + registry = FakeConnectorRegistry( + connectors={ + "wiki": [RemoteTool(name="search", description="Search wiki")], + "mail": [ + RemoteTool(name="send", description="Send email"), + RemoteTool(name="read", description="Read email"), + ], + } + ) + tools = registry.get_tools() + assert "connector_wiki_search" in tools + assert "connector_mail_send" in tools + assert "connector_mail_read" in tools + assert registry.connector_count == 2 + + def test_connector_names(self) -> None: + registry = FakeConnectorRegistry(connectors={"alpha": [], "beta": []}) + assert set(registry.get_connector_names()) == {"alpha", "beta"} + + +# --------------------------------------------------------------------------- +# Integration: ToolManager + ConnectorRegistry +# --------------------------------------------------------------------------- + + +class TestToolManagerConnectorIntegration: + @staticmethod + def _make_config() -> VibeConfig: + """Minimal VibeConfig-like stub for ToolManager.""" + return cast( + VibeConfig, + type( + "_Cfg", + (), + { + "mcp_servers": [], + "enabled_tools": [], + "disabled_tools": [], + "tools": {}, + "tool_paths": [], + }, + )(), + ) + + def test_connector_tools_registered(self) -> None: + registry = FakeConnectorRegistry( + connectors={"myconn": [RemoteTool(name="ping", description="Ping")]} + ) + config = self._make_config() + tm = ToolManager( + config_getter=lambda: config, + mcp_registry=FakeMCPRegistry(), + connector_registry=registry, + ) + assert "connector_myconn_ping" in tm.registered_tools + + def test_no_connector_registry(self) -> None: + config = self._make_config() + tm = ToolManager( + config_getter=lambda: config, + mcp_registry=FakeMCPRegistry(), + connector_registry=None, + ) + # No connector tools, but no crash + connector_tools = [ + name + for name, cls in tm.registered_tools.items() + if issubclass(cls, MCPTool) and cls.is_connector() + ] + assert connector_tools == [] + + +# --------------------------------------------------------------------------- +# ConnectorRegistry env var gating (tested via agent_loop helper logic) +# --------------------------------------------------------------------------- + + +class TestConnectorRegistryEnvGating: + def test_disabled_without_env_var(self) -> None: + with patch.dict(os.environ, {}, clear=False): + os.environ.pop(CONNECTORS_ENV_VAR, None) + assert os.getenv(CONNECTORS_ENV_VAR) != "1" + + def test_enabled_with_env_var(self) -> None: + with patch.dict(os.environ, {CONNECTORS_ENV_VAR: "1"}): + assert os.getenv(CONNECTORS_ENV_VAR) == "1" + + +# --------------------------------------------------------------------------- +# Error message helpers +# --------------------------------------------------------------------------- + + +def _make_http_status_error(status: int, body: str = "") -> httpx.HTTPStatusError: + response = httpx.Response( + status, text=body, request=httpx.Request("POST", "http://example.com") + ) + return httpx.HTTPStatusError("error", request=response.request, response=response) + + +class TestConnectorErrorMessage: + def test_timeout(self) -> None: + exc = httpx.ReadTimeout("timed out") + msg = _connector_error_message(exc, "id-1", "myconn") + assert "timed out" in msg.lower() + assert "myconn" in msg + + def test_connect_error(self) -> None: + exc = httpx.ConnectError("connection refused") + msg = _connector_error_message(exc, "id-1", "myconn") + assert "network" in msg.lower() + assert "myconn" in msg + + def test_exception_group(self) -> None: + exc = ExceptionGroup("errors", [ValueError("a"), RuntimeError("b")]) + msg = _connector_error_message(exc, "id-1", "myconn") + assert "multiple errors" in msg.lower() + assert "myconn" in msg + + def test_generic_error(self) -> None: + exc = RuntimeError("something broke") + msg = _connector_error_message(exc, "id-1", "myconn") + assert "something broke" in msg + assert "myconn" in msg + + def test_http_401_surfaces_auth_message(self) -> None: + exc = _make_http_status_error(401, "Unauthorized") + msg = _connector_error_message(exc, "id-1", "myconn") + assert "authentication failed" in msg.lower() + assert "401" in msg + assert "Unauthorized" in msg + + def test_http_400_surfaces_response_body(self) -> None: + exc = _make_http_status_error(400, '{"error": "missing field X"}') + msg = _connector_error_message(exc, "id-1", "myconn") + assert "400" in msg + assert "missing field X" in msg + + def test_http_404_surfaces_not_found(self) -> None: + exc = _make_http_status_error(404) + msg = _connector_error_message(exc, "id-1", "myconn") + assert "not found" in msg.lower() + + def test_http_error_wrapped_in_exception_group(self) -> None: + inner = _make_http_status_error(400, "bad request detail") + exc = ExceptionGroup("errors", [inner]) + msg = _connector_error_message(exc, "id-1", "myconn") + assert "400" in msg + assert "bad request detail" in msg + + +class TestUnwrapHttpStatusError: + def test_direct(self) -> None: + exc = _make_http_status_error(400) + assert _unwrap_http_status_error(exc) is exc + + def test_from_exception_group(self) -> None: + inner = _make_http_status_error(500) + exc = ExceptionGroup("g", [ValueError("x"), inner]) + assert _unwrap_http_status_error(exc) is inner + + def test_from_cause(self) -> None: + inner = _make_http_status_error(403) + outer = RuntimeError("wrapper") + outer.__cause__ = inner + assert _unwrap_http_status_error(outer) is inner + + def test_none_when_absent(self) -> None: + assert _unwrap_http_status_error(RuntimeError("no http")) is None + + +# --------------------------------------------------------------------------- +# ConnectorProxyTool.run() via MCP proxy +# --------------------------------------------------------------------------- + + +class TestConnectorProxyToolRun: + @staticmethod + def _make_tool_class() -> type[MCPTool]: + remote = RemoteTool(name="search", description="Search docs") + return cast( + type[MCPTool], + create_connector_proxy_tool_class( + connector_name="wiki", + connector_alias="wiki", + connector_id="conn-123", + remote=remote, + api_key="test-key", + server_url="https://custom.api.example.com", + ), + ) + + @staticmethod + def _make_tool(tool_cls: type[MCPTool]) -> MCPTool: + return cast(MCPTool, tool_cls.from_config(lambda: BaseToolConfig())) + + @pytest.mark.asyncio + async def test_run_calls_mcp_proxy(self) -> None: + cls = self._make_tool_class() + tool = self._make_tool(cls) + expected = MCPToolResult( + ok=True, server="test", tool="search", text="result text" + ) + + with patch( + "vibe.core.tools.connectors.connector_registry.call_tool_http", + new_callable=AsyncMock, + return_value=expected, + ) as mock_call: + results = [r async for r in tool.invoke(query="hello")] + + mock_call.assert_awaited_once() + call_args = mock_call.call_args + assert "/v1/experimental/connectors/conn-123/mcp" in call_args.args[0] + assert call_args.args[1] == "search" + assert call_args.kwargs["headers"]["Authorization"] == "Bearer test-key" + + assert len(results) == 1 + assert results[0] == expected + + @pytest.mark.asyncio + async def test_run_uses_default_base_url(self) -> None: + remote = RemoteTool(name="ping", description="Ping") + cls = cast( + type[MCPTool], + create_connector_proxy_tool_class( + connector_name="svc", + connector_alias="svc", + connector_id="c-1", + remote=remote, + api_key="key", + server_url=None, + ), + ) + tool = self._make_tool(cls) + expected = MCPToolResult(ok=True, server="s", tool="ping", text="pong") + + with patch( + "vibe.core.tools.connectors.connector_registry.call_tool_http", + new_callable=AsyncMock, + return_value=expected, + ) as mock_call: + [_ async for _ in tool.invoke()] + + url = mock_call.call_args.args[0] + assert url.startswith("https://api.mistral.ai/") + + @pytest.mark.asyncio + async def test_run_surfaces_timeout_error(self) -> None: + cls = self._make_tool_class() + tool = self._make_tool(cls) + + with ( + patch( + "vibe.core.tools.connectors.connector_registry.call_tool_http", + new_callable=AsyncMock, + side_effect=httpx.ReadTimeout("timed out"), + ), + pytest.raises(ToolError, match="timed out"), + ): + [_ async for _ in tool.invoke(query="hello")] + + @pytest.mark.asyncio + async def test_run_surfaces_connect_error(self) -> None: + cls = self._make_tool_class() + tool = self._make_tool(cls) + + with ( + patch( + "vibe.core.tools.connectors.connector_registry.call_tool_http", + new_callable=AsyncMock, + side_effect=httpx.ConnectError("refused"), + ), + pytest.raises(ToolError, match="network"), + ): + [_ async for _ in tool.invoke(query="hello")] diff --git a/uv.lock b/uv.lock index f3af77e..2059750 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.12" [options] -exclude-newer = "2026-04-06T21:12:11.186502Z" +exclude-newer = "2026-04-09T07:58:59.192295Z" exclude-newer-span = "P7D" [options.exclude-newer-package] @@ -808,7 +808,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.7.5" +version = "2.7.6" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, @@ -886,7 +886,7 @@ requires-dist = [ { name = "keyring", specifier = ">=25.6.0" }, { name = "markdownify", specifier = ">=1.2.2" }, { name = "mcp", specifier = ">=1.14.0" }, - { name = "mistralai", specifier = "==2.1.3" }, + { name = "mistralai", specifier = "==2.3.2" }, { name = "opentelemetry-api", specifier = ">=1.39.1" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.39.1" }, { name = "opentelemetry-sdk", specifier = ">=1.39.1" }, @@ -935,7 +935,7 @@ dev = [ [[package]] name = "mistralai" -version = "2.1.3" +version = "2.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "eval-type-backport" }, @@ -947,9 +947,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/98/5fe39d514c19477f06a07e088ce4a44c2e60ac9deebefb9e2c8ed8ef87d2/mistralai-2.1.3.tar.gz", hash = "sha256:0c5de4855b043cd0582406d5c1ddfd91e176f484a158e6ee0b4a0054231be266", size = 331929, upload-time = "2026-03-23T15:00:29.579Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/08/f8fed64eab35ad1ded82f92bf834160302db19f5bbda5a904df7d4dbf3c4/mistralai-2.3.2.tar.gz", hash = "sha256:a02c7e90ac165e8680c849551ff5fe9788e9fc10b7dbe71817443dc63cc5e9c9", size = 394665, upload-time = "2026-04-10T14:05:36.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/7c/f91a26bf469c1cff57325379afa112baeb113ac577d28e69dd408cee5745/mistralai-2.1.3-py3-none-any.whl", hash = "sha256:26daac3bdc69fc2dd58f2c421710eb34131be7883b44a9ea81904a6306e6a90a", size = 754931, upload-time = "2026-03-23T15:00:30.934Z" }, + { url = "https://files.pythonhosted.org/packages/83/e0/9f246d18f8fe6e815f7cae88c437c986b342d3ff568607c8e0d53f4710d0/mistralai-2.3.2-py3-none-any.whl", hash = "sha256:dec05f446502289b76add9796d75465c070fc539b3c01d6f7d3297833bb64981", size = 935914, upload-time = "2026-04-10T14:05:37.845Z" }, ] [[package]] diff --git a/vibe/__init__.py b/vibe/__init__.py index 6bb0182..4ebcbf1 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.7.5" +__version__ = "2.7.6" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index b26863c..eacb313 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -33,6 +33,7 @@ from acp.schema import ( AvailableCommandInput, ClientCapabilities, CloseSessionResponse, + ConfigOptionUpdate, ContentToolCallContent, Cost, EnvVarAuthMethod, @@ -44,6 +45,8 @@ from acp.schema import ( PromptCapabilities, ResumeSessionResponse, SessionCapabilities, + SessionConfigOptionBoolean, + SessionConfigOptionSelect, SessionInfo, SessionListCapabilities, SetSessionConfigOptionResponse, @@ -57,10 +60,11 @@ from acp.schema import ( Usage, UsageUpdate, ) -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, SkipValidation from vibe import VIBE_ROOT, __version__ from vibe.acp.acp_logger import acp_message_observer +from vibe.acp.commands import AcpCommandRegistry from vibe.acp.exceptions import ( ConfigurationError, ConversationLimitError, @@ -109,6 +113,7 @@ from vibe.core.proxy_setup import ( unset_proxy_var, ) from vibe.core.session.session_loader import SessionLoader +from vibe.core.skills.manager import SkillManager from vibe.core.tools.permissions import RequiredPermission from vibe.core.types import ( AgentProfileChangedEvent, @@ -143,6 +148,7 @@ class AcpSessionLoop(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) id: str agent_loop: AgentLoop + command_registry: SkipValidation[AcpCommandRegistry] task: asyncio.Task[None] | None = None @@ -254,9 +260,16 @@ class VibeAcpAgentLoop(AcpAgent): async def _create_acp_session( self, session_id: str, agent_loop: AgentLoop ) -> AcpSessionLoop: - session = AcpSessionLoop(id=session_id, agent_loop=agent_loop) + command_registry = AcpCommandRegistry() + session = AcpSessionLoop( + id=session_id, agent_loop=agent_loop, command_registry=command_registry + ) self.sessions[session.id] = session + command_registry.set_on_changed( + lambda: self._send_available_commands(session.id) + ) + if not agent_loop.auto_approve: agent_loop.set_approval_callback(self._create_approval_callback(session.id)) @@ -463,137 +476,41 @@ class VibeAcpAgentLoop(AcpAgent): ) async def _send_available_commands(self, session_id: str) -> None: - commands = [ - AvailableCommand( - name="proxy-setup", - description="Configure proxy and SSL certificate settings", - input=AvailableCommandInput( - root=UnstructuredCommandInput( - hint="KEY value to set, KEY to unset, or empty for help" - ) - ), - ), - AvailableCommand( - name="leanstall", - description="Install the Lean 4 agent (leanstral)", - input=AvailableCommandInput(root=UnstructuredCommandInput(hint="")), - ), - AvailableCommand( - name="data-retention", - description="Show data retention information", - input=AvailableCommandInput(root=UnstructuredCommandInput(hint="")), - ), - AvailableCommand( - name="unleanstall", - description="Uninstall the Lean 4 agent", - input=AvailableCommandInput(root=UnstructuredCommandInput(hint="")), - ), - ] - - update = update_available_commands(commands) - await self.client.session_update(session_id=session_id, update=update) - - async def _handle_proxy_setup_command( - self, session_id: str, text_prompt: str, message_id: str - ) -> PromptResponse: - args = text_prompt.strip()[len("/proxy-setup") :].strip() - - try: - if not args: - message = get_proxy_help_text() - else: - key, value = parse_proxy_command(args) - if value is not None: - set_proxy_var(key, value) - message = f"Set `{key}={value}` in ~/.vibe/.env\n\nPlease start a new chat for changes to take effect." - else: - unset_proxy_var(key) - message = f"Removed `{key}` from ~/.vibe/.env\n\nPlease start a new chat for changes to take effect." - except ProxySetupError as e: - message = f"Error: {e}" - - await self.client.session_update( - session_id=session_id, - update=AgentMessageChunk( - session_update="agent_message_chunk", - content=TextContentBlock(type="text", text=message), - message_id=str(uuid4()), - ), - ) - return PromptResponse(stop_reason="end_turn", user_message_id=message_id) - - async def _handle_leanstall_command( - self, session_id: str, message_id: str - ) -> PromptResponse: session = self._get_session(session_id) - current = list(session.agent_loop.base_config.installed_agents) - if "lean" in current: - message = "Lean agent is already installed." - else: - VibeConfig.save_updates({"installed_agents": [*current, "lean"]}) - new_config = VibeConfig.load( - tool_paths=session.agent_loop.config.tool_paths, - disabled_tools=["ask_user_question"], + + commands: list[AvailableCommand] = [] + + for cmd in session.command_registry.commands.values(): + input_spec = ( + AvailableCommandInput( + root=UnstructuredCommandInput(hint=cmd.input_hint) + ) + if cmd.input_hint + else None ) - await session.agent_loop.reload_with_initial_messages( - base_config=new_config + commands.append( + AvailableCommand( + name=cmd.name, description=cmd.description, input=input_spec + ) ) - message = ( - "Lean agent installed. Start a new session to switch to Lean mode." + + builtin_names = set(session.command_registry.commands) + for skill in session.agent_loop.skill_manager.available_skills.values(): + if not skill.user_invocable or skill.name in builtin_names: + continue + commands.append( + AvailableCommand( + name=skill.name, + description=skill.description, + input=AvailableCommandInput( + root=UnstructuredCommandInput(hint="instructions for the skill") + ), + ) ) await self.client.session_update( - session_id=session_id, - update=AgentMessageChunk( - session_update="agent_message_chunk", - content=TextContentBlock(type="text", text=message), - message_id=str(uuid4()), - ), + session_id=session_id, update=update_available_commands(commands) ) - return PromptResponse(stop_reason="end_turn", user_message_id=message_id) - - async def _handle_data_retention_command( - self, session_id: str, message_id: str - ) -> PromptResponse: - await self.client.session_update( - session_id=session_id, - update=AgentMessageChunk( - session_update="agent_message_chunk", - content=TextContentBlock(type="text", text=DATA_RETENTION_MESSAGE), - message_id=str(uuid4()), - ), - ) - return PromptResponse(stop_reason="end_turn", user_message_id=message_id) - - async def _handle_unleanstall_command( - self, session_id: str, message_id: str - ) -> PromptResponse: - session = self._get_session(session_id) - current = list(session.agent_loop.base_config.installed_agents) - if "lean" not in current: - message = "Lean agent is not installed." - else: - VibeConfig.save_updates({ - "installed_agents": [a for a in current if a != "lean"] - }) - new_config = VibeConfig.load( - tool_paths=session.agent_loop.config.tool_paths, - disabled_tools=["ask_user_question"], - ) - await session.agent_loop.reload_with_initial_messages( - base_config=new_config - ) - message = "Lean agent uninstalled." - - await self.client.session_update( - session_id=session_id, - update=AgentMessageChunk( - session_update="agent_message_chunk", - content=TextContentBlock(type="text", text=message), - message_id=str(uuid4()), - ), - ) - return PromptResponse(stop_reason="end_turn", user_message_id=message_id) @override async def load_session( @@ -723,16 +640,8 @@ class VibeAcpAgentLoop(AcpAgent): if not success: return None - profiles = list(session.agent_loop.agent_manager.available_agents.values()) - _, modes_config = make_mode_response( - profiles, session.agent_loop.agent_profile.name - ) - _, models_config = make_model_response( - session.agent_loop.config.models, session.agent_loop.config.active_model - ) - return SetSessionConfigOptionResponse( - config_options=[modes_config, models_config] + config_options=self._build_config_options(session) ) @override @@ -779,23 +688,21 @@ class VibeAcpAgentLoop(AcpAgent): text_prompt = self._build_text_prompt(prompt) resolved_message_id = _resolved_user_message_id(message_id) - if text_prompt.strip().lower().startswith("/proxy-setup"): - return await self._handle_proxy_setup_command( - session_id, text_prompt, resolved_message_id - ) + if command_response := await self._maybe_handle_builtin_command( + session, text_prompt, resolved_message_id + ): + return command_response - if text_prompt.strip().lower().startswith("/unleanstall"): - return await self._handle_unleanstall_command( - session_id, resolved_message_id - ) + try: + skill = session.agent_loop.skill_manager.parse_skill_command(text_prompt) + except OSError as e: + raise InternalError(f"Failed to read skill file: {e}") from e - if text_prompt.strip().lower().startswith("/leanstall"): - return await self._handle_leanstall_command(session_id, resolved_message_id) - - if text_prompt.strip().lower().startswith("/data-retention"): - return await self._handle_data_retention_command( - session_id, resolved_message_id + if skill: + session.agent_loop.telemetry_client.send_slash_command_used( + skill.name, "skill" ) + text_prompt = SkillManager.build_skill_prompt(text_prompt, skill) async def agent_loop_task() -> None: async for update in self._run_agent_loop( @@ -881,6 +788,22 @@ class VibeAcpAgentLoop(AcpAgent): ) return text_prompt + async def _maybe_handle_builtin_command( + self, session: AcpSessionLoop, text_prompt: str, message_id: str + ) -> PromptResponse | None: + normalized = text_prompt.strip().lower() + parts = normalized.split(None, 1) + if not parts or not parts[0].startswith("/"): + return None + + cmd_name = parts[0][1:] # strip leading "/" + command = session.command_registry.get(cmd_name) + if command is None: + return None + + handler = getattr(self, command.handler) + return await handler(session, text_prompt, message_id) + async def _run_agent_loop( self, session: AcpSessionLoop, prompt: str, client_message_id: str | None = None ) -> AsyncGenerator[SessionUpdate]: @@ -990,6 +913,218 @@ class VibeAcpAgentLoop(AcpAgent): def on_connect(self, conn: Client) -> None: self.client = conn + # -- Command handlers ------------------------------------------------------ + + async def _command_reply( + self, session: AcpSessionLoop, text: str, message_id: str + ) -> PromptResponse: + """Send a text message to the client and return an end-turn response.""" + await self.client.session_update( + session_id=session.id, + update=AgentMessageChunk( + session_update="agent_message_chunk", + content=TextContentBlock(type="text", text=text), + message_id=str(uuid4()), + ), + ) + return PromptResponse(stop_reason="end_turn", user_message_id=message_id) + + async def _handle_help( + self, session: AcpSessionLoop, text_prompt: str, message_id: str + ) -> PromptResponse: + lines = ["### Available Commands", ""] + for cmd in session.command_registry.commands.values(): + 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 + } + if invocable: + lines.extend(["", "### Available Skills", ""]) + for name, info in invocable.items(): + lines.append(f"- `/{name}`: {info.description}") + + return await self._command_reply(session, "\n".join(lines), message_id) + + async def _handle_compact( + self, session: AcpSessionLoop, text_prompt: str, message_id: str + ) -> PromptResponse: + if len(session.agent_loop.messages) <= 1: + return await self._command_reply( + session, "No conversation history to compact yet.", message_id + ) + + tool_call_id = str(uuid4()) + old_tokens = session.agent_loop.stats.context_tokens + + start_event = CompactStartEvent( + current_context_tokens=old_tokens or 0, + threshold=0, + tool_call_id=tool_call_id, + ) + await self.client.session_update( + session_id=session.id, + update=create_compact_start_session_update(start_event), + ) + + await session.agent_loop.compact() + new_tokens = session.agent_loop.stats.context_tokens + + end_event = CompactEndEvent( + old_context_tokens=old_tokens or 0, + new_context_tokens=new_tokens or 0, + summary_length=0, + tool_call_id=tool_call_id, + ) + await self.client.session_update( + session_id=session.id, update=create_compact_end_session_update(end_event) + ) + + return PromptResponse(stop_reason="end_turn", user_message_id=message_id) + + async def _reload_session_config(self, session: AcpSessionLoop) -> None: + """Reload config from disk and reinitialize the agent loop.""" + new_config = VibeConfig.load( + tool_paths=session.agent_loop.config.tool_paths, + disabled_tools=["ask_user_question"], + ) + await session.agent_loop.reload_with_initial_messages(base_config=new_config) + + async def _handle_reload( + self, session: AcpSessionLoop, text_prompt: str, message_id: str + ) -> PromptResponse: + try: + await self._reload_session_config(session) + except Exception as e: + return await self._command_reply( + session, f"Failed to reload config: {e}", message_id + ) + + try: + await session.command_registry.notify_changed() + except Exception as e: + return await self._command_reply( + session, + f"Configuration reloaded, but failed to advertise updated commands: {e}", + message_id, + ) + + return await self._command_reply( + session, + "Configuration reloaded (includes agent instructions and skills).", + message_id, + ) + + async def _handle_log( + self, session: AcpSessionLoop, text_prompt: str, message_id: str + ) -> PromptResponse: + logger = session.agent_loop.session_logger + if not logger.enabled: + return await self._command_reply( + session, "Session logging is disabled in configuration.", message_id + ) + + return await self._command_reply( + session, + f"## Current Log Directory\n\n`{logger.session_dir}`\n\n" + "You can send this directory to share your interaction.", + message_id, + ) + + async def _handle_proxy_setup( + self, session: AcpSessionLoop, text_prompt: str, message_id: str + ) -> PromptResponse: + parts = text_prompt.strip().split(None, 1) + args = parts[1] if len(parts) > 1 else "" + + try: + if not args: + message = get_proxy_help_text() + else: + key, value = parse_proxy_command(args) + if value is not None: + set_proxy_var(key, value) + message = ( + f"Set `{key}={value}` in ~/.vibe/.env\n\n" + "Please start a new chat for changes to take effect." + ) + else: + unset_proxy_var(key) + message = ( + f"Removed `{key}` from ~/.vibe/.env\n\n" + "Please start a new chat for changes to take effect." + ) + except ProxySetupError as e: + message = f"Error: {e}" + + return await self._command_reply(session, message, message_id) + + def _build_config_options( + self, session: AcpSessionLoop + ) -> list[SessionConfigOptionSelect | SessionConfigOptionBoolean]: + """Build the current modes + models config options for a session.""" + profiles = list(session.agent_loop.agent_manager.available_agents.values()) + _, modes_config = make_mode_response( + profiles, session.agent_loop.agent_profile.name + ) + _, models_config = make_model_response( + session.agent_loop.config.models, session.agent_loop.config.active_model + ) + return [modes_config, models_config] + + async def _send_config_option_update(self, session: AcpSessionLoop) -> None: + """Push updated config options (modes, models) to the client.""" + await self.client.session_update( + session_id=session.id, + update=ConfigOptionUpdate( + session_update="config_option_update", + config_options=self._build_config_options(session), + ), + ) + + async def _handle_leanstall( + self, session: AcpSessionLoop, text_prompt: str, message_id: str + ) -> PromptResponse: + current = list(session.agent_loop.base_config.installed_agents) + if "lean" in current: + return await self._command_reply( + session, "Lean agent is already installed.", message_id + ) + + VibeConfig.save_updates({"installed_agents": [*current, "lean"]}) + await self._reload_session_config(session) + await self._send_config_option_update(session) + return await self._command_reply( + session, + "Lean agent installed. Start a new session to switch to Lean mode.", + message_id, + ) + + async def _handle_unleanstall( + self, session: AcpSessionLoop, text_prompt: str, message_id: str + ) -> PromptResponse: + current = list(session.agent_loop.base_config.installed_agents) + if "lean" not in current: + return await self._command_reply( + session, "Lean agent is not installed.", message_id + ) + + VibeConfig.save_updates({ + "installed_agents": [a for a in current if a != "lean"] + }) + await self._reload_session_config(session) + await self._send_config_option_update(session) + return await self._command_reply(session, "Lean agent uninstalled.", message_id) + + async def _handle_data_retention( + self, session: AcpSessionLoop, text_prompt: str, message_id: str + ) -> PromptResponse: + return await self._command_reply(session, DATA_RETENTION_MESSAGE, message_id) + def run_acp_server() -> None: try: diff --git a/vibe/acp/commands/__init__.py b/vibe/acp/commands/__init__.py new file mode 100644 index 0000000..5d08b7a --- /dev/null +++ b/vibe/acp/commands/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from vibe.acp.commands.registry import AcpCommandRegistry + +__all__ = ["AcpCommandRegistry"] diff --git a/vibe/acp/commands/registry.py b/vibe/acp/commands/registry.py new file mode 100644 index 0000000..b48df8b --- /dev/null +++ b/vibe/acp/commands/registry.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field + +type OnCommandsChanged = Callable[[], Awaitable[None]] + + +@dataclass(frozen=True) +class AcpCommand: + """Command advertised to ACP clients via available_commands_update.""" + + name: str + description: str + handler: str + input_hint: str | None = None + + +@dataclass +class AcpCommandRegistry: + """Registry of ACP commands. Notifies listeners when commands change.""" + + _commands: dict[str, AcpCommand] = field(default_factory=dict) + _on_changed: OnCommandsChanged | None = None + + def __post_init__(self) -> None: + if not self._commands: + self._commands = _build_commands() + + def set_on_changed(self, callback: OnCommandsChanged) -> None: + self._on_changed = callback + + @property + def commands(self) -> dict[str, AcpCommand]: + return self._commands + + def get(self, name: str) -> AcpCommand | None: + return self._commands.get(name) + + async def notify_changed(self) -> None: + if self._on_changed is not None: + await self._on_changed() + + +def _build_commands() -> dict[str, AcpCommand]: + return { + "help": AcpCommand( + name="help", + description="Show available commands and keyboard shortcuts", + handler="_handle_help", + ), + "compact": AcpCommand( + name="compact", + description="Compact conversation history by summarizing", + handler="_handle_compact", + ), + "reload": AcpCommand( + name="reload", + description="Reload configuration, agent instructions, and skills from disk", + handler="_handle_reload", + ), + "log": AcpCommand( + name="log", + description="Show path to current session log directory", + handler="_handle_log", + ), + "proxy-setup": AcpCommand( + name="proxy-setup", + description="Configure proxy and SSL certificate settings", + handler="_handle_proxy_setup", + input_hint="KEY value to set, KEY to unset, or empty for help", + ), + "leanstall": AcpCommand( + name="leanstall", + description="Install the Lean 4 agent (leanstral)", + handler="_handle_leanstall", + ), + "unleanstall": AcpCommand( + name="unleanstall", + description="Uninstall the Lean 4 agent", + handler="_handle_unleanstall", + ), + "data-retention": AcpCommand( + name="data-retention", + description="Show data retention information", + handler="_handle_data_retention", + ), + } diff --git a/vibe/cli/commands.py b/vibe/cli/commands.py index 634fa10..69665bb 100644 --- a/vibe/cli/commands.py +++ b/vibe/cli/commands.py @@ -95,6 +95,11 @@ class CommandRegistry: description="Display available MCP servers. Pass the name of a server to list its tools", handler="_show_mcp", ), + "connectors": Command( + aliases=frozenset(["/connectors"]), + description="Manage workspace connectors. Subcommands: refresh", + handler="_handle_connectors", + ), "voice": Command( aliases=frozenset(["/voice"]), description="Configure voice settings", diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index 0176f0f..895ab39 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -126,6 +126,7 @@ from vibe.core.session.resume_sessions import ( short_session_id, ) from vibe.core.session.session_loader import SessionLoader +from vibe.core.skills.manager import SkillManager from vibe.core.teleport.types import ( TeleportAuthCompleteEvent, TeleportAuthRequiredEvent, @@ -144,6 +145,8 @@ from vibe.core.tools.builtins.ask_user_question import ( Choice, Question, ) +from vibe.core.tools.connectors import CONNECTORS_ENV_VAR, connectors_enabled +from vibe.core.tools.mcp.tools import MCPTool from vibe.core.tools.permissions import RequiredPermission from vibe.core.transcribe import make_transcribe_client from vibe.core.types import ( @@ -161,7 +164,6 @@ from vibe.core.utils import ( get_user_cancellation_message, is_dangerous_directory, ) -from vibe.core.utils.io import read_safe class BottomApp(StrEnum): @@ -332,6 +334,8 @@ class VibeApp(App): # noqa: PLR0904 excluded_commands = [] if not self.config.nuage_enabled: excluded_commands.append("teleport") + if not connectors_enabled(): + excluded_commands.append("connectors") self.commands = CommandRegistry(excluded_commands=excluded_commands) self._chat_input_container: ChatInputContainer | None = None @@ -376,10 +380,17 @@ class VibeApp(App): # noqa: PLR0904 def config(self) -> VibeConfig: return self.agent_loop.config + @property + def _connectors_enabled(self) -> bool: + return connectors_enabled() and self.agent_loop.connector_registry is not None + def compose(self) -> ComposeResult: with ChatScroll(id="chat"): self._banner = Banner( - self.config, self.agent_loop.skill_manager, self.agent_loop.mcp_registry + config=self.config, + skill_manager=self.agent_loop.skill_manager, + mcp_registry=self.agent_loop.mcp_registry, + connector_registry=self.agent_loop.connector_registry, ) yield self._banner yield VerticalGroup(id="messages") @@ -774,25 +785,11 @@ class VibeApp(App): # noqa: PLR0904 ] async def _handle_skill(self, user_input: str) -> bool: - if not user_input.startswith("/"): - return False - if not self.agent_loop: return False - parts = user_input[1:].strip().split(None, 1) - if not parts: - return False - skill_name = parts[0].lower() - - skill_info = self.agent_loop.skill_manager.get_skill(skill_name) - if not skill_info: - return False - - self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill") - try: - skill_content = read_safe(skill_info.skill_path).text + skill = self.agent_loop.skill_manager.parse_skill_command(user_input) except OSError as e: await self._mount_and_scroll( ErrorMessage( @@ -801,10 +798,12 @@ class VibeApp(App): # noqa: PLR0904 ) return True - if len(parts) > 1: - skill_content = f"{user_input}\n\n{skill_content}" + if skill is None: + return False - await self._handle_user_message(skill_content) + self.agent_loop.telemetry_client.send_slash_command_used(skill.name, "skill") + prompt = SkillManager.build_skill_prompt(user_input, skill) + await self._handle_user_message(prompt) return True async def _handle_bash_command(self, command: str) -> None: @@ -1038,11 +1037,14 @@ class VibeApp(App): # noqa: PLR0904 return if before is not None: await messages_area.mount_all(widgets, before=before) - return - if after is not None: + elif after is not None: await messages_area.mount_all(widgets, after=after) - return - await messages_area.mount_all(widgets) + else: + await messages_area.mount_all(widgets) + + for widget in widgets: + if isinstance(widget, StreamingMessageBase): + await widget.write_initial_content() def _is_tool_enabled_in_main_agent(self, tool: str) -> bool: return tool in self.agent_loop.tool_manager.available_tools @@ -1328,19 +1330,36 @@ class VibeApp(App): # noqa: PLR0904 async def _show_mcp(self, cmd_args: str = "", **kwargs: Any) -> None: mcp_servers = self.config.mcp_servers - if not mcp_servers: - await self._mount_and_scroll( - UserCommandMessage("No MCP servers configured.") + connector_registry = ( + self.agent_loop.connector_registry if self._connectors_enabled else None + ) + has_connectors = ( + connector_registry is not None and connector_registry.connector_count > 0 + ) + if not mcp_servers and not has_connectors: + msg = ( + "No MCP servers or connectors configured." + if self._connectors_enabled + else "No MCP servers configured." ) + await self._mount_and_scroll(UserCommandMessage(msg)) return if self._current_bottom_app == BottomApp.MCP: return name = cmd_args.strip() - if name and not any(s.name == name for s in mcp_servers): + connector_names = ( + connector_registry.get_connector_names() if connector_registry else [] + ) + if ( + name + and not any(s.name == name for s in mcp_servers) + and name not in connector_names + ): + all_names = [s.name for s in mcp_servers] + connector_names + entity = "MCP server or connector" if has_connectors else "MCP server" await self._mount_and_scroll( ErrorMessage( - f"Unknown MCP server: {name}. Known servers: " - + ", ".join(s.name for s in mcp_servers), + f"Unknown {entity}: {name}. Known: " + ", ".join(all_names), collapsed=self._tools_collapsed, ) ) @@ -1351,9 +1370,52 @@ class VibeApp(App): # noqa: PLR0904 mcp_servers=mcp_servers, tool_manager=self.agent_loop.tool_manager, initial_server=name, + connector_registry=connector_registry, ) ) + async def _handle_connectors(self, cmd_args: str = "", **kwargs: Any) -> None: + if not self._connectors_enabled: + await self._mount_and_scroll( + UserCommandMessage( + f"Connectors are disabled. Set {CONNECTORS_ENV_VAR}=1 to enable." + ) + ) + return + registry = self.agent_loop.connector_registry + assert registry is not None # guaranteed by _connectors_enabled + + subcmd = cmd_args.strip().lower() + match subcmd: + case "refresh": + registry.clear() + tool_manager = self.agent_loop.tool_manager + await tool_manager.integrate_connectors_async() + self.agent_loop.refresh_system_prompt() + self._refresh_banner() + count = registry.connector_count + tools = sum( + 1 + for cls in tool_manager.registered_tools.values() + if issubclass(cls, MCPTool) and cls.is_connector() + ) + await self._mount_and_scroll( + UserCommandMessage( + f"Connectors refreshed: {count} connectors, {tools} tools" + ) + ) + case "": + await self._mount_and_scroll( + UserCommandMessage("Usage: /connectors refresh") + ) + case _: + await self._mount_and_scroll( + ErrorMessage( + f"Unknown subcommand: {subcmd}. Available: refresh", + collapsed=self._tools_collapsed, + ) + ) + async def _show_status(self, **kwargs: Any) -> None: stats = self.agent_loop.stats status_text = f"""## Agent Statistics @@ -1598,7 +1660,8 @@ class VibeApp(App): # noqa: PLR0904 base_config, self.agent_loop.skill_manager, self.agent_loop.mcp_registry, - plan_title(self._plan_info), + connector_registry=self.agent_loop.connector_registry, + plan_description=plan_title(self._plan_info), ) await self._mount_and_scroll( UserCommandMessage( @@ -2314,7 +2377,8 @@ class VibeApp(App): # noqa: PLR0904 self.config, self.agent_loop.skill_manager, self.agent_loop.mcp_registry, - plan_title(self._plan_info), + connector_registry=self.agent_loop.connector_registry, + plan_description=plan_title(self._plan_info), ) def _update_profile_widgets(self, profile: AgentProfile) -> None: diff --git a/vibe/cli/textual_ui/widgets/banner/banner.py b/vibe/cli/textual_ui/widgets/banner/banner.py index 6210f4d..127df0c 100644 --- a/vibe/cli/textual_ui/widgets/banner/banner.py +++ b/vibe/cli/textual_ui/widgets/banner/banner.py @@ -13,14 +13,24 @@ from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.config import VibeConfig from vibe.core.skills.manager import SkillManager +from vibe.core.tools.connectors import ConnectorRegistry from vibe.core.tools.mcp.registry import MCPRegistry +def _pluralize(count: int, singular: str) -> str: + return f"{count} {singular}{'s' if count != 1 else ''}" + + +def _connector_count(registry: ConnectorRegistry | None) -> int: + return registry.connector_count if registry else 0 + + @dataclass class BannerState: active_model: str = "" models_count: int = 0 mcp_servers_count: int = 0 + connectors_count: int = 0 skills_count: int = 0 plan_description: str | None = None @@ -33,6 +43,7 @@ class Banner(Static): config: VibeConfig, skill_manager: SkillManager, mcp_registry: MCPRegistry, + connector_registry: ConnectorRegistry | None = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) @@ -41,6 +52,7 @@ class Banner(Static): active_model=config.active_model, models_count=len(config.models), mcp_servers_count=mcp_registry.count_loaded(config.mcp_servers), + connectors_count=_connector_count(connector_registry), skills_count=len(skill_manager.available_skills), plan_description=None, ) @@ -85,22 +97,25 @@ class Banner(Static): config: VibeConfig, skill_manager: SkillManager, mcp_registry: MCPRegistry, + connector_registry: ConnectorRegistry | None = None, plan_description: str | None = None, ) -> None: self.state = BannerState( active_model=config.active_model, models_count=len(config.models), mcp_servers_count=mcp_registry.count_loaded(config.mcp_servers), + connectors_count=_connector_count(connector_registry), skills_count=len(skill_manager.available_skills), plan_description=plan_description, ) def _format_meta_counts(self) -> str: - return ( - f"{self.state.models_count} model{'s' if self.state.models_count != 1 else ''}" - f" · {self.state.mcp_servers_count} MCP server{'s' if self.state.mcp_servers_count != 1 else ''}" - f" · {self.state.skills_count} skill{'s' if self.state.skills_count != 1 else ''}" - ) + parts = [_pluralize(self.state.models_count, "model")] + if self.state.connectors_count > 0: + parts.append(_pluralize(self.state.connectors_count, "connector")) + parts.append(_pluralize(self.state.mcp_servers_count, "MCP server")) + parts.append(_pluralize(self.state.skills_count, "skill")) + return " · ".join(parts) def _format_plan(self) -> str: return ( diff --git a/vibe/cli/textual_ui/widgets/chat_input/text_area.py b/vibe/cli/textual_ui/widgets/chat_input/text_area.py index 31a915a..835a260 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/text_area.py +++ b/vibe/cli/textual_ui/widgets/chat_input/text_area.py @@ -31,8 +31,6 @@ class ChatTextArea(TextArea): show=False, priority=True, ), - Binding("alt+left", "cursor_word_left", "Cursor word left", show=False), - Binding("alt+right", "cursor_word_right", "Cursor word right", show=False), Binding("ctrl+g", "open_external_editor", "External Editor", show=False), ] diff --git a/vibe/cli/textual_ui/widgets/mcp_app.py b/vibe/cli/textual_ui/widgets/mcp_app.py index e08aef7..67cd9bc 100644 --- a/vibe/cli/textual_ui/widgets/mcp_app.py +++ b/vibe/cli/textual_ui/widgets/mcp_app.py @@ -13,6 +13,7 @@ from textual.widgets import OptionList from textual.widgets.option_list import Option from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic +from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled from vibe.core.tools.mcp.tools import MCPTool if TYPE_CHECKING: @@ -22,26 +23,34 @@ if TYPE_CHECKING: class MCPToolIndex(NamedTuple): server_tools: dict[str, list[tuple[str, type[MCPTool]]]] + connector_tools: dict[str, list[tuple[str, type[MCPTool]]]] enabled_tools: dict[str, type[Any]] def collect_mcp_tool_index( - mcp_servers: Sequence[MCPServer], tool_manager: ToolManager + mcp_servers: Sequence[MCPServer], + tool_manager: ToolManager, + connector_names: Sequence[str] = (), ) -> MCPToolIndex: registered = tool_manager.registered_tools available = tool_manager.available_tools configured_servers = {server.name for server in mcp_servers} + connector_set = set(connector_names) if connectors_enabled() else set() server_tools: dict[str, list[tuple[str, type[MCPTool]]]] = {} + connector_tools: dict[str, list[tuple[str, type[MCPTool]]]] = {} for tool_name, cls in registered.items(): if not issubclass(cls, MCPTool): continue server_name = cls.get_server_name() - if server_name is None or server_name not in configured_servers: + if server_name is None: continue - server_tools.setdefault(server_name, []).append((tool_name, cls)) + if cls.is_connector() and server_name in connector_set: + connector_tools.setdefault(server_name, []).append((tool_name, cls)) + elif server_name in configured_servers: + server_tools.setdefault(server_name, []).append((tool_name, cls)) - return MCPToolIndex(server_tools, enabled_tools=available) + return MCPToolIndex(server_tools, connector_tools, enabled_tools=available) class MCPApp(Container): @@ -59,12 +68,21 @@ class MCPApp(Container): mcp_servers: Sequence[MCPServer], tool_manager: ToolManager, initial_server: str = "", + connector_registry: ConnectorRegistry | None = None, ) -> None: super().__init__(id="mcp-app") self._mcp_servers = mcp_servers + self._connector_registry = connector_registry + connector_names = ( + connector_registry.get_connector_names() if connector_registry else [] + ) + self._connector_names = connector_names self._tool_manager = tool_manager - self._index = collect_mcp_tool_index(mcp_servers, tool_manager) + self._index = collect_mcp_tool_index(mcp_servers, tool_manager, connector_names) + # Track both the name and the kind ("server" or "connector") to + # disambiguate entries that share the same normalised name. self._viewing_server: str | None = initial_server.strip() or None + self._viewing_kind: str | None = None def compose(self) -> ComposeResult: with Vertical(id="mcp-content"): @@ -80,8 +98,12 @@ class MCPApp(Container): def refresh_index(self) -> None: """Re-snapshot the tool index (e.g. after deferred MCP discovery).""" - self._index = collect_mcp_tool_index(self._mcp_servers, self._tool_manager) - self._refresh_view(self._viewing_server) + if self._connector_registry: + self._connector_names = self._connector_registry.get_connector_names() + self._index = collect_mcp_tool_index( + self._mcp_servers, self._tool_manager, self._connector_names + ) + self._refresh_view(self._viewing_server, kind=self._viewing_kind) def on_descendant_blur(self, _event: DescendantBlur) -> None: self.query_one(OptionList).focus() @@ -89,7 +111,9 @@ class MCPApp(Container): def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: option_id = event.option.id or "" if option_id.startswith("server:"): - self._refresh_view(option_id.removeprefix("server:")) + self._refresh_view(option_id.removeprefix("server:"), kind="server") + elif option_id.startswith("connector:"): + self._refresh_view(option_id.removeprefix("connector:"), kind="connector") def action_back(self) -> None: if self._viewing_server is not None: @@ -98,50 +122,150 @@ class MCPApp(Container): def action_close(self) -> None: self.post_message(self.MCPClosed()) - def _refresh_view(self, server_name: str | None) -> None: + # ── list view ──────────────────────────────────────────────────── + + def _refresh_view( + self, server_name: str | None, *, kind: str | None = None + ) -> None: index = self._index option_list = self.query_one(OptionList) option_list.clear_options() server_names = {s.name for s in self._mcp_servers} - if server_name is None or server_name not in server_names: - self._viewing_server = None - self.query_one("#mcp-title", NoMarkupStatic).update("MCP Servers") - self.query_one("#mcp-help", NoMarkupStatic).update( - "↑↓ Navigate Enter Show tools Esc Close" + all_names = server_names | set(self._connector_names) + if server_name is None or server_name not in all_names: + self._show_list_view(option_list, index) + return + + # Infer kind when not provided (e.g. initial_server from /mcp ). + # Prefer server over connector when the name is ambiguous. + if kind is None: + if server_name in server_names: + kind = "server" + else: + kind = "connector" + + self._show_detail_view(server_name, option_list, index, kind=kind) + + def _show_list_view(self, option_list: OptionList, index: MCPToolIndex) -> None: + self._viewing_server = None + self._viewing_kind = None + has_connectors = connectors_enabled() and bool(self._connector_names) + title = "MCP Servers & Connectors" if has_connectors else "MCP Servers" + self.query_one("#mcp-title", NoMarkupStatic).update(title) + self.query_one("#mcp-help", NoMarkupStatic).update( + "↑↓ Navigate Enter Show tools Esc Close" + ) + + has_servers = bool(self._mcp_servers) + + # ── Local MCP Servers ── + if has_servers: + max_name = max(len(srv.name) for srv in self._mcp_servers) + max_type = max(len(srv.transport) + 2 for srv in self._mcp_servers) # +[] + option_list.add_option( + Option( + Text("Local MCP Servers", style="bold", no_wrap=True), disabled=True + ) ) for srv in self._mcp_servers: tools = index.server_tools.get(srv.name, []) enabled = sum(1 for t, _ in tools if t in index.enabled_tools) - status = _server_status(enabled) + type_tag = f"[{srv.transport}]" label = Text(no_wrap=True) - label.append(srv.name) - label.append(f" [{srv.transport}] {status}") + label.append(f" {srv.name:<{max_name}}") + label.append(f" {type_tag:<{max_type}}", style="dim") + label.append(f" {_tool_count_text(enabled)}", style="dim") option_list.add_option(Option(label, id=f"server:{srv.name}")) - if self._mcp_servers: - option_list.highlighted = 0 - return + # ── Workspace Connectors ── + if has_connectors: + max_name = max(len(n) for n in self._connector_names) + type_tag = "[connector]" + type_width = len(type_tag) + tool_texts = { + n: _tool_count_text( + sum( + 1 + for t, _ in index.connector_tools.get(n, []) + if t in index.enabled_tools + ) + ) + for n in self._connector_names + } + max_tools = max(len(t) for t in tool_texts.values()) + if has_servers: + option_list.add_option(Option(Text("", no_wrap=True), disabled=True)) + option_list.add_option( + Option( + Text("Workspace Connectors", style="bold", no_wrap=True), + disabled=True, + ) + ) + for cname in self._connector_names: + connected = ( + self._connector_registry.is_connected(cname) + if self._connector_registry + else False + ) + label = Text(no_wrap=True) + label.append(f" {cname:<{max_name}}") + label.append(f" {type_tag:<{type_width}}", style="dim") + label.append(f" {tool_texts[cname]:<{max_tools}}", style="dim") + if connected: + label.append(" ") + label.append("●", style="green") + label.append(" connected", style="dim") + else: + label.append(" ") + label.append("○", style="dim") + label.append(" not connected", style="dim") + option_list.add_option(Option(label, id=f"connector:{cname}")) + + if not has_servers and not has_connectors: + empty_msg = ( + "No MCP servers or connectors configured" + if connectors_enabled() + else "No MCP servers configured" + ) + option_list.add_option(Option(empty_msg, disabled=True)) + + if has_servers or has_connectors: + # Skip disabled header options (e.g. section labels). + first_enabled = next( + (i for i, opt in enumerate(option_list._options) if not opt.disabled), 0 + ) + option_list.highlighted = first_enabled + + # ── detail view ────────────────────────────────────────────────── + + def _show_detail_view( + self, + server_name: str, + option_list: OptionList, + index: MCPToolIndex, + *, + kind: str = "server", + ) -> None: self._viewing_server = server_name + self._viewing_kind = kind + is_connector = kind == "connector" + title_prefix = "Connector" if is_connector else "MCP Server" self.query_one("#mcp-title", NoMarkupStatic).update( - f"MCP Server: {server_name}" + f"{title_prefix}: {server_name}" ) self.query_one("#mcp-help", NoMarkupStatic).update( "↑↓ Navigate Backspace Back Esc Close" ) - enabled_tools = [ - (tool_name, cls) - for tool_name, cls in sorted( - index.server_tools.get(server_name, []), key=lambda t: t[0] - ) - if tool_name in index.enabled_tools - ] - if not enabled_tools: + tools_source = index.connector_tools if is_connector else index.server_tools + all_tools = sorted(tools_source.get(server_name, []), key=lambda t: t[0]) + visible_tools = [(n, c) for n, c in all_tools if n in index.enabled_tools] + if not visible_tools: option_list.add_option( Option("No enabled tools for this server", disabled=True) ) return - for tool_name, cls in enabled_tools: + for tool_name, cls in visible_tools: remote_name = cls.get_remote_name() raw_desc = ( (cls.description or "").removeprefix(f"[{server_name}] ").split("\n")[0] @@ -151,12 +275,12 @@ class MCPApp(Container): if raw_desc: label.append(f" - {raw_desc}") option_list.add_option(Option(label, id=f"tool:{tool_name}")) - if enabled_tools: + if visible_tools: option_list.highlighted = 0 -def _server_status(enabled: int) -> str: - if enabled == 0: - return "unavailable" - noun = "tool" if enabled == 1 else "tools" - return f"{enabled} {noun} enabled" +def _tool_count_text(count: int) -> str: + if count == 0: + return "no tools" + noun = "tool" if count == 1 else "tools" + return f"{count} {noun}" diff --git a/vibe/cli/textual_ui/widgets/messages.py b/vibe/cli/textual_ui/widgets/messages.py index 1cf7bdf..0a586f2 100644 --- a/vibe/cli/textual_ui/widgets/messages.py +++ b/vibe/cli/textual_ui/widgets/messages.py @@ -149,9 +149,7 @@ class AssistantMessage(StreamingMessageBase): self.add_class("assistant-message") def compose(self) -> ComposeResult: - if self._content: - self._content_initialized = True - markdown = Markdown(self._content) + markdown = Markdown("") self._markdown = markdown yield markdown diff --git a/vibe/cli/textual_ui/widgets/question_app.py b/vibe/cli/textual_ui/widgets/question_app.py index 64c7eb4..403c1df 100644 --- a/vibe/cli/textual_ui/widgets/question_app.py +++ b/vibe/cli/textual_ui/widgets/question_app.py @@ -326,7 +326,27 @@ class QuestionApp(Container): def _switch_question(self, new_idx: int) -> None: self.current_question_idx = new_idx - self.selected_option = 0 + self.selected_option = self._restore_cursor(new_idx) + + def _restore_cursor(self, question_idx: int) -> int: + """Return the option index to restore the cursor to for a previously answered question.""" + q = self.questions[question_idx] + + if q.multi_select: + if selections := self.multi_selections.get(question_idx): + return min(selections) + return 0 + + if question_idx not in self.answers: + return 0 + + answer_text, is_other = self.answers[question_idx] + if is_other: + return len(q.options) + + return next( + (i for i, opt in enumerate(q.options) if opt.label == answer_text), 0 + ) def action_next_question(self) -> None: if self._is_other_selected: @@ -427,7 +447,32 @@ class QuestionApp(Container): elif not has_text and other_idx in selections: selections.discard(other_idx) + def _handle_number_key(self, event: events.Key) -> bool: + """Handle number key press to quickly select an option. Returns True if handled.""" + if self.other_input and self.other_input.has_focus: + return False + if not event.character or not event.character.isdigit(): + return False + + option_idx = int(event.character) - 1 + # Only handle valid option indices, excluding submit button + if option_idx < 0 or option_idx >= len(self._current_question.options) + ( + 1 if self._has_other else 0 + ): + return False + + event.stop() + event.prevent_default() + self.selected_option = option_idx + + if not (self._has_other and option_idx == self._other_option_idx): + self.action_select() + return True + def on_key(self, event: events.Key) -> None: + if self._handle_number_key(event): + return + if len(self.questions) <= 1: return if self.other_input and self.other_input.has_focus: diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index 443dc00..16d0efe 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -6,6 +6,7 @@ import contextlib import copy from enum import StrEnum, auto from http import HTTPStatus +import os from pathlib import Path import threading from threading import Thread @@ -60,6 +61,7 @@ from vibe.core.tools.base import ( ToolPermission, ToolPermissionError, ) +from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled from vibe.core.tools.manager import ToolManager from vibe.core.tools.mcp import MCPRegistry from vibe.core.tools.mcp_sampling import MCPSamplingHandler @@ -100,6 +102,7 @@ from vibe.core.utils import ( TOOL_ERROR_TAG, VIBE_STOP_EVENT_TAG, CancellationReason, + get_server_url_from_api_base, get_user_agent, get_user_cancellation_message, is_user_cancellation_event, @@ -170,6 +173,7 @@ class AgentLoop: self._init_error: Exception | None = None self.mcp_registry = MCPRegistry() + self.connector_registry = self._create_connector_registry() self.agent_manager = AgentManager( lambda: self._base_config, initial_agent=agent_name, @@ -178,6 +182,7 @@ class AgentLoop: self.tool_manager = ToolManager( lambda: self.config, mcp_registry=self.mcp_registry, + connector_registry=self.connector_registry, defer_mcp=defer_heavy_init, ) self.skill_manager = SkillManager(lambda: self.config) @@ -266,13 +271,13 @@ class AgentLoop: return self._init_complete.is_set() def _complete_init(self) -> None: - """Run deferred heavy I/O: MCP discovery and git status. + """Run deferred heavy I/O: MCP discovery, connectors, and git status. Intended to be called from a background thread when ``defer_heavy_init=True`` was passed to ``__init__``. """ try: - self.tool_manager.integrate_mcp(raise_on_failure=True) + self.tool_manager.integrate_all(raise_on_mcp_failure=True) system_prompt = get_universal_system_prompt( self.tool_manager, self.config, self.skill_manager, self.agent_manager ) @@ -403,6 +408,29 @@ class AgentLoop: terminal_emulator=terminal_emulator, ) + def _create_connector_registry(self) -> ConnectorRegistry | None: + if not connectors_enabled(): + return None + + provider = self._base_config.get_mistral_provider() + if provider is None: + return None + + api_key_env = provider.api_key_env_var or "MISTRAL_API_KEY" + api_key = os.getenv(api_key_env, "") + if not api_key: + return None + + server_url = get_server_url_from_api_base(provider.api_base) + return ConnectorRegistry(api_key=api_key, server_url=server_url) + + def refresh_system_prompt(self) -> None: + """Rebuild and replace the system prompt with current tool/skill state.""" + system_prompt = get_universal_system_prompt( + self.tool_manager, self.config, self.skill_manager, self.agent_manager + ) + self.messages.update_system_prompt(system_prompt) + def _select_backend(self) -> BackendLike: active_model = self.config.get_active_model() provider = self.config.get_provider_for_model(active_model) @@ -582,6 +610,7 @@ class AgentLoop: "call_type": ( "main_call" if self._is_user_prompt_call else "secondary_call" ), + "call_source": "vibe_code", } if self._current_user_message_id is not None: metadata["message_id"] = self._current_user_message_id @@ -1342,7 +1371,9 @@ class AgentLoop: self._max_price = max_price self.tool_manager = ToolManager( - lambda: self.config, mcp_registry=self.mcp_registry + lambda: self.config, + mcp_registry=self.mcp_registry, + connector_registry=self.connector_registry, ) self.skill_manager = SkillManager(lambda: self.config) diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py index d95fc77..abc2bf1 100644 --- a/vibe/core/config/_settings.py +++ b/vibe/core/config/_settings.py @@ -319,7 +319,7 @@ class ModelConfig(BaseModel): temperature: float = 0.2 input_price: float = 0.0 # Price per million input tokens output_price: float = 0.0 # Price per million output tokens - thinking: Literal["off", "low", "medium", "high"] = "off" + thinking: Literal["off", "low", "medium", "high", "max"] = "off" auto_compact_threshold: int = 200_000 _default_alias_to_name = model_validator(mode="before")(_default_alias_to_name) diff --git a/vibe/core/llm/backend/anthropic.py b/vibe/core/llm/backend/anthropic.py index 1bba3a3..9e7fb4f 100644 --- a/vibe/core/llm/backend/anthropic.py +++ b/vibe/core/llm/backend/anthropic.py @@ -321,12 +321,14 @@ class AnthropicAdapter(APIAdapter): BETA_FEATURES = ( "interleaved-thinking-2025-05-14," "fine-grained-tool-streaming-2025-05-14," - "prompt-caching-2024-07-31" + "prompt-caching-2024-07-31," + "context-1m-2025-08-07" ) THINKING_BUDGETS: ClassVar[dict[str, int]] = { "low": 1024, "medium": 10_000, "high": 32_000, + "max": 128_000, } DEFAULT_ADAPTIVE_MAX_TOKENS: ClassVar[int] = 32_768 DEFAULT_MAX_TOKENS = 8192 diff --git a/vibe/core/llm/backend/mistral.py b/vibe/core/llm/backend/mistral.py index 514b86d..aa46b03 100644 --- a/vibe/core/llm/backend/mistral.py +++ b/vibe/core/llm/backend/mistral.py @@ -175,6 +175,7 @@ _THINKING_TO_REASONING_EFFORT: dict[str, ReasoningEffortValue] = { "low": "none", "medium": "high", "high": "high", + "max": "high", } @@ -283,9 +284,10 @@ class MistralBackend: reasoning_effort=reasoning_effort, ) + message = response.choices[0].message parsed = ( - self._mapper.parse_content(response.choices[0].message.content) - if response.choices[0].message.content + self._mapper.parse_content(message.content) + if message and message.content else ParsedContent(content="", reasoning_content=None) ) return LLMChunk( @@ -293,10 +295,8 @@ class MistralBackend: role=Role.assistant, content=parsed.content, reasoning_content=parsed.reasoning_content, - tool_calls=self._mapper.parse_tool_calls( - response.choices[0].message.tool_calls - ) - if response.choices[0].message.tool_calls + tool_calls=self._mapper.parse_tool_calls(message.tool_calls) + if message and message.tool_calls else None, ), usage=LLMUsage( diff --git a/vibe/core/prompts/cli.md b/vibe/core/prompts/cli.md index 192ba69..56a18de 100644 --- a/vibe/core/prompts/cli.md +++ b/vibe/core/prompts/cli.md @@ -106,4 +106,5 @@ Prioritize technical accuracy over validating beliefs. Disagree when necessary. When uncertain, investigate before confirming. Your output must contain zero emoji. This includes smiley faces, icons, flags, symbols like ✅❌💡, and all other Unicode emoji. No over-the-top validation. -Stay focused on solving the problem regardless of user tone. Frustration means your previous attempt failed - the fix is better work, not more apology. +Stay focused on solving the problem regardless of user tone. Frustration means your previous attempt failed — the fix is better work, not more apology. +Other: requests unrelated to code → respond helpfully as a general assistant. diff --git a/vibe/core/skills/manager.py b/vibe/core/skills/manager.py index 3dd592e..a9ab3d6 100644 --- a/vibe/core/skills/manager.py +++ b/vibe/core/skills/manager.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.logger import logger -from vibe.core.skills.models import SkillInfo, SkillMetadata +from vibe.core.skills.models import ParsedSkillCommand, SkillInfo, SkillMetadata from vibe.core.skills.parser import SkillParseError, parse_frontmatter from vibe.core.utils import name_matches from vibe.core.utils.io import read_safe @@ -127,3 +127,32 @@ class SkillManager: def get_skill(self, name: str) -> SkillInfo | None: return self.available_skills.get(name) + + def parse_skill_command(self, text_prompt: str) -> ParsedSkillCommand | None: + stripped = text_prompt.strip() + if not stripped.startswith("/"): + return None + + parts = stripped[1:].split(None, 1) + if not parts: + return None + + skill_name = parts[0].lower() + skill_info = self.get_skill(skill_name) + if skill_info is None: + return None + + skill_content = read_safe(skill_info.skill_path).text + extra_instructions = parts[1] if len(parts) > 1 else None + + return ParsedSkillCommand( + name=skill_name, + content=skill_content, + extra_instructions=extra_instructions, + ) + + @staticmethod + def build_skill_prompt(text_prompt: str, parsed: ParsedSkillCommand) -> str: + if parsed.extra_instructions is not None: + return f"{text_prompt}\n\n{parsed.content}" + return parsed.content diff --git a/vibe/core/skills/models.py b/vibe/core/skills/models.py index aae951d..0b69704 100644 --- a/vibe/core/skills/models.py +++ b/vibe/core/skills/models.py @@ -90,3 +90,9 @@ class SkillInfo(BaseModel): user_invocable=meta.user_invocable, skill_path=skill_path.resolve(), ) + + +class ParsedSkillCommand(BaseModel): + name: str + content: str + extra_instructions: str | None = None diff --git a/vibe/core/system_prompt.py b/vibe/core/system_prompt.py index ace8027..cad127d 100644 --- a/vibe/core/system_prompt.py +++ b/vibe/core/system_prompt.py @@ -1,5 +1,6 @@ from __future__ import annotations +from concurrent.futures import ThreadPoolExecutor import html import os from pathlib import Path @@ -37,85 +38,79 @@ class ProjectContextProvider: _git_status_cache[self.root_path] = result return result + def _run_git( + self, args: list[str], timeout: float + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + capture_output=True, + check=True, + cwd=self.root_path, + stdin=subprocess.DEVNULL if is_windows() else None, + text=True, + timeout=timeout, + ) + + @staticmethod + def _format_git_status(status_output: str) -> str: + if not status_output: + return "(clean)" + status_lines = status_output.splitlines() + MAX_GIT_STATUS_SIZE = 50 + if len(status_lines) > MAX_GIT_STATUS_SIZE: + return f"({len(status_lines)} changes - use 'git status' for details)" + return f"({len(status_lines)} changes)" + + @staticmethod + def _parse_git_log(log_output: str) -> list[str]: + recent_commits: list[str] = [] + for line in log_output.split("\n"): + if not (line := line.strip()): + continue + if " " in line: + commit_hash, commit_msg = line.split(" ", 1) + if ( + "(" in commit_msg + and ")" in commit_msg + and (paren_index := commit_msg.rfind("(")) > 0 + ): + commit_msg = commit_msg[:paren_index].strip() + recent_commits.append(f"{commit_hash} {commit_msg}") + else: + recent_commits.append(line) + return recent_commits + def _fetch_git_status(self) -> str: try: timeout = min(self.config.timeout_seconds, 10.0) num_commits = self.config.default_commit_count - current_branch = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - check=True, - cwd=self.root_path, - stdin=subprocess.DEVNULL if is_windows() else None, - text=True, - timeout=timeout, - ).stdout.strip() + with ThreadPoolExecutor(max_workers=4) as pool: + branch_future = pool.submit( + self._run_git, ["branch", "--show-current"], timeout + ) + remote_future = pool.submit(self._run_git, ["branch", "-r"], timeout) + status_future = pool.submit( + self._run_git, ["status", "--porcelain"], timeout + ) + log_future = pool.submit( + self._run_git, + ["log", "--oneline", f"-{num_commits}", "--decorate"], + timeout, + ) + + current_branch = branch_future.result().stdout.strip() main_branch = "main" try: - branches_output = subprocess.run( - ["git", "branch", "-r"], - capture_output=True, - check=True, - cwd=self.root_path, - stdin=subprocess.DEVNULL if is_windows() else None, - text=True, - timeout=timeout, - ).stdout + branches_output = remote_future.result().stdout if "origin/master" in branches_output: main_branch = "master" except (subprocess.CalledProcessError, subprocess.TimeoutExpired): pass - status_output = subprocess.run( - ["git", "status", "--porcelain"], - capture_output=True, - check=True, - cwd=self.root_path, - stdin=subprocess.DEVNULL if is_windows() else None, - text=True, - timeout=timeout, - ).stdout.strip() - - if status_output: - status_lines = status_output.splitlines() - MAX_GIT_STATUS_SIZE = 50 - if len(status_lines) > MAX_GIT_STATUS_SIZE: - status = ( - f"({len(status_lines)} changes - use 'git status' for details)" - ) - else: - status = f"({len(status_lines)} changes)" - else: - status = "(clean)" - - log_output = subprocess.run( - ["git", "log", "--oneline", f"-{num_commits}", "--decorate"], - capture_output=True, - check=True, - cwd=self.root_path, - stdin=subprocess.DEVNULL if is_windows() else None, - text=True, - timeout=timeout, - ).stdout.strip() - - recent_commits = [] - for line in log_output.split("\n"): - if not (line := line.strip()): - continue - - if " " in line: - commit_hash, commit_msg = line.split(" ", 1) - if ( - "(" in commit_msg - and ")" in commit_msg - and (paren_index := commit_msg.rfind("(")) > 0 - ): - commit_msg = commit_msg[:paren_index].strip() - recent_commits.append(f"{commit_hash} {commit_msg}") - else: - recent_commits.append(line) + status = self._format_git_status(status_future.result().stdout.strip()) + recent_commits = self._parse_git_log(log_future.result().stdout.strip()) git_info_parts = [ f"Current branch: {current_branch}", diff --git a/vibe/core/telemetry/send.py b/vibe/core/telemetry/send.py index 98d3064..d47f152 100644 --- a/vibe/core/telemetry/send.py +++ b/vibe/core/telemetry/send.py @@ -16,7 +16,7 @@ from vibe.core.utils import get_server_url_from_api_base, get_user_agent if TYPE_CHECKING: from vibe.core.agent_loop import ToolDecision -_DEFAULT_TELEMETRY_BASE_URL = "https://codestral.mistral.ai" +_DEFAULT_TELEMETRY_BASE_URL = "https://api.mistral.ai" _DATALAKE_EVENTS_PATH = "/v1/datalake/events" diff --git a/vibe/core/tools/connectors/__init__.py b/vibe/core/tools/connectors/__init__.py new file mode 100644 index 0000000..732adc9 --- /dev/null +++ b/vibe/core/tools/connectors/__init__.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import os + +from vibe.core.tools.connectors.connector_registry import ConnectorRegistry + +CONNECTORS_ENV_VAR = "EXPERIMENTAL_ENABLE_CONNECTORS" + + +def connectors_enabled() -> bool: + return os.getenv(CONNECTORS_ENV_VAR) == "1" + + +__all__ = ["CONNECTORS_ENV_VAR", "ConnectorRegistry", "connectors_enabled"] diff --git a/vibe/core/tools/connectors/connector_registry.py b/vibe/core/tools/connectors/connector_registry.py new file mode 100644 index 0000000..6d92166 --- /dev/null +++ b/vibe/core/tools/connectors/connector_registry.py @@ -0,0 +1,417 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +import re +from typing import TYPE_CHECKING, Any, ClassVar + +import httpx +from mistralai.client import Mistral +from mistralai.client.models.connectorsqueryfilters import ( + ConnectorsQueryFiltersTypedDict, +) + +from vibe.core.logger import logger +from vibe.core.tools.base import ( + BaseTool, + BaseToolConfig, + BaseToolState, + InvokeContext, + ToolError, +) +from vibe.core.tools.mcp.tools import ( + MCPTool, + MCPToolResult, + RemoteTool, + _OpenArgs, + call_tool_http, +) +from vibe.core.tools.ui import ToolResultDisplay +from vibe.core.types import ToolStreamEvent +from vibe.core.utils import run_sync + +if TYPE_CHECKING: + from vibe.core.types import ToolResultEvent + +_LIST_PAGE_SIZE = 100 +_LIST_QUERY_FILTERS: ConnectorsQueryFiltersTypedDict = {"active": True} +_TOOL_FETCH_TIMEOUT = 5.0 + + +def _normalize_name(name: str) -> str: + normalized = re.sub(r"[^a-zA-Z0-9_-]", "_", name) + result = normalized.strip("_-")[:256] + return result or "unnamed" + + +def _connector_tool_to_remote(tool: dict[str, Any] | Any) -> RemoteTool | None: + """Convert a ConnectorTool (SDK object or raw dict) to a RemoteTool.""" + if isinstance(tool, dict): + name = tool.get("name") + description = tool.get("description") + schema = tool.get("jsonschema") or tool.get("json_schema") + else: + name = getattr(tool, "name", None) + description = getattr(tool, "description", None) + schema = getattr(tool, "jsonschema", None) or getattr(tool, "json_schema", None) + if not name: + return None + if schema is not None and not isinstance(schema, dict): + dump = getattr(schema, "model_dump", None) + schema = dump() if callable(dump) else None + return RemoteTool.model_validate({ + "name": name, + "description": description, + "inputSchema": schema or {"type": "object", "properties": {}}, + }) + + +_DEFAULT_BASE_URL = "https://api.mistral.ai" + + +def _format_http_status_error( + exc: httpx.HTTPStatusError, connector_name: str, connector_id: str +) -> str: + """Format an HTTP status error with response body for debugging.""" + status = exc.response.status_code + connector_ref = f"'{connector_name}' (id: {connector_id})" + try: + body = exc.response.text[:500] + except Exception: + body = "" + + match status: + case 401 | 403: + detail = ( + f"Connector {connector_ref} authentication failed " + f"(HTTP {status}). Check your MISTRAL_API_KEY." + ) + case 404: + detail = ( + f"Connector {connector_ref} not found (HTTP 404). " + "It may have been deleted or is not accessible." + ) + case _: + detail = f"Connector {connector_ref} request failed (HTTP {status})." + + if body: + detail += f"\nServer response: {body}" + return detail + + +def _unwrap_http_status_error(exc: Exception) -> httpx.HTTPStatusError | None: + """Extract an HTTPStatusError from an exception or ExceptionGroup.""" + if isinstance(exc, httpx.HTTPStatusError): + return exc + if isinstance(exc, ExceptionGroup): + for inner in exc.exceptions: + if found := _unwrap_http_status_error(inner): + return found + if cause := exc.__cause__: + if isinstance(cause, httpx.HTTPStatusError): + return cause + return None + + +def _connector_error_message( + exc: Exception, connector_id: str, connector_name: str +) -> str: + """Return an actionable error message for connector proxy failures.""" + if http_err := _unwrap_http_status_error(exc): + return _format_http_status_error(http_err, connector_name, connector_id) + if isinstance(exc, httpx.TimeoutException): + return ( + f"Connector '{connector_name}' timed out. " + "The remote service may be slow or unreachable." + ) + if isinstance(exc, httpx.ConnectError): + return ( + f"Cannot reach connector proxy for '{connector_name}'. " + "Check your network connection." + ) + if isinstance(exc, ExceptionGroup): + messages = [str(e) for e in exc.exceptions] + return ( + f"Connector '{connector_name}' call failed with multiple errors: " + + "; ".join(messages) + ) + return f"Connector '{connector_name}' call failed: {exc}" + + +def create_connector_proxy_tool_class( + *, + connector_name: str, + connector_alias: str, + connector_id: str, + remote: RemoteTool, + api_key: str, + server_url: str | None = None, +) -> type[BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]]: + alias = connector_alias + published_name = f"connector_{alias}_{remote.name}" + base_url = server_url or _DEFAULT_BASE_URL + + class ConnectorProxyTool(MCPTool): + description: ClassVar[str] = f"[{alias}] " + ( + remote.description or f"Connector tool '{remote.name}'" + ) + _server_name: ClassVar[str] = alias + _remote_name: ClassVar[str] = remote.name + _input_schema: ClassVar[dict[str, Any]] = remote.input_schema + _is_connector: ClassVar[bool] = True + _connector_id: ClassVar[str] = connector_id + _connector_name: ClassVar[str] = connector_name + _api_key: ClassVar[str] = api_key + _base_url: ClassVar[str] = base_url + + @classmethod + def get_name(cls) -> str: + return published_name + + @classmethod + def get_parameters(cls) -> dict[str, Any]: + return dict(cls._input_schema) + + async def run( + self, args: _OpenArgs, ctx: InvokeContext | None = None + ) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]: + url = ( + f"{self._base_url}/v1/experimental/connectors/{self._connector_id}/mcp" + ) + headers = {"Authorization": f"Bearer {self._api_key}"} + payload = args.model_dump(exclude_none=True) + try: + yield await call_tool_http( + url, self._remote_name, payload, headers=headers + ) + except Exception as exc: + msg = _connector_error_message( + exc, self._connector_id, self._connector_name + ) + raise ToolError(msg) from exc + + @classmethod + def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: + if not isinstance(event.result, MCPToolResult): + return ToolResultDisplay( + success=False, + message=event.error or event.skip_reason or "No result", + ) + message = f"Connector tool {event.result.tool} completed" + return ToolResultDisplay(success=event.result.ok, message=message) + + @classmethod + def get_status_text(cls) -> str: + return f"Calling connector tool {remote.name}" + + ConnectorProxyTool.__name__ = f"Connector_{alias}__{remote.name}" + return ConnectorProxyTool + + +class ConnectorRegistry: + """Discovers connector tools from the Mistral API. + + Fetches all connectors and their tools on first call, then caches. + """ + + def __init__(self, api_key: str, server_url: str | None = None) -> None: + self._api_key = api_key + self._server_url = server_url + self._cache: dict[str, dict[str, type[BaseTool]]] | None = None + self._connector_names: list[str] = [] + self._connector_connected: dict[str, bool] = {} + self._discover_lock = asyncio.Lock() + + def _get_client(self) -> Mistral: + return Mistral(api_key=self._api_key, server_url=self._server_url) + + def get_tools(self) -> dict[str, type[BaseTool]]: + """Return proxy tool classes for all connectors, using cache when possible.""" + return run_sync(self.get_tools_async()) + + async def get_tools_async(self) -> dict[str, type[BaseTool]]: + """Return proxy tool classes for all connectors, using cache when possible.""" + if self._cache is not None: + result: dict[str, type[BaseTool]] = {} + for tools in self._cache.values(): + result.update(tools) + return result + + return await self._discover_all() + + async def _discover_all(self) -> dict[str, type[BaseTool]]: + async with self._discover_lock: + # Re-check under lock — another coroutine may have finished + # discovery while we waited. + if self._cache is not None: + result: dict[str, type[BaseTool]] = {} + for tools in self._cache.values(): + result.update(tools) + return result + + async with self._get_client() as client: + connectors: list[Any] = [] + + try: + page = await client.beta.connectors.list_async( + page_size=_LIST_PAGE_SIZE, query_filters=_LIST_QUERY_FILTERS + ) + connectors.extend(page.items or []) + while page.pagination and page.pagination.next_cursor: + page = await client.beta.connectors.list_async( + page_size=_LIST_PAGE_SIZE, + cursor=page.pagination.next_cursor, + query_filters=_LIST_QUERY_FILTERS, + ) + connectors.extend(page.items or []) + except Exception as exc: + logger.warning(f"Failed to list connectors: {exc}") + self._cache = {} + self._connector_names = [] + self._connector_connected = {} + return {} + + # Build results locally to avoid publishing incomplete state. + cache: dict[str, dict[str, type[BaseTool]]] = {} + all_tools: dict[str, type[BaseTool]] = {} + connector_names: list[str] = [] + connector_connected: dict[str, bool] = {} + + # Deduplicate by normalized name, preserving order. + # When two connectors share the same alias, disambiguate + # with a numeric suffix rather than silently dropping. + seen_names: set[str] = set() + unique_connectors: list[tuple[str, str, Any]] = [] + for connector in connectors: + connector_id = str(connector.id) + raw_name = connector.name or connector_id + alias = _normalize_name(raw_name) + if alias in seen_names: + original = alias + suffix = 2 + while f"{alias}_{suffix}" in seen_names: + suffix += 1 + alias = f"{alias}_{suffix}" + logger.warning( + f"Connector {raw_name!r} alias {original!r} collides, " + f"using {alias!r}" + ) + seen_names.add(alias) + unique_connectors.append((connector_id, alias, connector)) + + # Fetch tools for all connectors in parallel. + tasks = [ + self._fetch_connector_tools(client, c, alias) + for _, alias, c in unique_connectors + ] + results = await asyncio.gather(*tasks) + + for (connector_id, alias, _), tools_map in zip( + unique_connectors, results, strict=True + ): + connector_names.append(alias) + if tools_map is None: + # Timeout or error — show in UI but don't cache so + # a refresh will retry discovery for this connector. + connector_connected[alias] = False + continue + cache[connector_id] = tools_map + all_tools.update(tools_map) + # TODO: replace with actual API field when available + connector_connected[alias] = bool(tools_map) + + # Publish atomically — concurrent callers waiting on the + # lock will see the completed cache. + self._connector_names = connector_names + self._connector_connected = connector_connected + self._cache = cache + + return all_tools + + async def _fetch_connector_tools( + self, client: Mistral, connector: Any, connector_alias: str + ) -> dict[str, type[BaseTool]] | None: + """Fetch tools for a single connector via ``list_tools_async``. + + The list endpoint does not always include tools inline, so we + call ``list_tools_async`` per connector to get the full set. + """ + connector_id = str(connector.id) + name = connector.name or connector_id + + try: + response = await asyncio.wait_for( + client.beta.connectors.list_tools_async( + connector_id_or_name=connector_id + ), + timeout=_TOOL_FETCH_TIMEOUT, + ) + except TimeoutError: + logger.warning( + f"Timeout fetching tools for connector {name} (>{_TOOL_FETCH_TIMEOUT}s)" + ) + return None + except Exception as exc: + logger.warning(f"Failed to list tools for connector {name}: {exc}") + return None + + # The SDK may return a plain list, an iterable wrapper, or an + # object with a `data`/`items` attribute. Handle all cases. + if isinstance(response, list): + raw_tools: list[Any] = response + elif hasattr(response, "data"): + raw_tools = list(response.data or []) + elif hasattr(response, "items"): + raw_tools = list(response.items or []) + else: + try: + raw_tools = list(response) + except TypeError: + logger.warning( + f"Unexpected response type from list_tools_async for {name}: " + f"{type(response).__name__}" + ) + raw_tools = [] + + result: dict[str, type[BaseTool]] = {} + for tool in raw_tools: + remote = _connector_tool_to_remote(tool) + if remote is None: + continue + try: + proxy_cls = create_connector_proxy_tool_class( + connector_name=name, + connector_alias=connector_alias, + connector_id=connector_id, + remote=remote, + api_key=self._api_key, + server_url=self._server_url, + ) + result[proxy_cls.get_name()] = proxy_cls + except Exception as exc: + tool_name = ( + tool.get("name") + if isinstance(tool, dict) + else getattr(tool, "name", "") + ) + logger.warning( + f"Failed to register connector tool {tool_name} for {name}: {exc}" + ) + return result + + @property + def connector_count(self) -> int: + if self._cache is None: + return 0 + return len(self._connector_names) + + def get_connector_names(self) -> list[str]: + return list(self._connector_names) + + def is_connected(self, name: str) -> bool: + return self._connector_connected.get(name, False) + + def clear(self) -> None: + self._cache = None + self._connector_names = [] + self._connector_connected = {} diff --git a/vibe/core/tools/manager.py b/vibe/core/tools/manager.py index 85ae852..4184bc6 100644 --- a/vibe/core/tools/manager.py +++ b/vibe/core/tools/manager.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from collections.abc import Callable, Iterator import hashlib import importlib.util @@ -14,8 +15,10 @@ from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.logger import logger from vibe.core.paths import DEFAULT_TOOL_DIR from vibe.core.tools.base import BaseTool, BaseToolConfig +from vibe.core.tools.connectors import ConnectorRegistry from vibe.core.tools.mcp import MCPRegistry -from vibe.core.utils import name_matches +from vibe.core.tools.mcp.tools import MCPTool +from vibe.core.utils import name_matches, run_sync if TYPE_CHECKING: from vibe.core.config import VibeConfig @@ -70,11 +73,13 @@ class ToolManager: self, config_getter: Callable[[], VibeConfig], mcp_registry: MCPRegistry | None = None, + connector_registry: ConnectorRegistry | None = None, *, defer_mcp: bool = False, ) -> None: self._config_getter = config_getter self._mcp_registry = mcp_registry or MCPRegistry() + self._connector_registry = connector_registry self._instances: dict[str, BaseTool] = {} self._search_paths: list[Path] = self._compute_search_paths(self._config) self._lock = threading.Lock() @@ -84,7 +89,7 @@ class ToolManager: cls.get_name(): cls for cls in self._iter_tool_classes(self._search_paths) } if not defer_mcp: - self.integrate_mcp() + self.integrate_all() @property def _config(self) -> VibeConfig: @@ -207,18 +212,24 @@ class ToolManager: return runtime_available def integrate_mcp(self, *, raise_on_failure: bool = False) -> None: - """Discover and register MCP tools. + """Discover and register MCP tools (sync wrapper). Idempotent: subsequent calls after a successful integration are no-ops to avoid redundant MCP discovery. """ + run_sync(self._integrate_mcp_async(raise_on_failure=raise_on_failure)) + + async def _integrate_mcp_async(self, *, raise_on_failure: bool = False) -> None: + """Async MCP discovery — canonical implementation.""" if self._mcp_integrated: return if not self._config.mcp_servers: return try: - mcp_tools = self._mcp_registry.get_tools(self._config.mcp_servers) + mcp_tools = await self._mcp_registry.get_tools_async( + self._config.mcp_servers + ) except Exception as exc: logger.warning("MCP integration failed: %s", exc) if raise_on_failure: @@ -232,6 +243,71 @@ class ToolManager: "MCP integration registered %d tools (via registry)", len(mcp_tools) ) + def _purge_connector_state(self) -> None: + """Remove stale connector tool classes and cached instances.""" + stale_keys = [ + name + for name, cls in self._available.items() + if issubclass(cls, MCPTool) and cls.is_connector() + ] + for key in stale_keys: + self._available.pop(key, None) + self._instances.pop(key, None) + + def integrate_connectors(self) -> None: + """Discover and register connector tools (sync wrapper).""" + run_sync(self.integrate_connectors_async()) + + async def integrate_connectors_async(self) -> None: + """Discover and register connector tools — canonical implementation. + + Thread-safe: can be called from the deferred-init background thread. + """ + if self._connector_registry is None: + return + + try: + connector_tools = await self._connector_registry.get_tools_async() + except Exception as exc: + logger.warning(f"Connector integration failed: {exc}") + with self._lock: + self._purge_connector_state() + return + + with self._lock: + self._purge_connector_state() + self._available.update(connector_tools) + logger.info(f"Connector integration registered {len(connector_tools)} tools") + + def integrate_all(self, *, raise_on_mcp_failure: bool = False) -> None: + """Discover MCP and connector tools in parallel. + + Runs both async discovery paths concurrently via ``asyncio.gather`` + inside a single ``run_sync`` call. + """ + run_sync(self._integrate_all_async(raise_on_mcp_failure=raise_on_mcp_failure)) + + async def _integrate_all_async(self, *, raise_on_mcp_failure: bool = False) -> None: + """Run MCP and connector discovery concurrently. + + Uses ``return_exceptions=True`` so that a failing MCP server does + not cancel in-flight connector discovery (or vice-versa). + """ + mcp_result, connector_result = await asyncio.gather( + self._integrate_mcp_async(raise_on_failure=raise_on_mcp_failure), + self.integrate_connectors_async(), + return_exceptions=True, + ) + + # Re-raise MCP errors when the caller asked for them. + if isinstance(mcp_result, BaseException): + if raise_on_mcp_failure: + raise mcp_result + logger.warning(f"MCP integration failed: {mcp_result}") + + if isinstance(connector_result, BaseException): + logger.warning(f"Connector integration failed: {connector_result}") + def get_tool_config(self, tool_name: str) -> BaseToolConfig: with self._lock: tool_class = self._available.get(tool_name) diff --git a/vibe/core/tools/mcp/registry.py b/vibe/core/tools/mcp/registry.py index d2b787e..7f6c19d 100644 --- a/vibe/core/tools/mcp/registry.py +++ b/vibe/core/tools/mcp/registry.py @@ -36,6 +36,12 @@ class MCPRegistry: def get_tools(self, servers: list[MCPServer]) -> dict[str, type[BaseTool]]: """Return proxy tool classes for *servers*, using cache when possible.""" + return run_sync(self.get_tools_async(servers)) + + async def get_tools_async( + self, servers: list[MCPServer] + ) -> dict[str, type[BaseTool]]: + """Async variant of :meth:`get_tools`.""" result: dict[str, type[BaseTool]] = {} to_discover: list[tuple[str, MCPServer]] = [] @@ -47,7 +53,7 @@ class MCPRegistry: to_discover.append((key, srv)) if to_discover: - discovered = run_sync(self._discover_all(to_discover)) + discovered = await self._discover_all(to_discover) result.update(discovered) return result diff --git a/vibe/core/tools/mcp/tools.py b/vibe/core/tools/mcp/tools.py index ce25e9e..c8cb363 100644 --- a/vibe/core/tools/mcp/tools.py +++ b/vibe/core/tools/mcp/tools.py @@ -77,6 +77,7 @@ class MCPTool( ): _server_name: ClassVar[str] = "" _remote_name: ClassVar[str] = "" + _is_connector: ClassVar[bool] = False @classmethod def get_server_name(cls) -> str | None: @@ -86,6 +87,10 @@ class MCPTool( def get_remote_name(cls) -> str: return cls._remote_name or cls.get_name() + @classmethod + def is_connector(cls) -> bool: + return cls._is_connector + class RemoteTool(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/vibe/core/utils/__init__.py b/vibe/core/utils/__init__.py index bbab8ce..67a0e7b 100644 --- a/vibe/core/utils/__init__.py +++ b/vibe/core/utils/__init__.py @@ -14,6 +14,7 @@ from vibe.core.utils.concurrency import ( from vibe.core.utils.display import compact_reduction_display from vibe.core.utils.http import get_server_url_from_api_base, get_user_agent from vibe.core.utils.matching import name_matches +from vibe.core.utils.merge import MergeConflictError, MergeStrategy from vibe.core.utils.paths import is_dangerous_directory from vibe.core.utils.platform import is_windows from vibe.core.utils.retry import async_generator_retry, async_retry @@ -39,6 +40,8 @@ __all__ = [ "AsyncExecutor", "CancellationReason", "ConversationLimitException", + "MergeConflictError", + "MergeStrategy", "TaggedText", "async_generator_retry", "async_retry", diff --git a/vibe/core/utils/http.py b/vibe/core/utils/http.py index a580103..711c0f8 100644 --- a/vibe/core/utils/http.py +++ b/vibe/core/utils/http.py @@ -15,5 +15,5 @@ def get_user_agent(backend: Backend | None) -> str: def get_server_url_from_api_base(api_base: str) -> str | None: - match = re.match(r"(https?://[^/]+)(/v.*)", api_base) + match = re.match(r"(https?://.+)(/v\d+.*)", api_base) return match.group(1) if match else None diff --git a/vibe/core/utils/merge.py b/vibe/core/utils/merge.py new file mode 100644 index 0000000..1bb3043 --- /dev/null +++ b/vibe/core/utils/merge.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from collections.abc import Callable +from enum import StrEnum, auto +from typing import Any + + +class MergeConflictError(Exception): + """Raised when CONFLICT strategy detects two layers both providing a value.""" + + def __init__(self, field_name: str = "") -> None: + msg = ( + f"Merge conflict on field '{field_name}'" + if field_name + else "Merge conflict" + ) + super().__init__(msg) + self.field_name = field_name + + +class MergeStrategy(StrEnum): + REPLACE = auto() + CONCAT = auto() + UNION = auto() + MERGE = auto() + CONFLICT = auto() + + def apply( + self, base: Any, override: Any, *, key_fn: Callable[[Any], str] | None = None + ) -> Any: + """Combine base and override according to this strategy.""" + if self is MergeStrategy.REPLACE: + return self._replace(base, override) + if self is MergeStrategy.CONCAT: + return self._concat(base, override) + if self is MergeStrategy.UNION: + return self._union(base, override, key_fn) + if self is MergeStrategy.MERGE: + return self._merge(base, override) + if self is MergeStrategy.CONFLICT: + return self._conflict(base, override) + raise NotImplementedError(f"Merge strategy {self!r} is not implemented") + + def _coalesce(self, base: Any, override: Any) -> tuple[bool, Any]: + """Return the non-None operand when at most one side is present. + + Returns a (resolved, value) pair: + - (True, ) — one or both sides are None; use value as-is. + - (False, None) — both sides are present; caller must merge them. + """ + if base is None: + return True, override + if override is None: + return True, base + return False, None + + def _replace(self, base: Any, override: Any) -> Any: + resolved, value = self._coalesce(base, override) + return value if resolved else override + + def _concat(self, base: Any, override: Any) -> Any: + resolved, value = self._coalesce(base, override) + if resolved: + return value + if not isinstance(base, list) or not isinstance(override, list): + raise TypeError( + f"CONCAT requires list operands, got {type(base).__name__} and {type(override).__name__}" + ) + return base + override + + def _union( + self, base: Any, override: Any, key_fn: Callable[[Any], str] | None + ) -> Any: + resolved, value = self._coalesce(base, override) + if resolved: + return value + if key_fn is None: + raise ValueError("UNION strategy requires key_fn") + if not isinstance(base, list) or not isinstance(override, list): + raise TypeError( + f"UNION requires list operands, got {type(base).__name__} and {type(override).__name__}" + ) + merged: dict[str, Any] = {} + for item in base: + merged[key_fn(item)] = item + for item in override: + merged[key_fn(item)] = item + return list(merged.values()) + + def _merge(self, base: Any, override: Any) -> Any: + resolved, value = self._coalesce(base, override) + if resolved: + return value + if not isinstance(base, dict) or not isinstance(override, dict): + raise TypeError( + f"MERGE requires dict operands, got {type(base).__name__} and {type(override).__name__}" + ) + return {**base, **override} + + def _conflict(self, base: Any, override: Any) -> Any: + resolved, value = self._coalesce(base, override) + if resolved: + return value + raise MergeConflictError() diff --git a/vibe/whats_new.md b/vibe/whats_new.md index e01d018..d4909f1 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,4 +1,3 @@ -# What's new in v2.7.5 +# What's new in v2.7.6 -- **Trust dialog**: Display detected files and LLM risks in the trust folder dialog -- **Faster startup**: Deferred MCP and git I/O to a background thread for faster CLI startup +- **Streaming fix**: Fixed markdown fence context loss causing streaming rendering problems