From 1fd7eea2893c23b1b5af2547e385c57a4d75d4a0 Mon Sep 17 00:00:00 2001 From: Mathias Gesbert Date: Wed, 29 Apr 2026 17:20:27 +0200 Subject: [PATCH] v2.9.1 (#644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Brice Carpentier Co-authored-by: Clément Drouin Co-authored-by: Clément Sirieix Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Pierre Rossinès Co-authored-by: Quentin Co-authored-by: Mistral Vibe --- CHANGELOG.md | 16 + README.md | 2 +- distribution/zed/extension.toml | 14 +- pyproject.toml | 2 +- tests/acp/test_initialize.py | 4 +- tests/acp/test_search_replace.py | 10 +- tests/acp/test_tool_call_session_update.py | 3 +- tests/acp/test_tool_field_meta.py | 296 ++++++++++++++ tests/acp/test_write_file.py | 10 +- tests/cli/test_connector_auth_app.py | 368 ++++++++++++++++++ tests/cli/test_mcp_app.py | 115 ++++++ tests/core/test_config_patch_ops.py | 124 ++++++ tests/core/test_config_resolution.py | 235 +++++++++++ tests/core/test_teleport_service.py | 1 + ...st_snapshot_connector_auth_back_to_mcp.svg | 206 ++++++++++ ...t_connector_auth_opens_on_disconnected.svg | 203 ++++++++++ .../test_snapshot_connector_auth_show_url.svg | 203 ++++++++++ ...shot_mcp_backspace_returns_to_overview.svg | 30 +- .../test_snapshot_mcp_broken_server.svg | 32 +- ...napshot_mcp_connector_back_to_overview.svg | 36 +- .../test_snapshot_mcp_connectors_only.svg | 30 +- ...apshot_mcp_connectors_sorted_by_status.svg | 32 +- ...test_snapshot_mcp_drill_into_connector.svg | 28 +- ..._snapshot_mcp_enter_drills_into_server.svg | 26 +- ...apshot_mcp_help_bar_shows_authenticate.svg | 206 ++++++++++ .../test_snapshot_mcp_overview.svg | 30 +- ...st_snapshot_mcp_overview_navigate_down.svg | 30 +- .../test_snapshot_mcp_refresh_shortcut.svg | 30 +- .../test_snapshot_mcp_server_arg.svg | 26 +- ..._snapshot_mcp_with_connectors_overview.svg | 36 +- .../snapshots/test_ui_snapshot_mcp_command.py | 87 +++++ tests/stubs/fake_connector_registry.py | 12 + tests/test_agent_backend.py | 25 ++ uv.lock | 4 +- vibe/__init__.py | 2 +- vibe/acp/acp_agent_loop.py | 8 +- vibe/acp/tools/builtins/bash.py | 19 +- vibe/acp/tools/builtins/grep.py | 78 ++++ vibe/acp/tools/builtins/read_file.py | 92 ++++- vibe/acp/tools/builtins/search_replace.py | 39 +- vibe/acp/tools/builtins/skill.py | 79 ++++ vibe/acp/tools/builtins/task.py | 75 ++++ vibe/acp/tools/builtins/web_fetch.py | 85 ++++ vibe/acp/tools/builtins/web_search.py | 80 ++++ vibe/acp/tools/builtins/write_file.py | 48 ++- vibe/acp/tools/session_update.py | 77 +++- vibe/acp/utils.py | 1 + vibe/cli/textual_ui/app.py | 33 +- vibe/cli/textual_ui/app.tcss | 12 +- .../textual_ui/widgets/connector_auth_app.py | 227 +++++++++++ vibe/cli/textual_ui/widgets/mcp_app.py | 55 ++- vibe/core/agent_loop.py | 8 +- vibe/core/config/__init__.py | 12 + vibe/core/config/_settings.py | 38 +- vibe/core/config/patch.py | 70 ++++ vibe/core/output_formatters.py | 4 +- vibe/core/skills/builtins/vibe.py | 13 +- vibe/core/teleport/teleport.py | 4 +- vibe/core/teleport/types.py | 1 + vibe/core/tools/builtins/grep.py | 45 +++ vibe/core/tools/builtins/read_file.py | 2 + .../tools/connectors/connector_registry.py | 59 +++ vibe/whats_new.md | 5 + 63 files changed, 3482 insertions(+), 301 deletions(-) create mode 100644 tests/acp/test_tool_field_meta.py create mode 100644 tests/cli/test_connector_auth_app.py create mode 100644 tests/core/test_config_patch_ops.py create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_back_to_mcp.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_opens_on_disconnected.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_show_url.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_authenticate.svg create mode 100644 vibe/acp/tools/builtins/grep.py create mode 100644 vibe/acp/tools/builtins/skill.py create mode 100644 vibe/acp/tools/builtins/task.py create mode 100644 vibe/acp/tools/builtins/web_fetch.py create mode 100644 vibe/acp/tools/builtins/web_search.py create mode 100644 vibe/cli/textual_ui/widgets/connector_auth_app.py create mode 100644 vibe/core/config/patch.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eaf153..c162d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ 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.9.1] - 2026-04-29 + +### Added + +- Connector OAuth authentication flow in `/mcp` menu +- `ConfigPatch` operation types for Vibe Code +- `extra_headers` field to `ProviderConfig` +- Structured metadata on ACP tool results +- `vibe.user_cancelled_action` ACP telemetry coverage +- `vibe.new_session` telemetry event emitted whenever the session is reset + +### Changed + +- Migrated default model to `mistral-medium-3.5` + + ## [2.9.0] - 2026-04-28 ### Added diff --git a/README.md b/README.md index 40b5512..13ac149 100644 --- a/README.md +++ b/README.md @@ -439,7 +439,7 @@ Example custom agent configuration (`~/.vibe/agents/redteam.toml`): ```toml # Custom agent configuration for red-teaming -active_model = "devstral-2" +active_model = "mistral-medium-3.5" system_prompt_id = "redteam" # Disable some tools for this agent diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index f8478b6..f1dbb2d 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.9.0" +version = "2.9.1" 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.9.0/vibe-acp-darwin-aarch64-2.9.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.1/vibe-acp-darwin-aarch64-2.9.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-darwin-x86_64-2.9.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.1/vibe-acp-darwin-x86_64-2.9.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-linux-aarch64-2.9.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.1/vibe-acp-linux-aarch64-2.9.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-linux-x86_64-2.9.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.1/vibe-acp-linux-x86_64-2.9.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-windows-aarch64-2.9.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.1/vibe-acp-windows-aarch64-2.9.1.zip" cmd = "./vibe-acp.exe" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-windows-x86_64-2.9.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.1/vibe-acp-windows-x86_64-2.9.1.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index 35e0339..a4fd1d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.9.0" +version = "2.9.1" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index b6dcb47..bb9401e 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -34,7 +34,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.1" ) assert response.auth_methods == [] @@ -62,7 +62,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.1" ) assert response.auth_methods is not None diff --git a/tests/acp/test_search_replace.py b/tests/acp/test_search_replace.py index 1ba06d3..a90b644 100644 --- a/tests/acp/test_search_replace.py +++ b/tests/acp/test_search_replace.py @@ -288,7 +288,7 @@ class TestAcpSearchReplaceSessionUpdates: assert update.content[0].new_text == "new text" assert update.locations is not None assert len(update.locations) == 1 - assert update.locations[0].path == "/tmp/test.txt" + assert update.locations[0].path == str(Path("/tmp/test.txt").resolve()) def test_tool_call_session_update_invalid_args(self) -> None: class InvalidArgs: @@ -302,7 +302,8 @@ class TestAcpSearchReplaceSessionUpdates: ) update = SearchReplace.tool_call_session_update(event) - assert update is None + assert update is not None + assert update.title == "search_replace" def test_tool_result_session_update(self) -> None: search_replace_content = ( @@ -337,7 +338,7 @@ class TestAcpSearchReplaceSessionUpdates: assert update.content[0].new_text == "new text" assert update.locations is not None assert len(update.locations) == 1 - assert update.locations[0].path == "/tmp/test.txt" + assert update.locations[0].path == str(Path("/tmp/test.txt").resolve()) def test_tool_result_session_update_invalid_result(self) -> None: class InvalidResult: @@ -351,4 +352,5 @@ class TestAcpSearchReplaceSessionUpdates: ) update = SearchReplace.tool_result_session_update(event) - assert update is None + assert update is not None + assert update.status == "failed" diff --git a/tests/acp/test_tool_call_session_update.py b/tests/acp/test_tool_call_session_update.py index aaf2545..ef92a8c 100644 --- a/tests/acp/test_tool_call_session_update.py +++ b/tests/acp/test_tool_call_session_update.py @@ -2,8 +2,9 @@ from __future__ import annotations from acp.schema import ToolCallStart +from vibe.acp.tools.builtins.read_file import ReadFile from vibe.acp.tools.session_update import tool_call_session_update -from vibe.core.tools.builtins.read_file import ReadFile, ReadFileArgs +from vibe.core.tools.builtins.read_file import ReadFileArgs from vibe.core.types import ToolCallEvent diff --git a/tests/acp/test_tool_field_meta.py b/tests/acp/test_tool_field_meta.py new file mode 100644 index 0000000..03e7c6d --- /dev/null +++ b/tests/acp/test_tool_field_meta.py @@ -0,0 +1,296 @@ +"""Tests for field_meta and locations in ACP tool overrides.""" + +from __future__ import annotations + +from pathlib import Path + +from acp.schema import ToolCallProgress, ToolCallStart +from pydantic import BaseModel + +from vibe.acp.tools.builtins.grep import Grep +from vibe.acp.tools.builtins.read_file import ReadFile +from vibe.acp.tools.builtins.skill import Skill +from vibe.acp.tools.builtins.task import Task +from vibe.acp.tools.builtins.web_fetch import WebFetch +from vibe.acp.tools.builtins.web_search import WebSearch +from vibe.acp.tools.builtins.write_file import WriteFile +from vibe.acp.tools.session_update import ( + tool_call_session_update, + tool_result_session_update, +) +from vibe.core.tools.builtins.grep import GrepArgs, GrepResult +from vibe.core.tools.builtins.read_file import ReadFileArgs, ReadFileResult +from vibe.core.tools.builtins.skill import SkillArgs, SkillResult +from vibe.core.tools.builtins.task import TaskArgs, TaskResult +from vibe.core.tools.builtins.webfetch import WebFetchArgs, WebFetchResult +from vibe.core.tools.builtins.websearch import ( + WebSearchArgs, + WebSearchResult, + WebSearchSource, +) +from vibe.core.tools.builtins.write_file import WriteFileArgs, WriteFileResult +from vibe.core.types import ToolCallEvent, ToolResultEvent + + +def _call_event( + tool_name: str, tool_class: type, args: BaseModel | None +) -> ToolCallEvent: + return ToolCallEvent( + tool_name=tool_name, tool_call_id="tc-1", tool_class=tool_class, args=args + ) + + +def _result_event( + tool_name: str, tool_class: type, result: BaseModel, *, error: str | None = None +) -> ToolResultEvent: + return ToolResultEvent( + tool_name=tool_name, + tool_call_id="tc-1", + tool_class=tool_class, + result=result, + error=error, + ) + + +class TestGrepFieldMeta: + def test_call_meta_contains_query_and_tool_name(self) -> None: + event = _call_event("grep", Grep, GrepArgs(pattern="TODO", path="src")) + update = tool_call_session_update(event) + + assert isinstance(update, ToolCallStart) + assert update.field_meta == {"tool_name": "grep", "query": "TODO"} + assert update.kind == "search" + + def test_call_location_is_resolved_search_path(self) -> None: + event = _call_event("grep", Grep, GrepArgs(pattern="TODO", path="src")) + update = tool_call_session_update(event) + + assert isinstance(update, ToolCallStart) + assert update.locations is not None + assert len(update.locations) == 1 + assert update.locations[0].path == str(Path("src").resolve()) + + def test_result_locations_from_parsed_matches(self) -> None: + result = GrepResult( + matches="src/a.py:10:match\nsrc/b.py:20:other", + match_count=2, + was_truncated=False, + ) + event = _result_event("grep", Grep, result) + update = tool_result_session_update(event) + + assert isinstance(update, ToolCallProgress) + assert update.status == "completed" + assert update.locations is not None + assert len(update.locations) == 2 + assert update.locations[0].path.endswith("src/a.py") + assert update.locations[0].line == 10 + assert update.locations[1].path.endswith("src/b.py") + assert update.locations[1].line == 20 + + def test_fallback_on_none_args(self) -> None: + event = _call_event("grep", Grep, None) + update = tool_call_session_update(event) + + assert isinstance(update, ToolCallStart) + assert update.kind == "search" + assert update.field_meta == {"tool_name": "grep"} + + +class TestReadFileFieldMeta: + def test_call_location_has_offset_and_limit(self) -> None: + event = _call_event( + "read_file", ReadFile, ReadFileArgs(path="/tmp/f.txt", offset=10, limit=50) + ) + update = tool_call_session_update(event) + + assert isinstance(update, ToolCallStart) + assert update.locations is not None + loc = update.locations[0] + assert loc.field_meta == {"type": "file_range", "offset": 10, "limit": 50} + assert update.field_meta == {"tool_name": "read_file"} + + def test_call_defaults_offset_zero_limit_none(self) -> None: + event = _call_event("read_file", ReadFile, ReadFileArgs(path="/tmp/f.txt")) + update = tool_call_session_update(event) + + assert isinstance(update, ToolCallStart) + assert update.locations is not None + loc = update.locations[0] + assert loc.field_meta == {"type": "file_range", "offset": 0, "limit": None} + + def test_result_location_has_offset_and_lines_read(self) -> None: + result = ReadFileResult( + path="/tmp/f.txt", + content="line1\nline2\nline3\n", + lines_read=3, + was_truncated=False, + offset=10, + ) + event = _result_event("read_file", ReadFile, result) + update = tool_result_session_update(event) + + assert isinstance(update, ToolCallProgress) + assert update.locations is not None + loc = update.locations[0] + assert loc.field_meta == {"type": "file_range", "offset": 10, "limit": 3} + + +class TestWebSearchFieldMeta: + def test_call_meta_contains_query(self) -> None: + event = _call_event( + "web_search", WebSearch, WebSearchArgs(query="python async") + ) + update = tool_call_session_update(event) + + assert isinstance(update, ToolCallStart) + assert update.field_meta == {"tool_name": "web_search", "query": "python async"} + assert update.kind == "search" + + def test_result_locations_are_source_urls_with_titles(self) -> None: + result = WebSearchResult( + answer="found it", + sources=[ + WebSearchSource(title="Docs", url="https://docs.python.org"), + WebSearchSource(title="Blog", url="https://blog.example.com"), + ], + ) + event = _result_event("web_search", WebSearch, result) + update = tool_result_session_update(event) + + assert isinstance(update, ToolCallProgress) + assert update.locations is not None + assert len(update.locations) == 2 + assert update.locations[0].path == "https://docs.python.org" + assert update.locations[0].field_meta == {"type": "url", "title": "Docs"} + assert update.locations[1].path == "https://blog.example.com" + assert update.locations[1].field_meta == {"type": "url", "title": "Blog"} + + +class TestWebFetchFieldMeta: + def test_call_location_is_normalized_url(self) -> None: + event = _call_event("web_fetch", WebFetch, WebFetchArgs(url="example.com/page")) + update = tool_call_session_update(event) + + assert isinstance(update, ToolCallStart) + assert update.kind == "fetch" + assert update.locations is not None + assert update.locations[0].path == "https://example.com/page" + + def test_result_location_has_char_count_and_truncated(self) -> None: + result = WebFetchResult( + url="https://example.com", + content="hello world", + content_type="text/html", + was_truncated=True, + ) + event = _result_event("web_fetch", WebFetch, result) + update = tool_result_session_update(event) + + assert isinstance(update, ToolCallProgress) + assert update.locations is not None + loc = update.locations[0] + assert loc.path == "https://example.com" + assert loc.field_meta == {"type": "url", "char_count": 11, "truncated": True} + + +class TestSkillFieldMeta: + def test_call_meta_contains_skill_name(self) -> None: + event = _call_event("skill", Skill, SkillArgs(name="debug")) + update = tool_call_session_update(event) + + assert isinstance(update, ToolCallStart) + assert update.field_meta == {"tool_name": "skill", "skill_name": "debug"} + assert update.kind == "read" + + def test_result_has_skill_dir_location(self) -> None: + result = SkillResult( + name="debug", content="do things", skill_dir="/home/user/.vibe/skills/debug" + ) + event = _result_event("skill", Skill, result) + update = tool_result_session_update(event) + + assert isinstance(update, ToolCallProgress) + assert update.locations is not None + assert len(update.locations) == 1 + assert update.locations[0].path == str( + Path("/home/user/.vibe/skills/debug").resolve() + ) + assert update.field_meta == {"tool_name": "skill", "skill_name": "debug"} + + def test_result_no_location_when_no_skill_dir(self) -> None: + result = SkillResult(name="debug", content="do things", skill_dir=None) + event = _result_event("skill", Skill, result) + update = tool_result_session_update(event) + + assert isinstance(update, ToolCallProgress) + assert update.locations is None + + +class TestTaskFieldMeta: + def test_call_meta_contains_agent_and_task(self) -> None: + event = _call_event( + "task", Task, TaskArgs(task="explore codebase", agent="explore") + ) + update = tool_call_session_update(event) + + assert isinstance(update, ToolCallStart) + assert update.field_meta == { + "tool_name": "task", + "agent": "explore", + "task": "explore codebase", + } + + def test_result_meta_contains_turn_count_and_response(self) -> None: + result = TaskResult(response="found 3 files", turns_used=5, completed=True) + event = _result_event("task", Task, result) + update = tool_result_session_update(event) + + assert isinstance(update, ToolCallProgress) + assert update.status == "completed" + assert update.field_meta == { + "tool_name": "task", + "turn_count": 5, + "response": "found 3 files", + } + + def test_result_failed_when_not_completed(self) -> None: + result = TaskResult(response="interrupted", turns_used=2, completed=False) + event = _result_event("task", Task, result) + update = tool_result_session_update(event) + + assert isinstance(update, ToolCallProgress) + assert update.status == "failed" + + +class TestWriteFileFieldMeta: + def test_call_meta_contains_tool_name(self) -> None: + event = _call_event( + "write_file", WriteFile, WriteFileArgs(path="out.txt", content="hello") + ) + update = tool_call_session_update(event) + + assert isinstance(update, ToolCallStart) + assert update.field_meta == {"tool_name": "write_file"} + assert update.kind == "edit" + + def test_call_location_is_resolved_path(self) -> None: + event = _call_event( + "write_file", WriteFile, WriteFileArgs(path="out.txt", content="hello") + ) + update = tool_call_session_update(event) + + assert isinstance(update, ToolCallStart) + assert update.locations is not None + assert update.locations[0].path == str(Path("out.txt").resolve()) + + def test_result_location_is_resolved_path(self) -> None: + result = WriteFileResult( + path="out.txt", content="hello", bytes_written=5, file_existed=False + ) + event = _result_event("write_file", WriteFile, result) + update = tool_result_session_update(event) + + assert isinstance(update, ToolCallProgress) + assert update.locations is not None + assert update.locations[0].path == str(Path("out.txt").resolve()) diff --git a/tests/acp/test_write_file.py b/tests/acp/test_write_file.py index aafa851..f627f7c 100644 --- a/tests/acp/test_write_file.py +++ b/tests/acp/test_write_file.py @@ -211,7 +211,7 @@ class TestAcpWriteFileSessionUpdates: assert update.content[0].new_text == "Hello" assert update.locations is not None assert len(update.locations) == 1 - assert update.locations[0].path == "/tmp/test.txt" + assert update.locations[0].path == str(Path("/tmp/test.txt").resolve()) def test_tool_call_session_update_invalid_args(self) -> None: from vibe.core.types import FunctionCall, ToolCall @@ -232,7 +232,8 @@ class TestAcpWriteFileSessionUpdates: ) update = WriteFile.tool_call_session_update(event) - assert update is None + assert update is not None + assert update.title == "write_file" def test_tool_result_session_update(self) -> None: result = WriteFileResult( @@ -260,7 +261,7 @@ class TestAcpWriteFileSessionUpdates: assert update.content[0].new_text == "Hello" assert update.locations is not None assert len(update.locations) == 1 - assert update.locations[0].path == "/tmp/test.txt" + assert update.locations[0].path == str(Path("/tmp/test.txt").resolve()) def test_tool_result_session_update_invalid_result(self) -> None: class InvalidResult: @@ -274,4 +275,5 @@ class TestAcpWriteFileSessionUpdates: ) update = WriteFile.tool_result_session_update(event) - assert update is None + assert update is not None + assert update.status == "failed" diff --git a/tests/cli/test_connector_auth_app.py b/tests/cli/test_connector_auth_app.py new file mode 100644 index 0000000..d22fba9 --- /dev/null +++ b/tests/cli/test_connector_auth_app.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +from typing import Any, cast +from unittest.mock import MagicMock, patch + +import pytest +from textual.widgets import OptionList + +from tests.stubs.fake_connector_registry import FakeConnectorRegistry +from vibe.cli.textual_ui.widgets.connector_auth_app import ( + ConnectorAuthApp, + _AuthOptionId, +) +from vibe.core.tools.connectors.connector_registry import RemoteTool + + +def _make_registry(*, with_disconnected: bool = True) -> FakeConnectorRegistry: + connectors: dict[str, list[RemoteTool]] = { + "gmail": [RemoteTool(name="search", description="Search emails")] + } + if with_disconnected: + connectors["slack"] = [] + return FakeConnectorRegistry(connectors=connectors) + + +def _make_app( + connector_name: str = "slack", registry: FakeConnectorRegistry | None = None +) -> ConnectorAuthApp: + reg = registry or _make_registry() + mgr = MagicMock() + return ConnectorAuthApp( + connector_name=connector_name, connector_registry=reg, tool_manager=mgr + ) + + +class TestConnectorAuthAppInit: + def test_widget_id(self) -> None: + app = _make_app() + assert app.id == "connectorauth-app" + + def test_stores_connector_name(self) -> None: + app = _make_app(connector_name="gmail") + assert app._connector_name == "gmail" + + def test_initial_state(self) -> None: + app = _make_app() + assert app._auth_url is None + assert app._auth_url_visible is False + assert app._status_message is None + + +class TestAuthUrlFetched: + def test_auth_url_available_shows_menu(self) -> None: + app = _make_app() + option_list = MagicMock() + option_list.get_option_index.return_value = 2 + detail = MagicMock() + help_widget = MagicMock() + + def query(sel, *a, **kw): # type: ignore[no-untyped-def] + if sel is OptionList: + return option_list + s = str(sel) + if "detail" in s: + return detail + if "help" in s: + return help_widget + return MagicMock() + + app.query_one = query # type: ignore[assignment] + + app._on_auth_url_fetched("https://auth.example.com/oauth") + + assert app._auth_url == "https://auth.example.com/oauth" + assert option_list.clear_options.called + assert option_list.add_option.call_count == 5 + option_ids = [ + call.args[0].id + for call in option_list.add_option.call_args_list + if hasattr(call.args[0], "id") and call.args[0].id + ] + assert _AuthOptionId.OPEN in option_ids + assert _AuthOptionId.COPY in option_ids + assert _AuthOptionId.SHOW in option_ids + + def test_auth_url_none_shows_no_auth_message(self) -> None: + app = _make_app() + option_list = MagicMock() + detail = MagicMock() + help_widget = MagicMock() + + def query(sel, *a, **kw): # type: ignore[no-untyped-def] + if sel is OptionList: + return option_list + s = str(sel) + if "detail" in s: + return detail + if "help" in s: + return help_widget + return MagicMock() + + app.query_one = query # type: ignore[assignment] + + app._on_auth_url_fetched(None) + + assert app._auth_url is None + last_option = option_list.add_option.call_args_list[-1].args[0] + assert "does not provide authentication" in str(last_option.prompt) + + +class TestAuthActions: + def test_open_browser_calls_webbrowser(self) -> None: + app = _make_app() + app._auth_url = "https://auth.example.com/oauth" + app.query_one = MagicMock() + + with patch("vibe.cli.textual_ui.widgets.connector_auth_app.webbrowser") as wb: + app._open_browser() + wb.open.assert_called_once_with("https://auth.example.com/oauth") + + assert app._status_message == "Opened in browser." + + def test_open_browser_noop_without_url(self) -> None: + app = _make_app() + app._auth_url = None + with patch("vibe.cli.textual_ui.widgets.connector_auth_app.webbrowser") as wb: + app._open_browser() + wb.open.assert_not_called() + + def test_copy_url_calls_clipboard(self) -> None: + app = cast(Any, _make_app()) + app._auth_url = "https://auth.example.com/oauth" + + with ( + patch.object( + type(app), "app", new_callable=lambda: property(lambda s: MagicMock()) + ), + patch( + "vibe.cli.textual_ui.widgets.connector_auth_app.copy_text_to_clipboard" + ) as copy_fn, + ): + app._copy_url() + copy_fn.assert_called_once() + assert copy_fn.call_args.args[1] == "https://auth.example.com/oauth" + assert ( + copy_fn.call_args.kwargs["success_message"] + == "Auth URL copied to clipboard" + ) + + def test_copy_url_noop_without_url(self) -> None: + app = _make_app() + app._auth_url = None + with patch( + "vibe.cli.textual_ui.widgets.connector_auth_app.copy_text_to_clipboard" + ) as copy_fn: + app._copy_url() + copy_fn.assert_not_called() + + def test_toggle_url_shows_then_hides(self) -> None: + app = cast(Any, _make_app()) + app._auth_url = "https://auth.example.com/oauth" + detail = MagicMock() + app.query_one = lambda sel, *a, **kw: detail + + assert app._auth_url_visible is False + + app._toggle_url() + assert app._auth_url_visible is True + text = detail.update.call_args.args[0] + assert "https://auth.example.com/oauth" in text + assert "Once authenticated" in text + + app._toggle_url() + assert app._auth_url_visible is False + text = detail.update.call_args.args[0] + assert "https://auth.example.com/oauth" not in text + + def test_toggle_url_noop_without_url(self) -> None: + app = _make_app() + app._auth_url = None + app._toggle_url() + assert app._auth_url_visible is False + + +class TestDetailText: + def test_without_url_visible(self) -> None: + app = cast(Any, _make_app()) + app._auth_url = "https://auth.example.com/oauth" + app._auth_url_visible = False + detail = MagicMock() + app.query_one = lambda sel, *a, **kw: detail + + app._update_detail_text() + + text = detail.update.call_args.args[0] + assert "Once authenticated, press R to refresh" in text + assert "https://auth.example.com" not in text + + def test_with_url_visible(self) -> None: + app = cast(Any, _make_app()) + app._auth_url = "https://auth.example.com/oauth" + app._auth_url_visible = True + detail = MagicMock() + app.query_one = lambda sel, *a, **kw: detail + + app._update_detail_text() + + text = detail.update.call_args.args[0] + assert "https://auth.example.com/oauth" in text + assert "Once authenticated, press R to refresh" in text + # URL should come before the footer + url_pos = text.index("https://auth.example.com/oauth") + footer_pos = text.index("Once authenticated") + assert url_pos < footer_pos + + +class TestCloseAndRefresh: + def test_action_close_posts_message(self) -> None: + app = _make_app() + app.post_message = MagicMock() + + app.action_close() + + msg = app.post_message.call_args.args[0] + assert isinstance(msg, ConnectorAuthApp.ConnectorAuthClosed) + assert msg.refreshed is False + + def test_on_connector_refreshed_with_tools_posts_closed(self) -> None: + app = _make_app() + app.post_message = MagicMock() + + app._on_connector_refreshed(3) + + msg = app.post_message.call_args.args[0] + assert isinstance(msg, ConnectorAuthApp.ConnectorAuthClosed) + assert msg.refreshed is True + assert msg.connector_name == "slack" + assert "3 tools" in (app._status_message or "") + + def test_on_connector_refreshed_without_tools_stays_on_page(self) -> None: + app = _make_app() + app.post_message = MagicMock() + app.query_one = MagicMock() + + app._on_connector_refreshed(0) + + app.post_message.assert_not_called() + assert app._status_message is not None + assert "pending" in app._status_message + + @pytest.mark.asyncio + async def test_action_refresh_dispatches_worker(self) -> None: + app = _make_app() + app.run_worker = MagicMock() + app.query_one = MagicMock() + + await app.action_refresh() + + assert app._status_message == "Refreshing connector..." + app.run_worker.assert_called_once() + _, kwargs = app.run_worker.call_args + assert kwargs["group"] == "connector_refresh" + + +class TestOptionSelection: + def test_auth_open_dispatches(self) -> None: + app = _make_app() + app._auth_url = "https://auth.example.com" + app.query_one = MagicMock() + event = MagicMock() + event.option.id = _AuthOptionId.OPEN + + with patch("vibe.cli.textual_ui.widgets.connector_auth_app.webbrowser") as wb: + app.on_option_list_option_selected(event) + wb.open.assert_called_once() + + def test_auth_copy_dispatches(self) -> None: + app = cast(Any, _make_app()) + app._auth_url = "https://auth.example.com" + event = MagicMock() + event.option.id = _AuthOptionId.COPY + + with ( + patch.object( + type(app), "app", new_callable=lambda: property(lambda s: MagicMock()) + ), + patch( + "vibe.cli.textual_ui.widgets.connector_auth_app.copy_text_to_clipboard" + ) as copy_fn, + ): + app.on_option_list_option_selected(event) + copy_fn.assert_called_once() + + def test_auth_show_dispatches(self) -> None: + app = cast(Any, _make_app()) + app._auth_url = "https://auth.example.com" + app.query_one = MagicMock() + event = MagicMock() + event.option.id = _AuthOptionId.SHOW + + app.on_option_list_option_selected(event) + assert app._auth_url_visible is True + + def test_unknown_option_is_noop(self) -> None: + app = _make_app() + event = MagicMock() + event.option.id = "something:else" + # Should not raise + app.on_option_list_option_selected(event) + + +class TestWorkerStateChanged: + def test_auth_url_worker_dispatches(self) -> None: + app = _make_app() + app.query_one = MagicMock() + worker = MagicMock() + worker.group = "auth_url" + worker.is_finished = True + worker.result = "https://auth.example.com" + event = MagicMock() + event.worker = worker + + app.on_worker_state_changed(event) + + assert app._auth_url == "https://auth.example.com" + + def test_connector_refresh_worker_with_tools_posts_closed(self) -> None: + app = _make_app() + app.post_message = MagicMock() + worker = MagicMock() + worker.group = "connector_refresh" + worker.is_finished = True + worker.result = 5 + event = MagicMock() + event.worker = worker + + app.on_worker_state_changed(event) + + msg = app.post_message.call_args.args[0] + assert isinstance(msg, ConnectorAuthApp.ConnectorAuthClosed) + assert msg.refreshed is True + + def test_connector_refresh_worker_without_tools_stays(self) -> None: + app = _make_app() + app.post_message = MagicMock() + app.query_one = MagicMock() + worker = MagicMock() + worker.group = "connector_refresh" + worker.is_finished = True + worker.result = 0 + event = MagicMock() + event.worker = worker + + app.on_worker_state_changed(event) + + app.post_message.assert_not_called() + assert "pending" in (app._status_message or "") + + def test_unrelated_worker_is_ignored(self) -> None: + app = _make_app() + worker = MagicMock() + worker.group = "other" + worker.is_finished = True + event = MagicMock() + event.worker = worker + + # Should not raise + app.on_worker_state_changed(event) diff --git a/tests/cli/test_mcp_app.py b/tests/cli/test_mcp_app.py index 132d1b9..6b9c80e 100644 --- a/tests/cli/test_mcp_app.py +++ b/tests/cli/test_mcp_app.py @@ -8,7 +8,10 @@ import pytest from tests.stubs.fake_connector_registry import FakeConnectorRegistry from vibe.cli.textual_ui.widgets.mcp_app import ( + _LIST_VIEW_HELP_AUTH, + _LIST_VIEW_HELP_TOOLS, MCPApp, + MCPSourceKind, _sort_connector_names_for_menu, collect_mcp_tool_index, ) @@ -258,3 +261,115 @@ class TestConnectorMenuOrdering: ) assert ordered == ["alpha", "Beta", "Zeta"] + + +class TestHelpBarChanges: + """The help bar should show 'Enter Authenticate' for disconnected connectors.""" + + def _make_app_with_connectors(self) -> MCPApp: + registry = FakeConnectorRegistry( + connectors={ + "gmail": [RemoteTool(name="search", description="Search")], + "slack": [], + } + ) + mgr = _make_tool_manager({}) + return MCPApp( + mcp_servers=[MCPStdio(name="srv", transport="stdio", command="cmd")], + tool_manager=mgr, + connector_registry=registry, + ) + + def test_help_shows_show_tools_for_server(self) -> None: + app = self._make_app_with_connectors() + option = MagicMock() + option.id = "server:srv" + assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS + + def test_help_shows_show_tools_for_connected_connector(self) -> None: + app = self._make_app_with_connectors() + option = MagicMock() + option.id = "connector:gmail" + assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS + + def test_help_shows_authenticate_for_disconnected_connector(self) -> None: + app = self._make_app_with_connectors() + option = MagicMock() + option.id = "connector:slack" + assert app._list_help_for_option(option) == _LIST_VIEW_HELP_AUTH + + def test_help_defaults_to_tools_for_unknown_option(self) -> None: + app = self._make_app_with_connectors() + option = MagicMock() + option.id = "" + assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS + + def test_help_shows_tools_without_registry(self) -> None: + mgr = _make_tool_manager({}) + app = MCPApp(mcp_servers=[], tool_manager=mgr) + option = MagicMock() + option.id = "connector:slack" + assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS + + +class TestConnectorAuthRequested: + """Clicking a disconnected connector posts ConnectorAuthRequested.""" + + def test_disconnected_connector_posts_auth_requested(self) -> None: + registry = FakeConnectorRegistry(connectors={"slack": []}) + mgr = _make_tool_manager({}) + app = MCPApp(mcp_servers=[], tool_manager=mgr, connector_registry=registry) + app.query_one = MagicMock() + app.post_message = MagicMock() + + option_list = MagicMock() + app._show_detail_view( + "slack", option_list, app._index, kind=MCPSourceKind.CONNECTOR + ) + + app.post_message.assert_called_once() + msg = app.post_message.call_args.args[0] + assert isinstance(msg, MCPApp.ConnectorAuthRequested) + assert msg.connector_name == "slack" + assert msg.connector_registry is registry + assert msg.tool_manager is mgr + + def test_connected_connector_with_no_indexed_tools_shows_message(self) -> None: + registry = MagicMock() + registry.get_connector_names.return_value = ["slack"] + registry.is_connected.return_value = True + mgr = _make_tool_manager({}) + app = MCPApp(mcp_servers=[], tool_manager=mgr, connector_registry=registry) + app.query_one = MagicMock() + app.post_message = MagicMock() + + option_list = MagicMock() + app._show_detail_view( + "slack", option_list, app._index, kind=MCPSourceKind.CONNECTOR + ) + + app.post_message.assert_not_called() + option_list.add_option.assert_called_once() + assert "No tools discovered" in str( + option_list.add_option.call_args.args[0].prompt + ) + + def test_server_with_no_tools_shows_message(self) -> None: + mgr = _make_tool_manager({}) + app = MCPApp( + mcp_servers=[MCPStdio(name="srv", transport="stdio", command="cmd")], + tool_manager=mgr, + ) + app.query_one = MagicMock() + app.post_message = MagicMock() + + option_list = MagicMock() + app._show_detail_view("srv", option_list, app._index, kind=MCPSourceKind.SERVER) + + # Should NOT post ConnectorAuthRequested for servers + app.post_message.assert_not_called() + # Should add a "no tools" option instead + option_list.add_option.assert_called_once() + assert "No tools discovered" in str( + option_list.add_option.call_args.args[0].prompt + ) diff --git a/tests/core/test_config_patch_ops.py b/tests/core/test_config_patch_ops.py new file mode 100644 index 0000000..c0089d9 --- /dev/null +++ b/tests/core/test_config_patch_ops.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import FrozenInstanceError +from typing import Any, get_args + +import pytest + +from vibe.core.config import ( + AppendToList, + DeleteField, + PatchOp, + RemoveFromList, + SetField, +) + + +def test_patch_op_union_contains_all_operations() -> None: + assert get_args(PatchOp) == (SetField, AppendToList, RemoveFromList, DeleteField) + + +def test_set_field_accepts_top_level_key() -> None: + op = SetField("active_model", "devstral-small") + + assert op.key == "active_model" + assert op.value == "devstral-small" + + +def test_set_field_accepts_nested_key() -> None: + op = SetField("models.providers", {"mistral": {"region": "eu"}}) + + assert op.key == "models.providers" + + +def test_append_to_list_accepts_nested_key() -> None: + op = AppendToList("tools.disabled_tools", ("bash", "python")) + + assert op.key == "tools.disabled_tools" + assert op.items == ("bash", "python") + + +def test_remove_from_list_accepts_nested_key() -> None: + op = RemoveFromList("models.available_models", ("codestral-latest",)) + + assert op.key == "models.available_models" + assert op.values == ("codestral-latest",) + + +def test_delete_field_accepts_nested_key() -> None: + op = DeleteField("tools.deprecated_setting") + + assert op.key == "tools.deprecated_setting" + + +@pytest.mark.parametrize( + "factory", + [ + lambda key: SetField(key, "value"), + lambda key: AppendToList(key, ("value",)), + lambda key: RemoveFromList(key, ("value",)), + lambda key: DeleteField(key), + ], +) +@pytest.mark.parametrize( + "invalid_key", ["", ".active_model", "active_model.", "tools..bash"] +) +def test_patch_operations_reject_invalid_key_paths( + factory: Callable[[str], object], invalid_key: str +) -> None: + with pytest.raises(ValueError, match="dot-separated path|must not be empty"): + factory(invalid_key) + + +@pytest.mark.parametrize( + "factory", + [ + lambda key: SetField(key, "value"), + lambda key: AppendToList(key, ("value",)), + lambda key: RemoveFromList(key, ("value",)), + lambda key: DeleteField(key), + ], +) +def test_patch_operations_reject_non_string_keys( + factory: Callable[[Any], object], +) -> None: + with pytest.raises(TypeError, match="Patch operation key must be a string"): + factory(1) + + +def test_append_to_list_rejects_non_tuple_items() -> None: + bad_items: Any = ["bash"] + + with pytest.raises(TypeError, match="AppendToList.items must be a tuple"): + AppendToList("tools.disabled_tools", bad_items) + + +def test_remove_from_list_rejects_non_tuple_values() -> None: + bad_values: Any = ["bash"] + + with pytest.raises(TypeError, match="RemoveFromList.values must be a tuple"): + RemoveFromList("tools.disabled_tools", bad_values) + + +def test_patch_operations_are_frozen() -> None: + op = SetField("active_model", "devstral-small") + + with pytest.raises(FrozenInstanceError): + op.__setattr__("key", "models.active_model") + + +def test_scenario_mini_vibe_patch_operations() -> None: + operations: list[PatchOp] = [ + SetField("active_model", "devstral-small"), + AppendToList("tools.disabled_tools", ("bash",)), + RemoveFromList("models.available_models", ("codestral-latest",)), + DeleteField("tools.deprecated_setting"), + ] + + assert operations == [ + SetField("active_model", "devstral-small"), + AppendToList("tools.disabled_tools", ("bash",)), + RemoveFromList("models.available_models", ("codestral-latest",)), + DeleteField("tools.deprecated_setting"), + ] diff --git a/tests/core/test_config_resolution.py b/tests/core/test_config_resolution.py index c982dee..973c57d 100644 --- a/tests/core/test_config_resolution.py +++ b/tests/core/test_config_resolution.py @@ -293,6 +293,241 @@ class TestMigrateLeavesFindInBashAllowlist: assert "tools" not in result +class TestMigrateMistralVibeCliLatestDefaults: + def test_updates_alias_temperature_and_thinking_for_default_model( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + config_file = tmp_path / "config.toml" + data = { + "models": [ + { + "name": "mistral-vibe-cli-latest", + "provider": "mistral", + "alias": "devstral-2", + "temperature": 0.2, + "thinking": "off", + } + ] + } + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + reset_harness_files_manager() + init_harness_files_manager("user") + VibeConfig._migrate() + + with config_file.open("rb") as f: + result = tomllib.load(f) + assert result["models"][0]["alias"] == "mistral-medium-3.5" + assert result["models"][0]["temperature"] == 1.0 + assert result["models"][0]["input_price"] == 1.5 + assert result["models"][0]["output_price"] == 7.5 + assert result["models"][0]["thinking"] == "high" + + def test_updates_active_model_when_devstral_2( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + config_file = tmp_path / "config.toml" + data = { + "active_model": "devstral-2", + "models": [ + { + "name": "mistral-vibe-cli-latest", + "provider": "mistral", + "alias": "devstral-2", + } + ], + } + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + reset_harness_files_manager() + init_harness_files_manager("user") + VibeConfig._migrate() + + with config_file.open("rb") as f: + result = tomllib.load(f) + assert result["active_model"] == "mistral-medium-3.5" + + def test_adds_temperature_and_thinking_when_missing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + config_file = tmp_path / "config.toml" + data = { + "models": [ + { + "name": "mistral-vibe-cli-latest", + "provider": "mistral", + "alias": "devstral-2", + } + ] + } + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + reset_harness_files_manager() + init_harness_files_manager("user") + VibeConfig._migrate() + + with config_file.open("rb") as f: + result = tomllib.load(f) + assert result["models"][0]["alias"] == "mistral-medium-3.5" + assert result["models"][0]["temperature"] == 1.0 + assert result["models"][0]["input_price"] == 1.5 + assert result["models"][0]["output_price"] == 7.5 + assert result["models"][0]["thinking"] == "high" + + def test_skips_model_with_customized_alias( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + config_file = tmp_path / "config.toml" + data = { + "models": [ + { + "name": "mistral-vibe-cli-latest", + "provider": "mistral", + "alias": "my-custom-alias", + "temperature": 0.2, + "thinking": "off", + } + ] + } + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + reset_harness_files_manager() + init_harness_files_manager("user") + VibeConfig._migrate() + + with config_file.open("rb") as f: + result = tomllib.load(f) + assert result["models"][0]["alias"] == "my-custom-alias" + assert result["models"][0]["temperature"] == 0.2 + assert result["models"][0]["thinking"] == "off" + + def test_does_not_touch_other_models( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + config_file = tmp_path / "config.toml" + data = { + "models": [ + { + "name": "mistral-vibe-cli-latest", + "provider": "mistral", + "alias": "devstral-2", + }, + { + "name": "other-model", + "provider": "mistral", + "alias": "devstral-2-clone", + "temperature": 0.5, + "thinking": "low", + }, + ] + } + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + reset_harness_files_manager() + init_harness_files_manager("user") + VibeConfig._migrate() + + with config_file.open("rb") as f: + result = tomllib.load(f) + assert result["models"][1]["alias"] == "devstral-2-clone" + assert result["models"][1]["temperature"] == 0.5 + assert result["models"][1]["thinking"] == "low" + + def test_noop_when_no_models_section( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + config_file = tmp_path / "config.toml" + data = {"theme": "dark"} + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + reset_harness_files_manager() + init_harness_files_manager("user") + VibeConfig._migrate() + + with config_file.open("rb") as f: + result = tomllib.load(f) + assert result == {"theme": "dark"} + + def test_idempotent_when_already_migrated( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + config_file = tmp_path / "config.toml" + data = { + "active_model": "mistral-medium-3.5", + "models": [ + { + "name": "mistral-vibe-cli-latest", + "provider": "mistral", + "alias": "mistral-medium-3.5", + "temperature": 1.0, + "input_price": 1.5, + "output_price": 7.5, + "thinking": "high", + } + ], + } + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + reset_harness_files_manager() + init_harness_files_manager("user") + VibeConfig._migrate() + VibeConfig._migrate() + + with config_file.open("rb") as f: + result = tomllib.load(f) + assert result["active_model"] == "mistral-medium-3.5" + assert result["models"][0]["alias"] == "mistral-medium-3.5" + assert result["models"][0]["temperature"] == 1.0 + assert result["models"][0]["input_price"] == 1.5 + assert result["models"][0]["output_price"] == 7.5 + assert result["models"][0]["thinking"] == "high" + + def test_migrates_model_and_active_model_together( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + config_file = tmp_path / "config.toml" + data = { + "active_model": "devstral-2", + "models": [ + { + "name": "mistral-vibe-cli-latest", + "provider": "mistral", + "alias": "devstral-2", + } + ], + } + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + reset_harness_files_manager() + init_harness_files_manager("user") + VibeConfig._migrate() + + with config_file.open("rb") as f: + result = tomllib.load(f) + assert result["active_model"] == "mistral-medium-3.5" + assert result["models"][0]["alias"] == "mistral-medium-3.5" + assert result["models"][0]["temperature"] == 1.0 + assert result["models"][0]["input_price"] == 1.5 + assert result["models"][0]["output_price"] == 7.5 + assert result["models"][0]["thinking"] == "high" + + class TestAutoCompactThresholdFallback: def test_model_without_explicit_threshold_inherits_global(self) -> None: model = ModelConfig(name="m", provider="p", alias="m") diff --git a/tests/core/test_teleport_service.py b/tests/core/test_teleport_service.py index e6cd8dc..a8e1998 100644 --- a/tests/core/test_teleport_service.py +++ b/tests/core/test_teleport_service.py @@ -357,6 +357,7 @@ class TestTeleportServiceExecute: github_pending = MagicMock() github_pending.connected = False github_pending.oauth_url = "https://github.com/login/oauth" + github_pending.error = None github_pending.status = GitHubStatus.WAITING_FOR_OAUTH github_connected = MagicMock() diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_back_to_mcp.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_back_to_mcp.svg new file mode 100644 index 0000000..926cabd --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_back_to_mcp.svg @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppConnectorsMixedState + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 3 connectors · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers & Connectors + +Workspace Connectors +  alpha  [connector]  1 tool   connected +  beta   [connector]  no tools not connected +  zeta   [connector]  no tools not connected +↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_opens_on_disconnected.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_opens_on_disconnected.svg new file mode 100644 index 0000000..9d60d6e --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_opens_on_disconnected.svg @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppConnectorsMixedState + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 3 connectors · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +Connector: beta + +This connector requires authentication + +  Press enter to open auth in your browser +  Copy URL to clipboard +  Manually show the URL + +Once authenticated, press R to refresh + +Backspace Back +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_show_url.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_show_url.svg new file mode 100644 index 0000000..09bc1f9 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_show_url.svg @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppConnectorsMixedState + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 3 connectors · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +Connector: beta + +This connector requires authentication + +  Press enter to open auth in your browser +  Copy URL to clipboard +  Manually show the URL + +https://fake-auth.example.com/beta + +Once authenticated, press R to refresh + +Backspace Back +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + 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 1a2971c..687a88f 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 @@ -162,7 +162,7 @@ - + @@ -181,22 +181,22 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [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 - -Local MCP Servers -  filesystem  [stdio]  1 tool -  search      [http]   1 tool - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem  [stdio]  1 tool +  search      [http]   1 tool ↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0% of 200k tokens 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 6f20c7a..04a3ed6 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 @@ -162,7 +162,7 @@ - + @@ -180,23 +180,23 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information -/mcp -MCP servers opened... - + +/mcp +MCP servers opened... -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -Local MCP Servers -  filesystem     [stdio]  1 tool -  broken-server  [stdio]  no tools -  search         [http]   1 tool - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem     [stdio]  1 tool +  broken-server  [stdio]  no tools +  search         [http]   1 tool ↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0% of 200k tokens 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 index 3b4c923..7a3c667 100644 --- 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 @@ -163,7 +163,7 @@ - + @@ -179,25 +179,25 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information -/mcp -MCP servers opened... - + +/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 - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +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  D Disable  E Enable  R Refresh  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 index fe45260..3cd6643 100644 --- 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 @@ -164,7 +164,7 @@ - + @@ -183,22 +183,22 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information -/mcp -MCP servers opened... - + +/mcp +MCP servers opened... -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers & Connectors - -Workspace Connectors -  gmail  [connector]  3 tools connected -  slack  [connector]  2 tools connected - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers & Connectors + +Workspace Connectors +  gmail  [connector]  3 tools connected +  slack  [connector]  2 tools connected ↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg index bb69b8d..1f59f1e 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg @@ -163,7 +163,7 @@ - + @@ -181,23 +181,23 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 3 connectors · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 3 connectors · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information -/mcp -MCP servers opened... - + +/mcp +MCP servers opened... -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers & Connectors - -Workspace Connectors -  alpha  [connector]  1 tool   connected -  beta   [connector]  no tools not connected -  zeta   [connector]  no tools not connected - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers & Connectors + +Workspace Connectors +  alpha  [connector]  1 tool   connected +  beta   [connector]  no tools not connected +  zeta   [connector]  no tools not connected ↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  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 index 59f7d9f..4d85cf0 100644 --- 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 @@ -160,7 +160,7 @@ - + @@ -180,21 +180,21 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information -/mcp -MCP servers opened... - + +/mcp +MCP servers opened... -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -Connector: slack - -search_messages  -  Search Slack messages -send_message  -  Send a Slack message - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +Connector: slack + +search_messages  -  Search Slack messages +send_message  -  Send a Slack message ↑↓ Navigate  D Disable  E Enable  Backspace Back  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg index 16b75c9..43212c7 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg @@ -160,7 +160,7 @@ - + @@ -181,20 +181,20 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information -/mcp -MCP servers opened... - + +/mcp +MCP servers opened... -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Server: filesystem - -fake_tool  -  A fake tool for filesystem - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Server: filesystem + +fake_tool  -  A fake tool for filesystem ↑↓ Navigate  D Disable  E Enable  Backspace Back  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_authenticate.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_authenticate.svg new file mode 100644 index 0000000..802fa4c --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_authenticate.svg @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppConnectorsMixedState + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 3 connectors · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers & Connectors + +Workspace Connectors +  alpha  [connector]  1 tool   connected +  beta   [connector]  no tools not connected +  zeta   [connector]  no tools not connected +↑↓ Navigate  Enter Authenticate  D Disable  E Enable  R Refresh  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 70f1944..48cd5ea 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 @@ -162,7 +162,7 @@ - + @@ -181,22 +181,22 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information -/mcp -MCP servers opened... - + +/mcp +MCP servers opened... -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -Local MCP Servers -  filesystem  [stdio]  1 tool -  search      [http]   1 tool - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem  [stdio]  1 tool +  search      [http]   1 tool ↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0% of 200k tokens 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 5694c37..26ecc77 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 @@ -162,7 +162,7 @@ - + @@ -181,22 +181,22 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information -/mcp -MCP servers opened... - + +/mcp +MCP servers opened... -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -Local MCP Servers -  filesystem  [stdio]  1 tool -  search      [http]   1 tool - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem  [stdio]  1 tool +  search      [http]   1 tool ↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg index 25716a9..1327316 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg @@ -162,7 +162,7 @@ - + @@ -181,22 +181,22 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information -/mcp -MCP servers opened... - + +/mcp +MCP servers opened... -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -Local MCP Servers -  filesystem  [stdio]  1 tool -  search      [http]   1 tool - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem  [stdio]  1 tool +  search      [http]   1 tool Refreshed.  ↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg index 703996f..27b442e 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg @@ -160,7 +160,7 @@ - + @@ -181,20 +181,20 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [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 Server: filesystem - -fake_tool  -  A fake tool for filesystem - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Server: filesystem + +fake_tool  -  A fake tool for filesystem ↑↓ Navigate  D Disable  E Enable  Backspace Back  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0% of 200k tokens 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 index 3b4c923..7a3c667 100644 --- 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 @@ -163,7 +163,7 @@ - + @@ -179,25 +179,25 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information -/mcp -MCP servers opened... - + +/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 - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +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  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /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 e77c771..a89cb35 100644 --- a/tests/snapshots/test_ui_snapshot_mcp_command.py +++ b/tests/snapshots/test_ui_snapshot_mcp_command.py @@ -253,6 +253,93 @@ def test_snapshot_mcp_with_connectors_overview(snap_compare: SnapCompare) -> Non ) +# --------------------------------------------------------------------------- +# Connector auth app snapshot tests +# --------------------------------------------------------------------------- + + +@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"}) +def test_snapshot_connector_auth_opens_on_disconnected( + snap_compare: SnapCompare, +) -> None: + """Clicking a disconnected connector opens the auth app.""" + + async def run_before(pilot: Pilot) -> None: + await _run_mcp_command(pilot, "/mcp") + # In mixed state: alpha (connected) is first, then beta, zeta (disconnected). + # Navigate to a disconnected connector and press enter. + await pilot.press("down") # beta + await pilot.pause(0.1) + await pilot.press("enter") # opens auth app + await pilot.pause(0.5) # wait for auth URL fetch worker + + assert snap_compare( + "test_ui_snapshot_mcp_command.py:SnapshotTestAppConnectorsMixedState", + terminal_size=(120, 36), + run_before=run_before, + ) + + +@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"}) +def test_snapshot_connector_auth_show_url(snap_compare: SnapCompare) -> None: + """Selecting 'Manually show the URL' reveals the auth URL.""" + + async def run_before(pilot: Pilot) -> None: + await _run_mcp_command(pilot, "/mcp") + await pilot.press("down") # beta (disconnected) + await pilot.pause(0.1) + await pilot.press("enter") # opens auth app + await pilot.pause(0.5) # wait for auth URL fetch + # Navigate to "Manually show the URL" (3rd selectable option) + await pilot.press("down", "down") + await pilot.pause(0.1) + await pilot.press("enter") # toggle URL display + await pilot.pause(0.1) + + assert snap_compare( + "test_ui_snapshot_mcp_command.py:SnapshotTestAppConnectorsMixedState", + terminal_size=(120, 36), + run_before=run_before, + ) + + +@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"}) +def test_snapshot_connector_auth_back_to_mcp(snap_compare: SnapCompare) -> None: + """Pressing backspace in the auth app returns to the /mcp menu.""" + + async def run_before(pilot: Pilot) -> None: + await _run_mcp_command(pilot, "/mcp") + await pilot.press("down") # beta (disconnected) + await pilot.pause(0.1) + await pilot.press("enter") # opens auth app + await pilot.pause(0.5) # wait for auth URL fetch + await pilot.press("backspace") # back to /mcp + await pilot.pause(0.3) + + assert snap_compare( + "test_ui_snapshot_mcp_command.py:SnapshotTestAppConnectorsMixedState", + terminal_size=(120, 36), + run_before=run_before, + ) + + +@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"}) +def test_snapshot_mcp_help_bar_shows_authenticate(snap_compare: SnapCompare) -> None: + """Help bar shows 'Enter Authenticate' when a disconnected connector is highlighted.""" + + async def run_before(pilot: Pilot) -> None: + await _run_mcp_command(pilot, "/mcp") + # Navigate to a disconnected connector (beta or zeta) + await pilot.press("down") # beta (disconnected) + await pilot.pause(0.1) + + assert snap_compare( + "test_ui_snapshot_mcp_command.py:SnapshotTestAppConnectorsMixedState", + 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: diff --git a/tests/stubs/fake_connector_registry.py b/tests/stubs/fake_connector_registry.py index 610a87b..f03749b 100644 --- a/tests/stubs/fake_connector_registry.py +++ b/tests/stubs/fake_connector_registry.py @@ -21,9 +21,11 @@ class FakeConnectorRegistry(ConnectorRegistry): self._cache = {} self._connector_names = [] self._connector_connected = {} + self._alias_to_id = {} for connector_name, tools in self._fake_connectors.items(): alias = _normalize_name(connector_name) connector_id = f"fake-id-{connector_name}" + self._alias_to_id[alias] = connector_id tool_map: dict[str, type[BaseTool]] = {} for remote in tools: proxy_cls = create_connector_proxy_tool_class( @@ -50,3 +52,13 @@ class FakeConnectorRegistry(ConnectorRegistry): async def get_tools_async(self) -> dict[str, type[BaseTool]]: return self.get_tools() + + async def get_auth_url(self, alias: str) -> str | None: + """Return a fake auth URL for connectors that have no tools (not connected).""" + if not self.is_connected(alias): + return f"https://fake-auth.example.com/{alias}" + return None + + async def refresh_connector_async(self, alias: str) -> dict[str, type[BaseTool]]: + """No-op refresh for tests.""" + return {} diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py index 68af45b..49db440 100644 --- a/tests/test_agent_backend.py +++ b/tests/test_agent_backend.py @@ -400,3 +400,28 @@ async def test_generic_provider_has_no_metadata_header() -> None: headers = backend.requests_extra_headers[0] assert headers is not None assert "metadata" not in headers + + +@pytest.mark.asyncio +async def test_provider_extra_headers_are_forwarded() -> None: + backend = FakeBackend([mock_llm_chunk(content="Response")]) + providers = [ + ProviderConfig( + name="custom", + api_base="https://custom.example.com/v1", + extra_headers={"X-Custom-Auth": "token123", "X-Org-Id": "org-456"}, + ) + ] + models = [ModelConfig(name="test-model", provider="custom", alias="test")] + config = build_test_vibe_config( + active_model="test", models=models, providers=providers + ) + agent = build_test_agent_loop(config=config, backend=backend) + + [_ async for _ in agent.act("Hello")] + + assert len(backend.requests_extra_headers) == 1 + headers = backend.requests_extra_headers[0] + assert headers is not None + assert headers["X-Custom-Auth"] == "token123" + assert headers["X-Org-Id"] == "org-456" diff --git a/uv.lock b/uv.lock index 6c78cfa..38c1221 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.12" [options] -exclude-newer = "2026-04-21T14:17:22.54525Z" +exclude-newer = "2026-04-22T13:12:26.10035Z" exclude-newer-span = "P7D" [options.exclude-newer-package] @@ -808,7 +808,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.9.0" +version = "2.9.1" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, diff --git a/vibe/__init__.py b/vibe/__init__.py index 3caa50d..db20725 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.9.0" +__version__ = "2.9.1" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index 6e92d6d..3a02fd8 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -367,7 +367,7 @@ class VibeAcpAgentLoop(AcpAgent): ) def _get_acp_tool_overrides(self) -> list[Path]: - overrides = ["todo"] + overrides = ["todo", "grep", "web_fetch", "web_search", "skill", "task"] if self.client_capabilities: if self.client_capabilities.terminal: @@ -399,6 +399,9 @@ class VibeAcpAgentLoop(AcpAgent): session.agent_loop.approve_always(tool_name, required_permissions) return (ApprovalResponse.YES, None) case ToolOption.REJECT_ONCE: + session.agent_loop.telemetry_client.send_user_cancelled_action( + "reject_approval" + ) return ( ApprovalResponse.NO, "User rejected the tool call, provide an alternative plan", @@ -946,6 +949,9 @@ class VibeAcpAgentLoop(AcpAgent): @override async def cancel(self, session_id: str, **kwargs: Any) -> None: session = self._get_session(session_id) + session.agent_loop.telemetry_client.send_user_cancelled_action( + "interrupt_agent" + ) await session.cancel_prompt() @override diff --git a/vibe/acp/tools/builtins/bash.py b/vibe/acp/tools/builtins/bash.py index 576db16..bcdee5b 100644 --- a/vibe/acp/tools/builtins/bash.py +++ b/vibe/acp/tools/builtins/bash.py @@ -5,7 +5,9 @@ from collections.abc import AsyncGenerator from pathlib import Path from acp.schema import ( + ContentToolCallContent, TerminalToolCallContent, + TextContentBlock, ToolCallProgress, ToolCallStart, WaitForTerminalExitResponse, @@ -13,6 +15,7 @@ from acp.schema import ( from vibe import VIBE_ROOT from vibe.acp.tools.base import AcpToolState, BaseAcpTool +from vibe.acp.tools.session_update import resolve_kind from vibe.core.logger import logger from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError from vibe.core.tools.builtins.bash import Bash as CoreBashTool, BashArgs, BashResult @@ -116,9 +119,10 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]): session_update="tool_call", title="bash", tool_call_id=event.tool_call_id, - kind="execute", + kind=resolve_kind(event.tool_name), content=None, raw_input=None, + field_meta={"tool_name": event.tool_name}, ) if not isinstance(event.args, BashArgs): raise ValueError(f"Unexpected tool args: {event.args}") @@ -128,8 +132,9 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]): title=Bash.get_summary(event.args), content=None, tool_call_id=event.tool_call_id, - kind="execute", + kind=resolve_kind(event.tool_name), raw_input=event.args.model_dump_json(), + field_meta={"tool_name": event.tool_name}, ) @classmethod @@ -140,4 +145,14 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]): session_update="tool_call_update", tool_call_id=event.tool_call_id, status="failed" if event.error else "completed", + content=[ + ContentToolCallContent( + type="content", + content=TextContentBlock( + type="text", text=cls.get_result_display(event).message + ), + ) + ], + kind=resolve_kind(event.tool_name), + field_meta={"tool_name": event.tool_name}, ) diff --git a/vibe/acp/tools/builtins/grep.py b/vibe/acp/tools/builtins/grep.py new file mode 100644 index 0000000..3e06a73 --- /dev/null +++ b/vibe/acp/tools/builtins/grep.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from pathlib import Path + +from acp.helpers import SessionUpdate +from acp.schema import ( + ContentToolCallContent, + TextContentBlock, + ToolCallLocation, + ToolCallProgress, + ToolCallStart, +) + +from vibe import VIBE_ROOT +from vibe.acp.tools.base import ( + ToolCallSessionUpdateProtocol, + ToolResultSessionUpdateProtocol, +) +from vibe.acp.tools.session_update import ( + failed_tool_result, + fallback_tool_call, + resolve_kind, +) +from vibe.core.tools.builtins.grep import Grep as CoreGrepTool, GrepArgs, GrepResult +from vibe.core.types import ToolCallEvent, ToolResultEvent + + +class Grep( + CoreGrepTool, ToolCallSessionUpdateProtocol, ToolResultSessionUpdateProtocol +): + prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "grep.md" + + @classmethod + def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None: + if not isinstance(event.args, GrepArgs): + return fallback_tool_call(event, "grep") + + search_path = str(Path(event.args.path).resolve()) + + return ToolCallStart( + session_update="tool_call", + title=cls.get_call_display(event).summary, + tool_call_id=event.tool_call_id, + kind=resolve_kind(event.tool_name), + raw_input=event.args.model_dump_json(), + locations=[ToolCallLocation(path=search_path)], + field_meta={"tool_name": event.tool_name, "query": event.args.pattern}, + ) + + @classmethod + def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None: + if failure := failed_tool_result(event, GrepResult): + return failure + + result = event.result + assert isinstance(result, GrepResult) + + locations = [ + ToolCallLocation(path=m.path, line=m.line) for m in result.parsed_matches + ] + + return ToolCallProgress( + session_update="tool_call_update", + tool_call_id=event.tool_call_id, + status="completed", + content=[ + ContentToolCallContent( + type="content", + content=TextContentBlock( + type="text", text=cls.get_result_display(event).message + ), + ) + ], + kind=resolve_kind(event.tool_name), + raw_output=result.model_dump_json(), + locations=locations if locations else None, + field_meta={"tool_name": event.tool_name}, + ) diff --git a/vibe/acp/tools/builtins/read_file.py b/vibe/acp/tools/builtins/read_file.py index af20697..66b017f 100644 --- a/vibe/acp/tools/builtins/read_file.py +++ b/vibe/acp/tools/builtins/read_file.py @@ -2,8 +2,27 @@ from __future__ import annotations from pathlib import Path +from acp.helpers import SessionUpdate +from acp.schema import ( + ContentToolCallContent, + TextContentBlock, + ToolCallLocation, + ToolCallProgress, + ToolCallStart, +) + from vibe import VIBE_ROOT -from vibe.acp.tools.base import AcpToolState, BaseAcpTool +from vibe.acp.tools.base import ( + AcpToolState, + BaseAcpTool, + ToolCallSessionUpdateProtocol, + ToolResultSessionUpdateProtocol, +) +from vibe.acp.tools.session_update import ( + failed_tool_result, + fallback_tool_call, + resolve_kind, +) from vibe.core.tools.base import ToolError from vibe.core.tools.builtins.read_file import ( ReadFile as CoreReadFileTool, @@ -12,6 +31,7 @@ from vibe.core.tools.builtins.read_file import ( ReadFileState, _ReadResult, ) +from vibe.core.types import ToolCallEvent, ToolResultEvent ReadFileResult = ReadFileResult @@ -20,7 +40,12 @@ class AcpReadFileState(ReadFileState, AcpToolState): pass -class ReadFile(CoreReadFileTool, BaseAcpTool[AcpReadFileState]): +class ReadFile( + CoreReadFileTool, + BaseAcpTool[AcpReadFileState], + ToolCallSessionUpdateProtocol, + ToolResultSessionUpdateProtocol, +): state: AcpReadFileState prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "read_file.md" @@ -28,6 +53,69 @@ class ReadFile(CoreReadFileTool, BaseAcpTool[AcpReadFileState]): def _get_tool_state_class(cls) -> type[AcpReadFileState]: return AcpReadFileState + @classmethod + def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None: + if not isinstance(event.args, ReadFileArgs): + return fallback_tool_call(event, "read_file") + + resolved = str(Path(event.args.path).resolve()) + + return ToolCallStart( + session_update="tool_call", + title=cls.format_call_display(event.args).summary, + tool_call_id=event.tool_call_id, + kind=resolve_kind(event.tool_name), + raw_input=event.args.model_dump_json(), + locations=[ + ToolCallLocation( + path=resolved, + field_meta={ + "type": "file_range", + "offset": event.args.offset, + "limit": event.args.limit, + }, + ) + ], + field_meta={"tool_name": event.tool_name}, + ) + + @classmethod + def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None: + if failure := failed_tool_result(event, ReadFileResult): + return failure + + result = event.result + assert isinstance(result, ReadFileResult) + resolved = str(Path(result.path).resolve()) + locations = [ + ToolCallLocation( + path=resolved, + field_meta={ + "type": "file_range", + "offset": result.offset, + "limit": result.lines_read, + }, + ) + ] + + return ToolCallProgress( + session_update="tool_call_update", + tool_call_id=event.tool_call_id, + status="completed", + content=[ + ContentToolCallContent( + type="content", + content=TextContentBlock( + type="text", text=cls.get_result_display(event).message + ), + ) + ], + kind=resolve_kind(event.tool_name), + raw_output=result.model_dump_json(), + locations=locations, + field_meta={"tool_name": event.tool_name}, + ) + async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult: client, session_id, _ = self._load_state() diff --git a/vibe/acp/tools/builtins/search_replace.py b/vibe/acp/tools/builtins/search_replace.py index f8557be..acb06ce 100644 --- a/vibe/acp/tools/builtins/search_replace.py +++ b/vibe/acp/tools/builtins/search_replace.py @@ -12,6 +12,11 @@ from acp.schema import ( from vibe import VIBE_ROOT from vibe.acp.tools.base import AcpToolState, BaseAcpTool +from vibe.acp.tools.session_update import ( + failed_tool_result, + fallback_tool_call, + resolve_kind, +) from vibe.core.tools.base import BaseToolState, ToolError from vibe.core.tools.builtins.search_replace import ( SearchReplace as CoreSearchReplaceTool, @@ -75,18 +80,10 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]): @classmethod def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None: + if not isinstance(event.args, SearchReplaceArgs): + return fallback_tool_call(event, "search_replace") + args = event.args - if args is None: - return ToolCallStart( - session_update="tool_call", - title="search_replace", - tool_call_id=event.tool_call_id, - kind="edit", - content=None, - raw_input=None, - ) - if not isinstance(args, SearchReplaceArgs): - return None blocks = cls._parse_search_replace_blocks(args.content) @@ -94,7 +91,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]): session_update="tool_call", title=cls.get_call_display(event).summary, tool_call_id=event.tool_call_id, - kind="edit", + kind=resolve_kind(event.tool_name), content=[ FileEditToolCallContent( type="diff", @@ -104,22 +101,18 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]): ) for block in blocks ], - locations=[ToolCallLocation(path=args.file_path)], + locations=[ToolCallLocation(path=str(Path(args.file_path).resolve()))], raw_input=args.model_dump_json(), + field_meta={"tool_name": event.tool_name}, ) @classmethod def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None: - if event.error: - return ToolCallProgress( - session_update="tool_call_update", - tool_call_id=event.tool_call_id, - status="failed", - ) + if failure := failed_tool_result(event, SearchReplaceResult): + return failure result = event.result - if not isinstance(result, SearchReplaceResult): - return None + assert isinstance(result, SearchReplaceResult) blocks = cls._parse_search_replace_blocks(result.content) @@ -127,6 +120,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]): session_update="tool_call_update", tool_call_id=event.tool_call_id, status="completed", + kind=resolve_kind(event.tool_name), content=[ FileEditToolCallContent( type="diff", @@ -136,6 +130,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]): ) for block in blocks ], - locations=[ToolCallLocation(path=result.file)], + locations=[ToolCallLocation(path=str(Path(result.file).resolve()))], raw_output=result.model_dump_json(), + field_meta={"tool_name": event.tool_name}, ) diff --git a/vibe/acp/tools/builtins/skill.py b/vibe/acp/tools/builtins/skill.py new file mode 100644 index 0000000..966dc4a --- /dev/null +++ b/vibe/acp/tools/builtins/skill.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from pathlib import Path + +from acp.helpers import SessionUpdate +from acp.schema import ( + ContentToolCallContent, + TextContentBlock, + ToolCallLocation, + ToolCallProgress, + ToolCallStart, +) + +from vibe import VIBE_ROOT +from vibe.acp.tools.base import ( + ToolCallSessionUpdateProtocol, + ToolResultSessionUpdateProtocol, +) +from vibe.acp.tools.session_update import ( + failed_tool_result, + fallback_tool_call, + resolve_kind, +) +from vibe.core.tools.builtins.skill import ( + Skill as CoreSkillTool, + SkillArgs, + SkillResult, +) +from vibe.core.types import ToolCallEvent, ToolResultEvent + + +class Skill( + CoreSkillTool, ToolCallSessionUpdateProtocol, ToolResultSessionUpdateProtocol +): + prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "skill.md" + + @classmethod + def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None: + if not isinstance(event.args, SkillArgs): + return fallback_tool_call(event, "skill") + + return ToolCallStart( + session_update="tool_call", + title=cls.get_call_display(event).summary, + tool_call_id=event.tool_call_id, + kind=resolve_kind(event.tool_name), + raw_input=event.args.model_dump_json(), + field_meta={"tool_name": event.tool_name, "skill_name": event.args.name}, + ) + + @classmethod + def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None: + if failure := failed_tool_result(event, SkillResult): + return failure + + result = event.result + assert isinstance(result, SkillResult) + + locations: list[ToolCallLocation] | None = None + if result.skill_dir: + locations = [ToolCallLocation(path=str(Path(result.skill_dir).resolve()))] + + return ToolCallProgress( + session_update="tool_call_update", + tool_call_id=event.tool_call_id, + status="completed", + content=[ + ContentToolCallContent( + type="content", + content=TextContentBlock( + type="text", text=cls.get_result_display(event).message + ), + ) + ], + kind=resolve_kind(event.tool_name), + raw_output=result.model_dump_json(), + locations=locations, + field_meta={"tool_name": event.tool_name, "skill_name": result.name}, + ) diff --git a/vibe/acp/tools/builtins/task.py b/vibe/acp/tools/builtins/task.py new file mode 100644 index 0000000..05eafd7 --- /dev/null +++ b/vibe/acp/tools/builtins/task.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from acp.helpers import SessionUpdate +from acp.schema import ( + ContentToolCallContent, + TextContentBlock, + ToolCallProgress, + ToolCallStart, +) + +from vibe import VIBE_ROOT +from vibe.acp.tools.base import ( + ToolCallSessionUpdateProtocol, + ToolResultSessionUpdateProtocol, +) +from vibe.acp.tools.session_update import ( + failed_tool_result, + fallback_tool_call, + resolve_kind, +) +from vibe.core.tools.builtins.task import Task as CoreTaskTool, TaskArgs, TaskResult +from vibe.core.types import ToolCallEvent, ToolResultEvent + + +class Task( + CoreTaskTool, ToolCallSessionUpdateProtocol, ToolResultSessionUpdateProtocol +): + prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "task.md" + + @classmethod + def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None: + if not isinstance(event.args, TaskArgs): + return fallback_tool_call(event, "task") + + return ToolCallStart( + session_update="tool_call", + title=cls.get_call_display(event).summary, + tool_call_id=event.tool_call_id, + kind=resolve_kind(event.tool_name), + raw_input=event.args.model_dump_json(), + field_meta={ + "tool_name": event.tool_name, + "agent": event.args.agent, + "task": event.args.task, + }, + ) + + @classmethod + def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None: + if failure := failed_tool_result(event, TaskResult): + return failure + + result = event.result + assert isinstance(result, TaskResult) + + return ToolCallProgress( + session_update="tool_call_update", + tool_call_id=event.tool_call_id, + status="completed" if result.completed else "failed", + content=[ + ContentToolCallContent( + type="content", + content=TextContentBlock( + type="text", text=cls.get_result_display(event).message + ), + ) + ], + kind=resolve_kind(event.tool_name), + raw_output=result.model_dump_json(), + field_meta={ + "tool_name": event.tool_name, + "turn_count": result.turns_used, + "response": result.response, + }, + ) diff --git a/vibe/acp/tools/builtins/web_fetch.py b/vibe/acp/tools/builtins/web_fetch.py new file mode 100644 index 0000000..25ba9e4 --- /dev/null +++ b/vibe/acp/tools/builtins/web_fetch.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from acp.helpers import SessionUpdate +from acp.schema import ( + ContentToolCallContent, + TextContentBlock, + ToolCallLocation, + ToolCallProgress, + ToolCallStart, +) + +from vibe import VIBE_ROOT +from vibe.acp.tools.base import ( + ToolCallSessionUpdateProtocol, + ToolResultSessionUpdateProtocol, +) +from vibe.acp.tools.session_update import ( + failed_tool_result, + fallback_tool_call, + resolve_kind, +) +from vibe.core.tools.builtins.webfetch import ( + WebFetch as CoreWebFetchTool, + WebFetchArgs, + WebFetchResult, +) +from vibe.core.types import ToolCallEvent, ToolResultEvent + + +class WebFetch( + CoreWebFetchTool, ToolCallSessionUpdateProtocol, ToolResultSessionUpdateProtocol +): + prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "webfetch.md" + + @classmethod + def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None: + if not isinstance(event.args, WebFetchArgs): + return fallback_tool_call(event, "web_fetch") + + url = cls._normalize_url(event.args.url) + + return ToolCallStart( + session_update="tool_call", + title=cls.get_call_display(event).summary, + tool_call_id=event.tool_call_id, + kind=resolve_kind(event.tool_name), + raw_input=event.args.model_dump_json(), + locations=[ToolCallLocation(path=url, field_meta={"type": "url"})], + field_meta={"tool_name": event.tool_name}, + ) + + @classmethod + def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None: + if failure := failed_tool_result(event, WebFetchResult): + return failure + + result = event.result + assert isinstance(result, WebFetchResult) + + return ToolCallProgress( + session_update="tool_call_update", + tool_call_id=event.tool_call_id, + status="completed", + content=[ + ContentToolCallContent( + type="content", + content=TextContentBlock( + type="text", text=cls.get_result_display(event).message + ), + ) + ], + kind=resolve_kind(event.tool_name), + raw_output=result.model_dump_json(), + locations=[ + ToolCallLocation( + path=result.url, + field_meta={ + "type": "url", + "char_count": len(result.content), + "truncated": result.was_truncated, + }, + ) + ], + field_meta={"tool_name": event.tool_name}, + ) diff --git a/vibe/acp/tools/builtins/web_search.py b/vibe/acp/tools/builtins/web_search.py new file mode 100644 index 0000000..c6a0b5f --- /dev/null +++ b/vibe/acp/tools/builtins/web_search.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from acp.helpers import SessionUpdate +from acp.schema import ( + ContentToolCallContent, + TextContentBlock, + ToolCallLocation, + ToolCallProgress, + ToolCallStart, +) + +from vibe import VIBE_ROOT +from vibe.acp.tools.base import ( + ToolCallSessionUpdateProtocol, + ToolResultSessionUpdateProtocol, +) +from vibe.acp.tools.session_update import ( + failed_tool_result, + fallback_tool_call, + resolve_kind, +) +from vibe.core.tools.builtins.websearch import ( + WebSearch as CoreWebSearchTool, + WebSearchArgs, + WebSearchResult, +) +from vibe.core.types import ToolCallEvent, ToolResultEvent + + +class WebSearch( + CoreWebSearchTool, ToolCallSessionUpdateProtocol, ToolResultSessionUpdateProtocol +): + prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "websearch.md" + + @classmethod + def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None: + if not isinstance(event.args, WebSearchArgs): + return fallback_tool_call(event, "web_search") + + return ToolCallStart( + session_update="tool_call", + title=cls.get_call_display(event).summary, + tool_call_id=event.tool_call_id, + kind=resolve_kind(event.tool_name), + raw_input=event.args.model_dump_json(), + field_meta={"tool_name": event.tool_name, "query": event.args.query}, + ) + + @classmethod + def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None: + if failure := failed_tool_result(event, WebSearchResult): + return failure + + result = event.result + assert isinstance(result, WebSearchResult) + + locations = [ + ToolCallLocation( + path=source.url, field_meta={"type": "url", "title": source.title} + ) + for source in result.sources + ] + + return ToolCallProgress( + session_update="tool_call_update", + tool_call_id=event.tool_call_id, + status="completed", + content=[ + ContentToolCallContent( + type="content", + content=TextContentBlock( + type="text", text=cls.get_result_display(event).message + ), + ) + ], + kind=resolve_kind(event.tool_name), + raw_output=result.model_dump_json(), + locations=locations if locations else None, + field_meta={"tool_name": event.tool_name}, + ) diff --git a/vibe/acp/tools/builtins/write_file.py b/vibe/acp/tools/builtins/write_file.py index 97fcbcf..f254544 100644 --- a/vibe/acp/tools/builtins/write_file.py +++ b/vibe/acp/tools/builtins/write_file.py @@ -12,6 +12,11 @@ from acp.schema import ( from vibe import VIBE_ROOT from vibe.acp.tools.base import AcpToolState, BaseAcpTool +from vibe.acp.tools.session_update import ( + failed_tool_result, + fallback_tool_call, + resolve_kind, +) from vibe.core.tools.base import BaseToolState, ToolError from vibe.core.tools.builtins.write_file import ( WriteFile as CoreWriteFileTool, @@ -49,50 +54,40 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]): @classmethod def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None: - args = event.args - if args is None: - return ToolCallStart( - session_update="tool_call", - title="write_file", - tool_call_id=event.tool_call_id, - kind="edit", - content=None, - raw_input=None, - ) - if not isinstance(args, WriteFileArgs): - return None + if not isinstance(event.args, WriteFileArgs): + return fallback_tool_call(event, "write_file") return ToolCallStart( session_update="tool_call", - title=cls.get_call_display(event).summary, + title=cls.format_call_display(event.args).summary, tool_call_id=event.tool_call_id, - kind="edit", + kind=resolve_kind(event.tool_name), content=[ FileEditToolCallContent( - type="diff", path=args.path, old_text=None, new_text=args.content + type="diff", + path=event.args.path, + old_text=None, + new_text=event.args.content, ) ], - locations=[ToolCallLocation(path=args.path)], - raw_input=args.model_dump_json(), + locations=[ToolCallLocation(path=str(Path(event.args.path).resolve()))], + raw_input=event.args.model_dump_json(), + field_meta={"tool_name": event.tool_name}, ) @classmethod def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None: - if event.error: - return ToolCallProgress( - session_update="tool_call_update", - tool_call_id=event.tool_call_id, - status="failed", - ) + if failure := failed_tool_result(event, WriteFileResult): + return failure result = event.result - if not isinstance(result, WriteFileResult): - return None + assert isinstance(result, WriteFileResult) return ToolCallProgress( session_update="tool_call_update", tool_call_id=event.tool_call_id, status="completed", + kind=resolve_kind(event.tool_name), content=[ FileEditToolCallContent( type="diff", @@ -101,6 +96,7 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]): new_text=result.content, ) ], - locations=[ToolCallLocation(path=result.path)], + locations=[ToolCallLocation(path=str(Path(result.path).resolve()))], raw_output=result.model_dump_json(), + field_meta={"tool_name": event.tool_name}, ) diff --git a/vibe/acp/tools/session_update.py b/vibe/acp/tools/session_update.py index 378dc09..c8f9337 100644 --- a/vibe/acp/tools/session_update.py +++ b/vibe/acp/tools/session_update.py @@ -8,6 +8,7 @@ from acp.schema import ( ToolCallStart, ToolKind, ) +from pydantic import BaseModel from vibe.acp.tools.base import ( ToolCallSessionUpdateProtocol, @@ -17,16 +18,75 @@ from vibe.core.tools.ui import ToolUIDataAdapter from vibe.core.types import ToolCallEvent, ToolResultEvent from vibe.core.utils import TaggedText, is_user_cancellation_event -TOOL_KIND: dict[str, ToolKind] = { - "grep": "search", + +def _cancellation_raw_output(event: ToolResultEvent) -> str | None: + if event.skip_reason: + return TaggedText.from_string(event.skip_reason).message + if event.error: + return TaggedText.from_string(event.error).message + return None + + +TOOL_KIND_MAP: dict[str, ToolKind] = { "read_file": "read", - # Right now, jetbrains implementation of "edit" tool kind is broken - # Leading to the tool not appearing in the chat - # "write_file": "edit", - # "search_replace": "edit", + "grep": "search", + "web_search": "search", + "web_fetch": "fetch", + "write_file": "edit", + "search_replace": "edit", + "bash": "execute", + "skill": "read", } +def resolve_kind(tool_name: str) -> ToolKind: + return TOOL_KIND_MAP.get(tool_name, "other") + + +def failed_tool_result( + event: ToolResultEvent, expected_type: type[BaseModel] +) -> ToolCallProgress | None: + """Return a failed ToolCallProgress if event is cancelled or has unexpected result type. + + Returns None when the result is valid (caller handles the success path). + """ + kind = resolve_kind(event.tool_name) + + if is_user_cancellation_event(event): + return ToolCallProgress( + session_update="tool_call_update", + tool_call_id=event.tool_call_id, + status="failed", + kind=kind, + raw_output=_cancellation_raw_output(event), + field_meta={"tool_name": event.tool_name}, + ) + + if not isinstance(event.result, expected_type): + return ToolCallProgress( + session_update="tool_call_update", + tool_call_id=event.tool_call_id, + status="failed", + kind=kind, + raw_output=event.error or event.skip_reason, + field_meta={"tool_name": event.tool_name}, + ) + + return None + + +def fallback_tool_call(event: ToolCallEvent, title: str) -> ToolCallStart: + """Default ToolCallStart when args are None or an unexpected type.""" + return ToolCallStart( + session_update="tool_call", + title=title, + tool_call_id=event.tool_call_id, + kind=resolve_kind(event.tool_name), + raw_input=None, + field_meta={"tool_name": event.tool_name}, + ) + + def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None: if issubclass(event.tool_class, ToolCallSessionUpdateProtocol): return event.tool_class.tool_call_session_update(event) @@ -49,8 +109,9 @@ def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None: title=display.summary, content=content, tool_call_id=event.tool_call_id, - kind=TOOL_KIND.get(event.tool_name, "other"), + kind=resolve_kind(event.tool_name), raw_input=event.args.model_dump_json() if event.args else None, + field_meta={"tool_name": event.tool_name}, ) @@ -116,6 +177,8 @@ def tool_result_session_update(event: ToolResultEvent) -> SessionUpdate | None: session_update="tool_call_update", tool_call_id=event.tool_call_id, status=tool_status, + kind=resolve_kind(event.tool_name), raw_output=raw_output, content=content, + field_meta={"tool_name": event.tool_name}, ) diff --git a/vibe/acp/utils.py b/vibe/acp/utils.py index 9482f77..29544c1 100644 --- a/vibe/acp/utils.py +++ b/vibe/acp/utils.py @@ -305,6 +305,7 @@ def create_tool_call_replay( tool_call_id=tool_call_id, kind="other", raw_input=arguments, + field_meta={"tool_name": tool_name}, ) diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index 4a13cc9..9d43f7b 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -57,6 +57,7 @@ from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea from vibe.cli.textual_ui.widgets.compact import CompactMessage from vibe.cli.textual_ui.widgets.config_app import ConfigApp +from vibe.cli.textual_ui.widgets.connector_auth_app import ConnectorAuthApp from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState from vibe.cli.textual_ui.widgets.debug_console import DebugConsole from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar @@ -198,6 +199,7 @@ class BottomApp(StrEnum): Approval = auto() Config = auto() + ConnectorAuth = auto() Input = auto() MCP = auto() ModelPicker = auto() @@ -808,6 +810,27 @@ class VibeApp(App): # noqa: PLR0904 self.query_one(MCPApp).refresh_index() self._refresh_banner() + async def on_mcpapp_connector_auth_requested( + self, message: MCPApp.ConnectorAuthRequested + ) -> None: + await self._switch_to_input_app() + await self._switch_from_input( + ConnectorAuthApp( + connector_name=message.connector_name, + connector_registry=message.connector_registry, + tool_manager=message.tool_manager, + ) + ) + + async def on_connector_auth_app_connector_auth_closed( + self, message: ConnectorAuthApp.ConnectorAuthClosed + ) -> None: + if message.refreshed: + await self.agent_loop.refresh_system_prompt() + self._refresh_banner() + await self._switch_to_input_app() + await self._show_mcp(cmd_args=message.connector_name) + async def on_proxy_setup_app_proxy_setup_closed( self, message: ProxySetupApp.ProxySetupClosed ) -> None: @@ -1336,9 +1359,9 @@ class VibeApp(App): # noqa: PLR0904 teleport_msg.set_status("Teleporting...") case TeleportWaitingForGitHubEvent(): teleport_msg.set_status("Connecting to GitHub...") - case TeleportAuthRequiredEvent(oauth_url=url): + case TeleportAuthRequiredEvent(oauth_url=url, message=msg): webbrowser.open(url) - teleport_msg.set_status("Authorizing GitHub...") + teleport_msg.set_status(msg or "Authorizing GitHub...") case TeleportAuthCompleteEvent(): teleport_msg.set_status("GitHub authorized") case TeleportFetchingUrlEvent(): @@ -2059,6 +2082,8 @@ class VibeApp(App): # noqa: PLR0904 self.query_one(SessionPickerApp).focus() case BottomApp.MCP: self.query_one(MCPApp).focus() + case BottomApp.ConnectorAuth: + self.query_one(ConnectorAuthApp).focus() case BottomApp.Rewind: self.query_one(RewindApp).focus() case BottomApp.Voice: @@ -2335,7 +2360,7 @@ class VibeApp(App): # noqa: PLR0904 self.run_worker(self._interrupt_agent_loop(), exclusive=False) def _handle_bottom_app_close_escape( - self, widget_type: type[MCPApp] | type[ProxySetupApp] + self, widget_type: type[MCPApp] | type[ProxySetupApp] | type[ConnectorAuthApp] ) -> None: try: self.query_one(widget_type).action_close() @@ -2350,6 +2375,8 @@ class VibeApp(App): # noqa: PLR0904 self._handle_voice_app_escape() elif self._current_bottom_app == BottomApp.MCP: self._handle_bottom_app_close_escape(MCPApp) + elif self._current_bottom_app == BottomApp.ConnectorAuth: + self._handle_bottom_app_close_escape(ConnectorAuthApp) elif self._current_bottom_app == BottomApp.ProxySetup: self._handle_bottom_app_close_escape(ProxySetupApp) elif self._current_bottom_app == BottomApp.Approval: diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss index 3912416..22b9fba 100644 --- a/vibe/cli/textual_ui/app.tcss +++ b/vibe/cli/textual_ui/app.tcss @@ -691,7 +691,7 @@ StatusMessage { } -#config-app, #voice-app, #mcp-app { +#config-app, #voice-app, #mcp-app, #connectorauth-app { width: 100%; height: auto; background: transparent; @@ -700,7 +700,7 @@ StatusMessage { margin: 0; } -#config-content, #voice-content, #mcp-content { +#config-content, #voice-content, #mcp-content, #connectorauth-content { width: 100%; height: auto; } @@ -711,16 +711,20 @@ StatusMessage { color: ansi_blue; } -#config-options, #mcp-options { +#config-options, #mcp-options, #connectorauth-options { width: 100%; max-height: 50vh; border: none; } -#config-options:focus, #mcp-options:focus { +#config-options:focus, #mcp-options:focus, #connectorauth-options:focus { border: none; } +#connectorauth-detail { + margin: 1 0; +} + .settings-help { height: auto; color: ansi_bright_black; diff --git a/vibe/cli/textual_ui/widgets/connector_auth_app.py b/vibe/cli/textual_ui/widgets/connector_auth_app.py new file mode 100644 index 0000000..983d4d8 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/connector_auth_app.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from enum import StrEnum, auto +from typing import TYPE_CHECKING, ClassVar +import webbrowser + +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding, BindingType +from textual.containers import Container, Vertical +from textual.events import DescendantBlur +from textual.message import Message +from textual.widgets import OptionList +from textual.widgets.option_list import Option +from textual.worker import Worker + +from vibe.cli.clipboard import copy_text_to_clipboard +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic +from vibe.core.tools.connectors import ConnectorRegistry + +if TYPE_CHECKING: + from vibe.core.tools.manager import ToolManager + +_HELP = "Backspace Back" +_OPTION_PADDING = " " + + +class _AuthOptionId(StrEnum): + OPEN = auto() + COPY = auto() + SHOW = auto() + + +class ConnectorAuthApp(Container): + """Bottom-panel app for authenticating a workspace connector.""" + + can_focus_children = True + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "close", "Close", show=False), + Binding("backspace", "close", "Back", show=False), + Binding("r", "refresh", "Refresh", show=False), + ] + + class ConnectorAuthClosed(Message): + def __init__( + self, *, refreshed: bool = False, connector_name: str = "" + ) -> None: + super().__init__() + self.refreshed = refreshed + self.connector_name = connector_name + + def __init__( + self, + connector_name: str, + connector_registry: ConnectorRegistry, + tool_manager: ToolManager, + ) -> None: + super().__init__(id="connectorauth-app") + self._connector_name = connector_name + self._connector_registry = connector_registry + self._tool_manager = tool_manager + self._auth_url: str | None = None + self._auth_url_visible = False + self._status_message: str | None = None + + def compose(self) -> ComposeResult: + with Vertical(id="connectorauth-content"): + yield NoMarkupStatic("", id="connectorauth-title", classes="settings-title") + yield NoMarkupStatic("") + yield OptionList(id="connectorauth-options") + yield NoMarkupStatic("", id="connectorauth-detail") + yield NoMarkupStatic("", id="connectorauth-help", classes="settings-help") + + def on_mount(self) -> None: + self.query_one("#connectorauth-title", NoMarkupStatic).update( + f"Connector: {self._connector_name}" + ) + option_list = self.query_one(OptionList) + option_list.add_option(Option("Fetching authentication info...", disabled=True)) + self._set_help_text(_HELP) + option_list.focus() + self.run_worker(self._fetch_auth_url(), exclusive=True, group="auth_url") + + def on_descendant_blur(self, _event: DescendantBlur) -> None: + self.query_one(OptionList).focus() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + option_id = event.option.id or "" + if option_id == _AuthOptionId.OPEN: + self._open_browser() + elif option_id == _AuthOptionId.COPY: + self._copy_url() + elif option_id == _AuthOptionId.SHOW: + self._toggle_url() + + def action_close(self) -> None: + self.post_message(self.ConnectorAuthClosed()) + + async def action_refresh(self) -> None: + self._status_message = "Refreshing connector..." + self._set_help_text(_HELP) + self.run_worker( + self._refresh_connector(), exclusive=True, group="connector_refresh" + ) + + # ── workers ────────────────────────────────────────────────────── + + async def _fetch_auth_url(self) -> str | None: + return await self._connector_registry.get_auth_url(self._connector_name) + + async def _refresh_connector(self) -> int: + """Refresh connector tools. Returns the number of tools discovered.""" + from vibe.core.tools.manager import ToolManager + + new_tools = await self._connector_registry.refresh_connector_async( + self._connector_name + ) + if isinstance(self._tool_manager, ToolManager): + await self._tool_manager.integrate_connectors_async() + return len(new_tools) + + def on_worker_state_changed(self, event: Worker.StateChanged) -> None: + if event.worker.group == "auth_url" and event.worker.is_finished: + self._on_auth_url_fetched(event.worker.result) + elif event.worker.group == "connector_refresh" and event.worker.is_finished: + tool_count = ( + event.worker.result if isinstance(event.worker.result, int) else 0 + ) + self._on_connector_refreshed(tool_count) + + # ── auth UI ────────────────────────────────────────────────────── + + def _on_auth_url_fetched(self, result: object) -> None: + option_list = self.query_one(OptionList) + option_list.clear_options() + auth_url = result if isinstance(result, str) else None + if auth_url: + self._auth_url = auth_url + option_list.add_option( + Option( + Text("This connector requires authentication", no_wrap=True), + disabled=True, + ) + ) + option_list.add_option(Option("", disabled=True)) + option_list.add_option( + Option( + Text( + f"{_OPTION_PADDING}Press enter to open auth in your browser", + no_wrap=True, + ), + id=_AuthOptionId.OPEN, + ) + ) + option_list.add_option( + Option( + Text(f"{_OPTION_PADDING}Copy URL to clipboard", no_wrap=True), + id=_AuthOptionId.COPY, + ) + ) + option_list.add_option( + Option( + Text(f"{_OPTION_PADDING}Manually show the URL", no_wrap=True), + id=_AuthOptionId.SHOW, + ) + ) + option_list.highlighted = option_list.get_option_index(_AuthOptionId.OPEN) + self._update_detail_text() + else: + self.query_one("#connectorauth-detail", NoMarkupStatic).update("") + option_list.add_option( + Option("This connector does not provide authentication", disabled=True) + ) + self._set_help_text(_HELP) + + def _on_connector_refreshed(self, tool_count: int) -> None: + if tool_count > 0: + self._status_message = f"{tool_count} tools discovered." + self.post_message( + self.ConnectorAuthClosed( + refreshed=True, connector_name=self._connector_name + ) + ) + else: + self._status_message = ( + "No tools discovered. Authentication may still be pending, " + "try again in a moment." + ) + self._set_help_text(_HELP) + + # ── actions ────────────────────────────────────────────────────── + + def _open_browser(self) -> None: + if self._auth_url is None: + return + webbrowser.open(self._auth_url) + self._status_message = "Opened in browser." + self._set_help_text(_HELP) + + def _copy_url(self) -> None: + if self._auth_url is None: + return + copy_text_to_clipboard( + self.app, self._auth_url, success_message="Auth URL copied to clipboard" + ) + + def _toggle_url(self) -> None: + if self._auth_url is None: + return + self._auth_url_visible = not self._auth_url_visible + self._update_detail_text() + + # ── helpers ────────────────────────────────────────────────────── + + def _update_detail_text(self) -> None: + detail = self.query_one("#connectorauth-detail", NoMarkupStatic) + parts: list[str] = [] + if self._auth_url_visible and self._auth_url: + parts.append(self._auth_url) + parts.append("") + parts.append("Once authenticated, press R to refresh") + detail.update("\n".join(parts)) + + def _set_help_text(self, text: str) -> None: + if self._status_message: + text = f"{self._status_message} {text}" + self.query_one("#connectorauth-help", NoMarkupStatic).update(text) diff --git a/vibe/cli/textual_ui/widgets/mcp_app.py b/vibe/cli/textual_ui/widgets/mcp_app.py index b066c9b..159f507 100644 --- a/vibe/cli/textual_ui/widgets/mcp_app.py +++ b/vibe/cli/textual_ui/widgets/mcp_app.py @@ -62,9 +62,12 @@ def collect_mcp_tool_index( return MCPToolIndex(server_tools, connector_tools, enabled_tools=available) -_LIST_VIEW_HELP = ( +_LIST_VIEW_HELP_TOOLS = ( "↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close" ) +_LIST_VIEW_HELP_AUTH = ( + "↑↓ Navigate Enter Authenticate D Disable E Enable R Refresh Esc Close" +) _DETAIL_VIEW_HELP = ( "↑↓ Navigate D Disable E Enable Backspace Back R Refresh Esc Close" ) @@ -99,6 +102,20 @@ class MCPApp(Container): self.disabled = disabled self.tool_name = tool_name + class ConnectorAuthRequested(Message): + """Posted when a disconnected connector needs authentication.""" + + def __init__( + self, + connector_name: str, + connector_registry: ConnectorRegistry, + tool_manager: ToolManager, + ) -> None: + super().__init__() + self.connector_name = connector_name + self.connector_registry = connector_registry + self.tool_manager = tool_manager + def __init__( self, mcp_servers: Sequence[MCPServer], @@ -134,7 +151,6 @@ class MCPApp(Container): yield NoMarkupStatic("", id="mcp-title", classes="settings-title") yield NoMarkupStatic("") yield OptionList(id="mcp-options") - yield NoMarkupStatic("") yield NoMarkupStatic("", id="mcp-help", classes="settings-help") def on_mount(self) -> None: @@ -175,6 +191,9 @@ class MCPApp(Container): # it are disabled headers, scroll to top so the header stays visible. if all(option_list.get_option_at_index(i).disabled for i in range(highlighted)): option_list.scroll_to(y=0, animate=False, force=True, immediate=True) + # Update help text based on whether highlighted connector needs auth. + if self._viewing_server is None: + self._set_help_text(self._list_help_for_option(event.option)) def action_back(self) -> None: if self._refreshing: @@ -192,7 +211,7 @@ class MCPApp(Container): return self._status_message = "Refreshing..." - help = _DETAIL_VIEW_HELP if self._viewing_server else _LIST_VIEW_HELP + help = _DETAIL_VIEW_HELP if self._viewing_server else _LIST_VIEW_HELP_TOOLS self._set_help_text(help) self._refreshing = True @@ -212,6 +231,15 @@ class MCPApp(Container): self._status_message = result if isinstance(result, str) else "Refreshed." self.refresh_index() + def _list_help_for_option(self, option: Option) -> str: + """Return the appropriate list-view help text for the given option.""" + option_id = option.id or "" + if option_id.startswith("connector:") and self._connector_registry: + name = option_id.removeprefix("connector:") + if not self._connector_registry.is_connected(name): + return _LIST_VIEW_HELP_AUTH + return _LIST_VIEW_HELP_TOOLS + def _set_help_text(self, text: str) -> None: if self._status_message: text = f"{self._status_message} {text}" @@ -385,7 +413,7 @@ class MCPApp(Container): 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._set_help_text(_LIST_VIEW_HELP) + self._set_help_text(_LIST_VIEW_HELP_TOOLS) has_servers = bool(self._mcp_servers) @@ -495,9 +523,22 @@ class MCPApp(Container): 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]) if not all_tools: - option_list.add_option( - Option("No tools discovered for this server", disabled=True) - ) + if ( + is_connector + and self._connector_registry + and not self._connector_registry.is_connected(server_name) + ): + self.post_message( + self.ConnectorAuthRequested( + connector_name=server_name, + connector_registry=self._connector_registry, + tool_manager=self._tool_manager, + ) + ) + else: + option_list.add_option( + Option("No tools discovered for this server", disabled=True) + ) return for tool_name, cls in all_tools: is_tool_enabled = tool_name in index.enabled_tools diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index 3d4d29a..87960d9 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -727,10 +727,9 @@ class AgentLoop: self, provider: ProviderConfig | None = None ) -> dict[str, str]: provider = self.config.get_active_provider() if provider is None else provider - headers: dict[str, str] = { - "user-agent": get_user_agent(provider.backend), - "x-affinity": self.session_id, - } + headers: dict[str, str] = {**provider.extra_headers} + headers["user-agent"] = get_user_agent(provider.backend) + headers["x-affinity"] = self.session_id return headers async def _conversation_loop( @@ -1423,6 +1422,7 @@ class AgentLoop: self.session_logger.reset_session( self.session_id, parent_session_id=old_session_id ) + self.emit_new_session_telemetry() async def fork(self, message_id: str | None = None) -> AgentLoop: messages = self._messages_for_fork(message_id) diff --git a/vibe/core/config/__init__.py b/vibe/core/config/__init__.py index aae05b3..7877bdf 100644 --- a/vibe/core/config/__init__.py +++ b/vibe/core/config/__init__.py @@ -41,6 +41,13 @@ from vibe.core.config.layer import ( TrustResolutionError, UntrustedLayerError, ) +from vibe.core.config.patch import ( + AppendToList, + DeleteField, + PatchOp, + RemoveFromList, + SetField, +) from vibe.core.config.schema import ( DuplicateMergeMetadataError, MergeFieldMetadata, @@ -60,9 +67,11 @@ __all__ = [ "DEFAULT_TTS_MODELS", "DEFAULT_TTS_PROVIDERS", "THINKING_LEVELS", + "AppendToList", "ConfigLayer", "ConfigLayerError", "ConnectorConfig", + "DeleteField", "DuplicateMergeMetadataError", "EmptyLayerError", "LayerImplementationError", @@ -75,10 +84,13 @@ __all__ = [ "MissingPromptFileError", "ModelConfig", "OtelSpanExporterConfig", + "PatchOp", "ProjectContextConfig", "ProviderConfig", "RawConfig", + "RemoveFromList", "SessionLoggingConfig", + "SetField", "TTSClient", "TTSModelConfig", "TTSProviderConfig", diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py index 50b574b..f7482b5 100644 --- a/vibe/core/config/_settings.py +++ b/vibe/core/config/_settings.py @@ -173,6 +173,7 @@ class ProviderConfig(BaseModel): reasoning_field_name: str = "reasoning_content" project_id: str = "" region: str = "" + extra_headers: dict[str, str] = Field(default_factory=dict) def _is_legacy_mistral_provider_without_backend(self) -> bool: return ( @@ -434,9 +435,11 @@ DEFAULT_MODELS = [ ModelConfig( name="mistral-vibe-cli-latest", provider="mistral", - alias="devstral-2", - input_price=0.4, - output_price=2.0, + alias="mistral-medium-3.5", + temperature=1.0, + input_price=1.5, + output_price=7.5, + thinking="high", ), ModelConfig( name="devstral-small-latest", @@ -973,14 +976,33 @@ class VibeConfig(BaseSettings): except (FileNotFoundError, tomllib.TOMLDecodeError, OSError): return + changed = False + bash_tools = data.get("tools", {}).get("bash", {}) allowlist = bash_tools.get("allowlist") - if allowlist is None or "find" in allowlist: - return + if allowlist is not None and "find" not in allowlist: + allowlist.append("find") + allowlist.sort() + changed = True - allowlist.append("find") - allowlist.sort() - cls.dump_config(data) + for model in data.get("models", []): + if ( + model.get("name") == "mistral-vibe-cli-latest" + and model.get("alias") == "devstral-2" + ): + model["alias"] = "mistral-medium-3.5" + model["temperature"] = 1.0 + model["input_price"] = 1.5 + model["output_price"] = 7.5 + model["thinking"] = "high" + changed = True + + if data.get("active_model") == "devstral-2": + data["active_model"] = "mistral-medium-3.5" + changed = True + + if changed: + cls.dump_config(data) @classmethod def load(cls, **overrides: Any) -> VibeConfig: diff --git a/vibe/core/config/patch.py b/vibe/core/config/patch.py new file mode 100644 index 0000000..caea8db --- /dev/null +++ b/vibe/core/config/patch.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True, slots=True) +class SetField: + """Set a top-level or nested field to a value.""" + + key: str + value: Any + + def __post_init__(self) -> None: + _validate_key_path(self.key) + + +@dataclass(frozen=True, slots=True) +class AppendToList: + """Append items to a list field.""" + + key: str + items: tuple[Any, ...] + + def __post_init__(self) -> None: + _validate_key_path(self.key) + _validate_tuple_value("AppendToList.items", self.items) + + +@dataclass(frozen=True, slots=True) +class RemoveFromList: + """Remove items from a list field by value.""" + + key: str + values: tuple[Any, ...] + + def __post_init__(self) -> None: + _validate_key_path(self.key) + _validate_tuple_value("RemoveFromList.values", self.values) + + +@dataclass(frozen=True, slots=True) +class DeleteField: + """Remove a field entirely from the config.""" + + key: str + + def __post_init__(self) -> None: + _validate_key_path(self.key) + + +PatchOp = SetField | AppendToList | RemoveFromList | DeleteField + + +def _validate_key_path(key: object) -> None: + if not isinstance(key, str): + raise TypeError( + f"Patch operation key must be a string, got {type(key).__name__}" + ) + if not key: + raise ValueError("Patch operation key must not be empty") + if any(not segment for segment in key.split(".")): + raise ValueError( + "Patch operation key must be a dot-separated path without empty segments" + ) + + +def _validate_tuple_value(field_name: str, value: object) -> None: + if not isinstance(value, tuple): + raise TypeError(f"{field_name} must be a tuple, got {type(value).__name__}") diff --git a/vibe/core/output_formatters.py b/vibe/core/output_formatters.py index 031f8fe..c91a407 100644 --- a/vibe/core/output_formatters.py +++ b/vibe/core/output_formatters.py @@ -64,8 +64,8 @@ class TextOutputFormatter(OutputFormatter): self._print("Teleporting...") case TeleportWaitingForGitHubEvent(): self._print("Connecting to GitHub...") - case TeleportAuthRequiredEvent(oauth_url=url): - self._print(f"Open to authorize GitHub: {url}") + case TeleportAuthRequiredEvent(oauth_url=url, message=msg): + self._print(msg or f"Open to authorize GitHub: {url}") case TeleportAuthCompleteEvent(): self._print("GitHub authorized") case TeleportFetchingUrlEvent(): diff --git a/vibe/core/skills/builtins/vibe.py b/vibe/core/skills/builtins/vibe.py index bbbe283..c9c99a0 100644 --- a/vibe/core/skills/builtins/vibe.py +++ b/vibe/core/skills/builtins/vibe.py @@ -57,7 +57,7 @@ environment variables with the `VIBE_` prefix (e.g., `VIBE_ACTIVE_MODEL=local`). ```toml # Model selection -active_model = "devstral-2" # Model alias to use (see [[models]]) +active_model = "mistral-medium-3.5" # Model alias to use (see [[models]]) # UI preferences vim_keybindings = false @@ -103,6 +103,7 @@ backend = "mistral" name = "llamacpp" api_base = "http://127.0.0.1:8080/v1" api_key_env_var = "" +extra_headers = { "X-Custom-Header" = "value" } # optional per-provider HTTP headers ``` ### Models @@ -111,11 +112,11 @@ api_key_env_var = "" [[models]] name = "mistral-vibe-cli-latest" provider = "mistral" -alias = "devstral-2" -temperature = 0.2 -input_price = 0.4 -output_price = 2.0 -thinking = "off" # "off", "low", "medium", "high", "max" +alias = "mistral-medium-3.5" +temperature = 1.0 +input_price = 1.5 +output_price = 7.5 +thinking = "high" # "off", "low", "medium", "high", "max" auto_compact_threshold = 200000 [[models]] diff --git a/vibe/core/teleport/teleport.py b/vibe/core/teleport/teleport.py index dd3e652..5dffaa1 100644 --- a/vibe/core/teleport/teleport.py +++ b/vibe/core/teleport/teleport.py @@ -188,7 +188,9 @@ class TeleportService: if not github_data.connected: if github_data.oauth_url: - yield TeleportAuthRequiredEvent(oauth_url=github_data.oauth_url) + yield TeleportAuthRequiredEvent( + oauth_url=github_data.oauth_url, message=github_data.error + ) await self._nuage_client.wait_for_github_connection(execution_id) yield TeleportAuthCompleteEvent() diff --git a/vibe/core/teleport/types.py b/vibe/core/teleport/types.py index 3eb0f33..28e9b13 100644 --- a/vibe/core/teleport/types.py +++ b/vibe/core/teleport/types.py @@ -5,6 +5,7 @@ from vibe.core.types import BaseEvent class TeleportAuthRequiredEvent(BaseEvent): oauth_url: str + message: str | None = None class TeleportAuthCompleteEvent(BaseEvent): diff --git a/vibe/core/tools/builtins/grep.py b/vibe/core/tools/builtins/grep.py index bf927e6..a6c0316 100644 --- a/vibe/core/tools/builtins/grep.py +++ b/vibe/core/tools/builtins/grep.py @@ -94,6 +94,43 @@ class GrepArgs(BaseModel): ) +class GrepMatch(BaseModel): + path: str + line: int | None = None + + @classmethod + def from_output_line(cls, raw: str) -> GrepMatch | None: + """Parse a single grep/rg output line in `file:line:content` format. + + Handles Windows drive-letter paths like ``C:\\repo\\file.py:10:match`` + by skipping a single-letter first segment. + """ + parts = raw.split(":", 3) + MIN_MATCH_PARTS = 2 + if len(parts) < MIN_MATCH_PARTS: + return None + + # Windows drive letter: first part is a single letter (e.g. "C") + MIN_WINDOWS_PARTS = 3 + is_windows_path = ( + len(parts[0]) == 1 + and parts[0].isalpha() + and len(parts) >= MIN_WINDOWS_PARTS + ) + if is_windows_path: + file_path = f"{parts[0]}:{parts[1]}" + line_str = parts[2] + else: + file_path = parts[0] + line_str = parts[1] + + try: + line_num = int(line_str) if line_str else None + except (ValueError, TypeError): + line_num = None + return cls(path=str(Path(file_path).resolve()), line=line_num) + + class GrepResult(BaseModel): matches: str match_count: int @@ -101,6 +138,14 @@ class GrepResult(BaseModel): description="True if output was cut short by max_matches or max_output_bytes." ) + @property + def parsed_matches(self) -> list[GrepMatch]: + results: list[GrepMatch] = [] + for line in self.matches.splitlines(): + if match := GrepMatch.from_output_line(line): + results.append(match) + return results + class Grep( BaseTool[GrepArgs, GrepResult, GrepToolConfig, BaseToolState], diff --git a/vibe/core/tools/builtins/read_file.py b/vibe/core/tools/builtins/read_file.py index 414b377..0d16a53 100644 --- a/vibe/core/tools/builtins/read_file.py +++ b/vibe/core/tools/builtins/read_file.py @@ -48,6 +48,7 @@ class ReadFileArgs(BaseModel): class ReadFileResult(BaseModel): path: str content: str + offset: int = 0 lines_read: int was_truncated: bool = Field( description="True if the reading was stopped due to the max_read_bytes limit." @@ -90,6 +91,7 @@ class ReadFile( yield ReadFileResult( path=str(file_path), content="".join(read_result.lines), + offset=args.offset, lines_read=len(read_result.lines), was_truncated=read_result.was_truncated, ) diff --git a/vibe/core/tools/connectors/connector_registry.py b/vibe/core/tools/connectors/connector_registry.py index ca5582b..1ca6bb9 100644 --- a/vibe/core/tools/connectors/connector_registry.py +++ b/vibe/core/tools/connectors/connector_registry.py @@ -220,6 +220,7 @@ class ConnectorRegistry: self._cache: dict[str, dict[str, type[BaseTool]]] | None = None self._connector_names: list[str] = [] self._connector_connected: dict[str, bool] = {} + self._alias_to_id: dict[str, str] = {} self._discover_lock = asyncio.Lock() def _get_client(self) -> Mistral: @@ -269,6 +270,7 @@ class ConnectorRegistry: self._cache = {} self._connector_names = [] self._connector_connected = {} + self._alias_to_id = {} return {} # Build results locally to avoid publishing incomplete state. @@ -324,6 +326,7 @@ class ConnectorRegistry: # lock will see the completed cache. self._connector_names = connector_names self._connector_connected = connector_connected + self._alias_to_id = {alias: cid for cid, alias, _ in unique_connectors} self._cache = cache return all_tools @@ -411,7 +414,63 @@ class ConnectorRegistry: def is_connected(self, name: str) -> bool: return self._connector_connected.get(name, False) + def get_connector_id(self, alias: str) -> str | None: + """Return the API connector ID for a given alias, or None.""" + return self._alias_to_id.get(alias) + + async def refresh_connector_async(self, alias: str) -> dict[str, type[BaseTool]]: + """Re-fetch tools for a single connector by alias. + + Updates the internal cache for that connector only. Returns + the new tool map (empty dict on failure). + """ + connector_id = self._alias_to_id.get(alias) + if connector_id is None: + return {} + + try: + async with self._get_client() as client: + connector = await client.beta.connectors.get_async( + connector_id_or_name=connector_id + ) + tools_map = await self._fetch_connector_tools(client, connector, alias) + except Exception: + logger.debug("Failed to refresh connector %s", alias) + tools_map = None + + if self._cache is None: + self._cache = {} + + if tools_map is None: + self._cache.pop(connector_id, None) + self._connector_connected[alias] = False + return {} + + self._cache[connector_id] = tools_map + self._connector_connected[alias] = bool(tools_map) + return tools_map + + async def get_auth_url(self, alias: str) -> str | None: + """Return the OAuth authorization URL for a connector, or None. + + Returns None when the connector does not support OAuth or the + alias is unknown. + """ + connector_id = self._alias_to_id.get(alias) + if connector_id is None: + return None + try: + async with self._get_client() as client: + result = await client.beta.connectors.get_auth_url_async( + connector_id_or_name=connector_id + ) + return result.auth_url + except Exception: + logger.debug("Failed to get auth URL for connector %s", alias) + return None + def clear(self) -> None: self._cache = None self._connector_names = [] self._connector_connected = {} + self._alias_to_id = {} diff --git a/vibe/whats_new.md b/vibe/whats_new.md index b5906cf..4d325bf 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,3 +1,8 @@ +# What's new in v2.9.1 + +- **Default model**: Migrated to `mistral-medium-3.5`. +- **Connector OAuth**: Authenticate connectors via OAuth directly from the `/mcp` menu. + # What's new in v2.9.0 - **Scratchpad directory**: Model's autonomous workspace for temporary files, intermediate results, and drafts shared with subagents - **`/copy` command**: New slash command to copy content directly from the conversation