diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index b02b524..f05cd61 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -29,11 +29,13 @@ jobs: - name: Apply component label uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + env: + BUG_COMPONENT: ${{ steps.parse-bug.outputs.issueparser_component }} + FEATURE_COMPONENT: ${{ steps.parse-feature.outputs.issueparser_component }} with: script: | - // Get component from either bug report or feature request - const bugComponent = '${{ steps.parse-bug.outputs.issueparser_component }}'; - const featureComponent = '${{ steps.parse-feature.outputs.issueparser_component }}'; + const bugComponent = process.env.BUG_COMPONENT; + const featureComponent = process.env.FEATURE_COMPONENT; const component = bugComponent || featureComponent; if (!component) { @@ -41,22 +43,22 @@ jobs: return; } - const labels = []; + const componentLabels = new Map([ + ['CLI', 'CLI'], + ['ACP', 'ACP'], + ['VS Code extension', 'VS Code extension'], + ]); + const label = componentLabels.get(component); - if (component === 'CLI') { - labels.push('CLI'); - } else if (component === 'ACP') { - labels.push('ACP'); - } else if (component === 'VS Code extension') { - labels.push('VS Code extension'); + if (!label) { + console.log(`Unknown component '${component}', skipping labeling`); + return; } - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - labels: labels - }); - console.log(`Added labels: ${labels.join(', ')}`); - } + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: [label], + }); + console.log(`Added label: ${label}`); diff --git a/.typos.toml b/.typos.toml index 0886bbe..e7ab024 100644 --- a/.typos.toml +++ b/.typos.toml @@ -5,3 +5,8 @@ extend-ignore-re = ["(?m)^.*(#|//)\\s*typos:disable-line$", "datas"] iterm = "iterm" ITERM = "ITERM" asend = "asend" + +[default.extend-identifiers] +# macOS pasteboard four-char-code for the PNG flavor — required verbatim +# by AppleScript syntax (`the clipboard as «class PNGf»`). +PNGf = "PNGf" diff --git a/CHANGELOG.md b/CHANGELOG.md index edce982..3502683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ 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.18.0] - 2026-06-25 + +### Added + +- Clickable URLs in web fetch and web search tool output +- Context window usage display in tokens at the bottom of the TUI +- Clipboard image paste support in the TUI (macOS) +- Max generated tokens set_config_option in ACP + +### Changed + +- MCP OAuth authentication UX improvements in `/mcp` panel +- Diff gutter and body split into separate widgets +- Faster UI startup with lazy heavy imports +- Improved connector performance with bootstrap caching + +### Fixed + +- macOS keychain access for Vibe credentials +- Standalone denylist incorrectly blocking commands with heredocs +- Copy selected text from prompt input and text areas +- Repeated keyring requests in Vibe Code +- Brew upgrade now always runs even when uv upgrade succeeds +- Terminal kill/release bounding for ACP to prevent hangs + + ## [2.17.1] - 2026-06-19 ### Changed diff --git a/README.md b/README.md index 5ce0825..789ee44 100644 --- a/README.md +++ b/README.md @@ -760,6 +760,7 @@ This affects where Vibe looks for: - `config.toml` - Main configuration - `.env` - API keys +- `connector_bootstrap_cache.json` - Short-lived connector discovery cache - `agents/` - Custom agent configurations - `prompts/` - Custom system and compaction prompts - `tools/` - Custom tools diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 4b4e81a..3d832c3 100644 --- a/distribution/zed/extension.toml +++ b/distribution/zed/extension.toml @@ -1,7 +1,7 @@ id = "mistral-vibe" name = "Mistral Vibe" description = "Mistral's open-source coding assistant" -version = "2.17.1" +version = "2.18.0" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,21 +11,21 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-darwin-aarch64-2.17.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-darwin-aarch64-2.18.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-darwin-x86_64-2.17.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-darwin-x86_64-2.18.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-linux-aarch64-2.17.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-linux-aarch64-2.18.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-linux-x86_64-2.17.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-linux-x86_64-2.18.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-windows-x86_64-2.17.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-windows-x86_64-2.18.0.zip" cmd = "./vibe-acp.exe" diff --git a/docs/acp-setup.md b/docs/acp-setup.md index 1277eb9..090af6e 100644 --- a/docs/acp-setup.md +++ b/docs/acp-setup.md @@ -5,7 +5,7 @@ Once you have set up `vibe` with the API keys, you are ready to use `vibe-acp` i ## Zed -For usage in Zed, we recommend using the [Mistral Vibe Zed's extension](https://zed.dev/extensions/mistral-vibe). Alternatively, you can set up a local install as follows: +For usage in Zed, we recommend using the [Mistral Vibe Zed ACP agent](https://zed.dev/acp/agent/mistral-vibe). Alternatively, you can set up a local install as follows: 1. Go to `~/.config/zed/settings.json` and, under the `agent_servers` JSON object, add the following key-value pair to invoke the `vibe-acp` command. Here is the snippet: @@ -22,7 +22,7 @@ For usage in Zed, we recommend using the [Mistral Vibe Zed's extension](https:// } ``` -1. In the `New Thread` pane on the right, select the `vibe` agent and start the conversation. +2. In the `Agent Panel` view, select the `Mistral Vibe` agent and start the conversation. ## JetBrains IDEs diff --git a/pyproject.toml b/pyproject.toml index 52213d6..5bcad57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.17.1" +version = "2.18.0" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" @@ -33,7 +33,7 @@ dependencies = [ "attrs==26.1.0", "beautifulsoup4==4.14.3", "cachetools==7.0.6", - "certifi==2026.4.22", + "certifi==2026.6.17", "cffi==2.0.0", "charset-normalizer==3.4.7", "click==8.3.3 ; sys_platform != 'emscripten'", @@ -41,7 +41,7 @@ dependencies = [ "cryptography==48.0.1", "eval-type-backport==0.3.1", "gitdb==4.0.12", - "gitpython==3.1.47", + "gitpython==3.1.50", "giturlparse==0.14.0", "google-auth==2.49.2", "googleapis-common-protos==1.74.0", @@ -86,7 +86,7 @@ dependencies = [ "pycparser==3.0 ; implementation_name != 'PyPy'", "pydantic==2.13.3", "pydantic-core==2.46.3", - "pydantic-settings==2.14.0", + "pydantic-settings==2.14.2", "pygments==2.20.0", "pyjwt==2.13.0", "pyperclip==1.11.0", diff --git a/tests/acp/smoke_binary.py b/tests/acp/smoke_binary.py index 37b236e..17039db 100644 --- a/tests/acp/smoke_binary.py +++ b/tests/acp/smoke_binary.py @@ -99,6 +99,7 @@ async def test_acp_initialize(binary: Path) -> None: env = os.environ.copy() env["VIBE_HOME"] = str(vibe_home) env["MISTRAL_API_KEY"] = "smoke-test-mock-key" + env["VIBE_TEST_DISABLE_KEYRING"] = "1" proc = await asyncio.create_subprocess_exec( str(binary), diff --git a/tests/acp/test_acp.py b/tests/acp/test_acp.py index e1d4ca9..affd5ec 100644 --- a/tests/acp/test_acp.py +++ b/tests/acp/test_acp.py @@ -206,6 +206,7 @@ async def get_acp_agent_loop_process( env.update(mock_env) env["MISTRAL_API_KEY"] = "mock" env["VIBE_HOME"] = str(vibe_home) + env["VIBE_TEST_DISABLE_KEYRING"] = "1" process = await asyncio.create_subprocess_exec( *cmd, diff --git a/tests/acp/test_acp_entrypoint_smoke.py b/tests/acp/test_acp_entrypoint_smoke.py index b219101..02b78c7 100644 --- a/tests/acp/test_acp_entrypoint_smoke.py +++ b/tests/acp/test_acp_entrypoint_smoke.py @@ -105,6 +105,7 @@ def _build_env(vibe_home_dir: Path, *, include_api_key: bool) -> dict[str, str]: env = os.environ.copy() env["PYTHONUNBUFFERED"] = "1" env["VIBE_HOME"] = str(vibe_home_dir) + env["VIBE_TEST_DISABLE_KEYRING"] = "1" vibe_home_dir.mkdir(parents=True, exist_ok=True) config_file = vibe_home_dir / "config.toml" diff --git a/tests/acp/test_auth_status.py b/tests/acp/test_auth_status.py index 760d6bc..ed1f6f1 100644 --- a/tests/acp/test_auth_status.py +++ b/tests/acp/test_auth_status.py @@ -260,7 +260,7 @@ class TestACPAuthSignOut: @pytest.mark.asyncio async def test_removes_keyring_key(self, monkeypatch: pytest.MonkeyPatch) -> None: - deleted: list[str] = [] + deleted: list[tuple[str, str]] = [] monkeypatch.delenv(DEFAULT_MISTRAL_API_ENV_KEY, raising=False) monkeypatch.setattr( keyring, "get_password", lambda service, username: "keyring-key" @@ -268,14 +268,17 @@ class TestACPAuthSignOut: monkeypatch.setattr( keyring, "delete_password", - lambda service, username: deleted.append(username), + lambda service, username: deleted.append((service, username)), ) acp_agent_loop = build_acp_agent_loop(build_mistral_provider()) response = await acp_agent_loop.ext_method("auth/signOut", {}) assert response == {} - assert deleted == [DEFAULT_MISTRAL_API_ENV_KEY] + assert deleted == [ + ("ai.mistral.vibe", DEFAULT_MISTRAL_API_ENV_KEY), + ("vibe", DEFAULT_MISTRAL_API_ENV_KEY), + ] @pytest.mark.asyncio async def test_surfaces_internal_error_when_keyring_delete_fails( diff --git a/tests/acp/test_bash.py b/tests/acp/test_bash.py index 3724ed5..87e529d 100644 --- a/tests/acp/test_bash.py +++ b/tests/acp/test_bash.py @@ -290,6 +290,37 @@ class TestAcpBashTimeout: assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'" assert custom_handle._killed + @pytest.mark.asyncio + async def test_run_timeout_bounded_when_kill_hangs( + self, mock_client: MockClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + "vibe.acp.tools.builtins.bash._TERMINAL_CLEANUP_TIMEOUT", 0.05 + ) + + custom_handle = MockTerminalHandle( + terminal_id="hanging_kill_terminal", wait_delay=20 + ) + mock_client._terminal_handle = custom_handle + + async def hanging_kill() -> None: + await asyncio.sleep(30) + + custom_handle.kill = hanging_kill + + tool = Bash( + config_getter=lambda: BashToolConfig(), + state=AcpBashState.model_construct( + client=mock_client, session_id="test_session" + ), + ) + + args = BashArgs(command="slow_command", timeout=1) + with pytest.raises(ToolError) as exc_info: + await asyncio.wait_for(collect_result(tool.run(args)), timeout=5) + + assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'" + @pytest.mark.asyncio async def test_run_timeout_handles_kill_failure( self, mock_client: MockClient diff --git a/tests/acp/test_fork_session.py b/tests/acp/test_fork_session.py index 7b0736c..73e7440 100644 --- a/tests/acp/test_fork_session.py +++ b/tests/acp/test_fork_session.py @@ -156,6 +156,34 @@ class TestACPForkSession: forked_session = acp_agent_loop.sessions[response.session_id] assert forked_session.agent_loop.parent_session_id == source_session.id + @pytest.mark.asyncio + async def test_fork_session_inherits_limits( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + source_session = acp_agent_loop.sessions[session_response.session_id] + + await acp_agent_loop.set_config_option( + session_id=source_session.id, config_id="max_turns", value="42" + ) + await acp_agent_loop.set_config_option( + session_id=source_session.id, config_id="max_tokens", value="8192" + ) + source_session.agent_loop._max_price = 1.5 + source_session.agent_loop._max_session_tokens = 100_000 + + response = await acp_agent_loop.fork_session( + cwd=str(Path.cwd()), session_id=source_session.id, mcp_servers=[] + ) + + forked = acp_agent_loop.sessions[response.session_id].agent_loop + assert forked._max_turns == 42 + assert forked._max_tokens == 8192 + assert forked._max_price == 1.5 + assert forked._max_session_tokens == 100_000 + @pytest.mark.asyncio async def test_fork_session_rejects_running_session( self, acp_agent_loop: VibeAcpAgentLoop diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index bc3dbbb..fe53d58 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -72,7 +72,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.1" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.0" ) assert response.auth_methods is not None @@ -172,7 +172,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.1" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.0" ) assert response.auth_methods is not None diff --git a/tests/acp/test_load_session.py b/tests/acp/test_load_session.py index 9127266..653814d 100644 --- a/tests/acp/test_load_session.py +++ b/tests/acp/test_load_session.py @@ -42,6 +42,7 @@ def acp_agent_with_session_config( ), ], session_logging=session_config, + include_project_context=True, ) class PatchedAgentLoop(AgentLoop): diff --git a/tests/acp/test_new_session.py b/tests/acp/test_new_session.py index a73c621..f22bd1f 100644 --- a/tests/acp/test_new_session.py +++ b/tests/acp/test_new_session.py @@ -35,6 +35,7 @@ def _workspace_trust_meta(session_response: NewSessionResponse) -> dict: def acp_agent_loop(backend) -> VibeAcpAgentLoop: config = build_test_vibe_config( active_model="devstral-latest", + include_project_context=True, models=[ ModelConfig( name="devstral-latest", provider="mistral", alias="devstral-latest" diff --git a/tests/acp/test_set_config_option.py b/tests/acp/test_set_config_option.py index bfb4282..bf507cf 100644 --- a/tests/acp/test_set_config_option.py +++ b/tests/acp/test_set_config_option.py @@ -528,3 +528,67 @@ class TestACPSetConfigOptionMaxTurns: ] assert len(turn_limits) == 1 assert turn_limits[0].max_turns == 200 + + +class TestACPSetConfigOptionMaxTokens: + @pytest.mark.asyncio + async def test_set_config_option_max_tokens_success( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + acp_session = next( + (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None + ) + assert acp_session is not None + + response = await acp_agent_loop.set_config_option( + session_id=session_id, config_id="max_tokens", value="8192" + ) + + assert response is not None + assert acp_session.agent_loop._max_tokens == 8192 + + @pytest.mark.asyncio + async def test_set_config_option_max_tokens_invalid_string_returns_none( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + acp_session = next( + (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None + ) + assert acp_session is not None + initial_max_tokens = acp_session.agent_loop._max_tokens + + response = await acp_agent_loop.set_config_option( + session_id=session_id, config_id="max_tokens", value="abc" + ) + + assert response is None + assert acp_session.agent_loop._max_tokens == initial_max_tokens + + @pytest.mark.asyncio + async def test_set_config_option_max_tokens_bool_returns_none( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + acp_session = next( + (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None + ) + assert acp_session is not None + initial_max_tokens = acp_session.agent_loop._max_tokens + + response = await acp_agent_loop.set_config_option( + session_id=session_id, config_id="max_tokens", value=True + ) + + assert response is None + assert acp_session.agent_loop._max_tokens == initial_max_tokens diff --git a/tests/agent_loop/e2e/conftest.py b/tests/agent_loop/e2e/conftest.py index e41e0b4..b52cce0 100644 --- a/tests/agent_loop/e2e/conftest.py +++ b/tests/agent_loop/e2e/conftest.py @@ -1,34 +1,17 @@ from __future__ import annotations from collections.abc import Iterator -import json from typing import Any -import httpx import pytest import respx +from tests.agent_loop.e2e.providers import MistralAPI from tests.conftest import build_test_agent_loop, build_test_vibe_config -from tests.constants import ( - ANTHROPIC_BASE_URL, - ANTHROPIC_MESSAGES_PATH, - CHAT_COMPLETIONS_PATH, - CONNECTORS_BOOTSTRAP_PATH, - MISTRAL_BASE_URL, - OPENAI_BASE_URL, - OPENAI_RESPONSES_PATH, -) from vibe.core.agent_loop import AgentLoop from vibe.core.agents.models import BuiltinAgentName -from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig +from vibe.core.config import VibeConfig from vibe.core.llm.backend.factory import BACKEND_FACTORY -from vibe.core.types import AssistantEvent, Backend, BaseEvent - - -def assistant_text(events: list[BaseEvent]) -> str: - return "".join( - e.content for e in events if isinstance(e, AssistantEvent) and e.content - ).strip() @pytest.fixture(autouse=True) @@ -38,71 +21,23 @@ def _git_offline(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("GIT_ALLOW_PROTOCOL", "file") -def e2e_config(**overrides: Any) -> VibeConfig: - # Defaults give the mistral provider/model (api.mistral.ai, the respx-mocked - # base). The default model reasons, but that only adds reasoning_effort to the - # request, which the mocked responses ignore. - # The e2e harness mocks the connector bootstrap endpoint via respx, so - # connector discovery is enabled (and fast) here unlike the unit-test default. - return build_test_vibe_config( - enabled_tools=overrides.pop("enabled_tools", []), - enable_connectors=overrides.pop("enable_connectors", True), - system_prompt_id="tests", - include_project_context=False, - include_prompt_detail=False, - **overrides, - ) - - -def _generic_e2e_config( - *, name: str, api_base: str, api_style: str, model: str, **overrides: Any -) -> VibeConfig: - provider = ProviderConfig( - name=name, - api_base=api_base, - api_key_env_var=f"{name.upper()}_API_KEY", - api_style=api_style, - backend=Backend.GENERIC, - ) - models = [ModelConfig(name=model, provider=name, alias=name)] - return e2e_config( - active_model=name, models=models, providers=[provider], **overrides - ) - - -def anthropic_e2e_config(**overrides: Any) -> VibeConfig: - return _generic_e2e_config( - name="anthropic", - api_base=ANTHROPIC_BASE_URL, - api_style="anthropic", - model="claude-test", - **overrides, - ) - - -def openai_responses_e2e_config(**overrides: Any) -> VibeConfig: - return _generic_e2e_config( - name="openai", - api_base=f"{OPENAI_BASE_URL}/v1", - api_style="openai-responses", - model="gpt-test", - **overrides, - ) +@pytest.fixture +def mock_mistral() -> Iterator[respx.MockRouter]: + with respx.mock(base_url=MistralAPI.base_url, assert_all_called=False) as router: + yield router @pytest.fixture -def mock_mistral() -> Iterator[respx.MockRouter]: - with respx.mock(base_url=MISTRAL_BASE_URL, assert_all_called=False) as router: - router.get(CONNECTORS_BOOTSTRAP_PATH).mock( - return_value=httpx.Response(200, json={"connectors": []}) - ) - yield router +def mistral_api(mock_mistral: respx.MockRouter) -> MistralAPI: + api = MistralAPI() + api.setup_router(mock_mistral) + return api def build_e2e_agent_loop( *, config: VibeConfig | None = None, enable_streaming: bool = False, **kwargs: Any ) -> AgentLoop: - resolved_config = config or e2e_config() + resolved_config = config or build_test_vibe_config() provider = resolved_config.providers[0] # todo now we use build_test_agent_loop for the fake mcp registry. # Maybe mock http tool calls and instantiate a real registry later for more coverage @@ -113,69 +48,3 @@ def build_e2e_agent_loop( enable_streaming=enable_streaming, **kwargs, ) - - -class ProviderAPI: - """Stubs a provider's completion wire; the AgentLoop stays the subject.""" - - def __init__(self, router: respx.MockRouter, path: str) -> None: - self.route = router.post(path) - - def reply(self, *completions: dict[str, Any]) -> None: - responses = [httpx.Response(200, json=c) for c in completions] - if len(responses) == 1: - self.route.mock(return_value=responses[0]) - else: - self.route.mock(side_effect=responses) - - def reply_stream(self, chunks: list[bytes]) -> None: - self.route.mock(return_value=self._stream_response(chunks)) - - def reply_streams(self, *chunk_lists: list[bytes]) -> None: - self.route.mock( - side_effect=[self._stream_response(chunks) for chunks in chunk_lists] - ) - - @staticmethod - def _stream_response(chunks: list[bytes]) -> httpx.Response: - return httpx.Response( - 200, - stream=httpx.ByteStream(stream=b"\n\n".join(chunks)), - headers={"Content-Type": "text/event-stream"}, - ) - - @property - def request_json(self) -> dict[str, Any]: - return json.loads(self.route.calls.last.request.content) - - -class MistralAPI(ProviderAPI): - def __init__(self, router: respx.MockRouter) -> None: - super().__init__(router, CHAT_COMPLETIONS_PATH) - - -@pytest.fixture -def mistral_api(mock_mistral: respx.MockRouter) -> MistralAPI: - return MistralAPI(mock_mistral) - - -@pytest.fixture -def mock_anthropic() -> Iterator[respx.MockRouter]: - with respx.mock(base_url=ANTHROPIC_BASE_URL, assert_all_called=False) as router: - yield router - - -@pytest.fixture -def anthropic_api(mock_anthropic: respx.MockRouter) -> ProviderAPI: - return ProviderAPI(mock_anthropic, ANTHROPIC_MESSAGES_PATH) - - -@pytest.fixture -def mock_openai() -> Iterator[respx.MockRouter]: - with respx.mock(base_url=OPENAI_BASE_URL, assert_all_called=False) as router: - yield router - - -@pytest.fixture -def openai_responses_api(mock_openai: respx.MockRouter) -> ProviderAPI: - return ProviderAPI(mock_openai, OPENAI_RESPONSES_PATH) diff --git a/tests/agent_loop/e2e/providers/__init__.py b/tests/agent_loop/e2e/providers/__init__.py new file mode 100644 index 0000000..2813aef --- /dev/null +++ b/tests/agent_loop/e2e/providers/__init__.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from tests.agent_loop.e2e.providers.base import ( + ProviderAPI, + ProviderMocks, + assistant_text, +) +from tests.agent_loop.e2e.providers.mistral import MistralAPI + +__all__ = ["MistralAPI", "ProviderAPI", "ProviderMocks", "assistant_text"] diff --git a/tests/agent_loop/e2e/providers/anthropic.py b/tests/agent_loop/e2e/providers/anthropic.py new file mode 100644 index 0000000..c174673 --- /dev/null +++ b/tests/agent_loop/e2e/providers/anthropic.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import json +from typing import Any + +from tests import constants as c +from tests.agent_loop.e2e.providers.base import ProviderAPI +from tests.backend.data import Chunk, JsonResponse +from tests.backend.data.anthropic import ( + anthropic_message, + anthropic_reasoning_tool_use_stream, + anthropic_text_stream, + anthropic_tool_use, +) +from tests.conftest import build_test_vibe_config +from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig +from vibe.core.types import Backend + + +def e2e_config(**overrides: Any) -> VibeConfig: + provider = ProviderConfig( + name="anthropic", + api_base=c.ANTHROPIC_BASE_URL, + api_key_env_var="ANTHROPIC_API_KEY", + api_style="anthropic", + backend=Backend.GENERIC, + ) + models = [ModelConfig(name="claude-test", provider="anthropic", alias="anthropic")] + return build_test_vibe_config( + active_model="anthropic", models=models, providers=[provider], **overrides + ) + + +class API(ProviderAPI): + base_url = c.ANTHROPIC_BASE_URL + post_path = c.ANTHROPIC_MESSAGES_PATH + + +class Mocks: + def answer(self, text: str) -> JsonResponse: + return anthropic_message(text) + + def text_stream(self, text: str) -> list[Chunk]: + return anthropic_text_stream(text) + + def tool_call(self, name: str, arguments: dict[str, Any]) -> JsonResponse: + return anthropic_tool_use(name, arguments) + + def reasoning_answer(self, text: str, reasoning: str) -> JsonResponse: + message = anthropic_message(text) + message["content"].insert( + 0, {"type": "thinking", "thinking": reasoning, "signature": "sig"} + ) + return message + + def reasoning_tool_call_stream( + self, name: str, arguments: dict[str, Any], *, reasoning: str + ) -> list[Chunk]: + return anthropic_reasoning_tool_use_stream( + name, json.dumps(arguments), reasoning=reasoning + ) diff --git a/tests/agent_loop/e2e/providers/base.py b/tests/agent_loop/e2e/providers/base.py new file mode 100644 index 0000000..678eeac --- /dev/null +++ b/tests/agent_loop/e2e/providers/base.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +from typing import Any, ClassVar, Protocol + +import httpx +import pytest +import respx + +from tests.backend.data import Chunk, JsonResponse +from vibe.core.types import AssistantEvent, BaseEvent + + +def assistant_text(events: list[BaseEvent]) -> str: + return "".join( + e.content for e in events if isinstance(e, AssistantEvent) and e.content + ).strip() + + +class ProviderMocks(Protocol): + """Canonical wire payloads a provider must produce for the shared e2e suite.""" + + def answer(self, text: str) -> JsonResponse: + """A non-streaming assistant turn that replies with `text`.""" + ... + + def text_stream(self, text: str) -> list[Chunk]: + """A streaming assistant turn whose deltas reassemble into `text`.""" + ... + + def tool_call(self, name: str, arguments: dict[str, Any]) -> JsonResponse: + """A non-streaming turn that calls tool `name` with `arguments`.""" + ... + + def reasoning_answer(self, text: str, reasoning: str) -> JsonResponse: + """A non-streaming turn that thinks `reasoning`, then replies `text`.""" + ... + + def reasoning_tool_call_stream( + self, name: str, arguments: dict[str, Any], *, reasoning: str + ) -> list[Chunk]: + """A streamed turn that thinks `reasoning`, then calls tool `name`.""" + ... + + +class ProviderAPI: + """Stubs a provider's completion wire; the AgentLoop stays the subject. + + Subclasses describe one provider via `base_url`/`post_path`; override + `setup_monkeypatch` for non-HTTP stubs (e.g. credentials) a provider needs. + """ + + base_url: ClassVar[str] + post_path: ClassVar[str] + + route: respx.Route + + def setup_router(self, router: respx.MockRouter) -> None: + """Bind the completion route; subclasses extend for extra wires/defaults.""" + self.route = router.post(self.post_path) + + @classmethod + def setup_monkeypatch(cls, monkeypatch: pytest.MonkeyPatch) -> None: + """Apply non-HTTP stubs (e.g. credential lookups) before use.""" + + def reply(self, *completions: dict[str, Any]) -> None: + responses = [httpx.Response(200, json=completion) for completion in completions] + if len(responses) == 1: + self.route.mock(return_value=responses[0]) + else: + self.route.mock(side_effect=responses) + + def reply_stream(self, chunks: list[bytes]) -> None: + self.route.mock(return_value=self._stream_response(chunks)) + + def reply_streams(self, *chunk_lists: list[bytes]) -> None: + self.route.mock( + side_effect=[self._stream_response(chunks) for chunks in chunk_lists] + ) + + @staticmethod + def _stream_response(chunks: list[bytes]) -> httpx.Response: + return httpx.Response( + 200, + stream=httpx.ByteStream(stream=b"\n\n".join(chunks)), + headers={"Content-Type": "text/event-stream"}, + ) + + @property + def request_json(self) -> dict[str, Any]: + return json.loads(self.route.calls.last.request.content) + + def model_facing_text(self, index: int) -> str: + """Return the text (raw prompt) that the model would have seen in the request + at the given call index. + """ + body = json.loads(self.route.calls[index].request.content) + return json.dumps([m for m in body["messages"] if m.get("role") != "system"]) diff --git a/tests/agent_loop/e2e/providers/mistral.py b/tests/agent_loop/e2e/providers/mistral.py new file mode 100644 index 0000000..fad4132 --- /dev/null +++ b/tests/agent_loop/e2e/providers/mistral.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import httpx +import respx + +from tests import constants as c +from tests.agent_loop.e2e.providers.base import ProviderAPI + + +class MistralAPI(ProviderAPI): + base_url = c.MISTRAL_BASE_URL + post_path = c.CHAT_COMPLETIONS_PATH + + def setup_router(self, router: respx.MockRouter) -> None: + super().setup_router(router) + router.get(c.CONNECTORS_BOOTSTRAP_PATH).mock( + return_value=httpx.Response(200, json={"connectors": []}) + ) diff --git a/tests/agent_loop/e2e/providers/openai_responses.py b/tests/agent_loop/e2e/providers/openai_responses.py new file mode 100644 index 0000000..dd425cc --- /dev/null +++ b/tests/agent_loop/e2e/providers/openai_responses.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import json +from typing import Any + +from tests import constants as c +from tests.agent_loop.e2e.providers.base import ProviderAPI +from tests.backend.data import Chunk, JsonResponse +from tests.backend.data.openai_responses import ( + openai_function_call_item, + openai_message_item, + openai_reasoning_tool_call_stream, + openai_response, + openai_text_stream, +) +from tests.conftest import build_test_vibe_config +from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig +from vibe.core.types import Backend + + +def e2e_config(**overrides: Any) -> VibeConfig: + provider = ProviderConfig( + name="openai", + api_base=f"{c.OPENAI_BASE_URL}/v1", + api_key_env_var="OPENAI_API_KEY", + api_style="openai-responses", + backend=Backend.GENERIC, + ) + models = [ModelConfig(name="gpt-test", provider="openai", alias="openai")] + return build_test_vibe_config( + active_model="openai", models=models, providers=[provider], **overrides + ) + + +class API(ProviderAPI): + base_url = c.OPENAI_BASE_URL + post_path = c.OPENAI_RESPONSES_PATH + + +class Mocks: + def answer(self, text: str) -> JsonResponse: + return openai_response([openai_message_item(text)]) + + def text_stream(self, text: str) -> list[Chunk]: + return openai_text_stream(text) + + def tool_call(self, name: str, arguments: dict[str, Any]) -> JsonResponse: + return openai_response([openai_function_call_item(name, json.dumps(arguments))]) + + def reasoning_answer(self, text: str, reasoning: str) -> JsonResponse: + return openai_response([ + openai_message_item(reasoning, phase="commentary"), + openai_message_item(text, phase="final_answer"), + ]) + + def reasoning_tool_call_stream( + self, name: str, arguments: dict[str, Any], *, reasoning: str + ) -> list[Chunk]: + return openai_reasoning_tool_call_stream( + name, json.dumps(arguments), reasoning=reasoning + ) diff --git a/tests/agent_loop/e2e/providers/reasoning.py b/tests/agent_loop/e2e/providers/reasoning.py new file mode 100644 index 0000000..b612d9d --- /dev/null +++ b/tests/agent_loop/e2e/providers/reasoning.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import json +from typing import Any + +from tests import constants as c +from tests.agent_loop.e2e.providers.base import ProviderAPI +from tests.backend.data import Chunk, JsonResponse +from tests.backend.data.reasoning import ( + reasoning_message, + reasoning_text_stream, + reasoning_thinking_message, + reasoning_thinking_tool_use_stream, + reasoning_tool_use, +) +from tests.conftest import build_test_vibe_config +from vibe.core.config import ModelConfig, ProviderConfig, ThinkingLevel, VibeConfig +from vibe.core.types import Backend + + +def e2e_config(*, thinking: ThinkingLevel = "off", **overrides: Any) -> VibeConfig: + provider = ProviderConfig( + name="reasoning", + api_base=c.REASONING_BASE_URL, + api_key_env_var="REASONING_API_KEY", + api_style="reasoning", + backend=Backend.GENERIC, + ) + models = [ + ModelConfig( + name="reasoning-test", + provider="reasoning", + alias="reasoning", + thinking=thinking, + ) + ] + return build_test_vibe_config( + active_model="reasoning", models=models, providers=[provider], **overrides + ) + + +class API(ProviderAPI): + base_url = c.REASONING_BASE_URL + post_path = c.REASONING_COMPLETIONS_PATH + + +class Mocks: + def answer(self, text: str) -> JsonResponse: + return reasoning_message(text) + + def text_stream(self, text: str) -> list[Chunk]: + return reasoning_text_stream(text) + + def tool_call(self, name: str, arguments: dict[str, Any]) -> JsonResponse: + return reasoning_tool_use(name, json.dumps(arguments)) + + def reasoning_answer(self, text: str, reasoning: str) -> JsonResponse: + return reasoning_thinking_message(text, reasoning) + + def reasoning_tool_call_stream( + self, name: str, arguments: dict[str, Any], *, reasoning: str + ) -> list[Chunk]: + return reasoning_thinking_tool_use_stream( + name, json.dumps(arguments), reasoning=reasoning + ) diff --git a/tests/agent_loop/e2e/providers/vertex.py b/tests/agent_loop/e2e/providers/vertex.py new file mode 100644 index 0000000..b441d6f --- /dev/null +++ b/tests/agent_loop/e2e/providers/vertex.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock + +import pytest +import respx + +from tests import constants as c +from tests.agent_loop.e2e.providers.anthropic import Mocks +from tests.agent_loop.e2e.providers.base import ProviderAPI +from tests.conftest import build_test_vibe_config +from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig +from vibe.core.types import Backend + +__all__ = ["API", "Mocks", "e2e_config"] + + +def e2e_config(**overrides: Any) -> VibeConfig: + provider = ProviderConfig( + name="vertex", + api_base="", + api_key_env_var="VERTEX_API_KEY", + api_style="vertex-anthropic", + backend=Backend.GENERIC, + project_id=c.VERTEX_PROJECT_ID, + region=c.VERTEX_REGION, + ) + models = [ModelConfig(name=c.VERTEX_MODEL, provider="vertex", alias="vertex")] + return build_test_vibe_config( + active_model="vertex", models=models, providers=[provider], **overrides + ) + + +class API(ProviderAPI): + """Vertex splits rawPredict and streamRawPredict across two wire paths.""" + + base_url = c.VERTEX_BASE_URL + post_path = c.VERTEX_RAW_PREDICT_PATH + + def setup_router(self, router: respx.MockRouter) -> None: + super().setup_router(router) + self._stream_route = router.post(c.VERTEX_STREAM_PREDICT_PATH) + + @classmethod + def setup_monkeypatch(cls, monkeypatch: pytest.MonkeyPatch) -> None: + # google.auth is the only external dependency Vertex pulls in; stub the + # credential lookup so the real token-refresh logic runs without network. + creds = MagicMock() + creds.valid = True + creds.token = "fake-vertex-token" + monkeypatch.setattr( + "vibe.core.llm.backend.vertex.google.auth.default", + lambda **_kwargs: (creds, c.VERTEX_PROJECT_ID), + ) + + def reply_stream(self, chunks: list[bytes]) -> None: + self._stream_route.mock(return_value=self._stream_response(chunks)) + + def reply_streams(self, *chunk_lists: list[bytes]) -> None: + self._stream_route.mock( + side_effect=[self._stream_response(chunks) for chunks in chunk_lists] + ) + + @property + def request_json(self) -> dict[str, Any]: + route = self._stream_route if self._stream_route.calls else self.route + return json.loads(route.calls.last.request.content) diff --git a/tests/agent_loop/e2e/test_e2e_agent_loop.py b/tests/agent_loop/e2e/test_e2e_agent_loop.py index a67941c..8ceb4b9 100644 --- a/tests/agent_loop/e2e/test_e2e_agent_loop.py +++ b/tests/agent_loop/e2e/test_e2e_agent_loop.py @@ -4,16 +4,13 @@ from typing import Any import pytest -from tests.agent_loop.e2e.conftest import ( - MistralAPI, - assistant_text, - build_e2e_agent_loop, - e2e_config, -) +from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop +from tests.agent_loop.e2e.providers import assistant_text from tests.backend.data.mistral import ( STREAMED_SIMPLE_CONVERSATION_PARAMS, mistral_completion, ) +from tests.conftest import build_test_vibe_config from vibe.core.types import ( AssistantEvent, ToolCallEvent, @@ -75,7 +72,7 @@ async def test_act_tool_call_round_trip(mistral_api: MistralAPI) -> None: mistral_completion("", tool_calls=TODO_TOOL_CALL), mistral_completion("All done"), ) - agent = build_e2e_agent_loop(config=e2e_config(enabled_tools=["todo"])) + agent = build_e2e_agent_loop(config=build_test_vibe_config(enabled_tools=["todo"])) events = [event async for event in agent.act("Show my todos")] @@ -89,7 +86,7 @@ async def test_act_tool_call_round_trip(mistral_api: MistralAPI) -> None: async def test_act_serializes_tools_in_request_payload(mistral_api: MistralAPI) -> None: # Enabled tools are serialized into the outgoing chat-completions request. mistral_api.reply(mistral_completion("ok")) - agent = build_e2e_agent_loop(config=e2e_config(enabled_tools=["todo"])) + agent = build_e2e_agent_loop(config=build_test_vibe_config(enabled_tools=["todo"])) _ = [event async for event in agent.act("Hello")] diff --git a/tests/agent_loop/e2e/test_e2e_bash.py b/tests/agent_loop/e2e/test_e2e_bash.py index e4423a0..9c5b8b2 100644 --- a/tests/agent_loop/e2e/test_e2e_bash.py +++ b/tests/agent_loop/e2e/test_e2e_bash.py @@ -7,8 +7,9 @@ from typing import Any, cast from pydantic import BaseModel import pytest -from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop, e2e_config +from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop from tests.backend.data.mistral import mistral_completion +from tests.conftest import build_test_vibe_config from vibe.core.agents.models import BuiltinAgentName from vibe.core.tools.builtins.bash import BashResult from vibe.core.tools.permissions import RequiredPermission @@ -41,7 +42,7 @@ async def _run_bash( ) -> ToolResultEvent: mistral_api.reply(_bash_call(command, timeout), mistral_completion("done")) agent = build_e2e_agent_loop( - config=e2e_config(enabled_tools=["bash"]), agent_name=agent_name + config=build_test_vibe_config(enabled_tools=["bash"]), agent_name=agent_name ) if approval is not None: diff --git a/tests/agent_loop/e2e/test_e2e_compaction.py b/tests/agent_loop/e2e/test_e2e_compaction.py new file mode 100644 index 0000000..5cdbecd --- /dev/null +++ b/tests/agent_loop/e2e/test_e2e_compaction.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import pytest + +from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop +from tests.backend.data.mistral import mistral_completion +from tests.conftest import build_test_vibe_config, make_test_models +from vibe.core.types import CompactStartEvent + +COMPACTION_MODELS = make_test_models(auto_compact_threshold=1) + + +@pytest.mark.asyncio +async def test_auto_compaction_preserves_user_message_and_embeds_summary( + mistral_api: MistralAPI, +) -> None: + mistral_api.reply( + # First: the compaction summary + mistral_completion("A summary of what happened so far"), + # Then: the final answer to user message + mistral_completion("final answer"), + ) + agent = build_e2e_agent_loop( + config=build_test_vibe_config(models=COMPACTION_MODELS) + ) + agent.stats.context_tokens = 5 # Trigger the auto-compaction immediately + + events = [event async for event in agent.act("Investigate the bug")] + + assert any(isinstance(e, CompactStartEvent) for e in events) + sent_after_compaction = mistral_api.model_facing_text(1) + assert "Investigate the bug" in sent_after_compaction + assert "A summary of what happened so far" in sent_after_compaction + + +@pytest.mark.asyncio +async def test_repeated_auto_compaction_preserves_earlier_user_messages( + mistral_api: MistralAPI, +) -> None: + mistral_api.reply( + mistral_completion("summary one"), + mistral_completion("reply one"), + mistral_completion("summary two"), + mistral_completion("reply two"), + ) + agent = build_e2e_agent_loop( + config=build_test_vibe_config(models=COMPACTION_MODELS) + ) + + agent.stats.context_tokens = 5 + [_ async for _ in agent.act("first ask")] + agent.stats.context_tokens = 5 + [_ async for _ in agent.act("second ask")] + + sent_after_second_compaction = mistral_api.model_facing_text(3) + assert "first ask" in sent_after_second_compaction + assert "second ask" in sent_after_second_compaction + + +@pytest.mark.asyncio +async def test_oversized_user_message_is_middle_truncated_in_compaction( + mistral_api: MistralAPI, +) -> None: + huge_message = "alpha " * 20_000 + mistral_api.reply( + mistral_completion("summary intro"), + mistral_completion("reply intro"), + mistral_completion("summary huge"), + mistral_completion("reply huge"), + ) + agent = build_e2e_agent_loop( + config=build_test_vibe_config(models=COMPACTION_MODELS) + ) + + agent.stats.context_tokens = 5 + [_ async for _ in agent.act("intro")] + agent.stats.context_tokens = 5 + [_ async for _ in agent.act(huge_message)] + + sent_after_second_compaction = mistral_api.model_facing_text(3) + assert "[... truncated ...]" in sent_after_second_compaction + assert "intro" not in sent_after_second_compaction diff --git a/tests/agent_loop/e2e/test_e2e_connectors.py b/tests/agent_loop/e2e/test_e2e_connectors.py index dbb29c5..0e487ec 100644 --- a/tests/agent_loop/e2e/test_e2e_connectors.py +++ b/tests/agent_loop/e2e/test_e2e_connectors.py @@ -6,8 +6,9 @@ import httpx import pytest import respx -from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop, e2e_config +from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop from tests.backend.data.mistral import mistral_completion +from tests.conftest import build_test_vibe_config from tests.constants import CONNECTORS_BOOTSTRAP_PATH from vibe.core.config import ConnectorConfig @@ -44,8 +45,9 @@ def _set_bootstrap(router: respx.MockRouter, connectors: list[dict[str, Any]]) - def _connectors_config(*aliases: str) -> Any: - return e2e_config( - connectors=[ConnectorConfig(name=alias, disabled=False) for alias in aliases] + return build_test_vibe_config( + enable_connectors=True, + connectors=[ConnectorConfig(name=alias, disabled=False) for alias in aliases], ) diff --git a/tests/agent_loop/e2e/test_e2e_fork_and_plan.py b/tests/agent_loop/e2e/test_e2e_fork_and_plan.py index 932405c..2957f8f 100644 --- a/tests/agent_loop/e2e/test_e2e_fork_and_plan.py +++ b/tests/agent_loop/e2e/test_e2e_fork_and_plan.py @@ -5,8 +5,9 @@ from typing import Any import pytest -from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop, e2e_config +from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop from tests.backend.data.mistral import mistral_completion +from tests.conftest import build_test_vibe_config from vibe.core.agents.models import BuiltinAgentName from vibe.core.tools.builtins.ask_user_question import ( AskUserQuestionArgs, @@ -93,7 +94,7 @@ def _plan_agent(mistral_api: MistralAPI) -> Any: mistral_completion("Staying in plan mode."), ) return build_e2e_agent_loop( - config=e2e_config(enabled_tools=["exit_plan_mode"]), + config=build_test_vibe_config(enabled_tools=["exit_plan_mode"]), agent_name=BuiltinAgentName.PLAN, ) diff --git a/tests/agent_loop/e2e/test_e2e_hooks.py b/tests/agent_loop/e2e/test_e2e_hooks.py new file mode 100644 index 0000000..b2cc5ac --- /dev/null +++ b/tests/agent_loop/e2e/test_e2e_hooks.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import json +import shlex +import sys +import textwrap +from typing import Any + +import pytest + +from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop +from tests.backend.data.mistral import mistral_completion +from tests.conftest import build_test_vibe_config +from vibe.core.hooks.config import HookConfigResult +from vibe.core.hooks.models import HookConfig, HookType +from vibe.core.types import AssistantEvent, ToolResultEvent + +TODO_TOOL_CALL: list[dict[str, Any]] = [ + { + "id": "call_todo", + "function": {"name": "todo", "arguments": '{"action": "read"}'}, + "index": 0, + } +] + + +def _emit_cmd(payload: dict[str, Any]) -> str: + # Payload is passed via stdin of the runner (stdout of parent process) + body = f"import sys; sys.stdout.write({json.dumps(payload)!r})" + return f"{sys.executable} -c {shlex.quote(body)}" + + +@pytest.mark.asyncio +async def test_before_tool_hook_denies_tool_and_skips_execution( + mistral_api: MistralAPI, +) -> None: + deny_hook = HookConfig( + name="deny", + type=HookType.BEFORE_TOOL, + command=_emit_cmd({"decision": "deny", "reason": "blocked by policy"}), + match="todo", + ) + mistral_api.reply( + mistral_completion("", tool_calls=TODO_TOOL_CALL), mistral_completion("done") + ) + agent = build_e2e_agent_loop( + config=build_test_vibe_config(enabled_tools=["todo"]), + hook_config_result=HookConfigResult(hooks=[deny_hook], issues=[]), + ) + + events = [event async for event in agent.act("Show my todos")] + + tool_result = next(e for e in events if isinstance(e, ToolResultEvent)) + assert tool_result.skipped is True + assert "blocked by policy" in (tool_result.skip_reason or "") + + +@pytest.mark.asyncio +async def test_before_tool_hook_rewrites_tool_input_seen_by_model( + mistral_api: MistralAPI, +) -> None: + rewritten = { + "action": "write", + "todos": [{"id": "1", "content": "rewritten by hook"}], + } + rewrite_hook = HookConfig( + name="rewrite", + type=HookType.BEFORE_TOOL, + command=_emit_cmd({"hook_specific_output": {"tool_input": rewritten}}), + match="todo", + ) + mistral_api.reply( + mistral_completion("", tool_calls=TODO_TOOL_CALL), mistral_completion("done") + ) + agent = build_e2e_agent_loop( + config=build_test_vibe_config(enabled_tools=["todo"]), + hook_config_result=HookConfigResult(hooks=[rewrite_hook], issues=[]), + ) + + [_ async for _ in agent.act("Update my todos")] + + assert "rewritten by hook" in mistral_api.model_facing_text(1) + + +@pytest.mark.asyncio +async def test_before_tool_rewrite_failing_validation_is_denied( + mistral_api: MistralAPI, +) -> None: + bad_rewrite_hook = HookConfig( + name="bad-rewrite", + type=HookType.BEFORE_TOOL, + command=_emit_cmd({"hook_specific_output": {"tool_input": {"todos": "x"}}}), + match="todo", + ) + mistral_api.reply( + mistral_completion("", tool_calls=TODO_TOOL_CALL), mistral_completion("done") + ) + agent = build_e2e_agent_loop( + config=build_test_vibe_config(enabled_tools=["todo"]), + hook_config_result=HookConfigResult(hooks=[bad_rewrite_hook], issues=[]), + ) + + events = [event async for event in agent.act("Update my todos")] + + tool_result = next(e for e in events if isinstance(e, ToolResultEvent)) + assert tool_result.skipped is True + assert "failed validation" in (tool_result.skip_reason or "") + + +@pytest.mark.asyncio +async def test_after_tool_hook_replaces_tool_output_seen_by_model( + mistral_api: MistralAPI, +) -> None: + replace_output_hook = HookConfig( + name="replace", + type=HookType.AFTER_TOOL, + command=_emit_cmd({"decision": "deny", "reason": "REPLACED OUTPUT"}), + match="todo", + ) + mistral_api.reply( + mistral_completion("", tool_calls=TODO_TOOL_CALL), mistral_completion("done") + ) + agent = build_e2e_agent_loop( + config=build_test_vibe_config(enabled_tools=["todo"]), + hook_config_result=HookConfigResult(hooks=[replace_output_hook], issues=[]), + ) + + [_ async for _ in agent.act("Show my todos")] + + assert "REPLACED OUTPUT" in mistral_api.model_facing_text(1) + + +@pytest.mark.asyncio +async def test_post_agent_turn_hook_injects_retry_user_message( + mistral_api: MistralAPI, +) -> None: + # A post-agent-turn hook that denies forever would loop, so this script + # denies only on its first run, using a sentinel file to stay silent after. + deny_decision = json.dumps({"decision": "deny", "reason": "please continue"}) + deny_once = textwrap.dedent(f""" + import os, sys + sentinel = "__post_turn_sentinel__" + if not os.path.exists(sentinel): + open(sentinel, "w").close() + sys.stdout.write({deny_decision!r}) + """) + retry_hook = HookConfig( + name="retry", + type=HookType.POST_AGENT_TURN, + command=f"{sys.executable} -c {shlex.quote(deny_once)}", + ) + mistral_api.reply( + mistral_completion("first answer"), mistral_completion("second answer") + ) + agent = build_e2e_agent_loop( + config=build_test_vibe_config(enabled_tools=["todo"]), + hook_config_result=HookConfigResult(hooks=[retry_hook], issues=[]), + ) + + events = [event async for event in agent.act("Hello")] + + assert "please continue" in mistral_api.model_facing_text(1) + answers = [e.content for e in events if isinstance(e, AssistantEvent)] + assert answers == ["first answer", "second answer"] diff --git a/tests/agent_loop/e2e/test_e2e_provider_apis.py b/tests/agent_loop/e2e/test_e2e_provider_apis.py index f40ea20..09a4339 100644 --- a/tests/agent_loop/e2e/test_e2e_provider_apis.py +++ b/tests/agent_loop/e2e/test_e2e_provider_apis.py @@ -1,135 +1,146 @@ from __future__ import annotations -import pytest +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass -from tests.agent_loop.e2e.conftest import ( +import pytest +import respx + +from tests.agent_loop.e2e.conftest import build_e2e_agent_loop +from tests.agent_loop.e2e.providers import ( + anthropic, + openai_responses, + reasoning, + vertex, +) +from tests.agent_loop.e2e.providers.base import ( ProviderAPI, - anthropic_e2e_config, + ProviderMocks, assistant_text, - build_e2e_agent_loop, - openai_responses_e2e_config, -) -from tests.backend.data.anthropic import ( - anthropic_message, - anthropic_reasoning_tool_use_stream, - anthropic_request_content_blocks, - anthropic_text_stream, - anthropic_tool_use, -) -from tests.backend.data.openai_responses import ( - openai_function_call_item, - openai_message_item, - openai_reasoning_tool_call_stream, - openai_response, - openai_text_stream, ) +from tests.backend.data import ANSWER_CONTEXT_TOKENS +from tests.backend.data.anthropic import anthropic_message +from tests.backend.data.reasoning import reasoning_thinking_message +from vibe.core.config import VibeConfig from vibe.core.types import ToolResultEvent -class TestAnthropic: +@dataclass(frozen=True) +class ProviderScenario: + """Everything the shared e2e suite needs to exercise one provider. + + Adding a provider is: implement `ProviderMocks`, then append one entry here. + """ + + id: str + config: Callable[..., VibeConfig] + api_cls: type[ProviderAPI] + mocks: ProviderMocks + + +PROVIDER_SCENARIOS: list[ProviderScenario] = [ + ProviderScenario( + id="anthropic", + config=anthropic.e2e_config, + api_cls=anthropic.API, + mocks=anthropic.Mocks(), + ), + ProviderScenario( + id="openai-responses", + config=openai_responses.e2e_config, + api_cls=openai_responses.API, + mocks=openai_responses.Mocks(), + ), + ProviderScenario( + id="reasoning", + config=reasoning.e2e_config, + api_cls=reasoning.API, + mocks=reasoning.Mocks(), + ), + ProviderScenario( + id="vertex-anthropic", + config=vertex.e2e_config, + api_cls=vertex.API, + mocks=vertex.Mocks(), + ), +] + + +@contextmanager +def open_provider_api( + api_cls: type[ProviderAPI], request: pytest.FixtureRequest +) -> Iterator[ProviderAPI]: + """Instantiate a provider API and setup respx routers and monkeypatches""" + api_cls.setup_monkeypatch(request.getfixturevalue("monkeypatch")) + with respx.mock(base_url=api_cls.base_url, assert_all_called=False) as router: + api = api_cls() + api.setup_router(router) + yield api + + +@pytest.fixture(params=PROVIDER_SCENARIOS, ids=lambda s: s.id) +def scenario(request: pytest.FixtureRequest) -> ProviderScenario: + """Parametrized fixture for each provider scenario in the shared e2e suite.""" + return request.param + + +@pytest.fixture +def provider_api( + scenario: ProviderScenario, request: pytest.FixtureRequest +) -> Iterator[ProviderAPI]: + """Returned setup provider API for the current scenario""" + with open_provider_api(scenario.api_cls, request) as api: + yield api + + +class TestProviderCommonBehaviors: + """Answer / stream / tool-call behaviors every provider shares.""" + @pytest.mark.asyncio - async def test_agent_answers(self, anthropic_api: ProviderAPI) -> None: - # A plain prompt is answered through the Anthropic messages wire. - anthropic_api.reply(anthropic_message("pong")) - agent = build_e2e_agent_loop(config=anthropic_e2e_config()) + async def test_agent_answers( + self, scenario: ProviderScenario, provider_api: ProviderAPI + ) -> None: + provider_api.reply(scenario.mocks.answer("pong")) + agent = build_e2e_agent_loop(config=scenario.config()) events = [event async for event in agent.act("Reply with exactly: pong")] assert assistant_text(events) == "pong" - assert agent.stats.context_tokens == 15 + assert agent.stats.context_tokens == ANSWER_CONTEXT_TOKENS @pytest.mark.asyncio - async def test_agent_streams(self, anthropic_api: ProviderAPI) -> None: - # Anthropic SSE deltas are reassembled into the final assistant content. - anthropic_api.reply_stream(anthropic_text_stream("pong")) - agent = build_e2e_agent_loop( - config=anthropic_e2e_config(), enable_streaming=True - ) + async def test_agent_streams( + self, scenario: ProviderScenario, provider_api: ProviderAPI + ) -> None: + provider_api.reply_stream(scenario.mocks.text_stream("pong")) + agent = build_e2e_agent_loop(config=scenario.config(), enable_streaming=True) events = [event async for event in agent.act("Reply with exactly: pong")] assert assistant_text(events) == "pong" @pytest.mark.asyncio - async def test_agent_executes_tool_call(self, anthropic_api: ProviderAPI) -> None: - # A tool_use turn runs the tool, then Anthropic returns the final answer. - anthropic_api.reply( - anthropic_tool_use("todo", {"action": "read"}), - anthropic_message("Your list is empty."), - ) - agent = build_e2e_agent_loop( - config=anthropic_e2e_config(enabled_tools=["todo"]) + async def test_agent_executes_tool_call( + self, scenario: ProviderScenario, provider_api: ProviderAPI + ) -> None: + provider_api.reply( + scenario.mocks.tool_call("todo", {"action": "read"}), + scenario.mocks.answer("Your list is empty."), ) + agent = build_e2e_agent_loop(config=scenario.config(enabled_tools=["todo"])) events = [event async for event in agent.act("What's on my todo list?")] assert any(isinstance(e, ToolResultEvent) for e in events) assert "Your list is empty." in assistant_text(events) - @pytest.mark.asyncio - async def test_agent_streams_reasoning_and_tool_call( - self, anthropic_api: ProviderAPI - ) -> None: - # A streamed thinking + tool_use turn runs the tool, then replays that - # reasoning/tool history into the follow-up Anthropic request. - anthropic_api.reply_streams( - anthropic_reasoning_tool_use_stream("todo", '{"action": "read"}'), - anthropic_text_stream("Your list is empty."), - ) - agent = build_e2e_agent_loop( - config=anthropic_e2e_config(enabled_tools=["todo"]), enable_streaming=True - ) - - events = [event async for event in agent.act("What's on my todo list?")] - - assert "Your list is empty." in assistant_text(events) - blocks = anthropic_request_content_blocks(anthropic_api.request_json) - thinking = [b["thinking"] for b in blocks if b.get("type") == "thinking"] - tool_uses = [b for b in blocks if b.get("type") == "tool_use"] - - assert any("thinking..." in text for text in thinking) - assert tool_uses - - -class TestOpenAIResponses: - @pytest.mark.asyncio - async def test_agent_answers(self, openai_responses_api: ProviderAPI) -> None: - # A plain prompt is answered through the OpenAI Responses wire. - openai_responses_api.reply(openai_response([openai_message_item("pong")])) - agent = build_e2e_agent_loop(config=openai_responses_e2e_config()) - - events = [event async for event in agent.act("Reply with exactly: pong")] - - assert assistant_text(events) == "pong" - assert agent.stats.context_tokens == 12 - - @pytest.mark.asyncio - async def test_agent_streams(self, openai_responses_api: ProviderAPI) -> None: - # OpenAI Responses SSE deltas are reassembled into the final content. - openai_responses_api.reply_stream(openai_text_stream("pong")) - agent = build_e2e_agent_loop( - config=openai_responses_e2e_config(), enable_streaming=True - ) - - events = [event async for event in agent.act("Reply with exactly: pong")] - - assert assistant_text(events) == "pong" - @pytest.mark.asyncio async def test_agent_captures_reasoning( - self, openai_responses_api: ProviderAPI + self, scenario: ProviderScenario, provider_api: ProviderAPI ) -> None: - # Commentary phase output is captured as reasoning on the assistant message. - openai_responses_api.reply( - openai_response( - [ - openai_message_item("Let me think.", phase="commentary"), - openai_message_item("pong", phase="final_answer"), - ], - output_tokens=5, - ) - ) - agent = build_e2e_agent_loop(config=openai_responses_e2e_config()) + provider_api.reply(scenario.mocks.reasoning_answer("pong", "Let me think.")) + agent = build_e2e_agent_loop(config=scenario.config()) events = [event async for event in agent.act("Reply with exactly: pong")] @@ -140,39 +151,55 @@ class TestOpenAIResponses: ) @pytest.mark.asyncio - async def test_agent_executes_tool_call( - self, openai_responses_api: ProviderAPI + async def test_agent_streams_reasoning_then_runs_tool( + self, scenario: ProviderScenario, provider_api: ProviderAPI ) -> None: - # A function_call item runs the tool, then OpenAI returns the final answer. - openai_responses_api.reply( - openai_response([openai_function_call_item("todo", '{"action": "read"}')]), - openai_response([openai_message_item("Your list is empty.")]), + provider_api.reply_streams( + scenario.mocks.reasoning_tool_call_stream( + "todo", {"action": "read"}, reasoning="thinking..." + ), + scenario.mocks.text_stream("Your list is empty."), ) agent = build_e2e_agent_loop( - config=openai_responses_e2e_config(enabled_tools=["todo"]) + config=scenario.config(enabled_tools=["todo"]), enable_streaming=True ) events = [event async for event in agent.act("What's on my todo list?")] assert any(isinstance(e, ToolResultEvent) for e in events) assert "Your list is empty." in assistant_text(events) + assert any( + m.reasoning_content and "thinking..." in m.reasoning_content + for m in agent.messages + ) + +# The following tests are provider-specific and not shared across all providers, so they are not parametrized by scenario. +class TestReasoning: @pytest.mark.asyncio - async def test_agent_streams_reasoning_and_tool_call( - self, openai_responses_api: ProviderAPI + async def test_forwards_thinking_level_as_reasoning_effort( + self, request: pytest.FixtureRequest ) -> None: - # A streamed commentary + function_call turn runs the tool, then OpenAI - # streams the final answer on the follow-up request. - openai_responses_api.reply_streams( - openai_reasoning_tool_call_stream("todo", '{"action": "read"}'), - openai_text_stream("Your list is empty."), - ) - agent = build_e2e_agent_loop( - config=openai_responses_e2e_config(enabled_tools=["todo"]), - enable_streaming=True, - ) + # The model's thinking level maps to reasoning_effort on the wire. + with open_provider_api(reasoning.API, request) as api: + api.reply(reasoning_thinking_message("pong", "Let me think.")) + agent = build_e2e_agent_loop(config=reasoning.e2e_config(thinking="medium")) - events = [event async for event in agent.act("What's on my todo list?")] + async for _ in agent.act("Reply with exactly: pong"): + pass - assert any(isinstance(e, ToolResultEvent) for e in events) - assert "Your list is empty." in assistant_text(events) + assert api.request_json["reasoning_effort"] == "medium" + + +class TestVertexAnthropic: + @pytest.mark.asyncio + async def test_uses_vertex_wire(self, request: pytest.FixtureRequest) -> None: + # The request goes out on the Vertex rawPredict wire format. + with open_provider_api(vertex.API, request) as api: + api.reply(anthropic_message("pong")) + agent = build_e2e_agent_loop(config=vertex.e2e_config()) + + events = [event async for event in agent.act("Reply with exactly: pong")] + + assert assistant_text(events) == "pong" + assert api.request_json["anthropic_version"] == "vertex-2023-10-16" diff --git a/tests/agent_loop/e2e/test_e2e_tools.py b/tests/agent_loop/e2e/test_e2e_tools.py index f75f111..0c7e91c 100644 --- a/tests/agent_loop/e2e/test_e2e_tools.py +++ b/tests/agent_loop/e2e/test_e2e_tools.py @@ -6,8 +6,9 @@ from typing import Any, cast import pytest -from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop, e2e_config +from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop from tests.backend.data.mistral import mistral_completion +from tests.conftest import build_test_vibe_config from vibe.core.tools.builtins.grep import GrepResult from vibe.core.tools.builtins.read import ReadResult from vibe.core.tools.builtins.todo import TodoResult @@ -33,7 +34,7 @@ async def _run_tool( # Drive one tool call against the real cwd, then a final reply, and return # the single tool result for the test to assert on. mistral_api.reply(_tool_call(name, arguments), mistral_completion("done")) - agent = build_e2e_agent_loop(config=e2e_config(enabled_tools=[name])) + agent = build_e2e_agent_loop(config=build_test_vibe_config(enabled_tools=[name])) events: list[BaseEvent] = [event async for event in agent.act("go")] result = next(e for e in events if isinstance(e, ToolResultEvent)) assert result.error is None diff --git a/tests/agent_loop/test_agent_backend.py b/tests/agent_loop/test_agent_backend.py index 093d3f9..a429327 100644 --- a/tests/agent_loop/test_agent_backend.py +++ b/tests/agent_loop/test_agent_backend.py @@ -100,6 +100,30 @@ async def test_passes_x_affinity_header_when_asking_an_answer_streaming( assert headers["x-affinity"] == agent.session_id +@pytest.mark.asyncio +async def test_max_tokens_is_passed_to_backend(vibe_config: VibeConfig): + backend = FakeBackend([mock_llm_chunk(content="Response")]) + agent = build_test_agent_loop(config=vibe_config, backend=backend) + + agent.set_max_tokens(8192) + [_ async for _ in agent.act("Hello")] + + assert backend.requests_max_tokens == [8192] + + +@pytest.mark.asyncio +async def test_max_tokens_is_passed_to_streaming_backend(vibe_config: VibeConfig): + backend = FakeBackend([mock_llm_chunk(content="Response")]) + agent = build_test_agent_loop( + config=vibe_config, backend=backend, enable_streaming=True + ) + + agent.set_max_tokens(8192) + [_ async for _ in agent.act("Hello")] + + assert backend.requests_max_tokens == [8192] + + @pytest.mark.asyncio async def test_updates_tokens_stats_based_on_backend_response(vibe_config: VibeConfig): chunk = mock_llm_chunk(content="Response", prompt_tokens=100, completion_tokens=50) diff --git a/tests/agent_loop/test_agent_loop_refresh_system_prompt.py b/tests/agent_loop/test_agent_loop_refresh_system_prompt.py index 368676c..ad577a9 100644 --- a/tests/agent_loop/test_agent_loop_refresh_system_prompt.py +++ b/tests/agent_loop/test_agent_loop_refresh_system_prompt.py @@ -14,7 +14,6 @@ async def test_refresh_system_prompt_preserves_scratchpad_section() -> None: # of the scratchpad on the very first turn for any user with telemetry # enabled and a Mistral API key. config = build_test_vibe_config( - include_project_context=False, include_prompt_detail=True, include_model_info=False, include_commit_signature=False, diff --git a/tests/agent_loop/test_agent_observer_streaming.py b/tests/agent_loop/test_agent_observer_streaming.py index f5f21f8..dffa0c4 100644 --- a/tests/agent_loop/test_agent_observer_streaming.py +++ b/tests/agent_loop/test_agent_observer_streaming.py @@ -57,9 +57,6 @@ def make_config( *, enabled_tools: list[str] | None = None, tools: dict[str, dict] | None = None ) -> VibeConfig: return build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - include_prompt_detail=False, include_model_info=False, include_commit_signature=False, enabled_tools=enabled_tools or [], diff --git a/tests/agent_loop/test_agent_tool_call.py b/tests/agent_loop/test_agent_tool_call.py index fec0fb7..b6f3256 100644 --- a/tests/agent_loop/test_agent_tool_call.py +++ b/tests/agent_loop/test_agent_tool_call.py @@ -39,11 +39,7 @@ async def act_and_collect_events(agent_loop: AgentLoop, prompt: str) -> list[Bas def make_config(todo_permission: ToolPermission = ToolPermission.ALWAYS) -> VibeConfig: return build_test_vibe_config( - enabled_tools=["todo"], - tools={"todo": {"permission": todo_permission.value}}, - system_prompt_id="tests", - include_project_context=False, - include_prompt_detail=False, + enabled_tools=["todo"], tools={"todo": {"permission": todo_permission.value}} ) diff --git a/tests/agent_loop/test_agents.py b/tests/agent_loop/test_agents.py index 68eea4a..445878c 100644 --- a/tests/agent_loop/test_agents.py +++ b/tests/agent_loop/test_agents.py @@ -366,12 +366,6 @@ class TestAgentProfileOverrides: class TestAgentManagerCycling: - @pytest.fixture - def base_config(self) -> VibeConfig: - return build_test_vibe_config( - include_project_context=False, include_prompt_detail=False - ) - @pytest.fixture def backend(self) -> FakeBackend: return FakeBackend([ @@ -382,10 +376,10 @@ class TestAgentManagerCycling: ]) def test_get_agent_order_includes_primary_agents( - self, base_config: VibeConfig, backend: FakeBackend + self, vibe_config: VibeConfig, backend: FakeBackend ) -> None: agent = build_test_agent_loop( - config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend + config=vibe_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend ) order = agent.agent_manager.get_agent_order() assert len(order) == 4 @@ -395,10 +389,10 @@ class TestAgentManagerCycling: assert BuiltinAgentName.ACCEPT_EDITS in order def test_next_agent_cycles_through_all( - self, base_config: VibeConfig, backend: FakeBackend + self, vibe_config: VibeConfig, backend: FakeBackend ) -> None: agent = build_test_agent_loop( - config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend + config=vibe_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend ) order = agent.agent_manager.get_agent_order() current = agent.agent_manager.active_profile @@ -409,10 +403,10 @@ class TestAgentManagerCycling: assert len(set(visited)) == len(order) def test_next_agent_wraps_around( - self, base_config: VibeConfig, backend: FakeBackend + self, vibe_config: VibeConfig, backend: FakeBackend ) -> None: agent = build_test_agent_loop( - config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend + config=vibe_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend ) order = agent.agent_manager.get_agent_order() last_profile = agent.agent_manager.get_agent(order[-1]) @@ -433,12 +427,6 @@ class TestAgentProfileConfig: class TestAgentSwitchAgent: - @pytest.fixture - def base_config(self) -> VibeConfig: - return build_test_vibe_config( - include_project_context=False, include_prompt_detail=False - ) - @pytest.fixture def backend(self) -> FakeBackend: return FakeBackend([ @@ -450,10 +438,10 @@ class TestAgentSwitchAgent: @pytest.mark.asyncio async def test_switch_to_plan_agent_has_tools_with_restricted_permissions( - self, base_config: VibeConfig, backend: FakeBackend + self, vibe_config: VibeConfig, backend: FakeBackend ) -> None: agent = build_test_agent_loop( - config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend + config=vibe_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend ) await agent.switch_agent(BuiltinAgentName.PLAN) @@ -471,10 +459,10 @@ class TestAgentSwitchAgent: @pytest.mark.asyncio async def test_switch_from_plan_to_default_restores_tools( - self, base_config: VibeConfig, backend: FakeBackend + self, vibe_config: VibeConfig, backend: FakeBackend ) -> None: agent = build_test_agent_loop( - config=base_config, agent_name=BuiltinAgentName.PLAN, backend=backend + config=vibe_config, agent_name=BuiltinAgentName.PLAN, backend=backend ) await agent.switch_agent(BuiltinAgentName.DEFAULT) @@ -486,10 +474,10 @@ class TestAgentSwitchAgent: @pytest.mark.asyncio async def test_switch_agent_preserves_conversation_history( - self, base_config: VibeConfig, backend: FakeBackend + self, vibe_config: VibeConfig, backend: FakeBackend ) -> None: agent = build_test_agent_loop( - config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend + config=vibe_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend ) user_msg = LLMMessage(role=Role.user, content="Hello") assistant_msg = LLMMessage(role=Role.assistant, content="Hi there") @@ -504,10 +492,10 @@ class TestAgentSwitchAgent: @pytest.mark.asyncio async def test_switch_to_same_agent_is_noop( - self, base_config: VibeConfig, backend: FakeBackend + self, vibe_config: VibeConfig, backend: FakeBackend ) -> None: agent = build_test_agent_loop( - config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend + config=vibe_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend ) original_config = agent.config @@ -587,11 +575,7 @@ class TestPlanAgentToolRestriction: class TestAgentManagerFiltering: def test_enabled_agents_filters_to_only_enabled(self) -> None: - config = build_test_vibe_config( - include_project_context=False, - include_prompt_detail=False, - enabled_agents=["default", "plan"], - ) + config = build_test_vibe_config(enabled_agents=["default", "plan"]) manager = AgentManager(lambda: config) agents = manager.available_agents @@ -603,9 +587,7 @@ class TestAgentManagerFiltering: def test_disabled_agents_excludes_disabled(self) -> None: config = build_test_vibe_config( - include_project_context=False, - include_prompt_detail=False, - disabled_agents=["auto-approve", "accept-edits"], + disabled_agents=["auto-approve", "accept-edits"] ) manager = AgentManager(lambda: config) @@ -618,8 +600,6 @@ class TestAgentManagerFiltering: def test_enabled_agents_takes_precedence_over_disabled(self) -> None: config = build_test_vibe_config( - include_project_context=False, - include_prompt_detail=False, enabled_agents=["default"], disabled_agents=["default"], # Should be ignored ) @@ -630,11 +610,7 @@ class TestAgentManagerFiltering: assert "default" in agents def test_glob_pattern_matching(self) -> None: - config = build_test_vibe_config( - include_project_context=False, - include_prompt_detail=False, - disabled_agents=["auto-*", "accept-*"], - ) + config = build_test_vibe_config(disabled_agents=["auto-*", "accept-*"]) manager = AgentManager(lambda: config) agents = manager.available_agents @@ -644,11 +620,7 @@ class TestAgentManagerFiltering: assert "accept-edits" not in agents def test_regex_pattern_matching(self) -> None: - config = build_test_vibe_config( - include_project_context=False, - include_prompt_detail=False, - enabled_agents=["re:^(default|plan)$"], - ) + config = build_test_vibe_config(enabled_agents=["re:^(default|plan)$"]) manager = AgentManager(lambda: config) agents = manager.available_agents @@ -657,11 +629,7 @@ class TestAgentManagerFiltering: assert "plan" in agents def test_empty_enabled_agents_returns_all(self) -> None: - config = build_test_vibe_config( - include_project_context=False, - include_prompt_detail=False, - enabled_agents=[], - ) + config = build_test_vibe_config(enabled_agents=[]) manager = AgentManager(lambda: config) agents = manager.available_agents @@ -671,31 +639,21 @@ class TestAgentManagerFiltering: assert "explore" in agents def test_install_required_agents_hidden_by_default(self) -> None: - config = build_test_vibe_config( - include_project_context=False, include_prompt_detail=False - ) + config = build_test_vibe_config() manager = AgentManager(lambda: config) agents = manager.available_agents assert "lean" not in agents def test_install_required_agents_visible_when_installed(self) -> None: - config = build_test_vibe_config( - include_project_context=False, - include_prompt_detail=False, - installed_agents=["lean"], - ) + config = build_test_vibe_config(installed_agents=["lean"]) manager = AgentManager(lambda: config) agents = manager.available_agents assert "lean" in agents def test_get_subagents_respects_filtering(self) -> None: - config = build_test_vibe_config( - include_project_context=False, - include_prompt_detail=False, - disabled_agents=["explore"], - ) + config = build_test_vibe_config(disabled_agents=["explore"]) manager = AgentManager(lambda: config) subagents = manager.get_subagents() @@ -722,11 +680,9 @@ class TestAgentLoopInitialization: monkeypatch.setattr("vibe.core.agents.models.BUILTIN_AGENTS", patched_agents) monkeypatch.setattr("vibe.core.agents.manager.BUILTIN_AGENTS", patched_agents) - config = build_test_vibe_config( - include_project_context=False, include_prompt_detail=False - ) + config = build_test_vibe_config(system_prompt_id="cli") assert config.system_prompt_id == "cli", ( - "Base config should use default 'cli' prompt" + "Base config should use the 'cli' prompt" ) agent_loop = build_test_agent_loop( diff --git a/tests/agent_loop/test_deferred_init.py b/tests/agent_loop/test_deferred_init.py index 9929cec..1738c75 100644 --- a/tests/agent_loop/test_deferred_init.py +++ b/tests/agent_loop/test_deferred_init.py @@ -19,7 +19,7 @@ from vibe.core.config import MCPStdio from vibe.core.telemetry.types import TerminalEmulator from vibe.core.tools.manager import ToolManager from vibe.core.tools.mcp import AuthStatus -from vibe.core.tools.mcp.tools import RemoteTool +from vibe.core.tools.remote import RemoteTool def _build_uninitiated_loop(**kwargs): @@ -67,8 +67,8 @@ class TestCompleteInit: loop = _build_uninitiated_loop(config=config) with patch.object( - loop.tool_manager._mcp_registry, - "get_tools_async", + loop.tool_manager, + "integrate_all", side_effect=RuntimeError("mcp discovery boom"), ): _run_init(loop) @@ -77,6 +77,20 @@ class TestCompleteInit: assert isinstance(loop._init_error, RuntimeError) assert str(loop._init_error) == "mcp discovery boom" + def test_delays_connector_registry_until_deferred_init(self) -> None: + config = build_test_vibe_config(enable_connectors=True) + with patch.object(AgentLoop, "_start_deferred_init"): + loop = AgentLoop( + config=config, backend=FakeBackend(), defer_heavy_init=True + ) + + assert loop.connector_registry is None + + with patch.object(loop.tool_manager, "integrate_all"): + _run_init(loop) + + assert loop.connector_registry is not None + # --------------------------------------------------------------------------- # wait_until_ready @@ -232,6 +246,27 @@ class TestDeferredInitPublicMethods: assert loop.is_initialized + @pytest.mark.asyncio + async def test_reload_creates_shared_mcp_registry_after_servers_are_added( + self, + ) -> None: + loop = build_test_agent_loop(defer_heavy_init=True, mcp_registry=None) + await loop.wait_until_ready() + assert loop.mcp_registry is None + + mcp_server = MCPStdio(name="srv", transport="stdio", command="echo") + config = build_test_vibe_config(mcp_servers=[mcp_server]) + registry = FakeMCPRegistry() + + with ( + patch.object(AgentLoop, "_create_mcp_registry", return_value=registry), + patch.object(ToolManager, "integrate_all"), + ): + await loop.reload_with_initial_messages(base_config=config) + + assert loop.mcp_registry is registry + assert loop.tool_manager._mcp_registry is registry + @pytest.mark.asyncio async def test_switch_agent_waits_for_deferred_init(self) -> None: loop = build_test_agent_loop(defer_heavy_init=True) diff --git a/tests/backend/data/__init__.py b/tests/backend/data/__init__.py index 0afb089..44650ba 100644 --- a/tests/backend/data/__init__.py +++ b/tests/backend/data/__init__.py @@ -4,3 +4,8 @@ Url = str JsonResponse = dict ResultData = dict Chunk = bytes + +# Shared usage every provider's `answer()` mock reports +ANSWER_PROMPT_TOKENS = 10 +ANSWER_COMPLETION_TOKENS = 5 +ANSWER_CONTEXT_TOKENS = ANSWER_PROMPT_TOKENS + ANSWER_COMPLETION_TOKENS diff --git a/tests/backend/data/anthropic.py b/tests/backend/data/anthropic.py index 5cd9804..657fd2b 100644 --- a/tests/backend/data/anthropic.py +++ b/tests/backend/data/anthropic.py @@ -3,13 +3,42 @@ from __future__ import annotations import json from typing import Any -from tests.backend.data import Chunk, JsonResponse +from tests.backend.data import ( + ANSWER_COMPLETION_TOKENS, + ANSWER_PROMPT_TOKENS, + Chunk, + JsonResponse, +) def _sse_event(data: dict[str, Any]) -> Chunk: return f"data: {json.dumps(data, separators=(',', ':'))}".encode() +def _block_start(index: int, block: dict[str, Any]) -> dict[str, Any]: + return {"type": "content_block_start", "index": index, "content_block": block} + + +def _block_delta(index: int, delta: dict[str, Any]) -> dict[str, Any]: + return {"type": "content_block_delta", "index": index, "delta": delta} + + +def _content_stream( + blocks: list[dict[str, Any]], *, input_tokens: int, output_tokens: int, stop: str +) -> list[Chunk]: + events = [ + {"type": "message_start", "message": {"usage": {"input_tokens": input_tokens}}}, + *blocks, + { + "type": "message_delta", + "delta": {"stop_reason": stop}, + "usage": {"output_tokens": output_tokens}, + }, + {"type": "message_stop"}, + ] + return [_sse_event(e) for e in events] + + def anthropic_request_content_blocks(payload: dict[str, Any]) -> list[dict[str, Any]]: # Flatten every structured content block across a request's messages # (text/thinking/tool_use/tool_result blocks). @@ -24,8 +53,8 @@ def anthropic_request_content_blocks(payload: dict[str, Any]) -> list[dict[str, def anthropic_message( text: str, *, - input_tokens: int = 12, - output_tokens: int = 3, + input_tokens: int = ANSWER_PROMPT_TOKENS, + output_tokens: int = ANSWER_COMPLETION_TOKENS, stop_reason: str = "end_turn", ) -> JsonResponse: return { @@ -62,71 +91,31 @@ def anthropic_reasoning_tool_use_stream( input_tokens: int = 20, output_tokens: int = 5, ) -> list[Chunk]: - return [ - _sse_event(e) - for e in ( - { - "type": "message_start", - "message": {"usage": {"input_tokens": input_tokens}}, - }, - { - "type": "content_block_start", - "index": 0, - "content_block": {"type": "thinking", "thinking": reasoning}, - }, - { - "type": "content_block_delta", - "index": 0, - "delta": {"type": "signature_delta", "signature": signature}, - }, + return _content_stream( + [ + _block_start(0, {"type": "thinking", "thinking": reasoning}), + _block_delta(0, {"type": "signature_delta", "signature": signature}), {"type": "content_block_stop", "index": 0}, - { - "type": "content_block_start", - "index": 1, - "content_block": {"type": "tool_use", "id": tool_id, "name": name}, - }, - { - "type": "content_block_delta", - "index": 1, - "delta": {"type": "input_json_delta", "partial_json": arguments}, - }, + _block_start(1, {"type": "tool_use", "id": tool_id, "name": name}), + _block_delta(1, {"type": "input_json_delta", "partial_json": arguments}), {"type": "content_block_stop", "index": 1}, - { - "type": "message_delta", - "delta": {"stop_reason": "tool_use"}, - "usage": {"output_tokens": output_tokens}, - }, - {"type": "message_stop"}, - ) - ] + ], + input_tokens=input_tokens, + output_tokens=output_tokens, + stop="tool_use", + ) def anthropic_text_stream( text: str, *, input_tokens: int = 12, output_tokens: int = 3 ) -> list[Chunk]: - return [ - _sse_event(e) - for e in ( - { - "type": "message_start", - "message": {"usage": {"input_tokens": input_tokens}}, - }, - { - "type": "content_block_start", - "index": 0, - "content_block": {"type": "text", "text": ""}, - }, - { - "type": "content_block_delta", - "index": 0, - "delta": {"type": "text_delta", "text": text}, - }, + return _content_stream( + [ + _block_start(0, {"type": "text", "text": ""}), + _block_delta(0, {"type": "text_delta", "text": text}), {"type": "content_block_stop", "index": 0}, - { - "type": "message_delta", - "delta": {"stop_reason": "end_turn"}, - "usage": {"output_tokens": output_tokens}, - }, - {"type": "message_stop"}, - ) - ] + ], + input_tokens=input_tokens, + output_tokens=output_tokens, + stop="end_turn", + ) diff --git a/tests/backend/data/openai_responses.py b/tests/backend/data/openai_responses.py index df83cb4..f8f3041 100644 --- a/tests/backend/data/openai_responses.py +++ b/tests/backend/data/openai_responses.py @@ -3,7 +3,14 @@ from __future__ import annotations import json from typing import Any -from tests.backend.data import Chunk, JsonResponse, ResultData, Url +from tests.backend.data import ( + ANSWER_COMPLETION_TOKENS, + ANSWER_PROMPT_TOKENS, + Chunk, + JsonResponse, + ResultData, + Url, +) from tests.constants import OPENAI_BASE_URL @@ -104,8 +111,8 @@ def openai_function_call_item( def openai_response( output: list[dict[str, Any]], *, - input_tokens: int = 10, - output_tokens: int = 2, + input_tokens: int = ANSWER_PROMPT_TOKENS, + output_tokens: int = ANSWER_COMPLETION_TOKENS, response_id: str = "resp_1", model: str = "gpt-test", ) -> JsonResponse: diff --git a/tests/backend/data/reasoning.py b/tests/backend/data/reasoning.py new file mode 100644 index 0000000..fd017e3 --- /dev/null +++ b/tests/backend/data/reasoning.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import json +from typing import Any + +from tests.backend.data import ( + ANSWER_COMPLETION_TOKENS, + ANSWER_PROMPT_TOKENS, + Chunk, + JsonResponse, +) + + +def _sse_event(data: dict[str, Any]) -> Chunk: + return f"data: {json.dumps(data, separators=(',', ':'))}".encode() + + +def _thinking_block(reasoning: str) -> dict[str, Any]: + return {"type": "thinking", "thinking": [{"type": "text", "text": reasoning}]} + + +def reasoning_message( + text: str, + *, + prompt_tokens: int = ANSWER_PROMPT_TOKENS, + completion_tokens: int = ANSWER_COMPLETION_TOKENS, +) -> JsonResponse: + return { + "choices": [{"message": {"role": "assistant", "content": text}}], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + }, + } + + +def reasoning_thinking_message( + text: str, reasoning: str, *, prompt_tokens: int = 12, completion_tokens: int = 5 +) -> JsonResponse: + return { + "choices": [ + { + "message": { + "role": "assistant", + "content": [ + _thinking_block(reasoning), + {"type": "text", "text": text}, + ], + } + } + ], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + }, + } + + +def reasoning_tool_use( + name: str, + arguments: str, + *, + reasoning: str | None = None, + tool_id: str = "call_1", + prompt_tokens: int = 20, + completion_tokens: int = 5, +) -> JsonResponse: + message: dict[str, Any] = { + "role": "assistant", + "tool_calls": [ + { + "id": tool_id, + "type": "function", + "function": {"name": name, "arguments": arguments}, + } + ], + } + if reasoning is not None: + message["content"] = [_thinking_block(reasoning)] + return { + "choices": [{"message": message}], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + }, + } + + +def _delta_stream( + deltas: list[dict[str, Any]], *, prompt_tokens: int, completion_tokens: int +) -> list[Chunk]: + events = [ + {"choices": [{"delta": {"role": "assistant"}}]}, + *({"choices": [{"delta": delta}]} for delta in deltas), + { + "choices": [{"delta": {}}], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + }, + }, + ] + return [_sse_event(e) for e in events] + [b"data: [DONE]"] + + +def reasoning_text_stream( + text: str, *, prompt_tokens: int = 10, completion_tokens: int = 3 +) -> list[Chunk]: + return _delta_stream( + [{"content": text}], + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + ) + + +def reasoning_thinking_tool_use_stream( + name: str, + arguments: str, + *, + reasoning: str = "thinking...", + tool_id: str = "call_1", + prompt_tokens: int = 20, + completion_tokens: int = 5, +) -> list[Chunk]: + tool_call = { + "id": tool_id, + "type": "function", + "index": 0, + "function": {"name": name, "arguments": arguments}, + } + return _delta_stream( + [{"content": [_thinking_block(reasoning)]}, {"tool_calls": [tool_call]}], + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + ) diff --git a/tests/backend/test_vertex_anthropic_adapter.py b/tests/backend/test_vertex_anthropic_adapter.py index c229e43..e1ca54b 100644 --- a/tests/backend/test_vertex_anthropic_adapter.py +++ b/tests/backend/test_vertex_anthropic_adapter.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest +from tests import constants as c from vibe.core.config import ProviderConfig from vibe.core.llm.backend.vertex import ( VertexAnthropicAdapter, @@ -32,8 +33,8 @@ def provider(): return ProviderConfig( name="vertex", api_base="", - project_id="test-project", - region="us-central1", + project_id=c.VERTEX_PROJECT_ID, + region=c.VERTEX_REGION, api_style="vertex-anthropic", ) @@ -58,8 +59,8 @@ class TestBuildVertexEndpoint: ) def test_base_url(self): - base = build_vertex_base_url("us-central1") - assert base == "https://us-central1-aiplatform.googleapis.com" + base = build_vertex_base_url(c.VERTEX_REGION) + assert base == c.VERTEX_BASE_URL def test_global_endpoint(self): endpoint = build_vertex_endpoint("global", "my-project", "claude-3-5-sonnet") @@ -96,7 +97,7 @@ class TestPrepareRequest: assert req.headers["anthropic-beta"] == adapter.BETA_FEATURES assert "rawPredict" in req.endpoint assert "streamRawPredict" not in req.endpoint - assert req.base_url == "https://us-central1-aiplatform.googleapis.com" + assert req.base_url == c.VERTEX_BASE_URL def test_streaming_request(self, adapter, provider): messages = [LLMMessage(role=Role.user, content="Hello")] @@ -182,7 +183,7 @@ class TestPrepareRequest: provider = ProviderConfig( name="vertex", api_base="", - region="us-central1", + region=c.VERTEX_REGION, api_style="vertex-anthropic", ) with pytest.raises(ValueError, match="project_id"): @@ -201,7 +202,7 @@ class TestPrepareRequest: provider = ProviderConfig( name="vertex", api_base="", - project_id="test-project", + project_id=c.VERTEX_PROJECT_ID, api_style="vertex-anthropic", ) with pytest.raises(ValueError, match="region"): diff --git a/tests/cli/plan_offer/test_decide_plan_offer.py b/tests/cli/plan_offer/test_decide_plan_offer.py index 47953a6..4104e72 100644 --- a/tests/cli/plan_offer/test_decide_plan_offer.py +++ b/tests/cli/plan_offer/test_decide_plan_offer.py @@ -183,7 +183,7 @@ def test_resolve_api_key_for_plan_falls_back_to_keyring( monkeypatch.delenv("MISTRAL_API_KEY", raising=False) keyring_api_key = "keyring_mistral_api_key" monkeypatch.setattr( - "vibe.core.config._settings.keyring.get_password", + "vibe.core.utils.keyring.keyring.get_password", lambda service, username: keyring_api_key, ) diff --git a/tests/cli/smoke_binary.py b/tests/cli/smoke_binary.py index 64fd1e6..5ebe4aa 100644 --- a/tests/cli/smoke_binary.py +++ b/tests/cli/smoke_binary.py @@ -35,6 +35,7 @@ def _fail(msg: str) -> NoReturn: def _isolated_env(vibe_home: Path) -> dict[str, str]: env = os.environ.copy() env["VIBE_HOME"] = str(vibe_home) + env["VIBE_TEST_DISABLE_KEYRING"] = "1" env["TERM"] = env.get("TERM") or "xterm-256color" env.pop("MISTRAL_API_KEY", None) env.pop("VIBE_MISTRAL_API_KEY", None) diff --git a/tests/cli/test_copy_shortcuts.py b/tests/cli/test_copy_shortcuts.py index 374fb40..a2f021d 100644 --- a/tests/cli/test_copy_shortcuts.py +++ b/tests/cli/test_copy_shortcuts.py @@ -3,8 +3,10 @@ from __future__ import annotations from unittest.mock import patch import pytest +from textual.widgets import TextArea from tests.conftest import build_test_vibe_app, build_test_vibe_config +from vibe.cli.clipboard import copy_selection_to_clipboard @pytest.mark.asyncio @@ -51,3 +53,20 @@ async def test_mouse_up_respects_autocopy_config_disabled() -> None: async with app.run_test() as pilot: await pilot.click() mock_copy.assert_not_called() + + +@pytest.mark.asyncio +async def test_copy_selection_copies_text_area_selection() -> None: + app = build_test_vibe_app() + + with patch("vibe.cli.clipboard.try_copy_text_to_clipboard") as mock_copy: + mock_copy.return_value = True + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.load_text("hello world") + text_area.select_all() + + result = copy_selection_to_clipboard(app, show_toast=False) + + assert result == "hello world" + mock_copy.assert_called_once_with("hello world") diff --git a/tests/cli/test_lazy_audio_managers.py b/tests/cli/test_lazy_audio_managers.py new file mode 100644 index 0000000..280de85 --- /dev/null +++ b/tests/cli/test_lazy_audio_managers.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import subprocess +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from tests.conftest import ( + build_test_agent_loop, + build_test_vibe_app, + build_test_vibe_config, +) +from tests.stubs.fake_voice_manager import FakeVoiceManager +from vibe.cli.narrator_manager.narrator_manager_port import ( + NarratorManagerListener, + NarratorState, +) +from vibe.cli.textual_ui.lazy_audio_managers import ( + LazyNarratorManager, + LazyVoiceManager, +) +from vibe.cli.voice_manager.voice_manager_port import TranscribeState +from vibe.core.config import VibeConfig +from vibe.core.types import BaseEvent + + +class FakeNarratorManager: + state = NarratorState.IDLE + is_playing = False + + def __init__(self) -> None: + self.listeners: list[NarratorManagerListener] = [] + self.synced = False + + def on_turn_start(self, user_message: str) -> None: + pass + + def on_turn_event(self, event: BaseEvent) -> None: + pass + + def on_turn_error(self, message: str) -> None: + pass + + def on_turn_cancel(self) -> None: + pass + + def on_turn_end(self) -> None: + pass + + def cancel(self) -> None: + pass + + def sync(self) -> None: + self.synced = True + + def add_listener(self, listener: NarratorManagerListener) -> None: + if listener not in self.listeners: + self.listeners.append(listener) + + def remove_listener(self, listener: NarratorManagerListener) -> None: + try: + self.listeners.remove(listener) + except ValueError: + pass + + async def close(self) -> None: + pass + + +def test_importing_tui_app_does_not_import_optional_audio_modules() -> None: + code = """ +import sys +import vibe.cli.textual_ui.app + +blocked = [ + "sounddevice", + "vibe.cli.narrator_manager.narrator_manager", + "vibe.cli.voice_manager.voice_manager", + "vibe.core.audio_player.audio_player", + "vibe.core.audio_recorder.audio_recorder", + "vibe.core.transcribe.factory", + "vibe.core.tts.factory", +] +loaded = [name for name in blocked if name in sys.modules] +if loaded: + raise SystemExit(f"unexpected optional modules loaded: {loaded}") +""" + + result = subprocess.run( + [sys.executable, "-c", code], check=False, capture_output=True, text=True + ) + + assert result.returncode == 0, result.stderr or result.stdout + + +def test_default_tui_app_does_not_materialize_disabled_optional_managers() -> None: + config = build_test_vibe_config(voice_mode_enabled=False, narrator_enabled=False) + + with ( + patch( + "vibe.cli.textual_ui.lazy_audio_managers._create_real_voice_manager", + side_effect=AssertionError("voice should stay lazy"), + ), + patch( + "vibe.cli.textual_ui.lazy_audio_managers._create_real_narrator_manager", + side_effect=AssertionError("narrator should stay lazy"), + ), + ): + app = build_test_vibe_app(config=config, voice_manager=None) + + assert app._voice_manager.is_enabled is False + assert app._voice_manager.transcribe_state == TranscribeState.IDLE + assert app._narrator_manager.state == NarratorState.IDLE + + +def test_lazy_voice_manager_materializes_when_used() -> None: + config = build_test_vibe_config(voice_mode_enabled=False) + factory = MagicMock(return_value=FakeVoiceManager(is_voice_ready=True)) + manager = LazyVoiceManager(lambda: config, factory) + + assert manager.is_enabled is False + assert manager.transcribe_state == TranscribeState.IDLE + factory.assert_not_called() + + manager.start_recording() + + factory.assert_called_once() + assert manager.transcribe_state == TranscribeState.RECORDING + + +def test_lazy_narrator_manager_materializes_when_enabled_at_startup() -> None: + config = build_test_vibe_config(narrator_enabled=True) + narrator = FakeNarratorManager() + factory = MagicMock(return_value=narrator) + + manager = LazyNarratorManager(lambda: config, factory) + + factory.assert_called_once() + assert manager.state == NarratorState.IDLE + + +def test_lazy_narrator_manager_sync_materializes_after_enable() -> None: + config = build_test_vibe_config(narrator_enabled=False) + narrator = FakeNarratorManager() + factory = MagicMock(return_value=narrator) + manager = LazyNarratorManager(lambda: config, factory) + + config.narrator_enabled = True + manager.sync() + + factory.assert_called_once() + assert narrator.synced is False + + +def test_tui_config_refresh_syncs_lazy_narrator( + monkeypatch: pytest.MonkeyPatch, +) -> None: + initial_config = build_test_vibe_config(narrator_enabled=False) + refreshed_config = build_test_vibe_config(narrator_enabled=True) + agent_loop = build_test_agent_loop(config=initial_config) + narrator = FakeNarratorManager() + factory = MagicMock(return_value=narrator) + narrator_manager = LazyNarratorManager(lambda: agent_loop.config, factory) + app = build_test_vibe_app(agent_loop=agent_loop, narrator_manager=narrator_manager) + monkeypatch.setattr(VibeConfig, "load", staticmethod(lambda: refreshed_config)) + + app._refresh_config_from_disk() + + factory.assert_called_once() + assert narrator.synced is False diff --git a/tests/cli/test_mcp_app.py b/tests/cli/test_mcp_app.py index 7824964..d23e3d2 100644 --- a/tests/cli/test_mcp_app.py +++ b/tests/cli/test_mcp_app.py @@ -8,6 +8,7 @@ import pytest from textual.widgets.option_list import Option from tests.stubs.fake_connector_registry import FakeConnectorRegistry +from tests.stubs.fake_mcp_registry import FakeMCPRegistry from vibe.cli.textual_ui.widgets.mcp_app import ( _LIST_VIEW_HELP_AUTH, _LIST_VIEW_HELP_TOOLS, @@ -16,7 +17,7 @@ from vibe.cli.textual_ui.widgets.mcp_app import ( _sort_connector_names_for_menu, collect_mcp_tool_index, ) -from vibe.core.config import MCPStdio +from vibe.core.config import MCPHttp, MCPOAuth, MCPStdio from vibe.core.tools.base import InvokeContext from vibe.core.tools.connectors.connector_registry import ( ConnectorAuthAction, @@ -62,6 +63,16 @@ def _make_tool_manager( return mgr +def _make_oauth_server(*, name: str = "oauth", disabled: bool = False) -> MCPHttp: + return MCPHttp( + name=name, + transport="http", + url="http://mcp.example.test", + auth=MCPOAuth(type="oauth", scopes=[]), + disabled=disabled, + ) + + class TestCollectMcpToolIndex: def test_non_mcp_tools_are_excluded(self) -> None: servers = [MCPStdio(name="srv", transport="stdio", command="cmd")] @@ -343,6 +354,26 @@ class TestHelpBarChanges: option = Option("", id="connector:slack") assert app._list_help_for_option(option) == _LIST_VIEW_HELP_AUTH + def test_help_shows_authenticate_for_oauth_server(self) -> None: + server = _make_oauth_server() + registry = FakeMCPRegistry() + mgr = _make_tool_manager({}) + app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry) + + option = Option("", id="server:oauth") + + assert app._list_help_for_option(option) == _LIST_VIEW_HELP_AUTH + + def test_help_shows_tools_for_disabled_oauth_server(self) -> None: + server = _make_oauth_server(disabled=True) + registry = FakeMCPRegistry() + mgr = _make_tool_manager({}) + app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry) + + option = Option("", id="server:oauth") + + assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS + def test_help_shows_tools_for_degraded_connector(self) -> None: registry = FakeConnectorRegistry(connectors={"slack": []}) mgr = _make_tool_manager({}) @@ -443,3 +474,84 @@ class TestConnectorAuthRequested: assert "No tools discovered" in str( option_list.add_option.call_args.args[0].prompt ) + + def test_oauth_server_with_no_tools_posts_auth_requested(self) -> None: + server = _make_oauth_server() + registry = FakeMCPRegistry() + mgr = _make_tool_manager({}) + app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry) + app.query_one = MagicMock() + app.post_message = MagicMock() + + option_list = MagicMock() + app._show_detail_view( + "oauth", option_list, app._index, kind=MCPSourceKind.SERVER + ) + + app.post_message.assert_called_once() + msg = app.post_message.call_args.args[0] + assert isinstance(msg, MCPApp.MCPOAuthRequested) + assert msg.server_name == "oauth" + assert msg.mcp_registry is registry + option_list.add_option.assert_not_called() + + def test_oauth_server_with_stale_tools_posts_auth_requested(self) -> None: + server = _make_oauth_server() + registry = FakeMCPRegistry() + stale_tool = _make_tool_cls( + is_mcp=True, server_name="oauth", remote_name="stale" + ) + mgr = _make_tool_manager({"oauth_stale": stale_tool}) + app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry) + app.query_one = MagicMock() + app.post_message = MagicMock() + + option_list = MagicMock() + app._show_detail_view( + "oauth", option_list, app._index, kind=MCPSourceKind.SERVER + ) + + app.post_message.assert_called_once() + assert isinstance(app.post_message.call_args.args[0], MCPApp.MCPOAuthRequested) + option_list.add_option.assert_not_called() + + +class TestServerStatusLabels: + def test_servers_show_enabled_disabled_and_needs_auth(self) -> None: + servers = [ + MCPStdio(name="filesystem", transport="stdio", command="npx"), + MCPHttp(name="search", transport="http", url="http://mcp.example.test"), + _make_oauth_server(name="oauth"), + _make_oauth_server(name="disabled-oauth", disabled=True), + ] + registry = FakeMCPRegistry() + mgr = _make_tool_manager({}) + app = MCPApp(mcp_servers=servers, tool_manager=mgr, mcp_registry=registry) + option_list = MagicMock() + + app._list_mcp_servers(option_list, app._index) + + prompts = [ + str(call.args[0].prompt) for call in option_list.add_option.call_args_list + ] + assert any("filesystem" in prompt and "enabled" in prompt for prompt in prompts) + assert any("search" in prompt and "enabled" in prompt for prompt in prompts) + assert any("oauth" in prompt and "needs auth" in prompt for prompt in prompts) + assert any( + "disabled-oauth" in prompt and "disabled" in prompt for prompt in prompts + ) + + def test_oauth_server_with_cached_tools_shows_connected(self) -> None: + server = _make_oauth_server() + registry = FakeMCPRegistry() + registry.set_tools([server], {}) + mgr = _make_tool_manager({}) + app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry) + option_list = MagicMock() + + app._list_mcp_servers(option_list, app._index) + + prompts = [ + str(call.args[0].prompt) for call in option_list.add_option.call_args_list + ] + assert any("oauth" in prompt and "connected" in prompt for prompt in prompts) diff --git a/tests/cli/test_mcp_oauth_app.py b/tests/cli/test_mcp_oauth_app.py new file mode 100644 index 0000000..acd6f7f --- /dev/null +++ b/tests/cli/test_mcp_oauth_app.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any, cast +from unittest.mock import MagicMock, patch + +import pytest +from textual.widgets import OptionList + +from vibe.cli.textual_ui.widgets.mcp_oauth_app import ( + MCPOAuthApp, + _LoginResult, + _OAuthOptionId, +) + + +class FakeLoginRegistry: + def __init__(self, *, error: Exception | None = None) -> None: + self.error = error + self.login_calls: list[str] = [] + + async def login( + self, alias: str, *, on_url: Callable[[str], Awaitable[None]] + ) -> None: + self.login_calls.append(alias) + await on_url("https://auth.example.com/oauth") + if self.error: + raise self.error + + +def _make_app(registry: FakeLoginRegistry | None = None) -> MCPOAuthApp: + return MCPOAuthApp( + server_name="oauth", mcp_registry=cast(Any, registry or FakeLoginRegistry()) + ) + + +def _wire_query(app: MCPOAuthApp) -> tuple[MagicMock, MagicMock, MagicMock]: + option_list = MagicMock() + option_list.get_option_index.return_value = 2 + detail = MagicMock() + help_widget = MagicMock() + + def query(sel: object, *a: object, **kw: object) -> MagicMock: + 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 = cast(Any, query) + return option_list, detail, help_widget + + +class TestMCPOAuthApp: + def test_widget_id(self) -> None: + app = _make_app() + assert app.id == "mcpoauth-app" + + def test_action_close_posts_cancelled_message(self) -> None: + app = _make_app() + app.post_message = MagicMock() + + app.action_close() + + msg = app.post_message.call_args.args[0] + assert isinstance(msg, MCPOAuthApp.MCPOAuthClosed) + assert msg.refreshed is False + assert msg.server_name == "" + + def test_auth_url_available_shows_menu(self) -> None: + app = _make_app() + option_list, _detail, _help = _wire_query(app) + + app._on_auth_url_available("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 _OAuthOptionId.OPEN in option_ids + assert _OAuthOptionId.COPY in option_ids + assert _OAuthOptionId.SHOW in option_ids + + @pytest.mark.asyncio + async def test_run_login_starts_registry_login(self) -> None: + registry = FakeLoginRegistry() + app = _make_app(registry) + _wire_query(app) + + result = await app._run_login() + + assert result == _LoginResult(authenticated=True) + assert registry.login_calls == ["oauth"] + assert app._auth_url == "https://auth.example.com/oauth" + + @pytest.mark.asyncio + async def test_run_login_returns_error(self) -> None: + registry = FakeLoginRegistry(error=ValueError("bad auth")) + app = _make_app(registry) + _wire_query(app) + + result = await app._run_login() + + assert result == _LoginResult(authenticated=False, error="bad auth") + + def test_worker_success_posts_closed(self) -> None: + app = _make_app() + app.post_message = MagicMock() + worker = MagicMock() + worker.group = "mcp_oauth_login" + worker.is_finished = True + worker.result = _LoginResult(authenticated=True) + event = MagicMock() + event.worker = worker + + app.on_worker_state_changed(event) + + msg = app.post_message.call_args.args[0] + assert isinstance(msg, MCPOAuthApp.MCPOAuthClosed) + assert msg.refreshed is True + assert msg.server_name == "oauth" + + def test_worker_failure_shows_retry_message(self) -> None: + app = _make_app() + _wire_query(app) + app.post_message = MagicMock() + worker = MagicMock() + worker.group = "mcp_oauth_login" + worker.is_finished = True + worker.result = _LoginResult(authenticated=False, error="bad auth") + event = MagicMock() + event.worker = worker + + app.on_worker_state_changed(event) + + app.post_message.assert_not_called() + assert app._status_message == "bad auth" + + 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.mcp_oauth_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_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.mcp_oauth_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" diff --git a/tests/cli/test_ui_teleport_availability.py b/tests/cli/test_ui_teleport_availability.py index b8fe67e..8cbff82 100644 --- a/tests/cli/test_ui_teleport_availability.py +++ b/tests/cli/test_ui_teleport_availability.py @@ -12,6 +12,7 @@ from tests.constants import OPENAI_BASE_URL from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer from vibe.cli.textual_ui.widgets.messages import ErrorMessage +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig from vibe.core.types import Backend @@ -73,6 +74,23 @@ async def test_teleport_command_visible_for_paid_chat_users() -> None: assert "&" in input_widget.mode_characters +@pytest.mark.asyncio +async def test_plan_resolution_updates_subscription_banner() -> None: + app = build_test_vibe_app( + config=_vibe_code_enabled_config(), + plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=False), + ) + + async with app.run_test() as pilot: + await _wait_until( + pilot.pause, + lambda: ( + "[Subscription] Pro" + in str(app.query_one("#banner-user-plan", NoMarkupStatic).content) + ), + ) + + @pytest.mark.asyncio async def test_teleport_command_without_history_sends_early_failure_telemetry( telemetry_events: list[dict[str, Any]], diff --git a/tests/cli/textual_ui/test_chat_input_keybindings.py b/tests/cli/textual_ui/test_chat_input_keybindings.py index d7faa68..d5ef211 100644 --- a/tests/cli/textual_ui/test_chat_input_keybindings.py +++ b/tests/cli/textual_ui/test_chat_input_keybindings.py @@ -1,6 +1,8 @@ from __future__ import annotations import pytest +from textual.geometry import Offset +from textual.selection import Selection from tests.conftest import build_test_vibe_app from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer, ChatTextArea @@ -62,3 +64,46 @@ async def test_shift_backspace_resets_mode_when_empty() -> None: await pilot.pause(0.1) assert text_area.input_mode == ">" + + +@pytest.mark.asyncio +async def test_selection_cleared_when_focus_leaves_text_area() -> None: + app = build_test_vibe_app() + async with app.run_test() as pilot: + await pilot.pause(0.1) + + text_area = app.query_one(ChatTextArea) + text_area.focus() + await pilot.pause(0.1) + + text_area.load_text("hello world") + text_area.select_all() + await pilot.pause(0.1) + assert text_area.selected_text == "hello world" + + app.query_one("#chat").focus() + await pilot.pause(0.1) + + assert text_area.selected_text == "" + + +@pytest.mark.asyncio +async def test_blur_does_not_clear_other_widget_selection() -> None: + app = build_test_vibe_app() + async with app.run_test() as pilot: + await pilot.pause(0.1) + + text_area = app.query_one(ChatTextArea) + text_area.focus() + text_area.load_text("hello world") + text_area.select_all() + await pilot.pause(0.1) + + chat = app.query_one("#chat") + sentinel = {chat: Selection(Offset(0, 0), Offset(0, 1))} + app.screen.selections = sentinel + text_area.screen.set_focus(chat) + await pilot.pause(0.1) + + assert text_area.selected_text == "" + assert app.screen.selections == sentinel diff --git a/tests/cli/textual_ui/test_context_progress.py b/tests/cli/textual_ui/test_context_progress.py new file mode 100644 index 0000000..250591d --- /dev/null +++ b/tests/cli/textual_ui/test_context_progress.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState + + +def test_context_progress_shows_percentage_when_empty() -> None: + widget = ContextProgress() + + widget.watch_tokens(TokenState(max_tokens=200_000, current_tokens=0)) + + assert str(widget.render()) == "0/200k tokens (0%)" + + +def test_context_progress_uses_compact_integer_format_for_used_tokens() -> None: + widget = ContextProgress() + + widget.watch_tokens(TokenState(max_tokens=200_000, current_tokens=12_500)) + + assert str(widget.render()) == "12k/200k tokens (6%)" + + +def test_context_progress_uses_compact_integer_k_format() -> None: + widget = ContextProgress() + + widget.watch_tokens(TokenState(max_tokens=568_000, current_tokens=170_000)) + + assert str(widget.render()) == "170k/568k tokens (30%)" + + +def test_context_progress_uses_compact_integer_m_format() -> None: + widget = ContextProgress() + + widget.watch_tokens(TokenState(max_tokens=40_000_000, current_tokens=35_900_000)) + + assert str(widget.render()) == "35.9M/40.0M tokens (90%)" diff --git a/tests/cli/textual_ui/test_lazy_startup_imports.py b/tests/cli/textual_ui/test_lazy_startup_imports.py new file mode 100644 index 0000000..c724a47 --- /dev/null +++ b/tests/cli/textual_ui/test_lazy_startup_imports.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys + + +def test_importing_tui_app_does_not_import_deferred_startup_modules() -> None: + code = """ +import sys +import vibe.cli.textual_ui.app + +blocked = [ + "vibe.cli.textual_ui.widgets.connector_auth_app", + "vibe.cli.textual_ui.widgets.mcp_app", + "vibe.core.agent_loop", + "vibe.core.tools.connectors.connector_registry", + "vibe.core.tools.mcp.tools", + "mcp", + "git", +] +loaded = [name for name in blocked if name in sys.modules] +if loaded: + raise SystemExit(f"unexpected startup modules loaded: {loaded}") +""" + + result = subprocess.run( + [sys.executable, "-c", code], check=False, capture_output=True, text=True + ) + + assert result.returncode == 0, result.stderr or result.stdout + + +def test_importing_agent_loop_does_not_import_remote_tool_modules() -> None: + code = """ +import sys +import vibe.core.agent_loop + +blocked = [ + "vibe.core.tools.connectors.connector_registry", + "vibe.core.tools.mcp.tools", + "vibe.core.teleport.git", + "vibe.core.teleport.teleport", + "mcp", + "git", +] +loaded = [name for name in blocked if name in sys.modules] +if loaded: + raise SystemExit(f"unexpected agent loop modules loaded: {loaded}") +""" + + result = subprocess.run( + [sys.executable, "-c", code], check=False, capture_output=True, text=True + ) + + assert result.returncode == 0, result.stderr or result.stdout + + +def test_importing_connector_registry_does_not_import_mcp_runtime() -> None: + code = """ +import sys +import vibe.core.tools.connectors.connector_registry + +blocked = [ + "vibe.core.tools.mcp.tools", + "mcp", +] +loaded = [name for name in blocked if name in sys.modules] +if loaded: + raise SystemExit(f"unexpected connector registry modules loaded: {loaded}") +""" + + result = subprocess.run( + [sys.executable, "-c", code], check=False, capture_output=True, text=True + ) + + assert result.returncode == 0, result.stderr or result.stdout + + +def test_importing_mcp_app_does_not_import_mcp_runtime() -> None: + code = """ +import sys +import vibe.cli.textual_ui.widgets.mcp_app + +blocked = [ + "vibe.core.tools.mcp.tools", + "mcp", +] +loaded = [name for name in blocked if name in sys.modules] +if loaded: + raise SystemExit(f"unexpected mcp app modules loaded: {loaded}") +""" + + result = subprocess.run( + [sys.executable, "-c", code], check=False, capture_output=True, text=True + ) + + assert result.returncode == 0, result.stderr or result.stdout + + +def test_constructing_deferred_agent_loop_does_not_import_mcp_package( + tmp_path: Path, +) -> None: + code = """ +import sys + +from vibe.core.agent_loop import AgentLoop +from vibe.core.config import SessionLoggingConfig, VibeConfig +from vibe.core.config.harness_files import ( + init_harness_files_manager, + reset_harness_files_manager, +) + + +class Backend: + async def complete(self, **kwargs): + raise AssertionError + + async def __aexit__(self, *args): + return None + + +init_harness_files_manager("user", "project") +try: + config = VibeConfig( + enable_connectors=False, + session_logging=SessionLoggingConfig(enabled=False), + ) + loop = AgentLoop( + config=config, + backend=Backend(), + defer_heavy_init=True, + headless=True, + ) + if loop._deferred_init_thread is not None: + loop._deferred_init_thread.join() +finally: + reset_harness_files_manager() + +blocked = [ + "vibe.core.tools.mcp.tools", + "mcp", +] +loaded = [name for name in blocked if name in sys.modules] +if loaded: + raise SystemExit(f"unexpected deferred agent loop modules loaded: {loaded}") +""" + + result = subprocess.run( + [sys.executable, "-c", code], + check=False, + capture_output=True, + text=True, + env={ + **os.environ, + "VIBE_HOME": str(tmp_path), + "VIBE_TEST_DISABLE_KEYRING": "1", + }, + ) + + assert result.returncode == 0, result.stderr or result.stdout diff --git a/tests/cli/textual_ui/test_paste_image.py b/tests/cli/textual_ui/test_paste_image.py new file mode 100644 index 0000000..d04a4a5 --- /dev/null +++ b/tests/cli/textual_ui/test_paste_image.py @@ -0,0 +1,500 @@ +from __future__ import annotations + +import importlib +from pathlib import Path +import platform +import subprocess +from unittest.mock import patch + +import pytest +from textual import events +from textual.app import App, ComposeResult + +from tests.conftest import build_test_vibe_app, build_test_vibe_config +from vibe.cli.commands import CommandRegistry +from vibe.cli.textual_ui.app import VibeApp +from vibe.cli.textual_ui.widgets.chat_input import paste_image +from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer +from vibe.cli.textual_ui.widgets.chat_input.paste_image import ( + _read_macos, + _read_macos_class, + handle_clipboard_image_paste, + read_clipboard_image, + write_clipboard_image, +) +from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea +from vibe.core.config import ModelConfig, ProviderConfig +from vibe.core.types import Backend + +_PNG_HEADER = b"\x89PNG\r\n\x1a\n" +_FAKE_PNG = _PNG_HEADER + b"fake-image-payload" + + +def _build_vision_app(*, supports_images: bool = True) -> VibeApp: + config = build_test_vibe_config( + active_model="devstral-latest", + models=[ + ModelConfig( + name="mistral-vibe-cli-latest", + provider="mistral", + alias="devstral-latest", + supports_images=supports_images, + ) + ], + providers=[ + ProviderConfig( + name="mistral", + api_base="https://api.mistral.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + backend=Backend.MISTRAL, + ) + ], + ) + return build_test_vibe_app(config=config) + + +def _completed(stdout: bytes = b"", returncode: int = 0) -> subprocess.CompletedProcess: + return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout) + + +@pytest.fixture +def _force_supported_platform(monkeypatch) -> None: + monkeypatch.setattr(paste_image, "is_clipboard_image_paste_supported", lambda: True) + + +async def _press_ctrl_v_under_platform(monkeypatch, system: str) -> tuple[bool, list]: + # The ctrl+v binding is registered at ChatTextArea class-definition time only + # on macOS, so the result of pressing it depends on the OS. Reload the module + # under a forced platform.system() to get a deterministically-defined class, + # then exercise it in a minimal app (the full app shares binding maps across + # instances via a shallow copy, which makes a host-dependent setup flaky). + monkeypatch.setattr(platform, "system", lambda: system) + text_area_module = importlib.import_module( + "vibe.cli.textual_ui.widgets.chat_input.text_area" + ) + importlib.reload(text_area_module) + try: + chat_text_area = text_area_module.ChatTextArea + has_binding = any(b.key == "ctrl+v" for b in chat_text_area.BINDINGS) + + class _MiniApp(App): + def compose(self) -> ComposeResult: + yield chat_text_area(command_registry=CommandRegistry()) + + posted: list = [] + async with _MiniApp().run_test() as pilot: + text_area = pilot.app.query_one(chat_text_area) + text_area.focus() + await pilot.pause() + original_post = text_area.post_message + + def wrapped(message): + if isinstance(message, chat_text_area.ClipboardImagePasted): + posted.append(message) + return original_post(message) + + text_area.post_message = wrapped + await pilot.press("ctrl+v") + await pilot.pause() + return has_binding, posted + finally: + monkeypatch.undo() + importlib.reload(text_area_module) + + +def test_read_clipboard_image_returns_none_when_no_readers( + monkeypatch, _force_supported_platform +) -> None: + monkeypatch.setattr(paste_image, "_readers_for_platform", lambda: []) + assert read_clipboard_image() is None + + +def test_read_clipboard_image_skips_non_png_bytes( + monkeypatch, _force_supported_platform +) -> None: + monkeypatch.setattr( + paste_image, "_readers_for_platform", lambda: [lambda: b"not-a-png"] + ) + assert read_clipboard_image() is None + + +def test_read_clipboard_image_returns_first_png( + monkeypatch, _force_supported_platform +) -> None: + monkeypatch.setattr( + paste_image, + "_readers_for_platform", + lambda: [lambda: None, lambda: _FAKE_PNG, lambda: b"unused"], + ) + assert read_clipboard_image() == _FAKE_PNG + + +def test_read_clipboard_image_swallows_reader_exceptions( + monkeypatch, _force_supported_platform +) -> None: + def _raises() -> bytes | None: + raise RuntimeError("boom") + + monkeypatch.setattr( + paste_image, "_readers_for_platform", lambda: [_raises, lambda: _FAKE_PNG] + ) + assert read_clipboard_image() == _FAKE_PNG + + +def test_macos_class_reader_returns_bytes_when_osascript_succeeds(monkeypatch) -> None: + def fake_run(cmd, **kwargs): + # osascript wrote the temp file before returning 0; mimic that. + script_text = cmd[2] + prefix = 'set targetFile to POSIX file "' + start = script_text.index(prefix) + len(prefix) + end = script_text.index('"', start) + Path(script_text[start:end]).write_bytes(_FAKE_PNG) + return _completed() + + monkeypatch.setattr(subprocess, "run", fake_run) + assert _read_macos_class("PNGf") == _FAKE_PNG + + +def test_macos_class_reader_returns_none_when_osascript_fails(monkeypatch) -> None: + monkeypatch.setattr(subprocess, "run", lambda *a, **k: _completed(returncode=1)) + assert _read_macos_class("PNGf") is None + + +def test_macos_reader_falls_back_to_tiff_when_png_missing(monkeypatch) -> None: + fake_tiff = b"II*\x00fake-tiff" + calls: list[str] = [] + # `sips` only exists on macOS; force the which() probe so the conversion + # path runs (and hits the mocked subprocess.run) on Linux CI too. + monkeypatch.setattr(paste_image.shutil, "which", lambda _name: "/usr/bin/sips") + + def fake_run(cmd, **kwargs): + if cmd[0] == "osascript": + script_text = cmd[2] + if "PNGf" in script_text: + calls.append("PNGf") + return _completed(returncode=1) + calls.append("TIFF") + prefix = 'set targetFile to POSIX file "' + start = script_text.index(prefix) + len(prefix) + end = script_text.index('"', start) + Path(script_text[start:end]).write_bytes(fake_tiff) + return _completed() + # sips invocation + calls.append("sips") + src = Path(cmd[cmd.index("--out") - 1]) + out = Path(cmd[cmd.index("--out") + 1]) + # Mimic conversion: write a fake PNG to the --out path. + assert src.read_bytes() == fake_tiff + out.write_bytes(_FAKE_PNG) + return _completed() + + monkeypatch.setattr(subprocess, "run", fake_run) + assert _read_macos() == _FAKE_PNG + assert calls == ["PNGf", "TIFF", "sips"] + + +def test_reader_timeout_is_swallowed(monkeypatch) -> None: + def fake_run(*args, **kwargs): + raise subprocess.TimeoutExpired(cmd=args[0], timeout=1.0) + + monkeypatch.setattr(subprocess, "run", fake_run) + monkeypatch.setattr(paste_image, "_readers_for_platform", lambda: [_read_macos]) + monkeypatch.setattr(paste_image, "is_clipboard_image_paste_supported", lambda: True) + assert read_clipboard_image() is None + + +def test_read_clipboard_image_returns_none_on_unsupported_platform(monkeypatch) -> None: + monkeypatch.setattr( + paste_image, "is_clipboard_image_paste_supported", lambda: False + ) + # Even if a reader were defined, the platform gate short-circuits. + monkeypatch.setattr( + paste_image, "_readers_for_platform", lambda: [lambda: _FAKE_PNG] + ) + assert read_clipboard_image() is None + + +def test_write_clipboard_image_uses_filename_without_whitespace(tmp_path) -> None: + path = write_clipboard_image(_FAKE_PNG, session_dir=tmp_path) + + assert path.parent == tmp_path / "attachments" + assert not any(char.isspace() for char in path.name) + + +@pytest.mark.asyncio +async def test_empty_paste_posts_clipboard_image_pasted_message( + vibe_app: VibeApp, +) -> None: + posted: list[ChatTextArea.ClipboardImagePasted] = [] + + def capture(message) -> None: + if isinstance(message, ChatTextArea.ClipboardImagePasted): + posted.append(message) + + async with vibe_app.run_test() as pilot: + text_area = vibe_app.query_one(ChatTextArea) + text_area.focus() + original_post = text_area.post_message + + def wrapped(message): + capture(message) + return original_post(message) + + text_area.post_message = wrapped # type: ignore[method-assign] + text_area.post_message(events.Paste(text="")) + await pilot.pause() + + assert len(posted) == 1 + + +@pytest.mark.asyncio +async def test_non_empty_paste_does_not_post_clipboard_image_pasted( + vibe_app: VibeApp, +) -> None: + posted: list[ChatTextArea.ClipboardImagePasted] = [] + + async with vibe_app.run_test() as pilot: + text_area = vibe_app.query_one(ChatTextArea) + text_area.focus() + original_post = text_area.post_message + + def wrapped(message): + if isinstance(message, ChatTextArea.ClipboardImagePasted): + posted.append(message) + return original_post(message) + + text_area.post_message = wrapped # type: ignore[method-assign] + text_area.post_message(events.Paste(text="hello world")) + await pilot.pause() + + assert posted == [] + + +@pytest.mark.asyncio +async def test_handle_clipboard_image_paste_writes_file_and_inserts_token() -> None: + app = _build_vision_app() + notifications: list[str] = [] + with ( + patch( + "vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image", + return_value=_FAKE_PNG, + ), + patch( + "vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported", + return_value=True, + ), + ): + async with app.run_test() as pilot: + text_area = app.query_one(ChatTextArea) + text_area.focus() + with patch.object( + app, "notify", side_effect=lambda msg, **_kw: notifications.append(msg) + ): + await handle_clipboard_image_paste(app, notify_when_empty=False) + await pilot.pause() + chat_input = app.query_one(ChatInputContainer) + value = chat_input.value + assert value.startswith("@") + assert value.endswith(".png ") + assert "clipboard-" in value + assert len(notifications) == 1 + assert notifications[0].startswith("Image pasted as clipboard-") + assert notifications[0].endswith(".png (26 Bytes)") + + +@pytest.mark.asyncio +async def test_handle_clipboard_image_paste_warns_when_token_insert_fails( + tmp_path, monkeypatch +) -> None: + app = _build_vision_app() + path = tmp_path / "clipboard.png" + notifications: list[tuple[str, str | None]] = [] + + def fake_write_clipboard_image(data: bytes, *, session_dir: Path | None) -> Path: + path.write_bytes(data) + return path + + def fake_query_one(*_args, **_kwargs): + raise RuntimeError("not mounted") + + monkeypatch.setattr(paste_image, "is_clipboard_image_paste_supported", lambda: True) + monkeypatch.setattr(paste_image, "read_clipboard_image", lambda: _FAKE_PNG) + monkeypatch.setattr( + paste_image, "write_clipboard_image", fake_write_clipboard_image + ) + monkeypatch.setattr(app, "query_one", fake_query_one) + monkeypatch.setattr( + app, + "notify", + lambda msg, **kwargs: notifications.append((msg, kwargs.get("severity"))), + ) + + await handle_clipboard_image_paste(app, notify_when_empty=False) + + assert not path.exists() + assert notifications == [("Failed to paste image into prompt.", "warning")] + + +@pytest.mark.asyncio +async def test_handle_clipboard_image_paste_noop_when_clipboard_empty() -> None: + app = _build_vision_app() + with ( + patch( + "vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image", + return_value=None, + ), + patch( + "vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported", + return_value=True, + ), + ): + async with app.run_test() as pilot: + text_area = app.query_one(ChatTextArea) + text_area.focus() + await handle_clipboard_image_paste(app, notify_when_empty=False) + await pilot.pause() + assert app.query_one(ChatInputContainer).value == "" + + +@pytest.mark.asyncio +async def test_handle_clipboard_image_paste_blocks_when_model_no_vision() -> None: + app = _build_vision_app(supports_images=False) + with ( + patch( + "vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image", + return_value=_FAKE_PNG, + ), + patch( + "vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported", + return_value=True, + ), + ): + async with app.run_test() as pilot: + text_area = app.query_one(ChatTextArea) + text_area.focus() + await handle_clipboard_image_paste(app, notify_when_empty=False) + await pilot.pause() + assert app.query_one(ChatInputContainer).value == "" + + +@pytest.mark.asyncio +async def test_handle_clipboard_image_paste_rejects_oversize(monkeypatch) -> None: + app = _build_vision_app() + notifications: list[str] = [] + monkeypatch.setattr( + "vibe.cli.textual_ui.widgets.chat_input.paste_image.MAX_IMAGE_BYTES", 10 + ) + with ( + patch( + "vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image", + return_value=_FAKE_PNG, + ), + patch( + "vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported", + return_value=True, + ), + ): + async with app.run_test() as pilot: + text_area = app.query_one(ChatTextArea) + text_area.focus() + with patch.object( + app, "notify", side_effect=lambda msg, **_kw: notifications.append(msg) + ): + await handle_clipboard_image_paste(app, notify_when_empty=False) + await pilot.pause() + assert app.query_one(ChatInputContainer).value == "" + assert notifications == ["Clipboard image is 26 Bytes; max is 10 Bytes."] + + +def test_paste_image_slash_command_hidden_on_unsupported_platform(monkeypatch) -> None: + from vibe.cli.commands import CommandRegistry + + monkeypatch.setattr("platform.system", lambda: "Linux") + registry = CommandRegistry() + assert not registry.has_command("paste-image") + + +def test_paste_image_slash_command_available_on_darwin(monkeypatch) -> None: + from vibe.cli.commands import CommandRegistry + + monkeypatch.setattr("platform.system", lambda: "Darwin") + registry = CommandRegistry() + assert registry.has_command("paste-image") + + +def test_ctrl_v_binding_absent_on_unsupported_platform(monkeypatch) -> None: + # Bindings are evaluated at class-definition time, so we have to + # re-import the module after patching platform.system(). + import importlib + + from vibe.cli.textual_ui.widgets.chat_input import text_area as text_area_module + + monkeypatch.setattr("platform.system", lambda: "Linux") + reloaded = importlib.reload(text_area_module) + try: + assert all(b.key != "ctrl+v" for b in reloaded.ChatTextArea.BINDINGS) + finally: + importlib.reload(text_area_module) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("notify_when_empty", [True, False]) +async def test_handle_clipboard_image_paste_is_silent_on_unsupported_platform( + notify_when_empty: bool, +) -> None: + app = _build_vision_app() + notifications: list[str] = [] + with patch( + "vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported", + return_value=False, + ): + async with app.run_test() as pilot: + with patch.object( + app, "notify", side_effect=lambda msg, **_kw: notifications.append(msg) + ): + await handle_clipboard_image_paste( + app, notify_when_empty=notify_when_empty + ) + await pilot.pause() + assert notifications == [] + + +@pytest.mark.asyncio +async def test_ctrl_v_keybinding_triggers_image_paste_with_notify_on_darwin( + monkeypatch, +) -> None: + has_binding, posted = await _press_ctrl_v_under_platform(monkeypatch, "Darwin") + assert has_binding + assert len(posted) == 1 + assert posted[0].notify_when_empty is True + + +@pytest.mark.asyncio +async def test_ctrl_v_keybinding_does_not_paste_image_on_linux(monkeypatch) -> None: + has_binding, posted = await _press_ctrl_v_under_platform(monkeypatch, "Linux") + assert not has_binding + assert posted == [] + + +@pytest.mark.asyncio +async def test_insert_image_token_adds_leading_space_when_needed() -> None: + app = _build_vision_app() + with ( + patch( + "vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image", + return_value=_FAKE_PNG, + ), + patch( + "vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported", + return_value=True, + ), + ): + async with app.run_test() as pilot: + text_area = app.query_one(ChatTextArea) + text_area.focus() + text_area.text = "look at this:" + text_area.move_cursor((0, len("look at this:"))) + await handle_clipboard_image_paste(app, notify_when_empty=False) + await pilot.pause() + value = app.query_one(ChatInputContainer).value + assert " @" in value and value.startswith("look at this: @") diff --git a/tests/cli/textual_ui/test_startup_prompt.py b/tests/cli/textual_ui/test_startup_prompt.py new file mode 100644 index 0000000..2445c1e --- /dev/null +++ b/tests/cli/textual_ui/test_startup_prompt.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, Mock + +import pytest + +from tests.conftest import build_test_vibe_app +from vibe.cli.plan_offer.decide_plan_offer import PlanInfo +from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType +from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp + + +@pytest.mark.asyncio +async def test_startup_prompt_waits_for_startup_resume_picker( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = build_test_vibe_app(initial_prompt="continue the work") + app._show_resume_picker = True + process_prompt = Mock() + + monkeypatch.setattr(app, "_resolve_plan", AsyncMock()) + monkeypatch.setattr(app, "_check_and_show_whats_new", AsyncMock()) + monkeypatch.setattr(app, "_schedule_update_notification", Mock()) + monkeypatch.setattr(app, "_process_initial_prompt", process_prompt) + + await app._complete_post_ready_startup() + + process_prompt.assert_not_called() + + +@pytest.mark.asyncio +async def test_startup_prompt_runs_after_startup_resume_picker_selection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = build_test_vibe_app(initial_prompt="continue the work") + app._show_resume_picker = True + app._startup_command_availability_ready.set() + process_prompt = Mock() + + monkeypatch.setattr(app, "_switch_to_input_app", AsyncMock()) + monkeypatch.setattr(app, "_resume_local_session", AsyncMock()) + monkeypatch.setattr(app, "_process_initial_prompt", process_prompt) + + await app.on_session_picker_app_session_selected( + SessionPickerApp.SessionSelected("local:session-1", "session-1") + ) + + assert app._show_resume_picker is False + process_prompt.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_startup_teleport_waits_for_plan_resolution_after_session_selection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = build_test_vibe_app(initial_prompt="continue the work") + app._show_resume_picker = True + app._teleport_on_start = True + run_worker = Mock() + handle_teleport = Mock(return_value=object()) + handle_user_message = Mock(return_value=object()) + + monkeypatch.setattr(app, "_switch_to_input_app", AsyncMock()) + monkeypatch.setattr(app, "_resume_local_session", AsyncMock()) + monkeypatch.setattr(app, "run_worker", run_worker) + monkeypatch.setattr(app, "_handle_teleport_command", handle_teleport) + monkeypatch.setattr(app, "_handle_user_message", handle_user_message) + monkeypatch.setattr(app.commands, "has_command", lambda name: name == "teleport") + + task = asyncio.create_task( + app.on_session_picker_app_session_selected( + SessionPickerApp.SessionSelected("local:session-1", "session-1") + ) + ) + await asyncio.sleep(0) + + handle_teleport.assert_not_called() + handle_user_message.assert_not_called() + + app._plan_info = PlanInfo(WhoAmIPlanType.CHAT) + app._refresh_command_registry() + app._startup_command_availability_ready.set() + await task + + handle_teleport.assert_called_once_with("continue the work") + handle_user_message.assert_not_called() + run_worker.assert_called_once_with(handle_teleport.return_value, exclusive=False) diff --git a/tests/cli/textual_ui/test_tool_result_link.py b/tests/cli/textual_ui/test_tool_result_link.py new file mode 100644 index 0000000..5c3443e --- /dev/null +++ b/tests/cli/textual_ui/test_tool_result_link.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import pytest +from textual.app import App, ComposeResult + +from vibe.cli.textual_ui.widgets.links import ( + LinkStatic, + link_markup, + linkify_urls_in_text, +) +from vibe.cli.textual_ui.widgets.tool_widgets import WebSearchResultWidget +from vibe.cli.textual_ui.widgets.tools import ToolCallMessage +from vibe.core.tools.builtins.websearch import WebSearchResult, WebSearchSource + + +def test_link_markup_encodes_url_in_action_and_renders_label() -> None: + # The action arg is percent-encoded; the visible label is the page name. + assert ( + link_markup("Example", "https://example.com") + == "[@click=open_url('https%3A%2F%2Fexample.com')]Example[/]" + ) + + +def test_link_markup_only_links_http_schemes() -> None: + # Non-http(s) schemes render as the plain label, not a clickable @click span. + for url in ("file:///etc/passwd", "javascript:alert(1)", "vscode://x"): + assert link_markup(url, url) == url + assert "@click" not in link_markup(url, url) + + +def test_link_markup_handles_previously_unsafe_urls() -> None: + # Brackets, quotes, parens — all encoded into the action, never a fallback. + for url in ( + "https://e.org/x[1]", + "https://e.org/it's", + "https://e.org/x)", + "https://en.wikipedia.org/wiki/Python_(programming_language)", + ): + markup = link_markup(url, url) + assert markup.startswith("[@click=open_url('") + assert "[1]" not in markup.split("]", 1)[0] # raw bracket not in the tag + + +class _Harness(App): + def __init__(self, url: str = "https://example.com") -> None: + super().__init__() + self.url = url + self.opened: list[str] = [] + + def compose(self) -> ComposeResult: + yield LinkStatic(link_markup(self.url, self.url)) + + def open_url(self, url: str, *, new_tab: bool = True) -> None: + self.opened.append(url) + + +@pytest.mark.asyncio +async def test_clicking_link_span_opens_url() -> None: + app = _Harness() + async with app.run_test() as pilot: + await pilot.pause(0.1) + await pilot.click(LinkStatic) + await pilot.pause(0.1) + + assert app.opened == ["https://example.com"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "url", + [ + "https://en.wikipedia.org/wiki/Python_(programming_language)", + "https://httpbin.org/anything/test[1]", + "https://e.org/it's", + ], +) +async def test_clicking_decodes_back_to_original_url(url: str) -> None: + app = _Harness(url) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await pilot.click(LinkStatic) + await pilot.pause(0.1) + + assert app.opened == [url] + + +@pytest.mark.asyncio +async def test_action_open_url_ignores_non_http_scheme() -> None: + from urllib.parse import quote + + app = _Harness() + async with app.run_test() as pilot: + await pilot.pause(0.1) + app.query_one(LinkStatic).action_open_url(quote("file:///etc/passwd", safe="")) + await pilot.pause(0.1) + + assert app.opened == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("scheme", ["javascript", "file", "data"]) +async def test_unsafe_schemes_are_rejected(scheme: str) -> None: + app = _Harness(f"{scheme}:payload") + async with app.run_test() as pilot: + await pilot.pause(0.1) + await pilot.click(LinkStatic) + await pilot.pause(0.1) + + assert app.opened == [] + + +def test_linkify_urls_in_text_auto_detects_url() -> None: + # Once a tool is opted into linkification, URLs are found in the message + # itself — the call site doesn't have to point at the URL span. + markup = linkify_urls_in_text("Fetched https://example.com (10 chars, text/html)") + assert markup.startswith("Fetched ") + assert ( + "[@click=open_url('https%3A%2F%2Fexample.com')]https://example.com[/]" in markup + ) + + +def test_linkify_urls_in_text_handles_multiple_urls() -> None: + markup = linkify_urls_in_text("see https://a.com and https://b.com here") + assert "[@click=open_url('https%3A%2F%2Fa.com')]https://a.com[/]" in markup + assert "[@click=open_url('https%3A%2F%2Fb.com')]https://b.com[/]" in markup + + +def test_linkify_urls_in_text_keeps_balanced_parens_in_url() -> None: + # Wikipedia-style URLs with `(…)` were the reason the @click action is + # percent-encoded; Rich's URL detector already keeps them in the span. + url = "https://en.wikipedia.org/wiki/Python_(programming_language)" + markup = linkify_urls_in_text(f"see {url} for details") + assert link_markup(url, url) in markup + + +def test_linkify_urls_in_text_escapes_and_keeps_plain_when_no_url() -> None: + # Brackets must be escaped so raw tool text can't break the markup. + assert ( + linkify_urls_in_text("Searched '[a]' (2 sources)") + == "Searched '\\[a]' (2 sources)" + ) + + +async def _rendered_lines(widget: WebSearchResultWidget) -> list[str]: + from textual.widgets import Static + + class _H(App): + def compose(self) -> ComposeResult: + yield widget + + async with _H().run_test() as pilot: + await pilot.pause(0.1) + return [str(w.render()) for w in widget.query(Static)] + + +@pytest.mark.asyncio +async def test_websearch_single_source_is_bulleted_without_header() -> None: + result = WebSearchResult( + query="uv", + answer="ans", + sources=[WebSearchSource(title="Docs", url="https://x.com")], + ) + lines = await _rendered_lines( + WebSearchResultWidget(result, success=True, message="m") + ) + joined = "\n".join(lines) + + assert "• Docs" in joined # bulleted, page name as the link label + assert "https://x.com" not in joined # url lives in the @click action, not text + assert "Source:" not in joined # singular "Source:" prefix dropped + assert "Sources:" not in joined # no header for a lone source + assert "WebSearchSource(" not in joined # raw `sources: [...]` dump dropped + + +@pytest.mark.asyncio +async def test_websearch_multiple_sources_is_bulleted_plural() -> None: + result = WebSearchResult( + query="uv", + answer="ans", + sources=[ + WebSearchSource(title="A", url="https://a.com"), + WebSearchSource(title="B", url="https://b.com"), + ], + ) + lines = await _rendered_lines( + WebSearchResultWidget(result, success=True, message="m") + ) + joined = "\n".join(lines) + + assert "Sources:" in joined + assert "• A" in joined + assert "• B" in joined + assert "https://a.com" not in joined # url lives in the @click action, not text + assert "WebSearchSource(" not in joined + + +@pytest.mark.asyncio +async def test_tool_call_message_set_result_text_renders_clickable_url() -> None: + call = ToolCallMessage(tool_name="web_fetch") + + class _H(App): + def compose(self) -> ComposeResult: + yield call + + async with _H().run_test() as pilot: + await pilot.pause(0.1) + call.set_result_text( + "Fetched https://example.com (10 chars, text/html)", linkify=True + ) + await pilot.pause(0.1) + rendered = str(call._text_widget.render()) if call._text_widget else "" + + # The URL becomes a clickable span in the status line; surrounding text stays. + assert "https://example.com" in rendered + assert "Fetched" in rendered + + +@pytest.mark.asyncio +async def test_tool_call_message_set_result_text_escapes_when_linkify_off() -> None: + call = ToolCallMessage(tool_name="bash") + + class _H(App): + def compose(self) -> ComposeResult: + yield call + + async with _H().run_test() as pilot: + await pilot.pause(0.1) + # Bash isn't in the linkify whitelist; URLs must stay plain text and + # brackets in the message must not be interpreted as markup. + call.set_result_text("ran: see https://example.com [exit 0]") + await pilot.pause(0.1) + rendered = str(call._text_widget.render()) if call._text_widget else "" + + assert "@click=open_url" not in rendered + assert "https://example.com" in rendered + assert "[exit 0]" in rendered diff --git a/tests/cli/textual_ui/test_user_message_attachments.py b/tests/cli/textual_ui/test_user_message_attachments.py index 25eb615..0bd3d4c 100644 --- a/tests/cli/textual_ui/test_user_message_attachments.py +++ b/tests/cli/textual_ui/test_user_message_attachments.py @@ -1,11 +1,23 @@ from __future__ import annotations from pathlib import Path +from unittest.mock import patch from weakref import WeakKeyDictionary +import pytest +from textual.app import App, ComposeResult +from textual.widgets import Link + from vibe.cli.textual_ui.widgets.messages import UserMessage +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.windowing.history import build_history_widgets -from vibe.core.types import FileImageSource, ImageAttachment, LLMMessage, Role +from vibe.core.types import ( + FileImageSource, + ImageAttachment, + InlineImageSource, + LLMMessage, + Role, +) def _att(path: Path, alias: str) -> ImageAttachment: @@ -15,35 +27,92 @@ def _att(path: Path, alias: str) -> ImageAttachment: ) -def test_attachments_footer_singular(tmp_path: Path) -> None: - att = _att(tmp_path / "shot.png", "shot.png") +class _UserMessageApp(App[None]): + CSS = """ + .user-message-wrapper, + .user-message-container, + .user-message-attachments, + .user-message-attachment-line { + width: 100%; + height: auto; + } - rendered = UserMessage._format_attachments_footer([att]) + .user-message-attachment-label, + .user-message-attachment-link { + width: auto; + height: auto; + } + """ - assert "attached image:" in rendered - assert '[link="file://' in rendered - assert "]shot.png[/link]" in rendered + def __init__(self, message: UserMessage) -> None: + super().__init__() + self._message = message + + def compose(self) -> ComposeResult: + yield self._message -def test_attachments_footer_plural(tmp_path: Path) -> None: +@pytest.mark.asyncio +async def test_attachments_render_one_clickable_link_per_image(tmp_path: Path) -> None: a = _att(tmp_path / "a.png", "a.png") b = _att(tmp_path / "b.png", "b.png") + app = _UserMessageApp(UserMessage("look", images=[a, b])) + opened: list[str] = [] - rendered = UserMessage._format_attachments_footer([a, b]) + a_uri = (tmp_path / "a.png").as_uri() + b_uri = (tmp_path / "b.png").as_uri() - assert "attached images:" in rendered - assert rendered.count('[link="file://') == 2 - assert "a.png" in rendered - assert "b.png" in rendered + async with app.run_test() as pilot: + links = list(app.query(Link)) + assert [link.text for link in links] == ["a.png", "b.png"] + assert [link.url for link in links] == [a_uri, b_uri] + + with patch.object( + app, "open_url", side_effect=lambda url, **_kwargs: opened.append(url) + ): + await pilot.click(links[0]) + + assert opened == [a_uri] -def test_attachments_footer_escapes_alias_brackets(tmp_path: Path) -> None: +@pytest.mark.asyncio +async def test_inline_attachment_renders_label_without_link() -> None: + att = ImageAttachment( + source=InlineImageSource(data="aW1n"), alias="pasted.png", mime_type="image/png" + ) + app = _UserMessageApp(UserMessage("look", images=[att])) + + async with app.run_test(): + assert list(app.query(Link)) == [] + label = app.query_one(".user-message-attachment-link") + + assert isinstance(label, NoMarkupStatic) + + +@pytest.mark.asyncio +async def test_attachment_link_renders_alias_brackets_literally(tmp_path: Path) -> None: att = _att(tmp_path / "shot.png", "weird [bracket].png") + app = _UserMessageApp(UserMessage("look", images=[att])) - rendered = UserMessage._format_attachments_footer([att]) + async with app.run_test(): + link = app.query_one(Link) - # Rich's escape() turns "[" into "\[". - assert "\\[bracket]" in rendered + assert link.text == "weird [bracket].png" + + +@pytest.mark.asyncio +async def test_attachment_link_shortens_home_absolute_alias() -> None: + path = Path.home() / "Pictures" / "shot.png" + att = ImageAttachment( + source=FileImageSource(path=path), alias=str(path), mime_type="image/png" + ) + app = _UserMessageApp(UserMessage("look", images=[att])) + + async with app.run_test(): + link = app.query_one(Link) + + assert link.text == "~/Pictures/shot.png" + assert link.url == path.as_uri() def test_resumed_user_message_with_images_renders_footer(tmp_path: Path) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 400be8f..6b10641 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,7 @@ from vibe.core.config.harness_files import ( reset_harness_files_manager, ) from vibe.core.llm.types import BackendLike +from vibe.core.utils import keyring as keyring_utils class _EmptyKeyring(KeyringBackend): @@ -49,11 +50,11 @@ class _EmptyKeyring(KeyringBackend): return None def delete_password(self, service: str, username: str) -> None: - raise keyring.errors.PasswordDeleteError(username) + raise keyring.errors.PasswordDeleteError() @pytest.fixture(autouse=True) -def _disable_os_keyring() -> Generator[None, None, None]: +def _disable_os_keyring(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]: """Keep the suite off the real OS keyring. ``resolve_api_key`` and ``VibeConfig._check_api_key`` now consult the keyring, so @@ -63,10 +64,13 @@ def _disable_os_keyring() -> Generator[None, None, None]: behaviour opt in by patching ``keyring.get_password`` / ``set_password`` directly. """ original = keyring.get_keyring() + monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: False) keyring.set_keyring(_EmptyKeyring()) + keyring_utils.clear_api_key_keyring_cache() try: yield finally: + keyring_utils.clear_api_key_keyring_cache() keyring.set_keyring(original) @@ -181,6 +185,8 @@ def _mock_api_key(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("MISTRAL_API_KEY", "mock") monkeypatch.setenv("ANTHROPIC_API_KEY", "mock") monkeypatch.setenv("OPENAI_API_KEY", "mock") + monkeypatch.setenv("VERTEX_API_KEY", "mock") + monkeypatch.setenv("REASONING_API_KEY", "mock") @pytest.fixture(autouse=True) @@ -303,6 +309,12 @@ def build_test_vibe_config(**kwargs) -> VibeConfig: # Connectors trigger a real HTTP discovery on agent construction; off by # default so tests don't pay for it. Connector tests pass enable_connectors=True. kwargs.setdefault("enable_connectors", False) + # Use the lightweight test system prompt unless a test asks for a real one. + kwargs.setdefault("system_prompt_id", "tests") + # Keep the test prompt minimal: skip project-context discovery and prompt + # detail unless a test opts in. + kwargs.setdefault("include_project_context", False) + kwargs.setdefault("include_prompt_detail", False) return VibeConfig( session_logging=resolved_session_logging, enable_update_checks=resolved_enable_update_checks, diff --git a/tests/constants.py b/tests/constants.py index acafc42..cb17dcf 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -10,5 +10,21 @@ ANTHROPIC_MESSAGES_PATH = "/v1/messages" OPENAI_BASE_URL = "https://api.openai.com" OPENAI_RESPONSES_PATH = "/v1/responses" +VERTEX_PROJECT_ID = "test-project" +VERTEX_REGION = "us-central1" +VERTEX_MODEL = "claude-test" +VERTEX_BASE_URL = "https://us-central1-aiplatform.googleapis.com" +VERTEX_RAW_PREDICT_PATH = ( + "/v1/projects/test-project/locations/us-central1/" + "publishers/anthropic/models/claude-test:rawPredict" +) +VERTEX_STREAM_PREDICT_PATH = ( + "/v1/projects/test-project/locations/us-central1/" + "publishers/anthropic/models/claude-test:streamRawPredict" +) + +REASONING_BASE_URL = "https://api.reasoning.test" +REASONING_COMPLETIONS_PATH = "/chat/completions" + TELEPORT_SESSIONS_PATH = "/api/v1/code/sessions" TELEPORT_COMPLETE_URL = "https://chat.example.com/code/project-id/web-session-id" diff --git a/tests/core/experiments/test_resume.py b/tests/core/experiments/test_resume.py index ea62f43..5691b0b 100644 --- a/tests/core/experiments/test_resume.py +++ b/tests/core/experiments/test_resume.py @@ -147,11 +147,7 @@ def _build_managers(config): @pytest.mark.asyncio async def test_graduated_experiment_with_deleted_variant_file_falls_back() -> None: config = build_test_vibe_config( - system_prompt_id="cli", - include_project_context=False, - include_prompt_detail=False, - include_model_info=False, - include_commit_signature=False, + system_prompt_id="cli", include_model_info=False, include_commit_signature=False ) response = _response_forcing("removed_after_graduation_2025_07") manager = ExperimentManager(client=_StubClient(None)) diff --git a/tests/core/experiments/test_system_prompt_variant.py b/tests/core/experiments/test_system_prompt_variant.py index 7db1582..9f5fca8 100644 --- a/tests/core/experiments/test_system_prompt_variant.py +++ b/tests/core/experiments/test_system_prompt_variant.py @@ -47,11 +47,7 @@ def _build_managers(config): @pytest.mark.asyncio async def test_system_prompt_uses_assigned_variant() -> None: config = build_test_vibe_config( - system_prompt_id="cli", - include_project_context=False, - include_prompt_detail=False, - include_model_info=False, - include_commit_signature=False, + system_prompt_id="cli", include_model_info=False, include_commit_signature=False ) response = EvalResponse.model_validate({ "features": { @@ -78,11 +74,7 @@ async def test_system_prompt_uses_assigned_variant() -> None: @pytest.mark.asyncio async def test_system_prompt_falls_back_to_default_when_variant_unknown() -> None: config = build_test_vibe_config( - system_prompt_id="cli", - include_project_context=False, - include_prompt_detail=False, - include_model_info=False, - include_commit_signature=False, + system_prompt_id="cli", include_model_info=False, include_commit_signature=False ) response = EvalResponse.model_validate({ "features": { @@ -109,11 +101,7 @@ async def test_system_prompt_falls_back_to_default_when_variant_unknown() -> Non def test_system_prompt_uses_default_when_no_manager() -> None: config = build_test_vibe_config( - system_prompt_id="cli", - include_project_context=False, - include_prompt_detail=False, - include_model_info=False, - include_commit_signature=False, + system_prompt_id="cli", include_model_info=False, include_commit_signature=False ) tool_manager, skill_manager, agent_manager = _build_managers(config) prompt = get_universal_system_prompt( @@ -125,8 +113,6 @@ def test_system_prompt_uses_default_when_no_manager() -> None: def test_system_prompt_honors_user_config_when_manager_uninitialized() -> None: config = build_test_vibe_config( system_prompt_id="lean", - include_project_context=False, - include_prompt_detail=False, include_model_info=False, include_commit_signature=False, ) @@ -143,8 +129,6 @@ def test_system_prompt_honors_user_config_when_manager_uninitialized() -> None: async def test_system_prompt_honors_user_config_when_no_remote_assignment() -> None: config = build_test_vibe_config( system_prompt_id="lean", - include_project_context=False, - include_prompt_detail=False, include_model_info=False, include_commit_signature=False, ) @@ -169,8 +153,6 @@ async def test_system_prompt_honors_user_config_when_no_remote_assignment() -> N async def test_user_config_overrides_assigned_experiment_variant() -> None: config = build_test_vibe_config( system_prompt_id="lean", - include_project_context=False, - include_prompt_detail=False, include_model_info=False, include_commit_signature=False, ) diff --git a/tests/core/test_agents.py b/tests/core/test_agents.py index 9eaa880..28e0f7e 100644 --- a/tests/core/test_agents.py +++ b/tests/core/test_agents.py @@ -31,9 +31,7 @@ class TestAgentProfile: class TestAgentManager: @pytest.fixture def manager(self) -> AgentManager: - config = build_test_vibe_config( - include_project_context=False, include_prompt_detail=False - ) + config = build_test_vibe_config() return AgentManager(lambda: config) def test_get_subagents_returns_only_subagents(self, manager: AgentManager) -> None: @@ -81,17 +79,13 @@ class TestAgentManager: def test_initial_agent_rejects_subagent(self) -> None: """Test that creating AgentManager with a subagent as initial_agent raises.""" - config = build_test_vibe_config( - include_project_context=False, include_prompt_detail=False - ) + config = build_test_vibe_config() with pytest.raises(ValueError, match="cannot be used as the primary agent"): AgentManager(lambda: config, initial_agent="explore") def test_initial_agent_accepts_subagent_when_allowed(self) -> None: """Test that allow_subagent=True permits subagent as initial_agent.""" - config = build_test_vibe_config( - include_project_context=False, include_prompt_detail=False - ) + config = build_test_vibe_config() manager = AgentManager( lambda: config, initial_agent="explore", allow_subagent=True ) @@ -99,18 +93,12 @@ class TestAgentManager: def test_initial_agent_accepts_agent_type(self) -> None: """Test that creating AgentManager with an agent-type agent works.""" - config = build_test_vibe_config( - include_project_context=False, include_prompt_detail=False - ) + config = build_test_vibe_config() manager = AgentManager(lambda: config, initial_agent="plan") assert manager.active_profile.name == "plan" def test_initial_agent_raises_when_agent_is_disabled(self) -> None: - config = build_test_vibe_config( - include_project_context=False, - include_prompt_detail=False, - disabled_agents=["plan"], - ) + config = build_test_vibe_config(disabled_agents=["plan"]) with pytest.raises(ValueError, match="disabled_agents") as exc_info: AgentManager(lambda: config, initial_agent="plan") message = str(exc_info.value) @@ -120,11 +108,7 @@ class TestAgentManager: def test_explicit_agent_excluded_by_enabled_agents_does_not_blame_default( self, ) -> None: - config = build_test_vibe_config( - include_project_context=False, - include_prompt_detail=False, - enabled_agents=["default"], - ) + config = build_test_vibe_config(enabled_agents=["default"]) with pytest.raises(ValueError, match="enabled_agents") as exc_info: AgentManager(lambda: config, initial_agent="plan") message = str(exc_info.value) @@ -132,20 +116,14 @@ class TestAgentManager: assert message.startswith("Agent 'plan'") def test_initial_agent_raises_when_agent_does_not_exist(self) -> None: - config = build_test_vibe_config( - include_project_context=False, include_prompt_detail=False - ) + config = build_test_vibe_config() with pytest.raises(ValueError, match="not found"): AgentManager(lambda: config, initial_agent="nonexistent-agent") def test_default_agent_excluded_by_enabled_agents_raises_config_contradiction( self, ) -> None: - config = build_test_vibe_config( - include_project_context=False, - include_prompt_detail=False, - enabled_agents=["plan"], - ) + config = build_test_vibe_config(enabled_agents=["plan"]) with pytest.raises(ValueError, match="enabled_agents") as exc_info: AgentManager(lambda: config) message = str(exc_info.value) @@ -155,11 +133,7 @@ class TestAgentManager: def test_default_agent_excluded_by_disabled_agents_raises_config_contradiction( self, ) -> None: - config = build_test_vibe_config( - include_project_context=False, - include_prompt_detail=False, - disabled_agents=["default"], - ) + config = build_test_vibe_config(disabled_agents=["default"]) with pytest.raises(ValueError, match="disabled_agents") as exc_info: AgentManager(lambda: config) assert "default_agent" in str(exc_info.value) @@ -168,10 +142,7 @@ class TestAgentManager: self, caplog: pytest.LogCaptureFixture ) -> None: config = build_test_vibe_config( - include_project_context=False, - include_prompt_detail=False, - enabled_agents=["plan"], - disabled_agents=["plan"], + enabled_agents=["plan"], disabled_agents=["plan"] ) with caplog.at_level("WARNING"): manager = AgentManager(lambda: config, initial_agent="plan") @@ -181,11 +152,7 @@ class TestAgentManager: def test_install_required_agent_reports_install_not_disabled_agents(self) -> None: # 'lean' is install_required and enabled but not installed: the message # must point to installation, not blame disabled_agents. - config = build_test_vibe_config( - include_project_context=False, - include_prompt_detail=False, - enabled_agents=["lean"], - ) + config = build_test_vibe_config(enabled_agents=["lean"]) with pytest.raises(ValueError, match="requires installation") as exc_info: AgentManager(lambda: config, initial_agent="lean") assert "disabled_agents" not in str(exc_info.value) diff --git a/tests/core/test_config_bus.py b/tests/core/test_config_bus.py new file mode 100644 index 0000000..513c194 --- /dev/null +++ b/tests/core/test_config_bus.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from vibe.core.config.event_bus import EventBus +from vibe.core.config.types import ConfigChangeEvent + + +def _make_event(changed: set[str]) -> ConfigChangeEvent: + return ConfigChangeEvent( + changed_keys=frozenset(changed), before={}, after={}, reason="" + ) + + +def test_wildcard_subscriber_fires_on_every_event() -> None: + bus = EventBus() + received: list[ConfigChangeEvent] = [] + bus.subscribe(received.append) + + event = _make_event({"value"}) + bus.publish(event) + + assert received == [event] + + +def test_keyed_subscriber_filters_by_key() -> None: + bus = EventBus() + received: list[ConfigChangeEvent] = [] + bus.subscribe(received.append, keys={"value"}) + + bus.publish(_make_event({"other"})) + assert received == [] + + bus.publish(_make_event({"value", "other"})) + assert len(received) == 1 + + +def test_ancestor_descendant_keys_match_bidirectionally() -> None: + bus = EventBus() + received: list[ConfigChangeEvent] = [] + bus.subscribe(received.append, keys={"models"}) + bus.subscribe(received.append, keys={"models/models"}) + + bus.publish(_make_event({"models/models"})) + bus.publish(_make_event({"models"})) + + assert len(received) == 4 + + +def test_partial_segment_is_not_a_prefix_match() -> None: + bus = EventBus() + received: list[ConfigChangeEvent] = [] + bus.subscribe(received.append, keys={"model"}) + + bus.publish(_make_event({"models"})) + + assert received == [] + + +def test_sibling_keys_do_not_match() -> None: + bus = EventBus() + received: list[ConfigChangeEvent] = [] + bus.subscribe(received.append, keys={"model/other1"}) + + bus.publish(_make_event({"model/other2"})) + + assert received == [] + + +def test_unsubscribe_stops_delivery() -> None: + bus = EventBus() + received: list[ConfigChangeEvent] = [] + unsubscribe = bus.subscribe(received.append) + unsubscribe() + + bus.publish(_make_event({"value"})) + + assert received == [] + + +def test_unsubscribe_during_publish_is_safe() -> None: + bus = EventBus() + calls: list[str] = [] + + def first(_: ConfigChangeEvent) -> None: + calls.append("first") + unsubscribe_second() + + bus.subscribe(first) + unsubscribe_second = bus.subscribe(lambda _: calls.append("second")) + + bus.publish(_make_event({"value"})) + bus.publish(_make_event({"value"})) + + assert calls == ["first", "second", "first"] diff --git a/tests/core/test_config_orchestrator.py b/tests/core/test_config_orchestrator.py index be49d63..901b445 100644 --- a/tests/core/test_config_orchestrator.py +++ b/tests/core/test_config_orchestrator.py @@ -1,15 +1,41 @@ from __future__ import annotations +import asyncio +from collections.abc import Sequence +from pathlib import Path +import tomllib from typing import Annotated, Any -from pydantic import ValidationError +from pydantic import Field, ValidationError import pytest -from vibe.core.config.layer import ConfigLayer, RawConfig -from vibe.core.config.orchestrator import ConfigOrchestrator -from vibe.core.config.patch import ConfigPatch -from vibe.core.config.schema import ConfigSchema, WithReplaceMerge -from vibe.core.config.types import LayerConfigSnapshot +from vibe.core.config.event_bus import EventBus +from vibe.core.config.layer import ( + ConfigLayer, + ConfigPatchApplicationError, + LayerImplementationError, + LayerNotLoadedError, + RawConfig, +) +from vibe.core.config.layers.environment import EnvironmentLayer +from vibe.core.config.layers.user import UserConfigLayer +from vibe.core.config.orchestrator import ConfigOrchestrator, ConfigPatchValidationError +from vibe.core.config.patch import ( + AddOperationPatch, + RemoveOperationPatch, + ReplaceOperationPatch, +) +from vibe.core.config.schema import ( + ConfigFragment, + ConfigSchema, + WithConcatMerge, + WithReplaceMerge, +) +from vibe.core.config.types import ( + ConcurrencyConflictError, + ConfigChangeEvent, + LayerConfigSnapshot, +) class FakeLayer(ConfigLayer[RawConfig]): @@ -27,15 +53,115 @@ class FakeLayer(ConfigLayer[RawConfig]): raise NotImplementedError +class NormalizingWritableLayer(FakeLayer): + async def _save_to_store(self, next_config: RawConfig) -> str: + value = next_config.model_dump()["value"] + self._data = {"value": f"{value}-normalized"} + return "write-fp" + + +class FieldWritableLayer(FakeLayer): + def __init__( + self, + *, + name: str, + field_name: str, + data: dict[str, Any], + barrier: ParallelSaveBarrier | None = None, + ) -> None: + super().__init__(name=name, data=data) + self._field_name = field_name + self._barrier = barrier + + async def _save_to_store(self, next_config: RawConfig) -> str: + if self._barrier is not None: + await self._barrier.wait(self.name) + + value = next_config.model_dump()[self._field_name] + self._data = {self._field_name: value} + return "write-fp" + + +class RawWritableLayer(FakeLayer): + async def _save_to_store(self, next_config: RawConfig) -> str: + self._data = next_config.model_dump() + return "write-fp" + + +class FailingSaveLayer(FakeLayer): + async def _save_to_store(self, _next_config: RawConfig) -> str: + raise RuntimeError("boom") + + +class ApplyErrorLayer(FakeLayer): + def __init__(self, *, name: str, data: dict[str, Any], error: Exception) -> None: + super().__init__(name=name, data=data) + self._error = error + + async def apply(self, *args: Any, **kwargs: Any) -> None: + raise self._error + + +class ParallelSaveBarrier: + def __init__(self, expected_starts: int) -> None: + self.expected_starts = expected_starts + self.started_layers: list[str] = [] + self.all_started = asyncio.Event() + + async def wait(self, layer_name: str) -> None: + self.started_layers.append(layer_name) + if len(self.started_layers) == self.expected_starts: + self.all_started.set() + + await asyncio.wait_for(self.all_started.wait(), timeout=0.1) + + class SimpleSchema(ConfigSchema): value: Annotated[str, WithReplaceMerge()] = "default" +class MultiValueSchema(ConfigSchema): + first: Annotated[str, WithReplaceMerge()] = "default-first" + second: Annotated[str, WithReplaceMerge()] = "default-second" + + +class ToolsFragment(ConfigFragment): + enabled_tools: Annotated[list[str], WithConcatMerge()] = Field(default_factory=list) + disabled_tools: Annotated[list[str], WithConcatMerge()] = Field( + default_factory=list + ) + deprecated_setting: Annotated[bool, WithReplaceMerge()] = False + + +class ToolSchema(ConfigSchema): + active_model: Annotated[str, WithReplaceMerge()] = "default-model" + tools: ToolsFragment = Field(default_factory=ToolsFragment) + + +class RoutingSchema(ConfigSchema): + active_model: Annotated[str, WithReplaceMerge()] = "default-model" + default_agent: Annotated[str, WithReplaceMerge()] = "default-agent" + + +class RequiredPairSchema(ConfigSchema): + first: Annotated[str, WithReplaceMerge()] + second: Annotated[str, WithReplaceMerge()] + + +def assert_single_failure[E: BaseException]( + result: Sequence[BaseException], expected_type: type[E] +) -> E: + assert len(result) == 1 + failure = result[0] + assert isinstance(failure, expected_type) + return failure + + @pytest.mark.asyncio async def test_create_builds_config() -> None: layer = FakeLayer(name="test", data={"value": "hello"}) orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer]) - assert orch.config.value == "hello" + assert orch.config.model_dump() == {"value": "hello"} @pytest.mark.asyncio @@ -48,7 +174,7 @@ async def test_get_layer_returns_named_layer() -> None: @pytest.mark.asyncio async def test_get_layer_unknown_raises() -> None: orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[]) - with pytest.raises(KeyError, match="unknown"): + with pytest.raises(KeyError, match="No layer named 'unknown'"): orch.get_layer("unknown") @@ -78,14 +204,333 @@ async def test_origin_of_missing_key_returns_none() -> None: @pytest.mark.asyncio -async def test_apply_patch_raises_not_implemented() -> None: - orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[]) - with pytest.raises(NotImplementedError, match="M2"): - await orch.apply_patch(ConfigPatch(fingerprint="fp-1")) +async def test_apply_patch_empty_operations_is_noop() -> None: + layer = FakeLayer(name="test", data={"value": "hello"}) + orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer]) + + result = await orch.apply_patch([], reason="no-op") + + assert result == [] + assert orch.config.value == "hello" @pytest.mark.asyncio -async def test_subscribe_raises_not_implemented() -> None: +async def test_apply_patch_rejects_invalid_schema_result_before_routing() -> None: orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[]) - with pytest.raises(NotImplementedError, match="M3"): - await orch.subscribe(lambda: None) + with pytest.raises(ConfigPatchValidationError) as exc_info: + await orch.apply_patch( + [ReplaceOperationPatch(path="/value", value={"invalid": "shape"})], + reason="test invalid patch", + ) + + assert exc_info.value.args == ( + "Config patch failed preflight validation against the merged config; fix the patch payload and retry", + ) + assert isinstance(exc_info.value.__cause__, ValidationError) + + +@pytest.mark.asyncio +async def test_apply_patch_returns_failure_when_default_fallback_layer_is_missing() -> ( + None +): + orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[]) + + result = await orch.apply_patch( + [ReplaceOperationPatch(path="/value", value="updated")], reason="test update" + ) + + failure = assert_single_failure(result, KeyError) + assert str(failure) == f'"No layer named {UserConfigLayer.LAYER_NAME!r}"' + assert orch.config.value == "default" + + +@pytest.mark.asyncio +async def test_apply_patch_unknown_explicit_target_returns_failure() -> None: + layer = NormalizingWritableLayer(name=UserConfigLayer.LAYER_NAME, data={}) + orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer]) + + result = await orch.apply_patch( + [ + AddOperationPatch( + path="/value", value="updated", target_layer_name="missing-layer" + ) + ], + reason="test update", + ) + + failure = assert_single_failure(result, KeyError) + assert str(failure) == "\"No layer named 'missing-layer'\"" + assert orch.config.value == "default" + assert layer._data == {} + + +@pytest.mark.asyncio +async def test_apply_patch_falls_back_to_default_user_layer_and_reloads_config() -> ( + None +): + layer = NormalizingWritableLayer(name=UserConfigLayer.LAYER_NAME, data={}) + orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer]) + + result = await orch.apply_patch( + [AddOperationPatch(path="/value", value="updated")], reason="test update" + ) + + assert result == [] + assert orch.config.value == "updated-normalized" + assert layer._data == {"value": "updated-normalized"} + + +@pytest.mark.asyncio +async def test_apply_patch_returns_layer_save_error_after_other_layer_commits() -> None: + first_layer = FieldWritableLayer( + name="first-layer", field_name="first", data={"first": "one"} + ) + second_layer = FailingSaveLayer(name="second-layer", data={"second": "two"}) + orch = await ConfigOrchestrator.create( + schema=MultiValueSchema, layers=[first_layer, second_layer] + ) + + result = await orch.apply_patch( + [ + ReplaceOperationPatch( + path="/first", value="updated-one", target_layer_name="first-layer" + ), + ReplaceOperationPatch( + path="/second", value="updated-two", target_layer_name="second-layer" + ), + ], + reason="test partial apply", + ) + + failure = assert_single_failure(result, LayerImplementationError) + assert str(failure) == "Layer 'second-layer': _save_to_store() failed" + assert isinstance(failure.__cause__, RuntimeError) + assert first_layer._data == {"first": "updated-one"} + assert second_layer._data == {"second": "two"} + + +@pytest.mark.parametrize( + "error", + [ + pytest.param( + ConcurrencyConflictError(expected_fp="before", actual_fp="after"), + id="concurrency-conflict", + ), + pytest.param(ConfigPatchApplicationError("test"), id="patch-application-error"), + pytest.param(RuntimeError("unexpected bug"), id="unexpected-runtime-error"), + ], +) +@pytest.mark.asyncio +async def test_apply_patch_returns_layer_apply_error_in_failures( + error: Exception, +) -> None: + layer = ApplyErrorLayer(name="test", data={"value": "hello"}, error=error) + orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer]) + + result = await orch.apply_patch( + [ + ReplaceOperationPatch( + path="/value", value="updated", target_layer_name="test" + ) + ], + reason="test update", + ) + + assert assert_single_failure(result, type(error)) is error + + +@pytest.mark.asyncio +async def test_apply_patch_returns_unloaded_layer_error_in_failures() -> None: + loaded_layer = FakeLayer(name="loaded", data={"value": "hello"}) + target_layer = FakeLayer(name="target", data={"value": "hello"}) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, layers=[loaded_layer, target_layer] + ) + await target_layer.invalidate_cache() + + result = await orch.apply_patch( + [ + ReplaceOperationPatch( + path="/value", value="updated", target_layer_name="target" + ) + ], + reason="test update", + ) + + failure = assert_single_failure(result, LayerNotLoadedError) + assert str(failure) == "Layer 'target' must be loaded before applying patches" + + +@pytest.mark.asyncio +async def test_apply_patch_applies_layers_in_parallel() -> None: + barrier = ParallelSaveBarrier(expected_starts=2) + first_layer = FieldWritableLayer( + name="first-layer", field_name="first", data={"first": "one"}, barrier=barrier + ) + second_layer = FieldWritableLayer( + name="second-layer", + field_name="second", + data={"second": "two"}, + barrier=barrier, + ) + orch = await ConfigOrchestrator.create( + schema=MultiValueSchema, layers=[first_layer, second_layer] + ) + + result = await orch.apply_patch( + [ + ReplaceOperationPatch( + path="/first", value="updated-one", target_layer_name="first-layer" + ), + ReplaceOperationPatch( + path="/second", value="updated-two", target_layer_name="second-layer" + ), + ], + reason="test parallel apply", + ) + + assert result == [] + assert barrier.started_layers == ["first-layer", "second-layer"] + assert first_layer._data == {"first": "updated-one"} + assert second_layer._data == {"second": "updated-two"} + + +@pytest.mark.asyncio +async def test_apply_patch_end_to_end_updates_real_user_config_file( + tmp_working_directory: Path, +) -> None: + toml_path = tmp_working_directory / "config.toml" + toml_path.write_text( + """\ +active_model = "old" + +[tools] +disabled_tools = ["bash", "python"] +deprecated_setting = true +""", + encoding="utf-8", + ) + + user_layer = UserConfigLayer(path=toml_path) + orch = await ConfigOrchestrator.create(schema=ToolSchema, layers=[user_layer]) + + result = await orch.apply_patch( + [ + ReplaceOperationPatch(path="/active_model", value="new"), + AddOperationPatch(path="/tools/enabled_tools", value=["read"]), + AddOperationPatch(path="/tools/disabled_tools/-", value="node"), + RemoveOperationPatch(path="/tools/disabled_tools/0"), + RemoveOperationPatch(path="/tools/deprecated_setting"), + ], + reason="update user defaults", + ) + + assert result == [] + with toml_path.open("rb") as file: + assert tomllib.load(file) == { + "active_model": "new", + "tools": {"disabled_tools": ["python", "node"], "enabled_tools": ["read"]}, + } + assert orch.config.active_model == "new" + assert orch.config.tools.disabled_tools == ["python", "node"] + assert orch.config.tools.enabled_tools == ["read"] + + +@pytest.mark.asyncio +async def test_apply_patch_fallback_to_user_layer_fails_when_user_file_is_missing( + tmp_working_directory: Path, +) -> None: + toml_path = tmp_working_directory / "config.toml" + user_layer = UserConfigLayer(path=toml_path) + orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[user_layer]) + + result = await orch.apply_patch( + [AddOperationPatch(path="/value", value="created-later")], + reason="fallback write without user file", + ) + + failure = assert_single_failure(result, LayerNotLoadedError) + assert str(failure) == "Layer 'user-toml' must be loaded before applying patches" + assert not toml_path.exists() + assert orch.config.value == "default" + + +@pytest.mark.asyncio +async def test_apply_patch_end_to_end_falls_back_to_user_layer_when_no_target_is_provided( + monkeypatch: pytest.MonkeyPatch, tmp_working_directory: Path +) -> None: + toml_path = tmp_working_directory / "config.toml" + toml_path.write_text('default_agent = "plan"\n', encoding="utf-8") + monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model") + + user_layer = UserConfigLayer(path=toml_path) + environment_layer = EnvironmentLayer(schema=RoutingSchema) + orch = await ConfigOrchestrator.create( + schema=RoutingSchema, layers=[user_layer, environment_layer] + ) + + result = await orch.apply_patch( + [ + AddOperationPatch(path="/active_model", value="persisted-in-user-file"), + ReplaceOperationPatch(path="/default_agent", value="accept-edits"), + ], + reason="update runtime defaults", + ) + + assert result == [] + with toml_path.open("rb") as file: + assert tomllib.load(file) == { + "active_model": "persisted-in-user-file", + "default_agent": "accept-edits", + } + assert orch.config.active_model == "env-model" + assert orch.config.default_agent == "accept-edits" + + +@pytest.mark.asyncio +async def test_apply_patch_end_to_end_respects_explicit_target_layer( + monkeypatch: pytest.MonkeyPatch, tmp_working_directory: Path +) -> None: + toml_path = tmp_working_directory / "config.toml" + toml_path.write_text('default_agent = "plan"\n', encoding="utf-8") + monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model") + + user_layer = UserConfigLayer(path=toml_path) + environment_layer = EnvironmentLayer(schema=RoutingSchema) + orch = await ConfigOrchestrator.create( + schema=RoutingSchema, layers=[user_layer, environment_layer] + ) + + result = await orch.apply_patch( + [ + ReplaceOperationPatch( + path="/active_model", + value="persist-me-nowhere", + target_layer_name="environment", + ), + ReplaceOperationPatch(path="/default_agent", value="accept-edits"), + ], + reason="update runtime defaults", + ) + + failure = assert_single_failure(result, NotImplementedError) + assert str(failure) == "EnvironmentLayer patch persistence is not implemented yet" + with toml_path.open("rb") as file: + assert tomllib.load(file) == {"default_agent": "accept-edits"} + assert orch.config.active_model == "env-model" + assert orch.config.default_agent == "accept-edits" + + +@pytest.mark.asyncio +async def test_subscribe_registers_on_the_bus() -> None: + bus = EventBus() + orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[], bus=bus) + received: list[ConfigChangeEvent] = [] + orch.subscribe(received.append) + + event = ConfigChangeEvent( + changed_keys=frozenset({"value"}), before={}, after={}, reason="" + ) + bus.publish(event) + + assert received == [event] diff --git a/tests/core/test_config_toml_end_to_end.py b/tests/core/test_config_toml_end_to_end.py index e1de0dc..3b77266 100644 --- a/tests/core/test_config_toml_end_to_end.py +++ b/tests/core/test_config_toml_end_to_end.py @@ -49,7 +49,7 @@ api_base = "https://api.mistral.ai/v1" from vibe.core.config.layers.user import UserConfigLayer from vibe.core.config.orchestrator import ConfigOrchestrator - layer = UserConfigLayer(path=toml_path, name="user-toml") + layer = UserConfigLayer(path=toml_path) orchestrator = await ConfigOrchestrator.create(schema=MinimalSchema, layers=[layer]) assert orchestrator.config.models.active_model == "mistral-large" diff --git a/tests/core/test_keyring.py b/tests/core/test_keyring.py new file mode 100644 index 0000000..9102fdc --- /dev/null +++ b/tests/core/test_keyring.py @@ -0,0 +1,614 @@ +from __future__ import annotations + +import subprocess +from threading import Event, Thread + +import keyring +from keyring.errors import KeyringError, PasswordDeleteError +import pytest + +import vibe.core.utils.keyring as keyring_utils +from vibe.core.utils.keyring import ( + delete_api_key_from_keyring, + get_api_key_from_keyring, + set_api_key_in_keyring, +) + +_CURRENT_SERVICE = "ai.mistral.vibe" +_RELEASED_LEGACY_SERVICE = "vibe" +_LEGACY_SERVICES = (_RELEASED_LEGACY_SERVICE,) +_ALL_SERVICES = (_CURRENT_SERVICE, *_LEGACY_SERVICES) + + +def test_set_writes_to_current_vibe_service_and_deletes_legacy_services( + monkeypatch: pytest.MonkeyPatch, +) -> None: + writes: list[tuple[str, str, str]] = [] + deletes: list[tuple[str, str]] = [] + monkeypatch.setattr( + keyring, + "set_password", + lambda service, username, password: writes.append(( + service, + username, + password, + )), + ) + monkeypatch.setattr( + keyring, + "delete_password", + lambda service, username: deletes.append((service, username)), + ) + + set_api_key_in_keyring("CUSTOM_API_KEY", "new-key") + + assert writes == [(_CURRENT_SERVICE, "CUSTOM_API_KEY", "new-key")] + assert deletes == [(service, "CUSTOM_API_KEY") for service in _LEGACY_SERVICES] + + +def test_set_ignores_legacy_deletion_errors_after_successful_write( + monkeypatch: pytest.MonkeyPatch, +) -> None: + writes: list[tuple[str, str, str]] = [] + monkeypatch.setattr( + keyring, + "set_password", + lambda service, username, password: writes.append(( + service, + username, + password, + )), + ) + + def _delete_failed(service: str, username: str) -> None: + raise KeyringError("delete failed") + + monkeypatch.setattr(keyring, "delete_password", _delete_failed) + + set_api_key_in_keyring("CUSTOM_API_KEY", "new-key") + + assert writes == [(_CURRENT_SERVICE, "CUSTOM_API_KEY", "new-key")] + assert get_api_key_from_keyring("CUSTOM_API_KEY") == "new-key" + + +def test_set_populates_cache(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + keyring, "set_password", lambda service, username, password: None + ) + + set_api_key_in_keyring("CUSTOM_API_KEY", "new-key") + + # A subsequent read is served from cache without consulting the backend. + def _fail(service: str, username: str) -> str | None: + raise AssertionError("keyring must not be consulted; value is cached") + + monkeypatch.setattr(keyring, "get_password", _fail) + assert get_api_key_from_keyring("CUSTOM_API_KEY") == "new-key" + + +def test_get_does_not_overwrite_concurrent_cache_write( + monkeypatch: pytest.MonkeyPatch, +) -> None: + read_started = Event() + allow_read_to_finish = Event() + results: list[str | None] = [] + errors: list[BaseException] = [] + + def _get(service: str, username: str) -> str | None: + if service != _CURRENT_SERVICE: + return None + read_started.set() + assert allow_read_to_finish.wait(timeout=5) + return "old-key" + + monkeypatch.setattr(keyring, "get_password", _get) + monkeypatch.setattr( + keyring, "set_password", lambda service, username, password: None + ) + monkeypatch.setattr(keyring, "delete_password", lambda service, username: None) + + def _read_key() -> None: + try: + results.append(get_api_key_from_keyring("CUSTOM_API_KEY")) + except BaseException as exc: + errors.append(exc) + + reader = Thread(target=_read_key) + reader.start() + assert read_started.wait(timeout=5) + + set_api_key_in_keyring("CUSTOM_API_KEY", "new-key") + allow_read_to_finish.set() + reader.join(timeout=5) + + assert not reader.is_alive() + if errors: + raise errors[0] + assert results == ["new-key"] + assert get_api_key_from_keyring("CUSTOM_API_KEY") == "new-key" + + +def test_disabled_keyring_get_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VIBE_TEST_DISABLE_KEYRING", "1") + + def _fail(service: str, username: str) -> str | None: + raise AssertionError("disabled keyring must not be consulted") + + monkeypatch.setattr(keyring, "get_password", _fail) + + assert get_api_key_from_keyring("CUSTOM_API_KEY") is None + + +def test_disabled_keyring_set_raises_and_forgets_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + keyring, "set_password", lambda service, username, password: None + ) + set_api_key_in_keyring("CUSTOM_API_KEY", "cached-key") + monkeypatch.setenv("VIBE_TEST_DISABLE_KEYRING", "1") + + with pytest.raises(KeyringError): + set_api_key_in_keyring("CUSTOM_API_KEY", "new-key") + + monkeypatch.delenv("VIBE_TEST_DISABLE_KEYRING") + monkeypatch.setattr(keyring, "get_password", lambda service, username: None) + assert get_api_key_from_keyring("CUSTOM_API_KEY") is None + + +def test_disabled_keyring_delete_forgets_cache(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + keyring, "set_password", lambda service, username, password: None + ) + set_api_key_in_keyring("CUSTOM_API_KEY", "cached-key") + monkeypatch.setenv("VIBE_TEST_DISABLE_KEYRING", "1") + + delete_api_key_from_keyring("CUSTOM_API_KEY") + + monkeypatch.delenv("VIBE_TEST_DISABLE_KEYRING") + monkeypatch.setattr(keyring, "get_password", lambda service, username: None) + assert get_api_key_from_keyring("CUSTOM_API_KEY") is None + + +def test_set_forgets_stale_cache_and_propagates_on_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Seed the cache with a stale value. + monkeypatch.setattr(keyring, "get_password", lambda service, username: "stale") + assert get_api_key_from_keyring("CUSTOM_API_KEY") == "stale" + + def _unavailable(service: str, username: str, password: str) -> None: + raise KeyringError("no keyring") + + monkeypatch.setattr(keyring, "set_password", _unavailable) + + with pytest.raises(KeyringError): + set_api_key_in_keyring("CUSTOM_API_KEY", "new-key") + + # The stale entry was dropped, so the next read consults the backend again. + reads: list[tuple[str, str]] = [] + + def _get(service: str, username: str) -> str | None: + reads.append((service, username)) + return None + + monkeypatch.setattr(keyring, "get_password", _get) + assert get_api_key_from_keyring("CUSTOM_API_KEY") is None + assert reads == [ + (_CURRENT_SERVICE, "CUSTOM_API_KEY"), + (_RELEASED_LEGACY_SERVICE, "CUSTOM_API_KEY"), + ] + + +def test_get_prefers_current_service(monkeypatch: pytest.MonkeyPatch) -> None: + reads: list[tuple[str, str]] = [] + + def _get(service: str, username: str) -> str | None: + reads.append((service, username)) + if service == _CURRENT_SERVICE: + return "current-key" + return "legacy-key" + + monkeypatch.setattr(keyring, "get_password", _get) + + assert get_api_key_from_keyring("CUSTOM_API_KEY") == "current-key" + assert reads == [(_CURRENT_SERVICE, "CUSTOM_API_KEY")] + + +@pytest.mark.parametrize("legacy_service", _LEGACY_SERVICES) +def test_get_migrates_legacy_service_value( + monkeypatch: pytest.MonkeyPatch, legacy_service: str +) -> None: + reads: list[tuple[str, str]] = [] + writes: list[tuple[str, str, str]] = [] + deletes: list[tuple[str, str]] = [] + + def _get(service: str, username: str) -> str | None: + reads.append((service, username)) + if service == legacy_service: + return "legacy-key" + return None + + monkeypatch.setattr(keyring, "get_password", _get) + monkeypatch.setattr( + keyring, + "set_password", + lambda service, username, password: writes.append(( + service, + username, + password, + )), + ) + monkeypatch.setattr( + keyring, + "delete_password", + lambda service, username: deletes.append((service, username)), + ) + + assert get_api_key_from_keyring("CUSTOM_API_KEY") == "legacy-key" + expected_services = _ALL_SERVICES[: _ALL_SERVICES.index(legacy_service) + 1] + assert reads == [(service, "CUSTOM_API_KEY") for service in expected_services] + assert writes == [(_CURRENT_SERVICE, "CUSTOM_API_KEY", "legacy-key")] + assert deletes == [(legacy_service, "CUSTOM_API_KEY")] + + +@pytest.mark.parametrize("legacy_service", _LEGACY_SERVICES) +def test_get_returns_legacy_value_when_migration_write_fails( + monkeypatch: pytest.MonkeyPatch, legacy_service: str +) -> None: + deletes: list[tuple[str, str]] = [] + + def _get(service: str, username: str) -> str | None: + if service == legacy_service: + return "legacy-key" + return None + + def _unavailable(service: str, username: str, password: str) -> None: + raise KeyringError("no keyring") + + monkeypatch.setattr(keyring, "get_password", _get) + monkeypatch.setattr(keyring, "set_password", _unavailable) + monkeypatch.setattr( + keyring, + "delete_password", + lambda service, username: deletes.append((service, username)), + ) + + assert get_api_key_from_keyring("CUSTOM_API_KEY") == "legacy-key" + assert deletes == [] + + +@pytest.mark.parametrize("legacy_service", _LEGACY_SERVICES) +def test_get_ignores_legacy_deletion_error_after_migration( + monkeypatch: pytest.MonkeyPatch, legacy_service: str +) -> None: + def _get(service: str, username: str) -> str | None: + if service == legacy_service: + return "legacy-key" + return None + + def _delete_failed(service: str, username: str) -> None: + raise KeyringError("delete failed") + + monkeypatch.setattr(keyring, "get_password", _get) + monkeypatch.setattr( + keyring, "set_password", lambda service, username, password: None + ) + monkeypatch.setattr(keyring, "delete_password", _delete_failed) + + assert get_api_key_from_keyring("CUSTOM_API_KEY") == "legacy-key" + + +def test_delete_uses_current_and_legacy_vibe_services_and_forgets_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Seed the cache. + monkeypatch.setattr(keyring, "get_password", lambda service, username: "cached") + assert get_api_key_from_keyring("CUSTOM_API_KEY") == "cached" + + deleted: list[tuple[str, str]] = [] + monkeypatch.setattr( + keyring, + "delete_password", + lambda service, username: deleted.append((service, username)), + ) + + delete_api_key_from_keyring("CUSTOM_API_KEY") + + assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES] + # Cache entry dropped: the next read consults the backend. + monkeypatch.setattr(keyring, "get_password", lambda service, username: None) + assert get_api_key_from_keyring("CUSTOM_API_KEY") is None + + +def test_delete_forgets_cache_even_when_backend_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(keyring, "get_password", lambda service, username: "cached") + assert get_api_key_from_keyring("CUSTOM_API_KEY") == "cached" + + def _unavailable(service: str, username: str) -> None: + raise KeyringError("no keyring") + + monkeypatch.setattr(keyring, "delete_password", _unavailable) + + with pytest.raises(KeyringError): + delete_api_key_from_keyring("CUSTOM_API_KEY") + + monkeypatch.setattr(keyring, "get_password", lambda service, username: None) + assert get_api_key_from_keyring("CUSTOM_API_KEY") is None + + +def test_delete_attempts_legacy_service_when_current_service_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + deleted: list[tuple[str, str]] = [] + + def _delete(service: str, username: str) -> None: + deleted.append((service, username)) + if service == _CURRENT_SERVICE: + raise KeyringError("delete failed") + + monkeypatch.setattr(keyring, "delete_password", _delete) + + with pytest.raises(KeyringError): + delete_api_key_from_keyring("CUSTOM_API_KEY") + + assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES] + + +def test_delete_prefers_operation_error_over_missing_entry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + deleted: list[tuple[str, str]] = [] + + def _delete(service: str, username: str) -> None: + deleted.append((service, username)) + if service == _CURRENT_SERVICE: + raise PasswordDeleteError() + if service == _RELEASED_LEGACY_SERVICE: + raise KeyringError("delete failed") + raise PasswordDeleteError() + + monkeypatch.setattr(keyring, "delete_password", _delete) + + with pytest.raises(KeyringError, match="delete failed"): + delete_api_key_from_keyring("CUSTOM_API_KEY") + + assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES] + + +def test_delete_ignores_missing_entries_after_successful_delete( + monkeypatch: pytest.MonkeyPatch, +) -> None: + deleted: list[tuple[str, str]] = [] + + def _delete(service: str, username: str) -> None: + deleted.append((service, username)) + if service != _RELEASED_LEGACY_SERVICE: + raise PasswordDeleteError() + + monkeypatch.setattr(keyring, "delete_password", _delete) + + delete_api_key_from_keyring("CUSTOM_API_KEY") + + assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES] + + +def test_delete_raises_missing_when_all_services_are_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + deleted: list[tuple[str, str]] = [] + + def _delete(service: str, username: str) -> None: + deleted.append((service, username)) + raise PasswordDeleteError() + + monkeypatch.setattr(keyring, "delete_password", _delete) + + with pytest.raises(PasswordDeleteError): + delete_api_key_from_keyring("CUSTOM_API_KEY") + + assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES] + + +def test_macos_set_recreates_item_with_security_stdin_and_unrestricted_acl( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[list[str], dict[str, object]]] = [] + monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True) + + def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append((args, kwargs)) + return subprocess.CompletedProcess(args, 0) + + monkeypatch.setattr(keyring_utils.subprocess, "run", _run) + + set_api_key_in_keyring("CUSTOM_API_KEY", "new-key") + + assert len(calls) == 3 + delete_args, delete_kwargs = calls[0] + assert delete_args == [ + "/usr/bin/security", + "delete-generic-password", + "-s", + _CURRENT_SERVICE, + "-a", + "CUSTOM_API_KEY", + ] + assert "new-key" not in delete_args + assert delete_kwargs["input"] is None + + args, kwargs = calls[1] + assert args == ["/usr/bin/security", "-i"] + assert kwargs["text"] is True + assert kwargs["capture_output"] is True + assert kwargs["check"] is True + assert "new-key" not in args + + command = kwargs["input"] + assert isinstance(command, str) + assert command.endswith("\n") + assert "add-generic-password" in command + assert "-A" in command + assert "-U" not in command + assert _CURRENT_SERVICE in command + assert "CUSTOM_API_KEY" in command + assert "new-key" in command + assert [call[0] for call in calls[2:]] == [ + [ + "/usr/bin/security", + "delete-generic-password", + "-s", + _RELEASED_LEGACY_SERVICE, + "-a", + "CUSTOM_API_KEY", + ] + ] + + +def test_macos_set_ignores_missing_item_before_recreate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[list[str]] = [] + monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True) + + def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(args) + if args[1] == "delete-generic-password": + raise subprocess.CalledProcessError( + 44, + args, + stderr="The specified item could not be found in the keychain.", + ) + return subprocess.CompletedProcess(args, 0) + + monkeypatch.setattr(keyring_utils.subprocess, "run", _run) + + set_api_key_in_keyring("CUSTOM_API_KEY", "new-key") + + assert calls == [ + [ + "/usr/bin/security", + "delete-generic-password", + "-s", + _CURRENT_SERVICE, + "-a", + "CUSTOM_API_KEY", + ], + ["/usr/bin/security", "-i"], + [ + "/usr/bin/security", + "delete-generic-password", + "-s", + _RELEASED_LEGACY_SERVICE, + "-a", + "CUSTOM_API_KEY", + ], + ] + + +def test_macos_get_uses_security_find_password(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[list[str], dict[str, object]]] = [] + monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True) + + def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append((args, kwargs)) + return subprocess.CompletedProcess(args, 0, stdout="stored-key\n") + + monkeypatch.setattr(keyring_utils.subprocess, "run", _run) + + assert get_api_key_from_keyring("CUSTOM_API_KEY") == "stored-key" + assert calls == [ + ( + [ + "/usr/bin/security", + "find-generic-password", + "-s", + _CURRENT_SERVICE, + "-a", + "CUSTOM_API_KEY", + "-w", + ], + {"input": None, "text": True, "capture_output": True, "check": True}, + ) + ] + + +def test_macos_get_raises_when_item_is_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True) + + def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + raise subprocess.CalledProcessError( + 44, args, stderr="The specified item could not be found in the keychain." + ) + + monkeypatch.setattr(keyring_utils.subprocess, "run", _run) + + with pytest.raises(keyring_utils._PasswordNotFoundError): + keyring_utils._get_password(_CURRENT_SERVICE, "CUSTOM_API_KEY") + + +def test_macos_get_checks_legacy_service_when_current_item_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[list[str]] = [] + monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True) + + def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(args) + if args[1] in {"find-generic-password", "delete-generic-password"} and ( + args[3] == _CURRENT_SERVICE + ): + raise subprocess.CalledProcessError( + 44, + args, + stderr="The specified item could not be found in the keychain.", + ) + return subprocess.CompletedProcess(args, 0, stdout="legacy-key\n") + + monkeypatch.setattr(keyring_utils.subprocess, "run", _run) + + assert get_api_key_from_keyring("CUSTOM_API_KEY") == "legacy-key" + assert calls[:2] == [ + [ + "/usr/bin/security", + "find-generic-password", + "-s", + _CURRENT_SERVICE, + "-a", + "CUSTOM_API_KEY", + "-w", + ], + [ + "/usr/bin/security", + "find-generic-password", + "-s", + _RELEASED_LEGACY_SERVICE, + "-a", + "CUSTOM_API_KEY", + "-w", + ], + ] + + +def test_macos_set_wraps_security_failure_and_forgets_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(keyring, "get_password", lambda service, username: "stale") + assert get_api_key_from_keyring("CUSTOM_API_KEY") == "stale" + + monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True) + + def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + if args[1] == "delete-generic-password": + return subprocess.CompletedProcess(args, 0) + raise subprocess.CalledProcessError(1, args) + + monkeypatch.setattr(keyring_utils.subprocess, "run", _run) + + with pytest.raises(KeyringError): + set_api_key_in_keyring("CUSTOM_API_KEY", "new-key") + + monkeypatch.setattr(keyring, "get_password", lambda service, username: None) + assert get_api_key_from_keyring("CUSTOM_API_KEY") is None diff --git a/tests/core/test_mcp_oauth.py b/tests/core/test_mcp_oauth.py index a168105..a733afa 100644 --- a/tests/core/test_mcp_oauth.py +++ b/tests/core/test_mcp_oauth.py @@ -31,6 +31,8 @@ from vibe.core.auth.mcp_oauth import ( ) from vibe.core.config import MCPOAuth, MCPStreamableHttp +_KEYRING_SERVICE = "ai.mistral.vibe" + class _MemoryKeyring(KeyringBackend): priority = 100 # type: ignore[assignment] @@ -46,7 +48,7 @@ class _MemoryKeyring(KeyringBackend): def delete_password(self, service: str, username: str) -> None: if (service, username) not in self.store: - raise keyring.errors.PasswordDeleteError(username) + raise keyring.errors.PasswordDeleteError() del self.store[(service, username)] @@ -143,7 +145,7 @@ class TestKeyringTokenStorage: assert loaded.access_token == "at" assert loaded.refresh_token == "rt" assert loaded.scope == "read write" - assert ("vibe", "mcp-oauth:linear:tokens") in memory_keyring.store + assert (_KEYRING_SERVICE, "mcp-oauth:linear:tokens") in memory_keyring.store @pytest.mark.asyncio async def test_round_trip_client_info(self, memory_keyring: _MemoryKeyring) -> None: @@ -160,7 +162,10 @@ class TestKeyringTokenStorage: loaded = await storage.get_client_info() assert loaded is not None assert loaded.client_id == "abc123" - assert ("vibe", "mcp-oauth:linear:client_info") in memory_keyring.store + assert ( + _KEYRING_SERVICE, + "mcp-oauth:linear:client_info", + ) in memory_keyring.store @pytest.mark.asyncio async def test_per_alias_isolation(self, memory_keyring: _MemoryKeyring) -> None: diff --git a/tests/core/test_project_config_layer.py b/tests/core/test_project_config_layer.py index 182c848..553862a 100644 --- a/tests/core/test_project_config_layer.py +++ b/tests/core/test_project_config_layer.py @@ -6,7 +6,7 @@ import pytest from vibe.core.config.layer import UntrustedLayerError from vibe.core.config.layers.project import ProjectConfigLayer -from vibe.core.config.types import MISSING_CONFIG_FILE_FINGERPRINT +from vibe.core.config.types import MISSING_BACKING_STORE_DATA_FINGERPRINT from vibe.core.paths._vibe_home import GlobalPath from vibe.core.trusted_folders import trusted_folders_manager @@ -28,7 +28,7 @@ async def test_reads_toml_when_trusted(tmp_working_directory: Path) -> None: config_path.unlink() data = await layer.load(force=True) assert data.model_extra == {} - assert layer.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT + assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT @pytest.mark.asyncio @@ -63,7 +63,7 @@ async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None: layer = ProjectConfigLayer(path=tmp_working_directory) data = await layer.load() assert data.model_extra == {} - assert layer.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT + assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT @pytest.mark.asyncio diff --git a/tests/core/test_resolve_api_key.py b/tests/core/test_resolve_api_key.py index 0f94af2..aaa927d 100644 --- a/tests/core/test_resolve_api_key.py +++ b/tests/core/test_resolve_api_key.py @@ -8,6 +8,7 @@ from tests.conftest import build_test_vibe_config from vibe.core.config import MissingAPIKeyError, ProviderConfig, resolve_api_key from vibe.core.llm.backend.mistral import MistralBackend from vibe.core.types import Backend +from vibe.core.utils.keyring import clear_api_key_keyring_cache def test_resolve_returns_env_value_without_consulting_keyring( @@ -34,6 +35,40 @@ def test_resolve_falls_back_to_keyring_when_env_unset( assert resolve_api_key("CUSTOM_API_KEY") == "keyring-key" +def test_resolve_caches_keyring_lookup_for_env_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, str]] = [] + monkeypatch.delenv("CUSTOM_API_KEY", raising=False) + + def _get_password(service: str, username: str) -> str: + calls.append((service, username)) + return "keyring-key" + + monkeypatch.setattr(keyring, "get_password", _get_password) + + assert resolve_api_key("CUSTOM_API_KEY") == "keyring-key" + assert resolve_api_key("CUSTOM_API_KEY") == "keyring-key" + assert calls == [("ai.mistral.vibe", "CUSTOM_API_KEY")] + + +def test_resolve_caches_empty_keyring_lookup_for_env_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, str]] = [] + monkeypatch.delenv("CUSTOM_API_KEY", raising=False) + + def _get_password(service: str, username: str) -> None: + calls.append((service, username)) + return None + + monkeypatch.setattr(keyring, "get_password", _get_password) + + assert resolve_api_key("CUSTOM_API_KEY") is None + assert resolve_api_key("CUSTOM_API_KEY") is None + assert calls == [("ai.mistral.vibe", "CUSTOM_API_KEY"), ("vibe", "CUSTOM_API_KEY")] + + def test_resolve_returns_none_when_env_unset_and_keyring_empty( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -56,6 +91,24 @@ def test_resolve_returns_none_when_keyring_raises( assert resolve_api_key("CUSTOM_API_KEY") is None +def test_resolve_does_not_cache_keyring_errors(monkeypatch: pytest.MonkeyPatch) -> None: + calls = 0 + monkeypatch.delenv("CUSTOM_API_KEY", raising=False) + + def _sometimes_unavailable(service: str, username: str) -> str: + nonlocal calls + calls += 1 + if calls == 1: + raise KeyringError("temporary failure") + return "keyring-key" + + monkeypatch.setattr(keyring, "get_password", _sometimes_unavailable) + + assert resolve_api_key("CUSTOM_API_KEY") is None + assert resolve_api_key("CUSTOM_API_KEY") == "keyring-key" + assert calls == 2 + + def test_resolve_returns_none_for_empty_env_key( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -123,7 +176,7 @@ def test_vibe_code_api_key_resolves_from_keyring( assert config.vibe_code_api_key == "keyring-key" -def test_vibe_code_api_key_empty_when_unresolved( +def test_vibe_code_api_key_reuses_cached_keyring_value( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("MISTRAL_API_KEY", raising=False) @@ -132,7 +185,21 @@ def test_vibe_code_api_key_empty_when_unresolved( ) config = build_test_vibe_config() - # Nothing resolves the key anymore; the property must return "" (not None). + monkeypatch.setattr(keyring, "get_password", lambda service, username: None) + + assert config.vibe_code_api_key == "keyring-key" + + +def test_vibe_code_api_key_empty_when_cache_cleared_and_unresolved( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + monkeypatch.setattr( + keyring, "get_password", lambda service, username: "keyring-key" + ) + config = build_test_vibe_config() + + clear_api_key_keyring_cache() monkeypatch.setattr(keyring, "get_password", lambda service, username: None) assert config.vibe_code_api_key == "" diff --git a/tests/core/test_rewind_integration.py b/tests/core/test_rewind_integration.py index 94a3677..cb9ec90 100644 --- a/tests/core/test_rewind_integration.py +++ b/tests/core/test_rewind_integration.py @@ -61,9 +61,6 @@ def _make_agent_loop(backend: FakeBackend): "edit": {"permission": "always"}, "bash": {"permission": "always"}, }, - system_prompt_id="tests", - include_project_context=False, - include_prompt_detail=False, ) return build_test_agent_loop( config=config, agent_name=BuiltinAgentName.AUTO_APPROVE, backend=backend diff --git a/tests/core/test_user_config_layer.py b/tests/core/test_user_config_layer.py index f36c339..44cad6a 100644 --- a/tests/core/test_user_config_layer.py +++ b/tests/core/test_user_config_layer.py @@ -16,7 +16,7 @@ from vibe.core.config.patch import ( RemoveOperationPatch, ReplaceOperationPatch, ) -from vibe.core.config.types import MISSING_CONFIG_FILE_FINGERPRINT +from vibe.core.config.types import MISSING_BACKING_STORE_DATA_FINGERPRINT def random_config_file_name() -> str: @@ -28,7 +28,7 @@ async def test_reads_toml_file(tmp_working_directory: Path) -> None: path = tmp_working_directory / random_config_file_name() path.write_text('active_model = "mistral-large"\ncount = 42\n') - layer = UserConfigLayer(path=path, name="user-toml") + layer = UserConfigLayer(path=path) data = await layer.load() assert data.model_extra == {"active_model": "mistral-large", "count": 42} fingerprint = layer.fingerprint @@ -41,7 +41,7 @@ async def test_always_trusted(tmp_working_directory: Path) -> None: path = tmp_working_directory / random_config_file_name() path.write_text('key = "value"\n') - layer = UserConfigLayer(path=path, name="user-toml") + layer = UserConfigLayer(path=path) assert layer.is_trusted is None data = await layer.load() assert layer.is_trusted is True @@ -51,10 +51,10 @@ async def test_always_trusted(tmp_working_directory: Path) -> None: @pytest.mark.asyncio async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None: path = tmp_working_directory / random_config_file_name() - layer = UserConfigLayer(path=path, name="user-toml") + layer = UserConfigLayer(path=path) data = await layer.load() assert data.model_extra == {} - assert layer.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT + assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT @pytest.mark.asyncio @@ -62,16 +62,16 @@ async def test_apply_raises_when_file_does_not_exist( tmp_working_directory: Path, ) -> None: path = tmp_working_directory / random_config_file_name() - layer = UserConfigLayer(path=path, name="user-toml") + layer = UserConfigLayer(path=path) await layer.load() - assert layer.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT + assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"): await layer.apply( ConfigPatch( AddOperationPatch(path="/active_model", value="mistral-large"), - fingerprint=MISSING_CONFIG_FILE_FINGERPRINT, + fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT, ) ) @@ -90,7 +90,7 @@ active_model = "old" disabled_tools = ["bash", "python"] deprecated_setting = true """) - layer = UserConfigLayer(path=path, name="user-toml") + layer = UserConfigLayer(path=path) await layer.load() fingerprint = layer.fingerprint @@ -126,7 +126,7 @@ async def test_apply_cache_fingerprint_matches_written_file( ) -> None: path = tmp_working_directory / random_config_file_name() path.write_text("") - layer = UserConfigLayer(path=path, name="user-toml") + layer = UserConfigLayer(path=path) await layer.load() fingerprint = layer.fingerprint @@ -149,7 +149,7 @@ async def test_apply_uses_unique_temp_file(tmp_working_directory: Path) -> None: fixed_tmp_path = tmp_working_directory / f".{path.name}.tmp" path.write_text("") fixed_tmp_path.write_text("stale") - layer = UserConfigLayer(path=path, name="user-toml") + layer = UserConfigLayer(path=path) await layer.load() fingerprint = layer.fingerprint @@ -190,13 +190,13 @@ async def test_apply_raises_when_layer_is_not_loaded( tmp_working_directory: Path, ) -> None: path = tmp_working_directory / random_config_file_name() - layer = UserConfigLayer(path=path, name="user-toml") + layer = UserConfigLayer(path=path) with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"): await layer.apply( ConfigPatch( AddOperationPatch(path="/active_model", value="mistral-large"), - fingerprint=MISSING_CONFIG_FILE_FINGERPRINT, + fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT, ) ) @@ -207,7 +207,7 @@ async def test_apply_raises_when_cache_is_invalidated( ) -> None: path = tmp_working_directory / random_config_file_name() path.write_text('active_model = "old"\n') - layer = UserConfigLayer(path=path, name="user-toml") + layer = UserConfigLayer(path=path) await layer.load() fingerprint = layer.fingerprint @@ -228,7 +228,7 @@ async def test_apply_raises_when_parent_directory_does_not_exist( tmp_working_directory: Path, ) -> None: path = tmp_working_directory / "nested" / random_config_file_name() - layer = UserConfigLayer(path=path, name="user-toml") + layer = UserConfigLayer(path=path) await layer.load() @@ -236,7 +236,7 @@ async def test_apply_raises_when_parent_directory_does_not_exist( await layer.apply( ConfigPatch( AddOperationPatch(path="/active_model", value="mistral-large"), - fingerprint=MISSING_CONFIG_FILE_FINGERPRINT, + fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT, ) ) @@ -247,7 +247,7 @@ async def test_apply_raises_when_parent_directory_does_not_exist( async def test_commit_sets_missing_nested_field(tmp_working_directory: Path) -> None: path = tmp_working_directory / random_config_file_name() path.write_text("[models]\n") - layer = UserConfigLayer(path=path, name="user-toml") + layer = UserConfigLayer(path=path) await layer.load() fingerprint = layer.fingerprint @@ -270,7 +270,7 @@ async def test_apply_overwrites_external_file_changes( ) -> None: path = tmp_working_directory / random_config_file_name() path.write_text('active_model = "old"\n') - layer = UserConfigLayer(path=path, name="user-toml") + layer = UserConfigLayer(path=path) await layer.load() fingerprint = layer.fingerprint @@ -301,7 +301,7 @@ active_model = "test" alias = "a" provider = "p" """) - layer = UserConfigLayer(path=path, name="user-toml") + layer = UserConfigLayer(path=path) data = await layer.load() assert data.model_extra == { "models": {"active_model": "test", "items": [{"alias": "a", "provider": "p"}]} @@ -312,7 +312,7 @@ provider = "p" async def test_invalid_toml_raises(tmp_working_directory: Path) -> None: path = tmp_working_directory / random_config_file_name() path.write_text("this is not valid = = = toml [[[") - layer = UserConfigLayer(path=path, name="user-toml") + layer = UserConfigLayer(path=path) with pytest.raises(LayerImplementationError, match="_build_config_snapshot"): await layer.load() @@ -321,7 +321,7 @@ async def test_invalid_toml_raises(tmp_working_directory: Path) -> None: async def test_force_reload_reads_fresh_data(tmp_working_directory: Path) -> None: path = tmp_working_directory / random_config_file_name() path.write_text('value = "first"\n') - layer = UserConfigLayer(path=path, name="user-toml") + layer = UserConfigLayer(path=path) data1 = await layer.load() fp1 = layer.fingerprint @@ -340,13 +340,13 @@ async def test_force_reload_reads_fresh_data(tmp_working_directory: Path) -> Non path.unlink() data3 = await layer.load(force=True) assert data3.model_extra == {} - assert layer.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT + assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT @pytest.mark.asyncio async def test_empty_toml_file(tmp_working_directory: Path) -> None: path = tmp_working_directory / random_config_file_name() path.write_text("") - layer = UserConfigLayer(path=path, name="user-toml") + layer = UserConfigLayer(path=path) data = await layer.load() assert data.model_extra == {} diff --git a/tests/core/test_vibe_config_schema.py b/tests/core/test_vibe_config_schema.py index ddb0078..50987e9 100644 --- a/tests/core/test_vibe_config_schema.py +++ b/tests/core/test_vibe_config_schema.py @@ -2,8 +2,15 @@ from __future__ import annotations from pathlib import Path +import keyring import pytest +from vibe.core.config import ( + DEFAULT_THEME, + MissingAPIKeyError, + ModelConfig, + ProviderConfig, +) from vibe.core.config._settings import VibeConfig from vibe.core.config.vibe_schema import VibeConfigSchema @@ -47,7 +54,7 @@ provider = "mistral" class VibeConfig(VibeConfigSchema): pass - layer = UserConfigLayer(path=toml_path, name="user-toml") + layer = UserConfigLayer(path=toml_path) orchestrator = await ConfigOrchestrator[VibeConfig].create( schema=VibeConfig, layers=[layer] ) @@ -62,3 +69,43 @@ provider = "mistral" assert config.default_agent == "plan" assert "search" in config.enabled_skills assert config.enable_otel is True + + +def test_duplicate_model_alias_raises() -> None: + with pytest.raises(ValueError, match="Duplicate alias"): + VibeConfigSchema( + models=[ + ModelConfig(name="model-a", provider="mistral", alias="same"), + ModelConfig(name="model-b", provider="mistral", alias="same"), + ] + ) + + +def test_compaction_model_provider_must_match_active() -> None: + providers = [ + ProviderConfig( + name="mistral", + api_base="https://api.mistral.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + ), + ProviderConfig( + name="other", + api_base="https://other.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + ), + ] + compaction = ModelConfig(name="compact-model", provider="other", alias="compact") + with pytest.raises(ValueError, match="must share the same provider"): + VibeConfigSchema(compaction_model=compaction, providers=providers) + + +def test_check_api_key_raises_when_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + monkeypatch.setattr(keyring, "get_password", lambda service, username: None) + with pytest.raises(MissingAPIKeyError): + VibeConfigSchema() + + +def test_unknown_theme_falls_back_to_default() -> None: + config = VibeConfigSchema(theme="totally-unknown-theme") + assert config.theme == DEFAULT_THEME diff --git a/tests/e2e/agent_loop_characterization/test_tool_execution.py b/tests/e2e/agent_loop_characterization/test_tool_execution.py index 5c6c29d..f0c7751 100644 --- a/tests/e2e/agent_loop_characterization/test_tool_execution.py +++ b/tests/e2e/agent_loop_characterization/test_tool_execution.py @@ -119,7 +119,7 @@ def test_denylisted_bash_tool_does_not_run_and_is_reported_to_the_model( ) -@pytest.mark.timeout(25) +@pytest.mark.timeout(40) @pytest.mark.parametrize( "streaming_mock_server", [pytest.param(_failing_bash_factory, id="failing-bash-tool")], @@ -148,7 +148,7 @@ def test_failed_bash_tool_result_is_reported_to_the_model_and_turn_recovers( timeout=10, ) wait_for_rendered_text( - child, captured, needle="Recovered after the shell failure.", timeout=10 + child, captured, needle="Recovered after the shell failure.", timeout=20 ) send_ctrl_c_until_quit_confirmation(child, captured, timeout=5) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 70e3a94..3a04326 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -38,6 +38,7 @@ def setup_e2e_env( write_e2e_config(vibe_home, streaming_mock_server.api_base) monkeypatch.setenv("MISTRAL_API_KEY", "fake-key") monkeypatch.setenv("VIBE_HOME", str(vibe_home)) + monkeypatch.setenv("VIBE_TEST_DISABLE_KEYRING", "1") monkeypatch.setenv("TERM", "xterm-256color") @@ -64,11 +65,13 @@ def spawned_vibe_process() -> SpawnedVibeFactory: workdir: Path, extra_args: Sequence[str] | None = None ) -> SpawnedVibeContext: captured = io.StringIO() + env = os.environ.copy() + env["VIBE_TEST_DISABLE_KEYRING"] = "1" child = pexpect.spawn( "uv", ["run", "vibe", "--workdir", str(workdir), *(extra_args or [])], cwd=str(TESTS_ROOT.parent), - env=os.environ, + env=cast("os._Environ[str]", env), encoding="utf-8", timeout=30, dimensions=(36, 120), diff --git a/tests/setup/auth/test_api_key_persistence.py b/tests/setup/auth/test_api_key_persistence.py index 39d25cf..e32b5f0 100644 --- a/tests/setup/auth/test_api_key_persistence.py +++ b/tests/setup/auth/test_api_key_persistence.py @@ -7,7 +7,7 @@ import keyring from keyring.errors import KeyringError, NoKeyringError, PasswordDeleteError import pytest -from vibe.core.config import ProviderConfig +from vibe.core.config import ProviderConfig, resolve_api_key from vibe.core.paths import GLOBAL_ENV_FILE from vibe.core.types import Backend from vibe.setup.auth.api_key_persistence import persist_api_key, remove_api_key @@ -45,6 +45,22 @@ def test_persist_stores_in_keyring_and_clears_stale_env( assert "CUSTOM_API_KEY" not in dotenv_values(GLOBAL_ENV_FILE.path) +def test_persist_updates_cached_keyring_value(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CUSTOM_API_KEY", raising=False) + monkeypatch.setattr(keyring, "get_password", lambda service, username: "old-key") + monkeypatch.setattr( + keyring, "set_password", lambda service, username, password: None + ) + + assert resolve_api_key("CUSTOM_API_KEY") == "old-key" + + result = persist_api_key(_provider(), "new-key") + monkeypatch.delenv("CUSTOM_API_KEY", raising=False) + + assert result == "completed" + assert resolve_api_key("CUSTOM_API_KEY") == "new-key" + + def test_persist_falls_back_to_env_when_keyring_unavailable( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -62,6 +78,27 @@ def test_persist_falls_back_to_env_when_keyring_unavailable( assert dotenv_values(GLOBAL_ENV_FILE.path)["CUSTOM_API_KEY"] == "new-key" +def test_persist_fallback_clears_stale_cached_keyring_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("CUSTOM_API_KEY", raising=False) + monkeypatch.setattr(keyring, "get_password", lambda service, username: "old-key") + + def _unavailable(service: str, username: str, password: str) -> None: + raise KeyringError("no keyring") + + assert resolve_api_key("CUSTOM_API_KEY") == "old-key" + + monkeypatch.setattr(keyring, "set_password", _unavailable) + monkeypatch.setattr(keyring, "get_password", lambda service, username: None) + + result = persist_api_key(_provider(), "new-key") + monkeypatch.delenv("CUSTOM_API_KEY", raising=False) + + assert result == "completed" + assert resolve_api_key("CUSTOM_API_KEY") is None + + def test_persist_returns_env_var_error_for_empty_env_var( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -78,21 +115,39 @@ def test_persist_returns_env_var_error_for_empty_env_var( def test_remove_deletes_keyring_env_and_process_env( monkeypatch: pytest.MonkeyPatch, ) -> None: - deleted: list[str] = [] + deleted: list[tuple[str, str]] = [] monkeypatch.setenv("CUSTOM_API_KEY", "live-key") monkeypatch.setattr( - keyring, "delete_password", lambda service, username: deleted.append(username) + keyring, + "delete_password", + lambda service, username: deleted.append((service, username)), ) GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True) set_key(GLOBAL_ENV_FILE.path, "CUSTOM_API_KEY", "file-key") remove_api_key(_provider()) - assert deleted == ["CUSTOM_API_KEY"] + assert deleted == [ + ("ai.mistral.vibe", "CUSTOM_API_KEY"), + ("vibe", "CUSTOM_API_KEY"), + ] assert "CUSTOM_API_KEY" not in dotenv_values(GLOBAL_ENV_FILE.path) assert "CUSTOM_API_KEY" not in os.environ +def test_remove_clears_cached_keyring_value(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CUSTOM_API_KEY", raising=False) + monkeypatch.setattr(keyring, "get_password", lambda service, username: "live-key") + monkeypatch.setattr(keyring, "delete_password", lambda service, username: None) + + assert resolve_api_key("CUSTOM_API_KEY") == "live-key" + + monkeypatch.setattr(keyring, "get_password", lambda service, username: None) + remove_api_key(_provider()) + + assert resolve_api_key("CUSTOM_API_KEY") is None + + def test_remove_ignores_keyring_unavailable_and_still_clears_env( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -117,7 +172,7 @@ def test_remove_ignores_missing_keyring_entry_and_still_clears_env( monkeypatch.setenv("CUSTOM_API_KEY", "live-key") def _missing(service: str, username: str) -> None: - raise PasswordDeleteError("not found") + raise PasswordDeleteError() monkeypatch.setattr(keyring, "delete_password", _missing) GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/setup/auth/test_auth_state.py b/tests/setup/auth/test_auth_state.py index 85382ef..f9ebca9 100644 --- a/tests/setup/auth/test_auth_state.py +++ b/tests/setup/auth/test_auth_state.py @@ -243,6 +243,30 @@ def test_assess_os_keyring_when_default_key_is_in_keyring( ) +def test_assess_os_keyring_reuses_cached_keyring_lookup( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[tuple[str, str]] = [] + + def _get_password(service: str, username: str) -> str: + calls.append((service, username)) + return "keyring-key" + + monkeypatch.setattr(keyring, "get_password", _get_password) + provider = _mistral_provider() + env_path = tmp_path / ".env" + + assert ( + assess_auth_state(provider, env_path=env_path, environ={}).kind + == AuthStateKind.OS_KEYRING + ) + assert ( + assess_auth_state(provider, env_path=env_path, environ={}).kind + == AuthStateKind.OS_KEYRING + ) + assert calls == [("ai.mistral.vibe", DEFAULT_MISTRAL_API_ENV_KEY)] + + def test_assess_vibe_home_env_file_when_dotenv_and_keyring_both_have_value( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/skills/conftest.py b/tests/skills/conftest.py index 4bde6a3..06bf778 100644 --- a/tests/skills/conftest.py +++ b/tests/skills/conftest.py @@ -19,11 +19,7 @@ def skills_dir(tmp_path: Path) -> Path: @pytest.fixture def skill_config(skills_dir: Path) -> VibeConfig: - return build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + return build_test_vibe_config(skill_paths=[skills_dir]) def create_skill( diff --git a/tests/skills/test_builtin_sync.py b/tests/skills/test_builtin_sync.py index 74ca931..24ddecc 100644 --- a/tests/skills/test_builtin_sync.py +++ b/tests/skills/test_builtin_sync.py @@ -38,9 +38,7 @@ class TestBuiltinSkills: def test_discovers_builtin_skills(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("vibe.core.skills.manager.BUILTIN_SKILLS", BUILTIN_SKILLS) - config = build_test_vibe_config( - system_prompt_id="tests", include_project_context=False - ) + config = build_test_vibe_config() manager = SkillManager(lambda: config) assert "vibe" in manager.available_skills @@ -53,11 +51,7 @@ class TestBuiltinSkills: skills_dir.mkdir() create_skill(skills_dir, "vibe", "Custom vibe override") - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + config = build_test_vibe_config(skill_paths=[skills_dir]) manager = SkillManager(lambda: config) assert "vibe" in manager.available_skills diff --git a/tests/skills/test_manager.py b/tests/skills/test_manager.py index 3643b28..bc1baae 100644 --- a/tests/skills/test_manager.py +++ b/tests/skills/test_manager.py @@ -14,15 +14,8 @@ from vibe.core.trusted_folders import trusted_folders_manager @pytest.fixture -def config() -> VibeConfig: - return build_test_vibe_config( - system_prompt_id="tests", include_project_context=False - ) - - -@pytest.fixture -def skill_manager(config: VibeConfig) -> SkillManager: - return SkillManager(lambda: config) +def skill_manager(vibe_config: VibeConfig) -> SkillManager: + return SkillManager(lambda: vibe_config) class TestSkillManagerDiscovery: @@ -39,11 +32,7 @@ class TestSkillManagerDiscovery: def test_discovers_skill_from_skill_paths(self, skills_dir: Path) -> None: create_skill(skills_dir, "test-skill", "A test skill") - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + config = build_test_vibe_config(skill_paths=[skills_dir]) manager = SkillManager(lambda: config) assert "test-skill" in manager.available_skills @@ -54,11 +43,7 @@ class TestSkillManagerDiscovery: create_skill(skills_dir, "skill-two", "Second skill") create_skill(skills_dir, "skill-three", "Third skill") - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + config = build_test_vibe_config(skill_paths=[skills_dir]) manager = SkillManager(lambda: config) assert len(manager.available_skills) == 3 + len(BUILTIN_SKILLS) @@ -75,11 +60,7 @@ class TestSkillManagerDiscovery: # Create a valid skill create_skill(skills_dir, "valid-skill", "A valid skill") - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + config = build_test_vibe_config(skill_paths=[skills_dir]) manager = SkillManager(lambda: config) skills = manager.available_skills @@ -94,11 +75,7 @@ class TestSkillManagerDiscovery: # Create a valid skill create_skill(skills_dir, "valid-skill", "A valid skill") - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + config = build_test_vibe_config(skill_paths=[skills_dir]) manager = SkillManager(lambda: config) skills = manager.available_skills @@ -118,11 +95,7 @@ class TestSkillManagerParsing: allowed_tools="bash read", ) - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + config = build_test_vibe_config(skill_paths=[skills_dir]) manager = SkillManager(lambda: config) skill = manager.get_skill("full-skill") @@ -137,11 +110,7 @@ class TestSkillManagerParsing: def test_sets_correct_skill_path(self, skills_dir: Path) -> None: create_skill(skills_dir, "test-skill", "A test skill") - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + config = build_test_vibe_config(skill_paths=[skills_dir]) manager = SkillManager(lambda: config) skill = manager.get_skill("test-skill") @@ -158,11 +127,7 @@ class TestSkillManagerParsing: # Create a valid skill create_skill(skills_dir, "valid-skill", "A valid skill") - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + config = build_test_vibe_config(skill_paths=[skills_dir]) manager = SkillManager(lambda: config) skills = manager.available_skills @@ -179,11 +144,7 @@ class TestSkillManagerParsing: # Create a valid skill create_skill(skills_dir, "valid-skill", "A valid skill") - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + config = build_test_vibe_config(skill_paths=[skills_dir]) manager = SkillManager(lambda: config) skills = manager.available_skills @@ -200,9 +161,7 @@ class TestSkillManagerSearchPaths: vibe_skills.mkdir(parents=True) create_skill(vibe_skills, "vibe-skill", "Skill from .vibe/skills") - config = build_test_vibe_config( - system_prompt_id="tests", include_project_context=False, skill_paths=[] - ) + config = build_test_vibe_config(skill_paths=[]) manager = SkillManager(lambda: config) assert "vibe-skill" in manager.available_skills @@ -219,9 +178,7 @@ class TestSkillManagerSearchPaths: agents_skills.mkdir(parents=True) create_skill(agents_skills, "agents-skill", "Skill from .agents/skills") - config = build_test_vibe_config( - system_prompt_id="tests", include_project_context=False, skill_paths=[] - ) + config = build_test_vibe_config(skill_paths=[]) manager = SkillManager(lambda: config) assert "agents-skill" in manager.available_skills @@ -241,9 +198,7 @@ class TestSkillManagerSearchPaths: create_skill(vibe_skills, "vibe-only", "From .vibe") create_skill(agents_skills, "agents-only", "From .agents") - config = build_test_vibe_config( - system_prompt_id="tests", include_project_context=False, skill_paths=[] - ) + config = build_test_vibe_config(skill_paths=[]) manager = SkillManager(lambda: config) skills = manager.available_skills @@ -262,9 +217,7 @@ class TestSkillManagerSearchPaths: create_skill(vibe_skills, "shared-skill", "First from .vibe") create_skill(agents_skills, "shared-skill", "Second from .agents") - config = build_test_vibe_config( - system_prompt_id="tests", include_project_context=False, skill_paths=[] - ) + config = build_test_vibe_config(skill_paths=[]) manager = SkillManager(lambda: config) skills = manager.available_skills @@ -281,11 +234,7 @@ class TestSkillManagerSearchPaths: skills_dir_2.mkdir() create_skill(skills_dir_2, "skill-from-dir2", "Skill from directory 2") - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir_1, skills_dir_2], - ) + config = build_test_vibe_config(skill_paths=[skills_dir_1, skills_dir_2]) manager = SkillManager(lambda: config) skills = manager.available_skills @@ -303,11 +252,7 @@ class TestSkillManagerSearchPaths: skills_dir_2.mkdir() create_skill(skills_dir_2, "duplicate-skill", "Second version") - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir_1, skills_dir_2], - ) + config = build_test_vibe_config(skill_paths=[skills_dir_1, skills_dir_2]) manager = SkillManager(lambda: config) skills = manager.available_skills @@ -320,9 +265,7 @@ class TestSkillManagerSearchPaths: create_skill(skills_dir, "valid-skill", "A valid skill") config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir, tmp_path / "nonexistent"], + skill_paths=[skills_dir, tmp_path / "nonexistent"] ) manager = SkillManager(lambda: config) @@ -335,11 +278,7 @@ class TestSkillManagerGetSkill: def test_returns_skill_by_name(self, skills_dir: Path) -> None: create_skill(skills_dir, "test-skill", "A test skill") - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + config = build_test_vibe_config(skill_paths=[skills_dir]) manager = SkillManager(lambda: config) skill = manager.get_skill("test-skill") @@ -357,10 +296,7 @@ class TestSkillManagerFiltering: create_skill(skills_dir, "skill-c", "Skill C") config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - enabled_skills=["skill-a", "skill-c"], + skill_paths=[skills_dir], enabled_skills=["skill-a", "skill-c"] ) manager = SkillManager(lambda: config) @@ -376,10 +312,7 @@ class TestSkillManagerFiltering: create_skill(skills_dir, "skill-c", "Skill C") config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - disabled_skills=["skill-b"], + skill_paths=[skills_dir], disabled_skills=["skill-b"] ) manager = SkillManager(lambda: config) @@ -396,8 +329,6 @@ class TestSkillManagerFiltering: create_skill(skills_dir, "skill-b", "Skill B") config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, skill_paths=[skills_dir], enabled_skills=["skill-a"], disabled_skills=["skill-a"], # Should be ignored @@ -414,10 +345,7 @@ class TestSkillManagerFiltering: create_skill(skills_dir, "other-skill", "Other skill") config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - enabled_skills=["search-*"], + skill_paths=[skills_dir], enabled_skills=["search-*"] ) manager = SkillManager(lambda: config) @@ -433,10 +361,7 @@ class TestSkillManagerFiltering: create_skill(skills_dir, "other-skill", "Other skill") config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - enabled_skills=["re:skill-v\\d+"], + skill_paths=[skills_dir], enabled_skills=["re:skill-v\\d+"] ) manager = SkillManager(lambda: config) @@ -451,10 +376,7 @@ class TestSkillManagerFiltering: create_skill(skills_dir, "disabled-skill", "Disabled") config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - disabled_skills=["disabled-skill"], + skill_paths=[skills_dir], disabled_skills=["disabled-skill"] ) manager = SkillManager(lambda: config) @@ -466,11 +388,7 @@ class TestSkillUserInvocable: def test_user_invocable_defaults_to_true(self, skills_dir: Path) -> None: create_skill(skills_dir, "default-skill", "A default skill") - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + config = build_test_vibe_config(skill_paths=[skills_dir]) manager = SkillManager(lambda: config) skill = manager.get_skill("default-skill") @@ -480,11 +398,7 @@ class TestSkillUserInvocable: def test_user_invocable_can_be_set_to_false(self, skills_dir: Path) -> None: create_skill(skills_dir, "hidden-skill", "A hidden skill", user_invocable=False) - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + config = build_test_vibe_config(skill_paths=[skills_dir]) manager = SkillManager(lambda: config) skill = manager.get_skill("hidden-skill") @@ -498,11 +412,7 @@ class TestSkillUserInvocable: skills_dir, "explicit-skill", "An explicit skill", user_invocable=True ) - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + config = build_test_vibe_config(skill_paths=[skills_dir]) manager = SkillManager(lambda: config) skill = manager.get_skill("explicit-skill") @@ -514,11 +424,7 @@ class TestSkillUserInvocable: create_skill(skills_dir, "hidden-skill", "Hidden", user_invocable=False) create_skill(skills_dir, "default-skill", "Default") - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + config = build_test_vibe_config(skill_paths=[skills_dir]) manager = SkillManager(lambda: config) skills = manager.available_skills @@ -576,11 +482,7 @@ class TestSkillManagerConfigIssues: broken_dir.mkdir() (broken_dir / "SKILL.md").write_text("No frontmatter here") - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + config = build_test_vibe_config(skill_paths=[skills_dir]) manager = SkillManager(lambda: config) assert len(manager.config_issues) == 1 @@ -597,11 +499,7 @@ class TestSkillManagerConfigIssues: (broken_dir / "SKILL.md").write_text("not valid") create_skill(skills_dir, "good-skill", "Works fine") - config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - skill_paths=[skills_dir], - ) + config = build_test_vibe_config(skill_paths=[skills_dir]) manager = SkillManager(lambda: config) assert { diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg index 7a08172..eec5af5 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg @@ -130,7 +130,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg index 7973735..bff68b6 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg @@ -170,7 +170,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg index d03cdf5..e5fa2ae 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg @@ -195,7 +195,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir6% of 200k tokens +/test/workdir12k/200k tokens (6%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_clipboard_notice/test_snapshot_clipboard_notice_visible.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_clipboard_notice/test_snapshot_clipboard_notice_visible.svg index 42fd75f..b91a843 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_clipboard_notice/test_snapshot_clipboard_notice_visible.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_clipboard_notice/test_snapshot_clipboard_notice_visible.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg index 5fc570d..1ec5986 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg @@ -201,7 +201,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_escape_closes.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_escape_closes.svg index c150ef5..5174202 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_escape_closes.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_escape_closes.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_initial.svg index 0efc9e0..2f8f4d3 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_initial.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_initial.svg @@ -198,7 +198,7 @@ ↑↓ Navigate  Enter Select/Toggle  Esc Exit └──────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_navigate_down.svg index 1e26095..b0b33c0 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_navigate_down.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_navigate_down.svg @@ -198,7 +198,7 @@ ↑↓ Navigate  Enter Select/Toggle  Esc Exit └──────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_toggle_autocopy.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_toggle_autocopy.svg index 293c3e9..19479d6 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_toggle_autocopy.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_toggle_autocopy.svg @@ -198,7 +198,7 @@ ↑↓ Navigate  Enter Select/Toggle  Esc Exit └──────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_issues/test_snapshot_shows_config_issue_notification.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_issues/test_snapshot_shows_config_issue_notification.svg index 6304c57..c95fac4 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_config_issues/test_snapshot_shows_config_issue_notification.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_issues/test_snapshot_shows_config_issue_notification.svg @@ -195,7 +195,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_issues/test_snapshot_shows_hook_config_issue_notification.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_issues/test_snapshot_shows_hook_config_issue_notification.svg index c0f3017..717393b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_config_issues/test_snapshot_shows_hook_config_issue_notification.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_issues/test_snapshot_shows_hook_config_issue_notification.svg @@ -195,7 +195,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_data_retention/test_snapshot_data_retention.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_data_retention/test_snapshot_data_retention.svg index dded449..2c4f5f7 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_data_retention/test_snapshot_data_retention.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_data_retention/test_snapshot_data_retention.svg @@ -196,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_close.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_close.svg index 8f9708c..81ba830 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_close.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_close.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_live_append.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_live_append.svg index 58a77c3..9eb5ba1 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_live_append.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_live_append.svg @@ -197,7 +197,7 @@                                                                       │                                                                       │ ────────────────────────────────────────────────────────────────────────│ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_open.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_open.svg index 9573fee..c2ec301 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_open.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_open.svg @@ -196,7 +196,7 @@                                                                       │                                                                       │ ────────────────────────────────────────────────────────────────────────│ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_after_resize.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_after_resize.svg index 5d5d078..18dc5c7 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_after_resize.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_after_resize.svg @@ -216,7 +216,7 @@ ↑↓ navigate  Enter select  ESC reject └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_bottom_lines_hidden.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_bottom_lines_hidden.svg index 3fb39bf..7ce0d5a 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_bottom_lines_hidden.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_bottom_lines_hidden.svg @@ -175,7 +175,7 @@ ↑↓ navigate  Enter select  ESC reject └──────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_short_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_short_content.svg index a49a0ff..b810d57 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_short_content.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_short_content.svg @@ -174,7 +174,7 @@ ↑↓ navigate  Enter select  ESC reject └──────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff.svg index fe9217a..cf802fa 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff.svg @@ -154,7 +154,7 @@ - +   ⡠⣒⠄  ⡔⢄⠔⡄ @@ -185,7 +185,7 @@ ↑↓ navigate  Enter select  ESC reject └──────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_ansi.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_ansi.svg index b70c9fb..219a86b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_ansi.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_ansi.svg @@ -177,7 +177,7 @@ ↑↓ navigate  Enter select  ESC reject └──────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_replace_all.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_replace_all.svg index 5b96157..ebf9761 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_replace_all.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_replace_all.svg @@ -153,7 +153,7 @@ - +   ⠉⠒⠣⠤⠵⠤⠬⠮⠆ @@ -184,7 +184,7 @@ ↑↓ navigate  Enter select  ESC reject └──────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_empty_assistant_before_reasoning/test_snapshot_empty_assistant_removed_when_reasoning_starts.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_empty_assistant_before_reasoning/test_snapshot_empty_assistant_removed_when_reasoning_starts.svg index b1c7cb3..8cb81ee 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_empty_assistant_before_reasoning/test_snapshot_empty_assistant_removed_when_reasoning_starts.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_empty_assistant_before_reasoning/test_snapshot_empty_assistant_removed_when_reasoning_starts.svg @@ -196,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir60/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_feedback_bar/test_snapshot_feedback_bar_visible.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_feedback_bar/test_snapshot_feedback_bar_visible.svg index 73b206c..0fe5283 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_feedback_bar/test_snapshot_feedback_bar_visible.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_feedback_bar/test_snapshot_feedback_bar_visible.svg @@ -196,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir6% of 200k tokens +/test/workdir12k/200k tokens (6%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_textbox_rewrites_typed_image_path.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_textbox_rewrites_typed_image_path.svg index 685da3e..9582e31 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_textbox_rewrites_typed_image_path.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_textbox_rewrites_typed_image_path.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_user_message_with_image_footer_plural.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_user_message_with_image_footer_plural.svg index 93c4448..648045e 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_user_message_with_image_footer_plural.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_user_message_with_image_footer_plural.svg @@ -37,6 +37,7 @@ .terminal-r3 { fill: #68a0b3 } .terminal-r4 { fill: #c5c8c6;font-weight: bold } .terminal-r5 { fill: #868887 } +.terminal-r6 { fill: #6b753d;text-decoration: underline; } @@ -172,19 +173,19 @@ - -  ⡠⣒⠄  ⡔⢄⠔⡄ - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣ -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆ - -Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro -1 model · 0 MCP servers · 0 skills -Type /help for more information +  ⡠⣒⠄  ⡔⢄⠔⡄ + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣ +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆ + +Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro +1 model · 0 MCP servers · 0 skills +Type /help for more information + - -compare @a.png @b.png -└ attached images: a.pngb.png +compare @a.png @b.png +└ attached image: a.png +└ attached image: b.png ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Got it. @@ -195,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir15/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_user_message_with_image_footer_singular.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_user_message_with_image_footer_singular.svg index 674cced..1380ff7 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_user_message_with_image_footer_singular.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_user_message_with_image_footer_singular.svg @@ -37,6 +37,7 @@ .terminal-r3 { fill: #68a0b3 } .terminal-r4 { fill: #c5c8c6;font-weight: bold } .terminal-r5 { fill: #868887 } +.terminal-r6 { fill: #6b753d;text-decoration: underline; } @@ -184,7 +185,7 @@ look @shot.png -└ attached image: shot.png +└ attached image: shot.png ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Got it. @@ -195,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir15/200k tokens (0%) 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 index d328679..1fd9bde 100644 --- 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 @@ -199,7 +199,7 @@   zeta   [connector]  no tools needs setup ↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) 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 index 36c5311..f52310f 100644 --- 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 @@ -196,7 +196,7 @@ Backspace Back └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) 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 index 49147d3..385b905 100644 --- 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 @@ -196,7 +196,7 @@ Backspace Back └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) 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 d9c490f..92cf740 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg @@ -39,7 +39,9 @@ .terminal-r5 { fill: #c5c8c6;font-weight: bold } .terminal-r6 { fill: #4b4e55;font-weight: bold } .terminal-r7 { fill: #7b7e82;font-weight: bold } -.terminal-r8 { fill: #868887 } +.terminal-r8 { fill: #98a84b;font-weight: bold } +.terminal-r9 { fill: #868887 } +.terminal-r10 { fill: #98a84b } @@ -161,7 +163,7 @@ - + @@ -194,11 +196,11 @@ 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 +  filesystem  [stdio]  1 tool enabled +  search      [http]   1 tool enabled +↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) 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 90debd4..897bc36 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg @@ -39,7 +39,9 @@ .terminal-r5 { fill: #c5c8c6;font-weight: bold } .terminal-r6 { fill: #4b4e55;font-weight: bold } .terminal-r7 { fill: #7b7e82;font-weight: bold } -.terminal-r8 { fill: #868887 } +.terminal-r8 { fill: #98a84b;font-weight: bold } +.terminal-r9 { fill: #868887 } +.terminal-r10 { fill: #98a84b } @@ -161,7 +163,7 @@ - + @@ -193,12 +195,12 @@ 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 +  filesystem     [stdio]  1 tool   enabled +  broken-server  [stdio]  no tools enabled +  search         [http]   1 tool   enabled +↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) 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 c8f9263..10bd823 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 @@ -39,8 +39,9 @@ .terminal-r5 { fill: #c5c8c6;font-weight: bold } .terminal-r6 { fill: #4b4e55;font-weight: bold } .terminal-r7 { fill: #7b7e82;font-weight: bold } -.terminal-r8 { fill: #868887 } -.terminal-r9 { fill: #98a84b } +.terminal-r8 { fill: #98a84b;font-weight: bold } +.terminal-r9 { fill: #868887 } +.terminal-r10 { fill: #98a84b } @@ -162,7 +163,7 @@ - + @@ -192,14 +193,14 @@ MCP Servers & Connectors Local MCP Servers -  filesystem  [stdio]  1 tool +  filesystem  [stdio]  1 tool enabled Workspace Connectors -  gmail  [connector]  3 tools connected -  slack  [connector]  2 tools connected -↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +  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 +/test/workdir0/200k tokens (0%) 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 b016d01..1f94edb 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 @@ -200,7 +200,7 @@   slack  [connector]  2 tools connected ↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) 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 953022c..0f04354 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 @@ -199,7 +199,7 @@   zeta   [connector]  no tools needs setup ↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) 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 23ea2e6..3aae522 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 @@ -197,7 +197,7 @@ send_message  -  Send a Slack message ↑↓ Navigate  D Disable  E Enable  Backspace Back  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) 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 169a6e9..3deae52 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 @@ -196,7 +196,7 @@ fake_tool  -  A fake tool for filesystem ↑↓ Navigate  D Disable  E Enable  Backspace Back  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_escape_closes.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_escape_closes.svg index 73625b4..7df4992 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_escape_closes.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_escape_closes.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_connect.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_connect.svg index b49ddf5..8e9dd3c 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_connect.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_connect.svg @@ -199,7 +199,7 @@   zeta   [connector]  no tools needs setup ↑↓ Navigate  Enter Connect  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_no_servers.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_no_servers.svg index bd32cb7..36e7e25 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_no_servers.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_no_servers.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) 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 0d55795..7d6327d 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg @@ -39,7 +39,9 @@ .terminal-r5 { fill: #c5c8c6;font-weight: bold } .terminal-r6 { fill: #4b4e55;font-weight: bold } .terminal-r7 { fill: #7b7e82;font-weight: bold } -.terminal-r8 { fill: #868887 } +.terminal-r8 { fill: #98a84b;font-weight: bold } +.terminal-r9 { fill: #868887 } +.terminal-r10 { fill: #98a84b } @@ -161,7 +163,7 @@ - + @@ -194,11 +196,11 @@ 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 +  filesystem  [stdio]  1 tool enabled +  search      [http]   1 tool enabled +↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) 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 b1467f7..e08ac1a 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 @@ -38,8 +38,10 @@ .terminal-r4 { fill: #608ab1;font-weight: bold } .terminal-r5 { fill: #c5c8c6;font-weight: bold } .terminal-r6 { fill: #868887 } -.terminal-r7 { fill: #4b4e55;font-weight: bold } -.terminal-r8 { fill: #7b7e82;font-weight: bold } +.terminal-r7 { fill: #98a84b } +.terminal-r8 { fill: #4b4e55;font-weight: bold } +.terminal-r9 { fill: #7b7e82;font-weight: bold } +.terminal-r10 { fill: #98a84b;font-weight: bold } @@ -161,7 +163,7 @@ - + @@ -194,11 +196,11 @@ MCP Servers Local MCP Servers -  filesystem  [stdio]  1 tool -  search      [http]   1 tool +  filesystem  [stdio]  1 tool enabled +  search      [http]   1 tool enabled ↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) 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 55074a9..205d4a2 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 @@ -39,7 +39,9 @@ .terminal-r5 { fill: #c5c8c6;font-weight: bold } .terminal-r6 { fill: #4b4e55;font-weight: bold } .terminal-r7 { fill: #7b7e82;font-weight: bold } -.terminal-r8 { fill: #868887 } +.terminal-r8 { fill: #98a84b;font-weight: bold } +.terminal-r9 { fill: #868887 } +.terminal-r10 { fill: #98a84b } @@ -161,7 +163,7 @@ - + @@ -194,11 +196,11 @@ 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 +  filesystem  [stdio]  1 tool enabled +  search      [http]   1 tool enabled +Refreshed.  ↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) 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 eef0111..4102d9a 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 @@ -196,7 +196,7 @@ fake_tool  -  A fake tool for filesystem ↑↓ Navigate  D Disable  E Enable  Backspace Back  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) 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 c8f9263..10bd823 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 @@ -39,8 +39,9 @@ .terminal-r5 { fill: #c5c8c6;font-weight: bold } .terminal-r6 { fill: #4b4e55;font-weight: bold } .terminal-r7 { fill: #7b7e82;font-weight: bold } -.terminal-r8 { fill: #868887 } -.terminal-r9 { fill: #98a84b } +.terminal-r8 { fill: #98a84b;font-weight: bold } +.terminal-r9 { fill: #868887 } +.terminal-r10 { fill: #98a84b } @@ -162,7 +163,7 @@ - + @@ -192,14 +193,14 @@ MCP Servers & Connectors Local MCP Servers -  filesystem  [stdio]  1 tool +  filesystem  [stdio]  1 tool enabled Workspace Connectors -  gmail  [connector]  3 tools connected -  slack  [connector]  2 tools connected -↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +  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 +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_escape_cancels.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_escape_cancels.svg index 383631f..9cd3be4 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_escape_cancels.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_escape_cancels.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_initial.svg index d2e8e6f..c9a9fb7 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_initial.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_initial.svg @@ -197,7 +197,7 @@ ↑↓ Navigate  Enter Select  Esc Cancel └──────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_navigate_down.svg index 0ca2340..98711d5 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_navigate_down.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_navigate_down.svg @@ -198,7 +198,7 @@ ↑↓ Navigate  Enter Select  Esc Cancel └──────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_select_different_model.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_select_different_model.svg index 6577289..622d889 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_select_different_model.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_select_different_model.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg index dce399a..e38d0d7 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg @@ -195,7 +195,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg index c9ef885..ed58f85 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg @@ -195,7 +195,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg index c8a7f36..b837ad0 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg @@ -195,7 +195,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg index c4fba67..b1dd6a4 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg index c4fba67..b1dd6a4 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_idle_after_speaking.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_idle_after_speaking.svg index 3dc421a..63754f8 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_idle_after_speaking.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_idle_after_speaking.svg @@ -195,7 +195,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir6% of 200k tokens +/test/workdir12k/200k tokens (6%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_speaking.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_speaking.svg index b959346..57e2f30 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_speaking.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_speaking.svg @@ -196,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir6% of 200k tokens +/test/workdir12k/200k tokens (6%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_summarizing.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_summarizing.svg index 02ee232..c8489c6 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_summarizing.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_summarizing.svg @@ -196,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir6% of 200k tokens +/test/workdir12k/200k tokens (6%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_plan_file_message/test_snapshot_plan_file_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_plan_file_message/test_snapshot_plan_file_message.svg index 03d4181..e3a80e5 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_plan_file_message/test_snapshot_plan_file_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_plan_file_message/test_snapshot_plan_file_message.svg @@ -196,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_cancel_discards_changes.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_cancel_discards_changes.svg index de7ed69..13f7242 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_cancel_discards_changes.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_cancel_discards_changes.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_edit_existing_values.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_edit_existing_values.svg index a32507e..871e9eb 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_edit_existing_values.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_edit_existing_values.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_empty.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_empty.svg index 1d0a62b..f504f8a 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_empty.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_empty.svg @@ -196,7 +196,7 @@ Path to directory containing SSL certificates ↑↓ navigate  Enter save & exit  ESC cancel └──────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_with_values.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_with_values.svg index 7b0d58e..1ac5d20 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_with_values.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_with_values.svg @@ -196,7 +196,7 @@ Path to directory containing SSL certificates ↑↓ navigate  Enter save & exit  ESC cancel └──────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_error.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_error.svg index b7ebdc0..bcd882f 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_error.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_error.svg @@ -195,7 +195,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_new_values.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_new_values.svg index 5c7aa4b..7eb60e4 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_new_values.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_new_values.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_queued_messages/test_snapshot_queued_bash_commands.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_queued_messages/test_snapshot_queued_bash_commands.svg index 9f56812..f23381d 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_queued_messages/test_snapshot_queued_bash_commands.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_queued_messages/test_snapshot_queued_bash_commands.svg @@ -196,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_queued_messages/test_snapshot_queued_mixed_prompts_and_bash.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_queued_messages/test_snapshot_queued_mixed_prompts_and_bash.svg index 54cfcf0..6ccb7a7 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_queued_messages/test_snapshot_queued_mixed_prompts_and_bash.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_queued_messages/test_snapshot_queued_mixed_prompts_and_bash.svg @@ -197,7 +197,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_queued_messages/test_snapshot_queued_user_prompts.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_queued_messages/test_snapshot_queued_user_prompts.svg index 17a164b..43e9c1d 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_queued_messages/test_snapshot_queued_user_prompts.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_queued_messages/test_snapshot_queued_user_prompts.svg @@ -197,7 +197,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg index 97b2788..e8f783e 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg @@ -196,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir75/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg index 27df9c0..0c24bae 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg @@ -196,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir120/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg index ec93303..d419767 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg @@ -196,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir75/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg index 3dd6c31..21938fc 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg @@ -196,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir75/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_error_shows_toast.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_error_shows_toast.svg index 4b56746..6309ca8 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_error_shows_toast.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_error_shows_toast.svg @@ -198,7 +198,7 @@ Alt+↑↓ or Ctrl+P/N browse messages  ↑↓ pick option  Enter confirm  ESC cancel └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir15/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_exit_on_escape.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_exit_on_escape.svg index 9ff6043..0ea5603 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_exit_on_escape.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_exit_on_escape.svg @@ -195,7 +195,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir15/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_down.svg index ddcd2c7..fcdbaf2 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_down.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_down.svg @@ -197,7 +197,7 @@ Alt+↑↓ or Ctrl+P/N browse messages  ↑↓ pick option  Enter confirm  ESC cancel └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir15/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_up.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_up.svg index 41cdbfd..17b388b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_up.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_up.svg @@ -197,7 +197,7 @@ Alt+↑↓ or Ctrl+P/N browse messages  ↑↓ pick option  Enter confirm  ESC cancel └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir15/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_panel_shown.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_panel_shown.svg index ddcd2c7..fcdbaf2 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_panel_shown.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_panel_shown.svg @@ -197,7 +197,7 @@ Alt+↑↓ or Ctrl+P/N browse messages  ↑↓ pick option  Enter confirm  ESC cancel └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir15/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_right_padding/test_snapshot_right_padding_assistant_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_right_padding/test_snapshot_right_padding_assistant_message.svg index 44322f0..17a9619 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_right_padding/test_snapshot_right_padding_assistant_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_right_padding/test_snapshot_right_padding_assistant_message.svg @@ -195,7 +195,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir8% of 200k tokens +/test/workdir15k/200k tokens (8%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_right_padding/test_snapshot_right_padding_reasoning.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_right_padding/test_snapshot_right_padding_reasoning.svg index edab68f..7f59e47 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_right_padding/test_snapshot_right_padding_reasoning.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_right_padding/test_snapshot_right_padding_reasoning.svg @@ -196,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir30/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_session_picker/test_snapshot_session_picker_header.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_session_picker/test_snapshot_session_picker_header.svg index e124c71..eb718a9 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_session_picker/test_snapshot_session_picker_header.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_session_picker/test_snapshot_session_picker_header.svg @@ -196,7 +196,7 @@ ↑↓ Navigate  Enter Select  D Delete  Esc Cancel └──────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg index 9efbcc2..350b9d7 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg @@ -196,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume_injected/test_snapshot_session_resume_hides_injected_messages.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume_injected/test_snapshot_session_resume_hides_injected_messages.svg index 5649a37..8be841f 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume_injected/test_snapshot_session_resume_hides_injected_messages.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume_injected/test_snapshot_session_resume_hides_injected_messages.svg @@ -195,7 +195,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_code_fence/test_snapshot_streaming_code_fence_preserved.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_code_fence/test_snapshot_streaming_code_fence_preserved.svg index 9991491..a9343ce 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_code_fence/test_snapshot_streaming_code_fence_preserved.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_code_fence/test_snapshot_streaming_code_fence_preserved.svg @@ -198,7 +198,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir75/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_hidden_for_non_pro_account.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_hidden_for_non_pro_account.svg deleted file mode 100644 index 640375b..0000000 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_hidden_for_non_pro_account.svg +++ /dev/null @@ -1,250 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - TeleportCommandHelpFreeApp - - - - - - - - - - • Enter Submit message -• Ctrl+J / Shift+Enter Insert newline -• Escape Interrupt agent or close dialogs -• Ctrl+C Quit (or clear input if text present) -• Ctrl+G Edit input in external editor -• Ctrl+O Toggle tool output view -• Shift+Tab Cycle through agents (default, plan, ...) -• Alt+↑↓ / Ctrl+P/N Rewind to previous/next message - -Special Features - -• !<command> Execute bash command directly -• @path/to/file/ Autocompletes file paths - -Commands - -• /help: Show help message -• /config: Edit config settings -• /model: Select active model -• /thinking: Select thinking level -• /reload: Reload configuration, agent instructions, and skills from disk -• /clear: Clear conversation history -• /copy: Copy the last agent message to the clipboard -• /log: Show path to current interaction log file -• /debug: Toggle debug console -• /compact: Compact conversation history by summarizing. Optionally pass instructions to guide the summary -• /exit:q:quitexitquit: Exit the application -• /status: Display agent statistics -• /proxy-setup: Configure proxy and SSL certificate settings -• /continue/resume: Browse, resume, or delete saved sessions -• /rename: Rename the current session -• /connectors/mcp: Display available MCP servers and connectors. Pass a name to list tools; subcommands: -status, login , logout  -• /voice: Configure voice settings -• /leanstall: Install the Lean 4 agent (leanstral) -• /unleanstall: Uninstall the Lean 4 agent -• /rewind: Rewind to a previous message -• /loop: Schedule a recurring prompt. Use /loop <interval> <prompt>/loop list, or /loop cancel <id|all> -• /data-retention: Show data retention information -• /theme: Select theme - - -────────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─ -> - - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - - - diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg deleted file mode 100644 index 3099ad9..0000000 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg +++ /dev/null @@ -1,250 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - TeleportCommandHelpProApp - - - - - - - - - - • Ctrl+J / Shift+Enter Insert newline -• Escape Interrupt agent or close dialogs -• Ctrl+C Quit (or clear input if text present) -• Ctrl+G Edit input in external editor -• Ctrl+O Toggle tool output view -• Shift+Tab Cycle through agents (default, plan, ...) -• Alt+↑↓ / Ctrl+P/N Rewind to previous/next message - -Special Features - -• !<command> Execute bash command directly -• @path/to/file/ Autocompletes file paths - -Commands - -• /clear: Clear conversation history -• /compact: Compact conversation history by summarizing. Optionally pass instructions to guide the summary -• /config: Edit config settings -• /copy: Copy the last agent message to the clipboard -• /data-retention: Show data retention information -• /debug: Toggle debug console -• /exit:q:quitexitquit: Exit the application -• /help: Show help message -• /leanstall: Install the Lean 4 agent (leanstral) -• /log: Show path to current interaction log file -• /loop: Schedule a recurring prompt. Use /loop <interval> <prompt>/loop list, or /loop cancel <id|all> -• /mcp/connectors: Display available MCP servers and connectors. Pass a name to list tools; subcommands: -status, login , logout  -• /model: Select active model -• /proxy-setup: Configure proxy and SSL certificate settings -• /reload: Reload configuration, agent instructions, and skills from disk -• /rename: Rename the current session -• /resume/continue: Browse, resume, or delete saved sessions -• /rewind: Rewind to a previous message -• /status: Display agent statistics -• /teleport: Teleport session to Vibe Code Web -• /theme: Select theme -• /thinking: Select thinking level -• /unleanstall: Uninstall the Lean 4 agent -• /voice: Configure voice settings - - -────────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─ -> - - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - - - diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_indicator_shown.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_indicator_shown.svg index 93f9f3e..8e62f23 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_indicator_shown.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_indicator_shown.svg @@ -195,7 +195,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_not_started_when_voice_disabled.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_not_started_when_voice_disabled.svg index 578ca70..289c46c 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_not_started_when_voice_disabled.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_not_started_when_voice_disabled.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_stopped_after_keypress.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_stopped_after_keypress.svg index f8c3314..da82d2d 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_stopped_after_keypress.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_stopped_after_keypress.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_then_conversation.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_then_conversation.svg index fc8438c..247528b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_then_conversation.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_then_conversation.svg @@ -195,7 +195,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir15/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_transcription_populates_text_area.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_transcription_populates_text_area.svg index b99e736..04ba3f7 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_transcription_populates_text_area.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_transcription_populates_text_area.svg @@ -195,7 +195,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_disable.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_disable.svg index a7b57ea..b1c7a27 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_disable.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_disable.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_enable.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_enable.svg index ca58e06..c706c3b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_enable.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_enable.svg @@ -194,7 +194,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg index 0ce7dfc..383e72d 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg @@ -196,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg index 1dcfaf7..7789d03 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg @@ -198,7 +198,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg index e4884d8..ff6fb4d 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg @@ -198,7 +198,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg index e7f2194..aae1850 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg @@ -196,7 +196,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens +/test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/test_ui_snapshot_teleport.py b/tests/snapshots/test_ui_snapshot_teleport.py index e45f44e..41e0e29 100644 --- a/tests/snapshots/test_ui_snapshot_teleport.py +++ b/tests/snapshots/test_ui_snapshot_teleport.py @@ -133,6 +133,7 @@ class TeleportCommandHelpSnapshotApp(BaseSnapshotTestApp): async def on_mount(self) -> None: await super().on_mount() + await self._resolve_plan() await self._show_help() @@ -276,16 +277,3 @@ def test_snapshot_teleport_push_confirmation_cancel_selected( terminal_size=(80, 12), run_before=run_before, ) - - -def test_snapshot_teleport_command_visible_for_pro_account( - snap_compare: SnapCompare, -) -> None: - async def run_before(pilot: Pilot) -> None: - await pilot.pause(0.2) - - assert snap_compare( - "test_ui_snapshot_teleport.py:TeleportCommandHelpProApp", - terminal_size=(120, 48), - run_before=run_before, - ) diff --git a/tests/stubs/fake_backend.py b/tests/stubs/fake_backend.py index e7e46c5..79b81b6 100644 --- a/tests/stubs/fake_backend.py +++ b/tests/stubs/fake_backend.py @@ -35,6 +35,7 @@ class FakeBackend: self._requests_messages: list[list[LLMMessage]] = [] self._requests_extra_headers: list[dict[str, str] | None] = [] self._requests_metadata: list[dict[str, str] | None] = [] + self._requests_max_tokens: list[int | None] = [] self._exception_to_raise = exception_to_raise self._streams: list[list[LLMChunk]] @@ -67,6 +68,10 @@ class FakeBackend: def requests_metadata(self) -> list[dict[str, str] | None]: return self._requests_metadata + @property + def requests_max_tokens(self) -> list[int | None]: + return self._requests_max_tokens + async def __aenter__(self): return self @@ -91,6 +96,7 @@ class FakeBackend: self._requests_messages.append(list(messages)) self._requests_extra_headers.append(extra_headers) self._requests_metadata.append(metadata) + self._requests_max_tokens.append(max_tokens) if self._streams: stream = self._streams.pop(0) @@ -119,6 +125,7 @@ class FakeBackend: self._requests_messages.append(list(messages)) self._requests_extra_headers.append(extra_headers) self._requests_metadata.append(metadata) + self._requests_max_tokens.append(max_tokens) if self._streams: stream = list(self._streams.pop(0)) diff --git a/tests/stubs/fake_connector_registry.py b/tests/stubs/fake_connector_registry.py index 95ed298..57a2e03 100644 --- a/tests/stubs/fake_connector_registry.py +++ b/tests/stubs/fake_connector_registry.py @@ -50,7 +50,7 @@ class FakeConnectorRegistry(ConnectorRegistry): connector_name, ConnectorAuthAction.NONE ) - def get_tools(self) -> dict[str, type[BaseTool]]: + def get_tools(self, *, force_refresh: bool = False) -> dict[str, type[BaseTool]]: if self._cache is None: self._build_cache() @@ -60,8 +60,10 @@ class FakeConnectorRegistry(ConnectorRegistry): result.update(tools) return result - async def get_tools_async(self) -> dict[str, type[BaseTool]]: - return self.get_tools() + async def get_tools_async( + self, *, force_refresh: bool = False + ) -> dict[str, type[BaseTool]]: + return self.get_tools(force_refresh=force_refresh) async def get_auth_url(self, alias: str) -> str | None: """Return a fake auth URL for connectors that have no tools (not connected).""" diff --git a/tests/test_cli_programmatic_preload.py b/tests/test_cli_programmatic_preload.py index b0829be..6626bee 100644 --- a/tests/test_cli_programmatic_preload.py +++ b/tests/test_cli_programmatic_preload.py @@ -42,11 +42,7 @@ def test_run_programmatic_preload_streaming_is_batched( ), ): cfg = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - include_prompt_detail=False, - include_model_info=False, - include_commit_signature=False, + include_model_info=False, include_commit_signature=False ) previous = [ @@ -116,11 +112,7 @@ def test_run_programmatic_ignores_system_messages_in_previous( lambda provider, **kwargs: FakeBackend([mock_llm_chunk(content="Understood.")]), ): cfg = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - include_prompt_detail=False, - include_model_info=False, - include_commit_signature=False, + include_model_info=False, include_commit_signature=False ) run_programmatic( @@ -166,9 +158,6 @@ def test_run_programmatic_teleport_ignored_when_nuage_disabled( ]), ): cfg = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - include_prompt_detail=False, include_model_info=False, include_commit_signature=False, vibe_code_enabled=False, diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 30cb4c2..3d3bacc 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -453,12 +453,7 @@ class TestReadOnlyAgentMiddlewareIntegration: self, ) -> None: config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - include_prompt_detail=False, - include_model_info=False, - include_commit_signature=False, - enabled_tools=[], + include_model_info=False, include_commit_signature=False, enabled_tools=[] ) agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN) @@ -486,12 +481,7 @@ class TestReadOnlyAgentMiddlewareIntegration: @pytest.mark.asyncio async def test_switch_agent_allows_reinjection_on_reentry(self) -> None: config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - include_prompt_detail=False, - include_model_info=False, - include_commit_signature=False, - enabled_tools=[], + include_model_info=False, include_commit_signature=False, enabled_tools=[] ) agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN) @@ -522,12 +512,7 @@ class TestReadOnlyAgentMiddlewareIntegration: @pytest.mark.asyncio async def test_switch_plan_to_auto_approve_fires_exit(self) -> None: config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - include_prompt_detail=False, - include_model_info=False, - include_commit_signature=False, - enabled_tools=[], + include_model_info=False, include_commit_signature=False, enabled_tools=[] ) agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN) @@ -550,12 +535,7 @@ class TestReadOnlyAgentMiddlewareIntegration: @pytest.mark.asyncio async def test_switch_between_non_plan_agents_no_injection(self) -> None: config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - include_prompt_detail=False, - include_model_info=False, - include_commit_signature=False, - enabled_tools=[], + include_model_info=False, include_commit_signature=False, enabled_tools=[] ) agent = build_test_agent_loop( config=config, agent_name=BuiltinAgentName.DEFAULT @@ -581,12 +561,7 @@ class TestReadOnlyAgentMiddlewareIntegration: async def test_full_lifecycle_plan_default_plan_default(self) -> None: """Integration test for a full plan -> default -> plan -> default cycle.""" config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - include_prompt_detail=False, - include_model_info=False, - include_commit_signature=False, - enabled_tools=[], + include_model_info=False, include_commit_signature=False, enabled_tools=[] ) agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN) diff --git a/tests/test_reasoning_content.py b/tests/test_reasoning_content.py index 95fdad2..f0c9243 100644 --- a/tests/test_reasoning_content.py +++ b/tests/test_reasoning_content.py @@ -32,9 +32,6 @@ from vibe.core.types import ( def make_config() -> VibeConfig: return build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - include_prompt_detail=False, include_model_info=False, include_commit_signature=False, enabled_tools=[], diff --git a/tests/test_system_prompt.py b/tests/test_system_prompt.py index cd25c6b..b0c734b 100644 --- a/tests/test_system_prompt.py +++ b/tests/test_system_prompt.py @@ -20,8 +20,6 @@ def test_get_universal_system_prompt_includes_windows_prompt_on_windows( monkeypatch.setenv("COMSPEC", "C:\\Windows\\System32\\cmd.exe") config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, include_prompt_detail=True, include_model_info=False, include_commit_signature=False, @@ -49,8 +47,6 @@ def test_get_universal_system_prompt_includes_windows_prompt_on_windows( def test_scratchpad_section_included_when_passed() -> None: sp = init_scratchpad("test-session") config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, include_prompt_detail=True, include_model_info=False, include_commit_signature=False, @@ -70,8 +66,6 @@ def test_scratchpad_section_included_when_passed() -> None: def test_scratchpad_section_absent_when_not_passed() -> None: config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, include_prompt_detail=True, include_model_info=False, include_commit_signature=False, @@ -89,11 +83,7 @@ def test_scratchpad_section_absent_when_not_passed() -> None: def test_headless_section_included_when_enabled() -> None: config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - include_prompt_detail=False, - include_model_info=False, - include_commit_signature=False, + include_model_info=False, include_commit_signature=False ) tool_manager = ToolManager(lambda: config) skill_manager = SkillManager(lambda: config) @@ -109,11 +99,7 @@ def test_headless_section_included_when_enabled() -> None: def test_headless_section_absent_by_default() -> None: config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - include_prompt_detail=False, - include_model_info=False, - include_commit_signature=False, + include_model_info=False, include_commit_signature=False ) tool_manager = ToolManager(lambda: config) skill_manager = SkillManager(lambda: config) @@ -128,11 +114,7 @@ def test_headless_section_absent_by_default() -> None: def test_current_date_placeholder_substituted_in_prompt() -> None: config = build_test_vibe_config( - system_prompt_id="cli", - include_project_context=False, - include_prompt_detail=False, - include_model_info=False, - include_commit_signature=False, + system_prompt_id="cli", include_model_info=False, include_commit_signature=False ) tool_manager = ToolManager(lambda: config) skill_manager = SkillManager(lambda: config) diff --git a/tests/test_tracing.py b/tests/test_tracing.py index 5dd3efb..9dd5480 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -331,9 +331,6 @@ class TestIntegration: config = build_test_vibe_config( enabled_tools=["todo"], tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)}, - system_prompt_id="tests", - include_project_context=False, - include_prompt_detail=False, ) agent_loop = build_test_agent_loop(config=config, backend=backend) diff --git a/tests/tools/test_connectors.py b/tests/tools/test_connectors.py index af45487..d37d67e 100644 --- a/tests/tools/test_connectors.py +++ b/tests/tools/test_connectors.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from typing import Any, cast from unittest.mock import AsyncMock, patch @@ -13,8 +14,12 @@ from tests.stubs.fake_connector_registry import FakeConnectorRegistry from tests.stubs.fake_mcp_registry import FakeMCPRegistry from vibe.core.config import ConnectorConfig, VibeConfig from vibe.core.tools.base import BaseToolConfig, ToolError -from vibe.core.tools.connectors import compute_connector_counts +from vibe.core.tools.connectors import ( + compute_connector_counts, + connector_registry as connector_registry_module, +) from vibe.core.tools.connectors.connector_registry import ( + _BOOTSTRAP_CACHE_TTL_SECONDS, ConnectorAuthAction, ConnectorRegistry, RemoteTool, @@ -26,6 +31,8 @@ from vibe.core.tools.connectors.connector_registry import ( from vibe.core.tools.manager import ToolManager from vibe.core.tools.mcp.tools import MCPTool, MCPToolResult +_BOOTSTRAP_CACHE_FILE_NAME = "connector_bootstrap_cache.json" + # --------------------------------------------------------------------------- # Unit tests for helper functions # --------------------------------------------------------------------------- @@ -561,6 +568,202 @@ class TestBootstrapDiscovery: assert tools == {} assert registry.connector_count == 0 + @pytest.mark.asyncio + async def test_fresh_bootstrap_cache_avoids_http( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + payload = _make_bootstrap_response([ + _make_connector_payload(tools=[_make_tool_payload("cached")]) + ]) + + with ( + patch.object(connector_registry_module.time, "time", return_value=1_000), + respx.mock, + ): + respx.get(_BOOTSTRAP_URL).mock( + return_value=httpx.Response(200, json=payload) + ) + await ConnectorRegistry(api_key="test-key").get_tools_async() + + with ( + patch.object(connector_registry_module.time, "time", return_value=1_100), + respx.mock(assert_all_called=False) as router, + ): + route = router.get(_BOOTSTRAP_URL).mock( + return_value=httpx.Response(500, text="should not be called") + ) + tools = await ConnectorRegistry(api_key="test-key").get_tools_async() + + assert "connector_wiki_cached" in tools + assert not route.called + + @pytest.mark.asyncio + async def test_stale_bootstrap_cache_falls_back_to_http( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + cached_payload = _make_bootstrap_response([ + _make_connector_payload(tools=[_make_tool_payload("cached")]) + ]) + fresh_payload = _make_bootstrap_response([ + _make_connector_payload(tools=[_make_tool_payload("fresh")]) + ]) + + with ( + patch.object(connector_registry_module.time, "time", return_value=1_000), + respx.mock, + ): + respx.get(_BOOTSTRAP_URL).mock( + return_value=httpx.Response(200, json=cached_payload) + ) + await ConnectorRegistry(api_key="test-key").get_tools_async() + + with ( + patch.object( + connector_registry_module.time, + "time", + return_value=1_000 + _BOOTSTRAP_CACHE_TTL_SECONDS + 1, + ), + respx.mock, + ): + route = respx.get(_BOOTSTRAP_URL).mock( + return_value=httpx.Response(200, json=fresh_payload) + ) + tools = await ConnectorRegistry(api_key="test-key").get_tools_async() + + assert route.called + assert "connector_wiki_fresh" in tools + assert "connector_wiki_cached" not in tools + + @pytest.mark.asyncio + async def test_malformed_bootstrap_cache_falls_back_to_http( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + (tmp_path / _BOOTSTRAP_CACHE_FILE_NAME).write_text("{bad toml") + payload = _make_bootstrap_response([ + _make_connector_payload(tools=[_make_tool_payload("fresh")]) + ]) + + with respx.mock: + route = respx.get(_BOOTSTRAP_URL).mock( + return_value=httpx.Response(200, json=payload) + ) + tools = await ConnectorRegistry(api_key="test-key").get_tools_async() + + assert route.called + assert "connector_wiki_fresh" in tools + + @pytest.mark.asyncio + async def test_force_refresh_bypasses_fresh_bootstrap_cache( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + cached_payload = _make_bootstrap_response([ + _make_connector_payload(tools=[_make_tool_payload("cached")]) + ]) + fresh_payload = _make_bootstrap_response([ + _make_connector_payload(tools=[_make_tool_payload("fresh")]) + ]) + + with ( + patch.object(connector_registry_module.time, "time", return_value=1_000), + respx.mock, + ): + respx.get(_BOOTSTRAP_URL).mock( + return_value=httpx.Response(200, json=cached_payload) + ) + await ConnectorRegistry(api_key="test-key").get_tools_async() + + with ( + patch.object(connector_registry_module.time, "time", return_value=1_100), + respx.mock, + ): + route = respx.get(_BOOTSTRAP_URL).mock( + return_value=httpx.Response(200, json=fresh_payload) + ) + tools = await ConnectorRegistry(api_key="test-key").get_tools_async( + force_refresh=True + ) + + assert route.called + assert "connector_wiki_fresh" in tools + assert "connector_wiki_cached" not in tools + + @pytest.mark.asyncio + async def test_bootstrap_cache_does_not_store_raw_api_key( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + payload = _make_bootstrap_response([ + _make_connector_payload(tools=[_make_tool_payload("cached")]) + ]) + + with respx.mock: + respx.get(_BOOTSTRAP_URL).mock( + return_value=httpx.Response(200, json=payload) + ) + await ConnectorRegistry(api_key="secret-test-key").get_tools_async() + + cache_text = (tmp_path / _BOOTSTRAP_CACHE_FILE_NAME).read_text() + assert "secret-test-key" not in cache_text + + @pytest.mark.asyncio + async def test_bootstrap_cache_stores_only_consumed_connector_fields( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + payload = _make_bootstrap_response([ + _make_connector_payload( + tools=[{**_make_tool_payload("cached"), "secret_extra": "tool-secret"}], + auth_action={"type": "oauth", "url": "https://secret.example.com"}, + ) + | { + "display_name": "Private display name", + "description": "Private connector description", + "bootstrap_errors": ["private error detail"], + } + ]) + + with respx.mock: + respx.get(_BOOTSTRAP_URL).mock( + return_value=httpx.Response(200, json=payload) + ) + await ConnectorRegistry(api_key="test-key").get_tools_async() + + cache_text = (tmp_path / _BOOTSTRAP_CACHE_FILE_NAME).read_text() + assert "Private display name" not in cache_text + assert "Private connector description" not in cache_text + assert "private error detail" not in cache_text + assert "https://secret.example.com" not in cache_text + assert "tool-secret" not in cache_text + + cache = json.loads(cache_text) + entry = next(iter(cache.values())) + connector = entry["payload"]["connectors"][0] + assert set(connector) == {"id", "name", "status", "tools", "auth_action"} + assert connector["auth_action"] == {"type": "oauth"} + assert set(connector["tools"][0]) == {"name", "description", "inputSchema"} + + @pytest.mark.asyncio + async def test_bootstrap_cache_uses_dedicated_file( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + payload = _make_bootstrap_response([ + _make_connector_payload(tools=[_make_tool_payload("cached")]) + ]) + + with respx.mock: + respx.get(_BOOTSTRAP_URL).mock( + return_value=httpx.Response(200, json=payload) + ) + await ConnectorRegistry(api_key="test-key").get_tools_async() + + assert (tmp_path / _BOOTSTRAP_CACHE_FILE_NAME).exists() + assert not (tmp_path / "cache.toml").exists() + @respx.mock @pytest.mark.asyncio async def test_deduplicates_connector_aliases(self) -> None: @@ -821,6 +1024,51 @@ class TestAuthActionablediscovery: assert registry.is_connected("linear") assert registry.get_auth_action("linear") == ConnectorAuthAction.NONE + @pytest.mark.asyncio + async def test_refresh_updates_bootstrap_file_cache( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + payload = _make_bootstrap_response([ + _make_connector_payload( + connector_id="c-1", + name="linear", + is_ready=False, + tools=[], + auth_action={"type": "oauth"}, + ) + ]) + + with respx.mock: + respx.get(_BOOTSTRAP_URL).mock( + return_value=httpx.Response(200, json=payload) + ) + registry = ConnectorRegistry(api_key="test-key") + await registry.get_tools_async() + + refresh_payload = _make_bootstrap_response([ + _make_connector_payload( + connector_id="c-1", + name="linear", + tools=[_make_tool_payload("search_issues")], + ) + ]) + with respx.mock: + respx.get(_BOOTSTRAP_URL).mock( + return_value=httpx.Response(200, json=refresh_payload) + ) + await registry.refresh_connector_async("linear") + + with respx.mock: + route = respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(500)) + warm_registry = ConnectorRegistry(api_key="test-key") + tools = await warm_registry.get_tools_async() + + assert not route.called + assert "connector_linear_search_issues" in tools + assert warm_registry.is_connected("linear") + assert warm_registry.get_auth_action("linear") == ConnectorAuthAction.NONE + def test_get_auth_action_unknown_alias_returns_none(self) -> None: registry = ConnectorRegistry(api_key="test-key") assert registry.get_auth_action("nobody") == ConnectorAuthAction.NONE diff --git a/tests/tools/test_granular_permissions.py b/tests/tools/test_granular_permissions.py index d78dfc6..d4417ce 100644 --- a/tests/tools/test_granular_permissions.py +++ b/tests/tools/test_granular_permissions.py @@ -63,6 +63,23 @@ class TestBashGranularPermissions: assert isinstance(result, PermissionContext) assert result.permission is ToolPermission.ASK + @pytest.mark.parametrize( + "command", + [ + "python3 << 'EOF'\nprint(42)\nEOF", + "python3 - << 'EOF'\nprint(42)\nEOF", + "python3 <<'PYEOF'\nimport sys\nprint('hello')\nPYEOF", + "python3 < input.txt", + ], + ) + def test_standalone_denylisted_with_redirect_not_denied(self, command): + bash = self._bash() + result = bash.resolve_permission(BashArgs(command=command)) + assert isinstance(result, PermissionContext) + assert result.permission is not ToolPermission.NEVER, ( + f"Command with redirect should not be denied: {command!r}" + ) + def test_unknown_command_returns_permission_context(self): bash = self._bash() result = bash.resolve_permission(BashArgs(command="npm install")) diff --git a/tests/tools/test_manager_get_tool_config.py b/tests/tools/test_manager_get_tool_config.py index 00e0430..0b7ee4b 100644 --- a/tests/tools/test_manager_get_tool_config.py +++ b/tests/tools/test_manager_get_tool_config.py @@ -10,15 +10,8 @@ from vibe.core.tools.manager import NoSuchToolError, ToolManager @pytest.fixture -def config(): - return build_test_vibe_config( - system_prompt_id="tests", include_project_context=False - ) - - -@pytest.fixture -def tool_manager(config): - return ToolManager(lambda: config) +def tool_manager(vibe_config): + return ToolManager(lambda: vibe_config) def test_returns_default_config_when_no_overrides(tool_manager): @@ -33,11 +26,7 @@ def test_returns_default_config_when_no_overrides(tool_manager): def test_merges_user_overrides_with_defaults(): - vibe_config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - tools={"bash": {"permission": "always"}}, - ) + vibe_config = build_test_vibe_config(tools={"bash": {"permission": "always"}}) manager = ToolManager(lambda: vibe_config) config = manager.get_tool_config("bash") @@ -50,11 +39,7 @@ def test_merges_user_overrides_with_defaults(): def test_preserves_tool_specific_fields_from_overrides(): - vibe_config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - tools={"bash": {"permission": "ask"}}, - ) + vibe_config = build_test_vibe_config(tools={"bash": {"permission": "ask"}}) vibe_config.tools["bash"]["default_timeout"] = 600 manager = ToolManager(lambda: vibe_config) @@ -73,9 +58,7 @@ def test_falls_back_to_base_config_for_unknown_tool(tool_manager): def test_partial_override_preserves_tool_defaults(): vibe_config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - tools={"read": {"sensitive_patterns": ["**/*.key"]}}, + tools={"read": {"sensitive_patterns": ["**/*.key"]}} ) manager = ToolManager(lambda: vibe_config) @@ -89,11 +72,7 @@ def test_partial_override_preserves_tool_defaults(): class TestToolManagerFiltering: def test_enabled_tools_filters_to_only_enabled(self): - vibe_config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - enabled_tools=["bash", "grep"], - ) + vibe_config = build_test_vibe_config(enabled_tools=["bash", "grep"]) manager = ToolManager(lambda: vibe_config) tools = manager.available_tools @@ -104,11 +83,7 @@ class TestToolManagerFiltering: assert "write_file" not in tools def test_disabled_tools_excludes_disabled(self): - vibe_config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - disabled_tools=["bash", "write_file"], - ) + vibe_config = build_test_vibe_config(disabled_tools=["bash", "write_file"]) manager = ToolManager(lambda: vibe_config) tools = manager.available_tools @@ -120,8 +95,6 @@ class TestToolManagerFiltering: def test_enabled_tools_takes_precedence_over_disabled(self): vibe_config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, enabled_tools=["bash"], disabled_tools=["bash"], # Should be ignored ) @@ -133,9 +106,7 @@ class TestToolManagerFiltering: def test_glob_pattern_matching(self): vibe_config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - disabled_tools=["write_*"], # Matches write_file + disabled_tools=["write_*"] # Matches write_file ) manager = ToolManager(lambda: vibe_config) @@ -145,11 +116,7 @@ class TestToolManagerFiltering: assert "grep" in tools def test_regex_pattern_matching(self): - vibe_config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - enabled_tools=["re:^(bash|grep)$"], - ) + vibe_config = build_test_vibe_config(enabled_tools=["re:^(bash|grep)$"]) manager = ToolManager(lambda: vibe_config) tools = manager.available_tools @@ -158,11 +125,7 @@ class TestToolManagerFiltering: assert "grep" in tools def test_get_raises_for_disabled_tool(self): - vibe_config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - disabled_tools=["bash"], - ) + vibe_config = build_test_vibe_config(disabled_tools=["bash"]) manager = ToolManager(lambda: vibe_config) assert "bash" not in manager.available_tools @@ -170,11 +133,7 @@ class TestToolManagerFiltering: manager.get("bash") def test_case_insensitive_matching(self): - vibe_config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - enabled_tools=["BASH", "GREP"], - ) + vibe_config = build_test_vibe_config(enabled_tools=["BASH", "GREP"]) manager = ToolManager(lambda: vibe_config) tools = manager.available_tools @@ -182,9 +141,7 @@ class TestToolManagerFiltering: assert "grep" in tools def test_empty_enabled_tools_returns_all(self): - vibe_config = build_test_vibe_config( - system_prompt_id="tests", include_project_context=False, enabled_tools=[] - ) + vibe_config = build_test_vibe_config(enabled_tools=[]) manager = ToolManager(lambda: vibe_config) tools = manager.available_tools @@ -242,11 +199,7 @@ class FileTool(BaseTool[FileToolArgs, FileToolResult, BaseToolConfig, BaseToolSt for k in to_remove: del sys.modules[k] - vibe_config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - tool_paths=[tool_dir, file_tool], - ) + vibe_config = build_test_vibe_config(tool_paths=[tool_dir, file_tool]) manager = ToolManager(lambda: vibe_config) tools = manager.available_tools @@ -292,11 +245,7 @@ class ConditionalTool(BaseTool[ConditionalToolArgs, ConditionalToolResult, BaseT del sys.modules[k] monkeypatch.delenv("TEST_VAR", raising=False) - vibe_config = build_test_vibe_config( - system_prompt_id="tests", - include_project_context=False, - tool_paths=[tool_dir], - ) + vibe_config = build_test_vibe_config(tool_paths=[tool_dir]) manager = ToolManager(lambda: vibe_config) assert "conditional_tool" not in manager.available_tools @@ -310,9 +259,7 @@ class ConditionalTool(BaseTool[ConditionalToolArgs, ConditionalToolResult, BaseT def test_default_is_available_returns_true(self): """Tools without is_available() override should be available.""" - vibe_config = build_test_vibe_config( - system_prompt_id="tests", include_project_context=False - ) + vibe_config = build_test_vibe_config() manager = ToolManager(lambda: vibe_config) assert "bash" in manager.available_tools @@ -329,9 +276,7 @@ class TestToolManagerModuleReuse: def test_multiple_managers_share_tool_classes(self): """Tool classes should be identical across multiple ToolManager instances.""" - vibe_config = build_test_vibe_config( - system_prompt_id="tests", include_project_context=False - ) + vibe_config = build_test_vibe_config() manager1 = ToolManager(lambda: vibe_config) manager2 = ToolManager(lambda: vibe_config) @@ -347,9 +292,7 @@ class TestToolManagerModuleReuse: def test_tool_state_classes_are_identical(self): """Tool state classes should be identical across managers.""" - vibe_config = build_test_vibe_config( - system_prompt_id="tests", include_project_context=False - ) + vibe_config = build_test_vibe_config() manager1 = ToolManager(lambda: vibe_config) manager2 = ToolManager(lambda: vibe_config) @@ -364,9 +307,7 @@ class TestToolManagerModuleReuse: def test_tool_args_results_classes_are_identical(self): """Tool args and result classes should be identical across managers.""" - vibe_config = build_test_vibe_config( - system_prompt_id="tests", include_project_context=False - ) + vibe_config = build_test_vibe_config() manager1 = ToolManager(lambda: vibe_config) manager2 = ToolManager(lambda: vibe_config) @@ -386,9 +327,7 @@ class TestToolManagerModuleReuse: This ensures subagents have isolated state (e.g., separate todo lists) while still sharing class definitions for Pydantic validation. """ - vibe_config = build_test_vibe_config( - system_prompt_id="tests", include_project_context=False - ) + vibe_config = build_test_vibe_config() manager1 = ToolManager(lambda: vibe_config) manager2 = ToolManager(lambda: vibe_config) @@ -412,9 +351,7 @@ class TestToolManagerModuleReuse: def test_class_shared_but_instances_isolated(self): """Classes must be shared (for validation) but instances isolated (for state).""" - vibe_config = build_test_vibe_config( - system_prompt_id="tests", include_project_context=False - ) + vibe_config = build_test_vibe_config() manager1 = ToolManager(lambda: vibe_config) manager2 = ToolManager(lambda: vibe_config) diff --git a/tests/tools/test_mcp_registry_oauth.py b/tests/tools/test_mcp_registry_oauth.py index 8b72260..3204a55 100644 --- a/tests/tools/test_mcp_registry_oauth.py +++ b/tests/tools/test_mcp_registry_oauth.py @@ -41,7 +41,7 @@ class MemoryKeyring(KeyringBackend): def delete_password(self, service: str, username: str) -> None: if (service, username) not in self.store: - raise keyring.errors.PasswordDeleteError(username) + raise keyring.errors.PasswordDeleteError() del self.store[(service, username)] diff --git a/tests/tools/test_task.py b/tests/tools/test_task.py index e038bd0..86f7118 100644 --- a/tests/tools/test_task.py +++ b/tests/tools/test_task.py @@ -34,9 +34,7 @@ class TestTaskArgs: class TestTaskToolValidation: @pytest.fixture def ctx(self) -> InvokeContext: - config = build_test_vibe_config( - include_project_context=False, include_prompt_detail=False - ) + config = build_test_vibe_config() manager = AgentManager(lambda: config) return InvokeContext( tool_call_id="test-call-id", @@ -133,9 +131,7 @@ class TestTaskToolResolvePermission: class TestTaskToolExecution: @pytest.fixture def ctx(self) -> InvokeContext: - config = build_test_vibe_config( - include_project_context=False, include_prompt_detail=False - ) + config = build_test_vibe_config() manager = AgentManager(lambda: config) return InvokeContext( tool_call_id="test-call-id", diff --git a/tests/update_notifier/test_do_update.py b/tests/update_notifier/test_do_update.py index 1053801..337ebc9 100644 --- a/tests/update_notifier/test_do_update.py +++ b/tests/update_notifier/test_do_update.py @@ -8,7 +8,7 @@ from vibe.cli.update_notifier.update import do_update @pytest.mark.asyncio -async def test_do_update_returns_true_when_first_command_succeeds() -> None: +async def test_do_update_runs_all_commands_even_when_first_succeeds() -> None: mock_process = MagicMock() mock_process.wait = AsyncMock(return_value=None) mock_process.returncode = 0 @@ -24,8 +24,9 @@ async def test_do_update_returns_true_when_first_command_succeeds() -> None: result = await do_update() assert result is True - mock_create.assert_called_once() - assert "command_1" in mock_create.call_args[0][0] + assert mock_create.call_count == 2 + assert "command_1" in mock_create.call_args_list[0][0][0] + assert "command_2" in mock_create.call_args_list[1][0][0] @pytest.mark.asyncio diff --git a/uv.lock b/uv.lock index 150c5ec..4ad1204 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-06-23T12:49:20.1698Z" exclude-newer-span = "P2D" [[package]] @@ -88,11 +88,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.4.22" +version = "2026.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] [[package]] @@ -388,14 +388,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.47" +version = "3.1.50" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/bd/50db468e9b1310529a19fce651b3b0e753b5c07954d486cba31bbee9a5d5/gitpython-3.1.47.tar.gz", hash = "sha256:dba27f922bd2b42cb54c87a8ab3cb6beb6bf07f3d564e21ac848913a05a8a3cd", size = 216978, upload-time = "2026-04-22T02:44:44.059Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/c5/a1bc0996af85757903cf2bf444a7824e68e0035ce63fb41d6f76f9def68b/gitpython-3.1.47-py3-none-any.whl", hash = "sha256:489f590edfd6d20571b2c0e72c6a6ac6915ee8b8cd04572330e3842207a78905", size = 209547, upload-time = "2026-04-22T02:44:41.271Z" }, + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, ] [[package]] @@ -831,7 +831,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.17.1" +version = "2.18.0" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, @@ -969,7 +969,7 @@ requires-dist = [ { name = "attrs", specifier = "==26.1.0" }, { name = "beautifulsoup4", specifier = "==4.14.3" }, { name = "cachetools", specifier = "==7.0.6" }, - { name = "certifi", specifier = "==2026.4.22" }, + { name = "certifi", specifier = "==2026.6.17" }, { name = "cffi", specifier = "==2.0.0" }, { name = "charset-normalizer", specifier = "==3.4.7" }, { name = "click", marker = "sys_platform != 'emscripten'", specifier = "==8.3.3" }, @@ -977,7 +977,7 @@ requires-dist = [ { name = "cryptography", specifier = "==48.0.1" }, { name = "eval-type-backport", specifier = "==0.3.1" }, { name = "gitdb", specifier = "==4.0.12" }, - { name = "gitpython", specifier = "==3.1.47" }, + { name = "gitpython", specifier = "==3.1.50" }, { name = "giturlparse", specifier = "==0.14.0" }, { name = "google-auth", specifier = "==2.49.2" }, { name = "googleapis-common-protos", specifier = "==1.74.0" }, @@ -1022,7 +1022,7 @@ requires-dist = [ { name = "pycparser", marker = "implementation_name != 'PyPy'", specifier = "==3.0" }, { name = "pydantic", specifier = "==2.13.3" }, { name = "pydantic-core", specifier = "==2.46.3" }, - { name = "pydantic-settings", specifier = "==2.14.0" }, + { name = "pydantic-settings", specifier = "==2.14.2" }, { name = "pygments", specifier = "==2.20.0" }, { name = "pyjwt", specifier = "==2.13.0" }, { name = "pyperclip", specifier = "==1.11.0" }, @@ -1453,16 +1453,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.14.0" +version = "2.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [[package]] diff --git a/vibe/__init__.py b/vibe/__init__.py index fedf2cc..4c078d1 100644 --- a/vibe/__init__.py +++ b/vibe/__init__.py @@ -3,4 +3,4 @@ from __future__ import annotations from pathlib import Path VIBE_ROOT = Path(__file__).parent -__version__ = "2.17.1" +__version__ = "2.18.0" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index e46e8bd..b5d0873 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -748,7 +748,10 @@ class VibeAcpAgentLoop(AcpAgent): pass async def _notify_mcp_auth_required(self, session: AcpSessionLoop) -> None: - statuses = session.agent_loop.mcp_registry.status() + registry = session.agent_loop.mcp_registry + if registry is None: + return + statuses = registry.status() aliases = sorted( alias for alias, status in statuses.items() @@ -1336,6 +1339,14 @@ class VibeAcpAgentLoop(AcpAgent): else: session.agent_loop.set_max_turns(max_turns) success = True + case "max_tokens" if isinstance(value, str): + try: + max_tokens = int(value) + except ValueError: + success = False + else: + session.agent_loop.set_max_tokens(max_tokens) + success = True case _: success = False @@ -2085,7 +2096,8 @@ class VibeAcpAgentLoop(AcpAgent): session, "Usage: `/mcp status`", message_id ) await session.agent_loop.wait_until_ready() - statuses = session.agent_loop.mcp_registry.status() + registry = session.agent_loop.mcp_registry + statuses = registry.status() if registry is not None else {} if not statuses: return await self._command_reply( session, "No MCP servers configured.", message_id @@ -2103,7 +2115,8 @@ class VibeAcpAgentLoop(AcpAgent): session, "Usage: `/mcp login `", message_id ) await session.agent_loop.wait_until_ready() - statuses = session.agent_loop.mcp_registry.status() + registry = session.agent_loop.mcp_registry + statuses = registry.status() if registry is not None else {} if alias not in statuses: return await self._command_reply( session, f"Unknown MCP server: `{alias}`", message_id @@ -2126,8 +2139,13 @@ class VibeAcpAgentLoop(AcpAgent): session, "Usage: `/mcp logout `", message_id ) await session.agent_loop.wait_until_ready() + registry = session.agent_loop.mcp_registry + if registry is None: + return await self._command_reply( + session, "No MCP servers configured.", message_id + ) try: - await session.agent_loop.mcp_registry.logout(alias) + await registry.logout(alias) await session.agent_loop.tool_manager.refresh_remote_tools_async() await session.agent_loop.refresh_system_prompt() except (MCPOAuthError, ValueError) as exc: diff --git a/vibe/acp/tools/builtins/bash.py b/vibe/acp/tools/builtins/bash.py index b950806..9262a29 100644 --- a/vibe/acp/tools/builtins/bash.py +++ b/vibe/acp/tools/builtins/bash.py @@ -21,6 +21,8 @@ from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError from vibe.core.tools.builtins.bash import Bash as CoreBashTool, BashArgs, BashResult from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent +_TERMINAL_CLEANUP_TIMEOUT = 10 + class AcpBashState(BaseToolState, AcpToolState): pass @@ -79,8 +81,11 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]): finally: try: - await client.release_terminal( - session_id=session_id, terminal_id=terminal_id + await asyncio.wait_for( + client.release_terminal( + session_id=session_id, terminal_id=terminal_id + ), + timeout=_TERMINAL_CLEANUP_TIMEOUT, ) except Exception as e: logger.error(f"Failed to release terminal: {e!r}") @@ -107,8 +112,11 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]): ) except TimeoutError: try: - await client.kill_terminal( - session_id=session_id, terminal_id=terminal_id + await asyncio.wait_for( + client.kill_terminal( + session_id=session_id, terminal_id=terminal_id + ), + timeout=_TERMINAL_CLEANUP_TIMEOUT, ) except Exception as e: logger.error(f"Failed to kill terminal: {e!r}") diff --git a/vibe/cli/clipboard.py b/vibe/cli/clipboard.py index c0ea8b4..d14f881 100644 --- a/vibe/cli/clipboard.py +++ b/vibe/cli/clipboard.py @@ -8,6 +8,7 @@ import subprocess import pyperclip from textual.app import App +from textual.widgets import Input, TextArea from vibe.core.utils.io import decode_safe @@ -133,6 +134,11 @@ def _get_selected_texts(app: App) -> list[str]: selected_texts = [] for widget in app.query("*"): + if isinstance(widget, (TextArea, Input)): + if (selected_text := widget.selected_text).strip(): + selected_texts.append(selected_text) + continue + try: if not hasattr(widget, "text_selection") or not widget.text_selection: continue diff --git a/vibe/cli/commands.py b/vibe/cli/commands.py index f777773..b28a913 100644 --- a/vibe/cli/commands.py +++ b/vibe/cli/commands.py @@ -2,8 +2,11 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +import platform import sys +from vibe.cli.constants import CLIPBOARD_IMAGE_PASTE_SUPPORTED_SYSTEM + ALT_KEY = "⌥" if sys.platform == "darwin" else "Alt" @@ -68,6 +71,14 @@ class CommandRegistry: description="Copy the last agent message to the clipboard", handler="_copy_last_agent_message", ), + "paste-image": Command( + aliases=frozenset(["/paste-image"]), + description="Paste an image from the OS clipboard into the prompt", + handler="_paste_clipboard_image_command", + is_available=lambda _ctx: ( + platform.system() == CLIPBOARD_IMAGE_PASTE_SUPPORTED_SYSTEM + ), + ), "log": Command( aliases=frozenset(["/log"]), description="Show path to current interaction log file", diff --git a/vibe/cli/constants.py b/vibe/cli/constants.py new file mode 100644 index 0000000..592a5fa --- /dev/null +++ b/vibe/cli/constants.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +CLIPBOARD_IMAGE_PASTE_SUPPORTED_SYSTEM = "Darwin" + +__all__ = ["CLIPBOARD_IMAGE_PASTE_SUPPORTED_SYSTEM"] diff --git a/vibe/cli/narrator_manager/__init__.py b/vibe/cli/narrator_manager/__init__.py index 17c3be9..dbce3c2 100644 --- a/vibe/cli/narrator_manager/__init__.py +++ b/vibe/cli/narrator_manager/__init__.py @@ -1,15 +1,27 @@ from __future__ import annotations -from vibe.cli.narrator_manager.narrator_manager import NarratorManager +from typing import TYPE_CHECKING + from vibe.cli.narrator_manager.narrator_manager_port import ( NarratorManagerListener, NarratorManagerPort, NarratorState, ) +if TYPE_CHECKING: + from vibe.cli.narrator_manager.narrator_manager import NarratorManager + __all__ = [ "NarratorManager", "NarratorManagerListener", "NarratorManagerPort", "NarratorState", ] + + +def __getattr__(name: str) -> object: + if name == "NarratorManager": + from vibe.cli.narrator_manager.narrator_manager import NarratorManager + + return NarratorManager + raise AttributeError(name) diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index 2fce0f1..8a303b7 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -12,7 +12,7 @@ from pathlib import Path import signal import sys import time -from typing import Any, ClassVar, assert_never, cast +from typing import TYPE_CHECKING, Any, ClassVar, cast from uuid import uuid4 from weakref import WeakKeyDictionary import webbrowser @@ -32,8 +32,7 @@ from textual.widgets import Static from vibe import __version__ as CORE_VERSION from vibe.cli.clipboard import copy_selection_to_clipboard, copy_text_to_clipboard from vibe.cli.commands import CommandRegistry -from vibe.cli.narrator_manager import ( - NarratorManager, +from vibe.cli.narrator_manager.narrator_manager_port import ( NarratorManagerPort, NarratorState, ) @@ -49,6 +48,10 @@ from vibe.cli.plan_offer.decide_plan_offer import ( from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway, WhoAmIPlanType from vibe.cli.terminal_detect import Terminal, detect_terminal from vibe.cli.textual_ui.handlers.event_handler import EventHandler +from vibe.cli.textual_ui.lazy_audio_managers import ( + create_default_narrator_manager, + create_default_voice_manager, +) from vibe.cli.textual_ui.message_queue import MessageQueue, QueueController, QueuePorts from vibe.cli.textual_ui.notifications import ( NotificationContext, @@ -70,11 +73,13 @@ from vibe.cli.textual_ui.widgets.chat_input.input_kinds import ( Teleport, classify, ) +from vibe.cli.textual_ui.widgets.chat_input.paste_image import ( + handle_clipboard_image_paste, +) from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea from vibe.cli.textual_ui.widgets.collapsible import CollapsibleSection 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 @@ -85,7 +90,6 @@ from vibe.cli.textual_ui.widgets.loading import ( LoadingWidget, paused_timer, ) -from vibe.cli.textual_ui.widgets.mcp_app import MCPApp, MCPSourceKind from vibe.cli.textual_ui.widgets.messages import ( VSCODE_EXTENSION_PROMO_WHATS_NEW_SUFFIX, AssistantMessage, @@ -141,7 +145,7 @@ from vibe.cli.update_notifier import ( mark_version_as_seen, should_show_whats_new, ) -from vibe.cli.voice_manager import VoiceManager, VoiceManagerPort +from vibe.cli.voice_manager import VoiceManagerPort from vibe.cli.voice_manager.voice_manager_port import TranscribeState from vibe.cli.vscode_extension_promo import ( FileSystemVscodeExtensionPromoRepository, @@ -149,11 +153,7 @@ from vibe.cli.vscode_extension_promo import ( VscodeExtensionPromoState, should_show_promo, ) -from vibe.core.agent_loop import AgentLoop, TeleportError from vibe.core.agents import AgentProfile -from vibe.core.audio_player.audio_player import AudioPlayer -from vibe.core.audio_recorder import AudioRecorder -from vibe.core.auth import MCPOAuthError from vibe.core.autocompletion.path_prompt import ( PathPromptPayload, PathResource, @@ -201,11 +201,9 @@ from vibe.core.tools.builtins.ask_user_question import ( Choice, Question, ) -from vibe.core.tools.connectors import compute_connector_counts -from vibe.core.tools.mcp import AuthStatus +from vibe.core.tools.connectors.counts import compute_connector_counts from vibe.core.tools.mcp_settings import persist_mcp_toggle from vibe.core.tools.permissions import RequiredPermission -from vibe.core.transcribe import make_transcribe_client from vibe.core.types import ( MAX_IMAGE_BYTES, MAX_IMAGES_PER_MESSAGE, @@ -232,6 +230,30 @@ from vibe.core.utils import ( _VSCODE_FAMILY_TERMINALS = {Terminal.VSCODE, Terminal.VSCODE_INSIDERS, Terminal.CURSOR} +if TYPE_CHECKING: + from vibe.cli.textual_ui.widgets.connector_auth_app import ConnectorAuthApp + from vibe.cli.textual_ui.widgets.mcp_app import MCPApp + from vibe.cli.textual_ui.widgets.mcp_oauth_app import MCPOAuthApp + from vibe.core.agent_loop import AgentLoop + + +def _get_connector_auth_app_class() -> type[ConnectorAuthApp]: + from vibe.cli.textual_ui.widgets.connector_auth_app import ConnectorAuthApp + + return ConnectorAuthApp + + +def _get_mcp_app_class() -> type[MCPApp]: + from vibe.cli.textual_ui.widgets.mcp_app import MCPApp + + return MCPApp + + +def _get_mcp_oauth_app_class() -> type[MCPOAuthApp]: + from vibe.cli.textual_ui.widgets.mcp_oauth_app import MCPOAuthApp + + return MCPOAuthApp + def is_progress_event(event: object) -> bool: return isinstance( @@ -256,6 +278,7 @@ class BottomApp(StrEnum): ConnectorAuth = auto() Input = auto() MCP = auto() + MCPOAuth = auto() ModelPicker = auto() ProxySetup = auto() Question = auto() @@ -509,6 +532,8 @@ class VibeApp(App): # noqa: PLR0904 ) self._show_resume_picker = opts.show_resume_picker self._is_resuming_session = opts.is_resuming_session + self._startup_prompt_processed = False + self._startup_command_availability_ready = asyncio.Event() @property def config(self) -> VibeConfig: @@ -598,6 +623,10 @@ class VibeApp(App): # noqa: PLR0904 def _refresh_command_registry(self) -> None: self.commands.refresh(self.agent_loop.base_config.vibe_code_enabled) + def _refresh_config_from_disk(self) -> None: + self.agent_loop.refresh_config() + self._narrator_manager.sync() + def compose(self) -> ComposeResult: with ChatScroll(id="chat"): connectors_connected, connectors_total = compute_connector_counts( @@ -687,18 +716,16 @@ class VibeApp(App): # noqa: PLR0904 chat_input_container = self.query_one(ChatInputContainer) chat_input_container.focus_input() - await self._resolve_plan() await self._show_dangerous_directory_warning() await self._resume_history_from_messages() self._loop_runner.restore_from_session() self._loop_runner.start() - await self._check_and_show_whats_new() - self._schedule_update_notification() if self._is_resuming_session: await self.agent_loop.hydrate_experiments_from_session() else: self.agent_loop.start_initialize_experiments() + self.call_after_refresh(self._start_post_ready_startup) self.call_after_refresh(self._refresh_banner) self._show_config_issues() @@ -706,12 +733,35 @@ class VibeApp(App): # noqa: PLR0904 if self._show_resume_picker: self.run_worker(self._show_session_picker(), exclusive=False) - elif self._initial_prompt or self._teleport_on_start: - self.call_after_refresh(self._process_initial_prompt) gc.collect() gc.freeze() + def _start_post_ready_startup(self) -> None: + self.run_worker(self._complete_post_ready_startup(), exclusive=False) + + async def _complete_post_ready_startup(self) -> None: + try: + await self._resolve_plan() + finally: + self._startup_command_availability_ready.set() + await self._check_and_show_whats_new() + self._schedule_update_notification() + if self._show_resume_picker: + return + self._process_startup_prompt() + + async def _process_startup_prompt_when_available(self) -> None: + await self._startup_command_availability_ready.wait() + self._process_startup_prompt() + + def _process_startup_prompt(self) -> None: + if self._startup_prompt_processed: + return + self._startup_prompt_processed = True + if self._initial_prompt or self._teleport_on_start: + self._process_initial_prompt() + def _show_config_issues(self) -> None: for issue in ( *self.agent_loop.hook_config_issues, @@ -752,12 +802,17 @@ class VibeApp(App): # noqa: PLR0904 await self._remove_loading_widget() self._refresh_banner() try: - self.query_one(MCPApp).refresh_index() + self.query_one(_get_mcp_app_class()).refresh_index() except Exception: pass async def _show_mcp_auth_required_notice(self) -> None: - statuses = self.agent_loop.mcp_registry.status() + registry = self.agent_loop.mcp_registry + if registry is None: + return + from vibe.core.tools.mcp import AuthStatus + + statuses = registry.status() aliases = sorted( alias for alias, status in statuses.items() @@ -1056,6 +1111,19 @@ class VibeApp(App): # noqa: PLR0904 except ImageSnapshotError as e: return f"Failed to attach image {resource.alias}: {e}" + def on_chat_text_area_clipboard_image_pasted( + self, message: ChatTextArea.ClipboardImagePasted + ) -> None: + self.run_worker( + handle_clipboard_image_paste( + self, notify_when_empty=message.notify_when_empty + ), + exclusive=False, + ) + + async def _paste_clipboard_image_command(self, **_kwargs: Any) -> None: + await handle_clipboard_image_paste(self, notify_when_empty=True) + async def on_config_app_open_model_picker( self, _message: ConfigApp.OpenModelPicker ) -> None: @@ -1131,7 +1199,7 @@ class VibeApp(App): # noqa: PLR0904 self.agent_loop.telemetry_client.send_telemetry_event( "vibe.voice_mode_toggled", {"enabled": desired} ) - self.agent_loop.refresh_config() + self._refresh_config_from_disk() if desired: await self._mount_and_scroll( UserCommandMessage( @@ -1148,8 +1216,7 @@ class VibeApp(App): # noqa: PLR0904 } if non_voice_changes: VibeConfig.save_updates(non_voice_changes) - self.agent_loop.refresh_config() - self._narrator_manager.sync() + self._refresh_config_from_disk() async def on_model_picker_app_model_selected( self, message: ModelPickerApp.ModelSelected @@ -1209,6 +1276,8 @@ class VibeApp(App): # noqa: PLR0904 await self._switch_to_input_app() async def on_mcpapp_mcptoggled(self, message: MCPApp.MCPToggled) -> None: + from vibe.cli.textual_ui.widgets.mcp_app import MCPSourceKind + persist_mcp_toggle( self.agent_loop.config, name=message.name, @@ -1216,22 +1285,33 @@ class VibeApp(App): # noqa: PLR0904 disabled=message.disabled, tool_name=message.tool_name, ) - self.agent_loop.refresh_config() - self.query_one(MCPApp).refresh_index() + self._refresh_config_from_disk() + self.query_one(_get_mcp_app_class()).refresh_index() self._refresh_banner() async def on_mcpapp_connector_auth_requested( self, message: MCPApp.ConnectorAuthRequested ) -> None: + connector_auth_app_class = _get_connector_auth_app_class() await self._switch_to_input_app() await self._switch_from_input( - ConnectorAuthApp( + connector_auth_app_class( connector_name=message.connector_name, connector_registry=message.connector_registry, tool_manager=message.tool_manager, ) ) + async def on_mcpapp_mcpoauth_requested( + self, message: MCPApp.MCPOAuthRequested + ) -> None: + await self._switch_to_input_app() + await self._switch_from_input( + _get_mcp_oauth_app_class()( + server_name=message.server_name, mcp_registry=message.mcp_registry + ) + ) + async def on_connector_auth_app_connector_auth_closed( self, message: ConnectorAuthApp.ConnectorAuthClosed ) -> None: @@ -1241,6 +1321,14 @@ class VibeApp(App): # noqa: PLR0904 await self._switch_to_input_app() await self._show_mcp(cmd_args=message.connector_name) + async def on_mcpoauth_app_mcpoauth_closed( + self, message: MCPOAuthApp.MCPOAuthClosed + ) -> None: + if message.refreshed: + await self._refresh_mcp_browser() + await self._switch_to_input_app() + await self._show_mcp(cmd_args=message.server_name) + async def on_proxy_setup_app_proxy_setup_closed( self, message: ProxySetupApp.ProxySetupClosed ) -> None: @@ -1933,6 +2021,8 @@ class VibeApp(App): # noqa: PLR0904 teleport_msg = TeleportMessage() await self._mount_and_scroll(teleport_msg) + from vibe.core.agent_loop import TeleportError + try: gen = self.agent_loop.teleport_to_vibe_code(prompt) async for event in gen: @@ -2086,7 +2176,8 @@ class VibeApp(App): # noqa: PLR0904 async def _show_mcp_status(self) -> None: await self.agent_loop.wait_until_ready() - statuses = self.agent_loop.mcp_registry.status() + registry = self.agent_loop.mcp_registry + statuses = registry.status() if registry is not None else {} if not statuses: await self._mount_and_scroll( UserCommandMessage("No MCP servers configured.") @@ -2105,6 +2196,12 @@ class VibeApp(App): # noqa: PLR0904 return await self.agent_loop.wait_until_ready() + registry = self.agent_loop.mcp_registry + if registry is None: + await self._mount_and_scroll( + ErrorMessage("No MCP servers configured.", collapsed=True) + ) + return async def on_url(url: str) -> None: await self._mount_and_scroll( @@ -2115,8 +2212,10 @@ class VibeApp(App): # noqa: PLR0904 except Exception as exc: logger.debug("Failed to open MCP OAuth URL in browser: %s", exc) + from vibe.core.auth import MCPOAuthError + try: - await self.agent_loop.mcp_registry.login(alias, on_url=on_url) + await registry.login(alias, on_url=on_url) await self._refresh_mcp_browser() except (MCPOAuthError, ValueError) as exc: await self._mount_and_scroll(ErrorMessage(str(exc), collapsed=True)) @@ -2134,8 +2233,17 @@ class VibeApp(App): # noqa: PLR0904 return await self.agent_loop.wait_until_ready() + registry = self.agent_loop.mcp_registry + if registry is None: + await self._mount_and_scroll( + ErrorMessage("No MCP servers configured.", collapsed=True) + ) + return + + from vibe.core.auth import MCPOAuthError + try: - await self.agent_loop.mcp_registry.logout(alias) + await registry.logout(alias) await self._refresh_mcp_browser() except (MCPOAuthError, ValueError) as exc: await self._mount_and_scroll(ErrorMessage(str(exc), collapsed=True)) @@ -2182,13 +2290,15 @@ class VibeApp(App): # noqa: PLR0904 ) ) return + mcp_app_class = _get_mcp_app_class() await self._mount_and_scroll(UserCommandMessage("MCP servers opened...")) await self._switch_from_input( - MCPApp( + mcp_app_class( mcp_servers=mcp_servers, tool_manager=self.agent_loop.tool_manager, initial_server=name, connector_registry=connector_registry, + mcp_registry=self.agent_loop.mcp_registry, get_vibe_config=lambda: self.agent_loop.config, refresh_callback=self._refresh_mcp_browser, ) @@ -2292,6 +2402,9 @@ class VibeApp(App): # noqa: PLR0904 await self._mount_and_scroll( UserCommandMessage("No sessions found for this directory.") ) + if self._show_resume_picker: + self._show_resume_picker = False + await self._process_startup_prompt_when_available() return await self._switch_from_input(self._build_picker(local_sessions)) @@ -2306,11 +2419,19 @@ class VibeApp(App): # noqa: PLR0904 try: await self._resume_local_session(session) except Exception as e: + if self._show_resume_picker: + self._show_resume_picker = False + self._startup_prompt_processed = True await self._mount_and_scroll( ErrorMessage( f"Failed to load session: {e}", collapsed=self._tools_collapsed ) ) + return + + if self._show_resume_picker: + self._show_resume_picker = False + await self._process_startup_prompt_when_available() async def on_session_picker_app_session_delete_requested( self, event: SessionPickerApp.SessionDeleteRequested @@ -2366,6 +2487,9 @@ class VibeApp(App): # noqa: PLR0904 self, event: SessionPickerApp.Cancelled ) -> None: await self._switch_to_input_app() + if self._show_resume_picker: + self._show_resume_picker = False + self._startup_prompt_processed = True await self._mount_and_scroll(UserCommandMessage("Resume cancelled.")) @@ -2618,23 +2742,9 @@ class VibeApp(App): # noqa: PLR0904 finally: self.exit(result=self._get_session_resume_info()) - def _make_default_voice_manager(self) -> VoiceManager: - try: - model = self.config.get_active_transcribe_model() - provider = self.config.get_transcribe_provider_for_model(model) - transcribe_client = make_transcribe_client(provider, model) - except (ValueError, KeyError) as exc: - logger.error( - "Failed to initialize transcription, check transcribe model configuration", - exc_info=exc, - ) - transcribe_client = None - - return VoiceManager( - lambda: self.config, - audio_recorder=AudioRecorder(), - transcribe_client=transcribe_client, - telemetry_client=self.agent_loop.telemetry_client, + def _make_default_voice_manager(self) -> VoiceManagerPort: + return create_default_voice_manager( + lambda: self.config, self.agent_loop.telemetry_client ) async def _show_voice_settings(self, **kwargs: Any) -> None: @@ -2761,36 +2871,26 @@ class VibeApp(App): # noqa: PLR0904 self.call_after_refresh(self._chat_widget.anchor) def _focus_current_bottom_app(self) -> None: + focus_widget_by_app: dict[BottomApp, type[Widget]] = { + BottomApp.Config: ConfigApp, + BottomApp.ModelPicker: ModelPickerApp, + BottomApp.ThemePicker: ThemePickerApp, + BottomApp.ThinkingPicker: ThinkingPickerApp, + BottomApp.ProxySetup: ProxySetupApp, + BottomApp.Approval: ApprovalApp, + BottomApp.Question: QuestionApp, + BottomApp.SessionPicker: SessionPickerApp, + BottomApp.MCP: _get_mcp_app_class(), + BottomApp.ConnectorAuth: _get_connector_auth_app_class(), + BottomApp.MCPOAuth: _get_mcp_oauth_app_class(), + BottomApp.Rewind: RewindApp, + BottomApp.Voice: VoiceApp, + } try: - match self._current_bottom_app: - case BottomApp.Input: - self.query_one(ChatInputContainer).focus_input() - case BottomApp.Config: - self.query_one(ConfigApp).focus() - case BottomApp.ModelPicker: - self.query_one(ModelPickerApp).focus() - case BottomApp.ThemePicker: - self.query_one(ThemePickerApp).focus() - case BottomApp.ThinkingPicker: - self.query_one(ThinkingPickerApp).focus() - case BottomApp.ProxySetup: - self.query_one(ProxySetupApp).focus() - case BottomApp.Approval: - self.query_one(ApprovalApp).focus() - case BottomApp.Question: - self.query_one(QuestionApp).focus() - case BottomApp.SessionPicker: - 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: - self.query_one(VoiceApp).focus() - case app: - assert_never(app) + if self._current_bottom_app == BottomApp.Input: + self.query_one(ChatInputContainer).focus_input() + return + self.query_one(focus_widget_by_app[self._current_bottom_app]).focus() except Exception: pass @@ -3077,11 +3177,9 @@ class VibeApp(App): # noqa: PLR0904 self.agent_loop.telemetry_client.send_user_cancelled_action("interrupt_agent") self.run_worker(self._interrupt_agent_loop(), exclusive=False) - def _handle_bottom_app_close_escape( - self, widget_type: type[MCPApp] | type[ProxySetupApp] | type[ConnectorAuthApp] - ) -> None: + def _handle_bottom_app_close_escape(self, widget_type: type[Widget]) -> None: try: - self.query_one(widget_type).action_close() + cast(Any, self.query_one(widget_type)).action_close() except Exception: pass self._last_escape_time = None @@ -3092,9 +3190,11 @@ class VibeApp(App): # noqa: PLR0904 elif self._current_bottom_app == BottomApp.Voice: self._handle_voice_app_escape() elif self._current_bottom_app == BottomApp.MCP: - self._handle_bottom_app_close_escape(MCPApp) + self._handle_bottom_app_close_escape(_get_mcp_app_class()) elif self._current_bottom_app == BottomApp.ConnectorAuth: - self._handle_bottom_app_close_escape(ConnectorAuthApp) + self._handle_bottom_app_close_escape(_get_connector_auth_app_class()) + elif self._current_bottom_app == BottomApp.MCPOAuth: + self._handle_bottom_app_close_escape(_get_mcp_oauth_app_class()) elif self._current_bottom_app == BottomApp.ProxySetup: self._handle_bottom_app_close_escape(ProxySetupApp) elif self._current_bottom_app == BottomApp.Approval: @@ -3493,6 +3593,7 @@ class VibeApp(App): # noqa: PLR0904 self._plan_info = None finally: self._refresh_command_registry() + self._refresh_banner() async def _mount_and_scroll( self, widget: Widget, after: Widget | None = None, before: Widget | None = None @@ -3616,10 +3717,9 @@ class VibeApp(App): # noqa: PLR0904 # force a full layout refresh so the UI isn't garbled. self.refresh(layout=True) - def _make_default_narrator_manager(self) -> NarratorManager: - return NarratorManager( + def _make_default_narrator_manager(self) -> NarratorManagerPort: + return create_default_narrator_manager( config_getter=lambda: self.config, - audio_player=AudioPlayer(), telemetry_client=self.agent_loop.telemetry_client, ) diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss index 18d3ed0..6784da7 100644 --- a/vibe/cli/textual_ui/app.tcss +++ b/vibe/cli/textual_ui/app.tcss @@ -4,6 +4,10 @@ $mistral_orange: #FF8205; scrollbar-size: 1 1; } +*:ansi { + link-color-hover: $ansi-background; +} + Screen { background: transparent; } @@ -337,6 +341,21 @@ Markdown { } } +.user-message-attachment-line { + width: 100%; + height: auto; +} + +.user-message-attachment-label { + width: auto; + height: auto; +} + +.user-message-attachment-link { + width: auto; + height: auto; +} + .user-message-prompt { width: auto; height: auto; @@ -992,7 +1011,7 @@ StatusMessage { } -#config-app, #voice-app, #mcp-app, #connectorauth-app { +#config-app, #voice-app, #mcp-app, #connectorauth-app, #mcpoauth-app { width: 100%; height: auto; background: transparent; @@ -1001,7 +1020,7 @@ StatusMessage { margin: 0; } -#config-content, #voice-content, #mcp-content, #connectorauth-content { +#config-content, #voice-content, #mcp-content, #connectorauth-content, #mcpoauth-content { width: 100%; height: auto; } @@ -1012,17 +1031,17 @@ StatusMessage { color: $primary; } -#config-options, #mcp-options, #connectorauth-options { +#config-options, #mcp-options, #connectorauth-options, #mcpoauth-options { width: 100%; max-height: 50vh; border: none; } -#config-options:focus, #mcp-options:focus, #connectorauth-options:focus { +#config-options:focus, #mcp-options:focus, #connectorauth-options:focus, #mcpoauth-options:focus { border: none; } -#connectorauth-detail { +#connectorauth-detail, #mcpoauth-detail { margin: 1 0; } @@ -1375,11 +1394,6 @@ NarratorStatus { content-align: left middle; } } - - link-style: none; - link-background-hover: transparent; - link-style-hover: underline; - link-color-hover: $mistral_orange; } .whats-new-message.after-history { @@ -1402,11 +1416,6 @@ NarratorStatus { margin-bottom: 0; } } - - link-style: none; - link-background-hover: transparent; - link-style-hover: underline; - link-color-hover: $mistral_orange; } #sessionpicker-app { diff --git a/vibe/cli/textual_ui/lazy_audio_managers.py b/vibe/cli/textual_ui/lazy_audio_managers.py new file mode 100644 index 0000000..92cd258 --- /dev/null +++ b/vibe/cli/textual_ui/lazy_audio_managers.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +from vibe.cli.narrator_manager.narrator_manager_port import ( + NarratorManagerListener, + NarratorManagerPort, + NarratorState, +) +from vibe.cli.voice_manager.voice_manager_port import ( + TranscribeState, + VoiceManagerListener, + VoiceManagerPort, + VoiceToggleResult, +) +from vibe.core.audio_recorder.audio_recorder_port import RecordingMode +from vibe.core.logger import logger + +if TYPE_CHECKING: + from vibe.core.config import VibeConfig + from vibe.core.telemetry.send import TelemetryClient + from vibe.core.types import BaseEvent + + +class LazyVoiceManager: + def __init__( + self, + config_getter: Callable[[], VibeConfig], + factory: Callable[[], VoiceManagerPort], + ) -> None: + self._config_getter = config_getter + self._factory = factory + self._manager: VoiceManagerPort | None = None + self._listeners: list[VoiceManagerListener] = [] + if self._config_getter().voice_mode_enabled: + self._materialize() + + @property + def is_enabled(self) -> bool: + if self._manager is not None: + return self._manager.is_enabled + return self._config_getter().voice_mode_enabled + + @property + def transcribe_state(self) -> TranscribeState: + if self._manager is None: + return TranscribeState.IDLE + return self._manager.transcribe_state + + @property + def peak(self) -> float: + if self._manager is None: + return 0.0 + return self._manager.peak + + def toggle_voice_mode(self) -> VoiceToggleResult: + return self._materialize().toggle_voice_mode() + + def start_recording(self, mode: RecordingMode = RecordingMode.STREAM) -> None: + self._materialize().start_recording(mode) + + async def stop_recording(self) -> None: + if self._manager is not None: + await self._manager.stop_recording() + + def cancel_recording(self) -> None: + if self._manager is not None: + self._manager.cancel_recording() + + def add_listener(self, listener: VoiceManagerListener) -> None: + if listener not in self._listeners: + self._listeners.append(listener) + if self._manager is not None: + self._manager.add_listener(listener) + + def remove_listener(self, listener: VoiceManagerListener) -> None: + try: + self._listeners.remove(listener) + except ValueError: + pass + if self._manager is not None: + self._manager.remove_listener(listener) + + async def close(self) -> None: + if self._manager is not None: + await self._manager.close() + + def _materialize(self) -> VoiceManagerPort: + if self._manager is None: + self._manager = self._factory() + for listener in self._listeners: + self._manager.add_listener(listener) + return self._manager + + +class LazyNarratorManager: + def __init__( + self, + config_getter: Callable[[], VibeConfig], + factory: Callable[[], NarratorManagerPort], + ) -> None: + self._config_getter = config_getter + self._factory = factory + self._manager: NarratorManagerPort | None = None + self._listeners: list[NarratorManagerListener] = [] + if self._config_getter().narrator_enabled: + self._materialize() + + @property + def state(self) -> NarratorState: + if self._manager is None: + return NarratorState.IDLE + return self._manager.state + + @property + def is_playing(self) -> bool: + if self._manager is None: + return False + return self._manager.is_playing + + def on_turn_start(self, user_message: str) -> None: + if self._manager is not None: + self._manager.on_turn_start(user_message) + + def on_turn_event(self, event: BaseEvent) -> None: + if self._manager is not None: + self._manager.on_turn_event(event) + + def on_turn_error(self, message: str) -> None: + if self._manager is not None: + self._manager.on_turn_error(message) + + def on_turn_cancel(self) -> None: + if self._manager is not None: + self._manager.on_turn_cancel() + + def on_turn_end(self) -> None: + if self._manager is not None: + self._manager.on_turn_end() + + def cancel(self) -> None: + if self._manager is not None: + self._manager.cancel() + + def sync(self) -> None: + if self._manager is None: + if self._config_getter().narrator_enabled: + self._materialize() + return + self._manager.sync() + + def add_listener(self, listener: NarratorManagerListener) -> None: + if listener not in self._listeners: + self._listeners.append(listener) + if self._manager is not None: + self._manager.add_listener(listener) + + def remove_listener(self, listener: NarratorManagerListener) -> None: + try: + self._listeners.remove(listener) + except ValueError: + pass + if self._manager is not None: + self._manager.remove_listener(listener) + + async def close(self) -> None: + if self._manager is not None: + await self._manager.close() + + def _materialize(self) -> NarratorManagerPort: + if self._manager is None: + self._manager = self._factory() + for listener in self._listeners: + self._manager.add_listener(listener) + return self._manager + + +def create_default_voice_manager( + config_getter: Callable[[], VibeConfig], telemetry_client: TelemetryClient | None +) -> VoiceManagerPort: + return LazyVoiceManager( + config_getter, + lambda: _create_real_voice_manager(config_getter, telemetry_client), + ) + + +def create_default_narrator_manager( + config_getter: Callable[[], VibeConfig], telemetry_client: TelemetryClient | None +) -> NarratorManagerPort: + return LazyNarratorManager( + config_getter, + lambda: _create_real_narrator_manager(config_getter, telemetry_client), + ) + + +def _create_real_voice_manager( + config_getter: Callable[[], VibeConfig], telemetry_client: TelemetryClient | None +) -> VoiceManagerPort: + from vibe.cli.voice_manager.voice_manager import VoiceManager + from vibe.core.audio_recorder.audio_recorder import AudioRecorder + from vibe.core.transcribe.factory import make_transcribe_client + + config = config_getter() + try: + model = config.get_active_transcribe_model() + provider = config.get_transcribe_provider_for_model(model) + transcribe_client = make_transcribe_client(provider, model) + except (ValueError, KeyError) as exc: + logger.error( + "Failed to initialize transcription, check transcribe model configuration", + exc_info=exc, + ) + transcribe_client = None + + return VoiceManager( + config_getter, + audio_recorder=AudioRecorder(), + transcribe_client=transcribe_client, + telemetry_client=telemetry_client, + ) + + +def _create_real_narrator_manager( + config_getter: Callable[[], VibeConfig], telemetry_client: TelemetryClient | None +) -> NarratorManagerPort: + from vibe.cli.narrator_manager.narrator_manager import NarratorManager + from vibe.core.audio_player.audio_player import AudioPlayer + + return NarratorManager( + config_getter=config_getter, + audio_player=AudioPlayer(), + telemetry_client=telemetry_client, + ) diff --git a/vibe/cli/textual_ui/widgets/chat_input/paste_image.py b/vibe/cli/textual_ui/widgets/chat_input/paste_image.py new file mode 100644 index 0000000..7584eb5 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/chat_input/paste_image.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from datetime import datetime +from pathlib import Path +import platform +import shutil +import subprocess +import tempfile +from typing import TYPE_CHECKING + +from humanize import naturalsize + +from vibe.cli.constants import CLIPBOARD_IMAGE_PASTE_SUPPORTED_SYSTEM +from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea +from vibe.core.logger import logger +from vibe.core.types import MAX_IMAGE_BYTES + +if TYPE_CHECKING: + from vibe.cli.textual_ui.app import VibeApp + +_READ_TIMEOUT_S = 5.0 +_PNG_MAGIC = b"\x89PNG\r\n\x1a\n" +_MAX_SAME_SECOND_COLLISIONS = 1000 + + +def is_clipboard_image_paste_supported() -> bool: + return platform.system() == CLIPBOARD_IMAGE_PASTE_SUPPORTED_SYSTEM + + +def read_clipboard_image() -> bytes | None: + if not is_clipboard_image_paste_supported(): + return None + for reader in _readers_for_platform(): + try: + data = reader() + except Exception: + continue + if data and data.startswith(_PNG_MAGIC): + return data + return None + + +def _readers_for_platform() -> list[Callable[[], bytes | None]]: + if platform.system() == CLIPBOARD_IMAGE_PASTE_SUPPORTED_SYSTEM: + return [_read_macos] + return [] + + +def _read_macos() -> bytes | None: + # Preview's Copy, Screenshot, browser Copy Image, etc. each pick their own + # image flavor on the pasteboard. Try PNG first (most common), then TIFF + # converted via `sips` (Preview frequently only writes TIFF picture). + data = _read_macos_class("PNGf") + if data is not None: + return data + tiff = _read_macos_class("TIFF") + if tiff is None: + return None + return _convert_to_png_via_sips(tiff, source_format="tiff") + + +def _read_macos_class(four_cc: str) -> bytes | None: + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as tmp: + tmp_path = Path(tmp.name) + # «class XXXX» is the AppleScript four-char-code form. The chevrons are + # part of the syntax — without them, "XXXX" is a plain string literal. + cast_clause = f"\u00abclass {four_cc}\u00bb" + script = ( + f'set targetFile to POSIX file "{tmp_path}"\n' + "try\n" + f" set imgData to the clipboard as {cast_clause}\n" + "on error\n" + " return\n" + "end try\n" + "set fh to open for access targetFile with write permission\n" + "set eof of fh to 0\n" + "write imgData to fh\n" + "close access fh\n" + ) + try: + result = subprocess.run( + ["osascript", "-e", script], + capture_output=True, + timeout=_READ_TIMEOUT_S, + check=False, + ) + if result.returncode != 0: + return None + data = tmp_path.read_bytes() + return data if data else None + finally: + tmp_path.unlink(missing_ok=True) + + +def _convert_to_png_via_sips(data: bytes, *, source_format: str) -> bytes | None: + if shutil.which("sips") is None: + return None + with tempfile.NamedTemporaryFile(suffix=f".{source_format}", delete=False) as src: + src_path = Path(src.name) + src_path.write_bytes(data) + out_path = src_path.with_suffix(".png") + try: + result = subprocess.run( + ["sips", "-s", "format", "png", str(src_path), "--out", str(out_path)], + capture_output=True, + timeout=_READ_TIMEOUT_S, + check=False, + ) + if result.returncode != 0 or not out_path.is_file(): + return None + return out_path.read_bytes() or None + finally: + src_path.unlink(missing_ok=True) + out_path.unlink(missing_ok=True) + + +async def handle_clipboard_image_paste( + app: VibeApp, *, notify_when_empty: bool +) -> None: + if not is_clipboard_image_paste_supported(): + # Silently no-op: keybinding and slash command are hidden on + # unsupported platforms so the user is never invited to use them. + return + data = await asyncio.to_thread(read_clipboard_image) + if data is None: + if notify_when_empty: + app.notify( + "No image found on the clipboard.", severity="warning", timeout=3 + ) + return + if len(data) > MAX_IMAGE_BYTES: + app.notify( + f"Clipboard image is {naturalsize(len(data), binary=True)}; " + f"max is {naturalsize(MAX_IMAGE_BYTES, binary=True)}.", + severity="warning", + ) + return + try: + active_model = app.agent_loop.config.get_active_model() + except ValueError: + active_model = None + if active_model is not None and not active_model.supports_images: + app.notify( + f"Model `{active_model.alias}` does not support images. " + f"Switch with /model or ask me to enable image support for this model.", + severity="warning", + ) + return + try: + path = write_clipboard_image( + data, session_dir=app.agent_loop.session_logger.session_dir + ) + except OSError as e: + logger.warning("Failed to write pasted clipboard image: %r", e) + app.notify("Failed to save pasted image to disk.", severity="warning") + return + if not insert_image_token_at_cursor(app, path): + try: + path.unlink(missing_ok=True) + except OSError as e: + logger.warning("Failed to remove unused pasted clipboard image: %r", e) + app.notify("Failed to paste image into prompt.", severity="warning") + return + app.notify( + f"Image pasted as {path.name} ({naturalsize(len(data), binary=True)})", + severity="information", + timeout=2, + ) + + +def write_clipboard_image(data: bytes, *, session_dir: Path | None) -> Path: + if session_dir is not None: + target_dir = session_dir / "attachments" + else: + target_dir = Path(tempfile.gettempdir()) / "vibe-pasted-images" + target_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") + path = target_dir / f"clipboard-{timestamp}.png" + # Same-second collision: append a short numeric suffix until free. + if path.exists(): + for n in range(1, _MAX_SAME_SECOND_COLLISIONS): + candidate = target_dir / f"clipboard-{timestamp}-{n}.png" + if not candidate.exists(): + path = candidate + break + path.write_bytes(data) + return path + + +def insert_image_token_at_cursor(app: VibeApp, path: Path) -> bool: + try: + text_area = app.query_one(ChatTextArea) + except Exception: + return False + token = f"@{path}" if " " not in str(path) else f"@'{path}'" + current = text_area.text + offset = text_area.get_cursor_offset() + prev_char = current[offset - 1] if offset > 0 else "" + prefix = "" if not prev_char or prev_char.isspace() else " " + text_area.insert(f"{prefix}{token} ") + return True + + +# Re-exports for testing without touching private names from outside. +__all__ = [ + "handle_clipboard_image_paste", + "insert_image_token_at_cursor", + "is_clipboard_image_paste_supported", + "read_clipboard_image", + "write_clipboard_image", +] diff --git a/vibe/cli/textual_ui/widgets/chat_input/text_area.py b/vibe/cli/textual_ui/widgets/chat_input/text_area.py index d94a240..0054a56 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/text_area.py +++ b/vibe/cli/textual_ui/widgets/chat_input/text_area.py @@ -1,5 +1,6 @@ from __future__ import annotations +import platform import time from typing import Any, ClassVar, Literal @@ -7,9 +8,11 @@ from textual import events from textual.binding import Binding from textual.message import Message from textual.widgets import TextArea +from textual.widgets.text_area import Selection from vibe.cli.autocompletion.base import CompletionResult from vibe.cli.commands import CommandRegistry +from vibe.cli.constants import CLIPBOARD_IMAGE_PASTE_SUPPORTED_SYSTEM from vibe.cli.textual_ui.external_editor import ExternalEditor from vibe.cli.textual_ui.widgets.chat_input.completion_manager import ( MultiCompletionManager, @@ -40,6 +43,23 @@ class ChatTextArea(TextArea): Binding("shift+backspace", "delete_left", "Delete character left", show=False), Binding("shift+delete", "delete_right", "Delete character right", show=False), Binding("ctrl+g", "open_external_editor", "External Editor", show=False), + # Ctrl+V triggers an explicit clipboard-image paste on platforms where + # we support it. On other platforms the binding is not registered, so + # Textual's default text-paste action handles the key instead and the + # user never discovers a feature that wouldn't work for them. + *( + [ + Binding( + "ctrl+v", + "paste_image_from_clipboard", + "Paste image from clipboard", + show=False, + priority=True, + ) + ] + if platform.system() == CLIPBOARD_IMAGE_PASTE_SUPPORTED_SYSTEM + else [] + ), ] DEFAULT_MODE: ClassVar[Literal[">"]] = ">" @@ -65,6 +85,21 @@ class ChatTextArea(TextArea): self.mode = mode super().__init__() + class ClipboardImagePasted(Message): + """Posted when the OS clipboard should be probed for an image. + + `notify_when_empty` is True for explicit user actions (ctrl+v key + binding, /paste-image command) so the user gets clear feedback if + nothing pasteable is on the clipboard. It is False for the implicit + empty-bracketed-paste trigger, which should stay silent because a + no-op is the expected outcome of pressing Cmd+V with an empty + clipboard. + """ + + def __init__(self, *, notify_when_empty: bool = False) -> None: + self.notify_when_empty = notify_when_empty + super().__init__() + def __init__( self, command_registry: CommandRegistry, @@ -86,6 +121,10 @@ class ChatTextArea(TextArea): self._last_keystroke_time: float = 0.0 def on_blur(self, event: events.Blur) -> None: + # set_reactive avoids the selection watcher, which would call + # app.clear_selection() and wipe an in-progress selection elsewhere. + self.set_reactive(TextArea.selection, Selection.cursor(self.cursor_location)) + self.refresh() if self._app_has_focus: self.call_after_refresh(self.focus) @@ -107,11 +146,20 @@ class ChatTextArea(TextArea): # MRO still runs inside this dispatch cycle and performs the # single insertion using the mutated text. event.text = maybe_prepend_at_for_image_path(event.text) + # Empty paste = either truly empty clipboard, or clipboard holds + # image bytes the terminal cannot deliver as text. The app handler + # peeks the OS clipboard in a worker and, if it finds image bytes, + # writes them to attachments and inserts an @ token. + if not event.text.strip(): + self.post_message(self.ClipboardImagePasted()) event.stop() def action_insert_newline(self) -> None: self.insert("\n") + def action_paste_image_from_clipboard(self) -> None: + self.post_message(self.ClipboardImagePasted(notify_when_empty=True)) + def action_open_external_editor(self) -> None: editor = ExternalEditor() current_text = self.get_full_text() diff --git a/vibe/cli/textual_ui/widgets/context_progress.py b/vibe/cli/textual_ui/widgets/context_progress.py index 81909fa..2d691d1 100644 --- a/vibe/cli/textual_ui/widgets/context_progress.py +++ b/vibe/cli/textual_ui/widgets/context_progress.py @@ -7,6 +7,9 @@ from textual.reactive import reactive from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic +_THOUSAND = 1_000 +_MILLION = 1_000_000 + @dataclass class TokenState: @@ -14,6 +17,14 @@ class TokenState: current_tokens: int = 0 +def _format_token_count(tokens: int) -> str: + if tokens >= _MILLION: + return f"{tokens / _MILLION:.1f}M" + if tokens >= _THOUSAND: + return f"{tokens // _THOUSAND}k" + return str(tokens) + + class ContextProgress(NoMarkupStatic): tokens = reactive(TokenState()) @@ -26,5 +37,8 @@ class ContextProgress(NoMarkupStatic): return ratio = min(1, new_state.current_tokens / new_state.max_tokens) - text = f"{ratio:.0%} of {new_state.max_tokens // 1000}k tokens" + text = ( + f"{_format_token_count(new_state.current_tokens)}/" + f"{_format_token_count(new_state.max_tokens)} tokens ({ratio:.0%})" + ) self.update(text) diff --git a/vibe/cli/textual_ui/widgets/debug_console.py b/vibe/cli/textual_ui/widgets/debug_console.py index 8b2fd4b..0b68b65 100644 --- a/vibe/cli/textual_ui/widgets/debug_console.py +++ b/vibe/cli/textual_ui/widgets/debug_console.py @@ -4,6 +4,8 @@ import bisect from collections.abc import Callable from rich.markup import escape +from rich.segment import Segment +from rich.style import Style from rich.text import Text from textual import events from textual.app import ComposeResult @@ -26,6 +28,7 @@ LOG_LEVEL_COLORS: dict[str, str] = { } DEFAULT_LOG_PAGE_SIZE = 30 +_EMPTY_STYLE = Style() class _LogView(ScrollView, can_focus=True): @@ -122,7 +125,13 @@ class _LogView(ScrollView, can_focus=True): base = self._wrap_prefix[logical_idx] for i, line_text in enumerate(wrapped): - strip = Strip(line_text.render(self.app.console), line_text.cell_len) + segments = [ + segment + if segment.style is not None + else Segment(segment.text, _EMPTY_STYLE, segment.control) + for segment in line_text.render(self.app.console) + ] + strip = Strip(segments, line_text.cell_len) strip = strip.crop_extend(0, width, rich_style) self._render_line_cache[base + i] = strip diff --git a/vibe/cli/textual_ui/widgets/feedback_bar_manager.py b/vibe/cli/textual_ui/widgets/feedback_bar_manager.py index 4d9f9b9..812f45a 100644 --- a/vibe/cli/textual_ui/widgets/feedback_bar_manager.py +++ b/vibe/cli/textual_ui/widgets/feedback_bar_manager.py @@ -1,14 +1,49 @@ from __future__ import annotations -from vibe.core.agent_loop import AgentLoop +from collections.abc import Sequence +from typing import TYPE_CHECKING, Protocol + from vibe.core.feedback import record_feedback_asked, should_show_feedback from vibe.core.types import Role +if TYPE_CHECKING: + from vibe.core.cache_store import VibeCodeCacheStore + + +class _FeedbackTelemetry(Protocol): + def is_active(self) -> bool: ... + + +class _FeedbackConfig(Protocol): + def is_active_model_mistral(self) -> bool: ... + + +class _FeedbackMessage(Protocol): + @property + def role(self) -> str: ... + + @property + def injected(self) -> bool: ... + + +class _FeedbackSource(Protocol): + @property + def messages(self) -> Sequence[_FeedbackMessage]: ... + + @property + def telemetry_client(self) -> _FeedbackTelemetry: ... + + @property + def config(self) -> _FeedbackConfig: ... + + @property + def cache_store(self) -> VibeCodeCacheStore: ... + class FeedbackBarManager: """Decides whether to show the feedback bar and records when feedback is given.""" - def should_show(self, agent_loop: AgentLoop) -> bool: + def should_show(self, agent_loop: _FeedbackSource) -> bool: user_message_count = ( sum(m.role == Role.user and not m.injected for m in agent_loop.messages) + 1 # +1 for the message the user just sent @@ -20,5 +55,5 @@ class FeedbackBarManager: cache_store=agent_loop.cache_store, ) - def record_feedback_asked(self, agent_loop: AgentLoop) -> None: + def record_feedback_asked(self, agent_loop: _FeedbackSource) -> None: record_feedback_asked(agent_loop.cache_store) diff --git a/vibe/cli/textual_ui/widgets/links.py b/vibe/cli/textual_ui/widgets/links.py new file mode 100644 index 0000000..ba6a438 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/links.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Any +from urllib.parse import quote, unquote, urlsplit + +from rich.highlighter import ReprHighlighter +from rich.markup import escape +from rich.text import Text +from textual.widgets import Static + +from vibe.core.logger import logger + +_SAFE_SCHEMES = {"http", "https"} + +# Rich's repr highlighter tags URL spans with the style name "repr.url". +# This is a stable public-ish style key (used by Rich's pretty printer) but +# we depend on it implicitly — if Rich renames it, linkify silently no-ops. +_URL_HIGHLIGHTER = ReprHighlighter() +_URL_STYLE = "repr.url" + + +def _is_safe_url(url: str) -> bool: + return urlsplit(url).scheme.lower() in _SAFE_SCHEMES + + +def link_markup(label: str, url: str) -> str: + # Only http(s) URLs become clickable; other schemes (file:, custom protocol + # handlers, ...) render as plain text so an untrusted source URL can't steer + # a click to a local handler. Percent-encode so brackets/quotes/parens can't + # break the markup or the @click action literal; action_open_url decodes it. + if not _is_safe_url(url): + return escape(label) + return f"[@click=open_url('{quote(url, safe='')}')]{escape(label)}[/]" + + +def linkify_urls_in_text(text: str) -> str: + rich = Text(text) + _URL_HIGHLIGHTER.highlight(rich) + spans = sorted( + (s for s in rich.spans if s.style == _URL_STYLE), key=lambda s: s.start + ) + if not spans: + return escape(text) + parts: list[str] = [] + cursor = 0 + for span in spans: + parts.append(escape(text[cursor : span.start])) + url = text[span.start : span.end] + parts.append(link_markup(url, url)) + cursor = span.end + parts.append(escape(text[cursor:])) + return "".join(parts) + + +class LinkStatic(Static): + def __init__(self, content: str = "", **kwargs: Any) -> None: + super().__init__(content, markup=True, **kwargs) + + def action_open_url(self, url: str) -> None: + target = unquote(url) + if not _is_safe_url(target): + logger.warning("Refusing to open url=%s", target) + return + self.app.open_url(target) + # Hover highlight only refreshes on mouse move, so re-render to keep the + # link styled after a click. + self.refresh() diff --git a/vibe/cli/textual_ui/widgets/mcp_app.py b/vibe/cli/textual_ui/widgets/mcp_app.py index 92c5817..c8c466e 100644 --- a/vibe/cli/textual_ui/widgets/mcp_app.py +++ b/vibe/cli/textual_ui/widgets/mcp_app.py @@ -17,12 +17,13 @@ from textual.worker import Worker from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.config import ConnectorConfig, VibeConfig from vibe.core.tools.connectors import ConnectorAuthAction, ConnectorRegistry -from vibe.core.tools.mcp.tools import MCPTool from vibe.core.tools.mcp_settings import updated_tool_list +from vibe.core.tools.remote import MCPTool if TYPE_CHECKING: from vibe.core.config import MCPServer from vibe.core.tools.manager import ToolManager + from vibe.core.tools.mcp import AuthStatus, MCPRegistry class MCPSourceKind(StrEnum): @@ -117,18 +118,29 @@ class MCPApp(Container): self.connector_registry = connector_registry self.tool_manager = tool_manager + class MCPOAuthRequested(Message): + """Posted when an OAuth MCP server needs authentication.""" + + def __init__(self, server_name: str, mcp_registry: MCPRegistry) -> None: + super().__init__() + self.server_name = server_name + self.mcp_registry = mcp_registry + def __init__( self, mcp_servers: Sequence[MCPServer], tool_manager: ToolManager, initial_server: str = "", connector_registry: ConnectorRegistry | None = None, + mcp_registry: MCPRegistry | None = None, get_vibe_config: Callable[[], VibeConfig] | None = None, refresh_callback: Callable[[], Awaitable[str]] | None = None, ) -> None: super().__init__(id="mcp-app") self._mcp_servers = mcp_servers self._connector_registry = connector_registry + self._mcp_registry = mcp_registry + self._sync_mcp_registry() self._get_vibe_config = get_vibe_config connector_names = ( connector_registry.get_connector_names() if connector_registry else [] @@ -254,6 +266,10 @@ class MCPApp(Container): == ConnectorAuthAction.OAUTH ): return _LIST_VIEW_HELP_AUTH + if option_id.startswith("server:"): + name = option_id.removeprefix("server:") + if self._server_needs_auth(name): + return _LIST_VIEW_HELP_AUTH return _LIST_VIEW_HELP_TOOLS def _set_help_text(self, text: str) -> None: @@ -261,6 +277,24 @@ class MCPApp(Container): text = f"{self._status_message} {text}" self.query_one("#mcp-help", NoMarkupStatic).update(text) + def _sync_mcp_registry(self) -> None: + if self._mcp_registry is None: + return + self._mcp_registry.sync_active_servers(list(self._mcp_servers)) + + def _server_auth_status(self, name: str) -> AuthStatus | None: + if self._mcp_registry is None: + return None + return self._mcp_registry.status().get(name) + + def _server_needs_auth(self, name: str) -> bool: + from vibe.core.tools.mcp import AuthStatus + + server = next((srv for srv in self._mcp_servers if srv.name == name), None) + if server is None or server.disabled: + return False + return self._server_auth_status(name) is AuthStatus.NEEDS_AUTH + def _connector_configs(self) -> list[ConnectorConfig]: return self._get_vibe_config().connectors if self._get_vibe_config else [] @@ -372,6 +406,7 @@ class MCPApp(Container): def _rebuild_preserving_scroll(self) -> None: """Rebuild the tool index and refresh the view, preserving highlight and scroll.""" + self._sync_mcp_registry() option_list = self.query_one(OptionList) saved_option_id: str | None = None if (idx := option_list.highlighted) is not None: @@ -470,22 +505,37 @@ class MCPApp(Container): option_list.highlighted = first_enabled def _list_mcp_servers(self, option_list: OptionList, index: MCPToolIndex) -> None: + from vibe.core.tools.mcp import AuthStatus + max_name = max(len(srv.name) for srv in self._mcp_servers) max_type = max(len(srv.transport) + 2 for srv in self._mcp_servers) - option_list.add_option( - Option(Text("Local MCP Servers", style="bold", no_wrap=True), disabled=True) - ) + tool_texts: dict[str, str] = {} for srv in self._mcp_servers: tools = index.server_tools.get(srv.name, []) total = len(tools) enabled = sum(1 for t, _ in tools if t in index.enabled_tools) + tool_texts[srv.name] = _tool_count_text(enabled, total) + max_tools = max(len(t) for t in tool_texts.values()) + statuses = self._mcp_registry.status() if self._mcp_registry else {} + option_list.add_option( + Option(Text("Local MCP Servers", style="bold", no_wrap=True), disabled=True) + ) + for srv in self._mcp_servers: type_tag = f"[{srv.transport}]" label = Text(no_wrap=True) label.append(f" {srv.name:<{max_name}}") label.append(f" {type_tag:<{max_type}}", style="dim") - label.append(f" {_tool_count_text(enabled, total)}", style="dim") + label.append(f" {tool_texts[srv.name]:<{max_tools}}", style="dim") if srv.disabled: _append_status(label, "○", "dim", "disabled") + else: + match statuses.get(srv.name): + case AuthStatus.NEEDS_AUTH: + _append_status(label, "○", "dim", "needs auth") + case AuthStatus.OK: + _append_status(label, "●", "green", "connected") + case _: + _append_status(label, "●", "green", "enabled") option_list.add_option(Option(label, id=f"server:{srv.name}")) def _list_connectors(self, option_list: OptionList, index: MCPToolIndex) -> None: @@ -556,6 +606,15 @@ 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 is_connector and self._server_needs_auth(server_name): + self._set_help_text(_DETAIL_VIEW_HELP_NO_TOOLS) + if self._mcp_registry is not None: + self.post_message( + self.MCPOAuthRequested( + server_name=server_name, mcp_registry=self._mcp_registry + ) + ) + return self._set_help_text( _DETAIL_VIEW_HELP if all_tools else _DETAIL_VIEW_HELP_NO_TOOLS ) diff --git a/vibe/cli/textual_ui/widgets/mcp_oauth_app.py b/vibe/cli/textual_ui/widgets/mcp_oauth_app.py new file mode 100644 index 0000000..5403f7d --- /dev/null +++ b/vibe/cli/textual_ui/widgets/mcp_oauth_app.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum, auto +from typing import 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.auth import MCPOAuthError +from vibe.core.tools.mcp import MCPRegistry + +_HELP = "R Retry Backspace Back" +_OPTION_PADDING = " " + + +class _OAuthOptionId(StrEnum): + OPEN = auto() + COPY = auto() + SHOW = auto() + + +@dataclass(frozen=True) +class _LoginResult: + authenticated: bool + error: str | None = None + + +class MCPOAuthApp(Container): + can_focus_children = True + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "close", "Close", show=False), + Binding("backspace", "close", "Back", show=False), + Binding("r", "refresh", "Retry", show=False), + ] + + class MCPOAuthClosed(Message): + def __init__(self, *, refreshed: bool = False, server_name: str = "") -> None: + super().__init__() + self.refreshed = refreshed + self.server_name = server_name + + def __init__(self, server_name: str, mcp_registry: MCPRegistry) -> None: + super().__init__(id="mcpoauth-app") + self._server_name = server_name + self._mcp_registry = mcp_registry + self._auth_url: str | None = None + self._auth_url_visible = False + self._status_message: str | None = None + self._logging_in = False + + def compose(self) -> ComposeResult: + with Vertical(id="mcpoauth-content"): + yield NoMarkupStatic("", id="mcpoauth-title", classes="settings-title") + yield NoMarkupStatic("") + yield OptionList(id="mcpoauth-options") + yield NoMarkupStatic("", id="mcpoauth-detail") + yield NoMarkupStatic("", id="mcpoauth-help", classes="settings-help") + + def on_mount(self) -> None: + self.query_one("#mcpoauth-title", NoMarkupStatic).update( + f"MCP Server: {self._server_name}" + ) + self._start_login() + self.query_one(OptionList).focus() + + 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 == _OAuthOptionId.OPEN: + self._open_browser() + elif option_id == _OAuthOptionId.COPY: + self._copy_url() + elif option_id == _OAuthOptionId.SHOW: + self._toggle_url() + + def action_close(self) -> None: + self.post_message(self.MCPOAuthClosed()) + + async def action_refresh(self) -> None: + if self._logging_in: + return + self._start_login() + + def _start_login(self) -> None: + self._auth_url = None + self._auth_url_visible = False + self._logging_in = True + self._status_message = "Preparing authentication..." + option_list = self.query_one(OptionList) + option_list.clear_options() + option_list.add_option(Option("Starting OAuth login...", disabled=True)) + self.query_one("#mcpoauth-detail", NoMarkupStatic).update("") + self._set_help_text(_HELP) + self.run_worker(self._run_login(), exclusive=True, group="mcp_oauth_login") + + async def _run_login(self) -> _LoginResult: + async def on_url(url: str) -> None: + self._on_auth_url_available(url) + + try: + await self._mcp_registry.login(self._server_name, on_url=on_url) + except (MCPOAuthError, ValueError) as exc: + return _LoginResult(authenticated=False, error=str(exc)) + return _LoginResult(authenticated=True) + + def on_worker_state_changed(self, event: Worker.StateChanged) -> None: + if event.worker.group != "mcp_oauth_login" or not event.worker.is_finished: + return + self._logging_in = False + result = event.worker.result + if not isinstance(result, _LoginResult): + self._on_login_failed("Authentication failed. Press R to retry.") + return + if result.authenticated: + self.post_message( + self.MCPOAuthClosed(refreshed=True, server_name=self._server_name) + ) + return + self._on_login_failed( + result.error or "Authentication failed. Press R to retry." + ) + + def _on_auth_url_available(self, url: str) -> None: + self._auth_url = url + self._status_message = "Waiting for browser sign-in..." + option_list = self.query_one(OptionList) + option_list.clear_options() + option_list.add_option( + Option( + Text("This MCP server 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=_OAuthOptionId.OPEN, + ) + ) + option_list.add_option( + Option( + Text(f"{_OPTION_PADDING}Copy URL to clipboard", no_wrap=True), + id=_OAuthOptionId.COPY, + ) + ) + option_list.add_option( + Option( + Text(f"{_OPTION_PADDING}Manually show the URL", no_wrap=True), + id=_OAuthOptionId.SHOW, + ) + ) + option_list.highlighted = option_list.get_option_index(_OAuthOptionId.OPEN) + self._update_detail_text() + self._set_help_text(_HELP) + + def _on_login_failed(self, message: str) -> None: + self._status_message = message + option_list = self.query_one(OptionList) + option_list.clear_options() + option_list.add_option( + Option("Authentication failed. Press R to retry.", disabled=True) + ) + self.query_one("#mcpoauth-detail", NoMarkupStatic).update("") + self._set_help_text(_HELP) + + 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() + + def _update_detail_text(self) -> None: + detail = self.query_one("#mcpoauth-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 in your browser, return to Vibe") + 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("#mcpoauth-help", NoMarkupStatic).update(text) diff --git a/vibe/cli/textual_ui/widgets/messages.py b/vibe/cli/textual_ui/widgets/messages.py index 1496ae6..da075fb 100644 --- a/vibe/cli/textual_ui/widgets/messages.py +++ b/vibe/cli/textual_ui/widgets/messages.py @@ -4,8 +4,6 @@ import asyncio from pathlib import Path from typing import TYPE_CHECKING, ClassVar, cast -from rich.markup import escape - from vibe.core.hooks.models import HookMessageSeverity from vibe.core.logger import logger from vibe.core.types import FileImageSource, ImageAttachment, InlineImageSource @@ -22,7 +20,7 @@ from textual.content import Content from textual.css.query import NoMatches from textual.reactive import reactive from textual.widget import Widget -from textual.widgets import Markdown, Static +from textual.widgets import Link, Markdown, Static from textual.widgets._markdown import MarkdownStream from watchfiles import awatch @@ -77,6 +75,48 @@ class ExpandingSeparator(NonSelectableStatic): self.refresh() +def _attachment_label(attachment: ImageAttachment) -> str: + alias_path = Path(attachment.alias).expanduser() + if not alias_path.is_absolute(): + return attachment.alias + return _format_display_path(alias_path) + + +def _format_display_path(path: Path) -> str: + home = Path.home() + try: + relative = path.relative_to(home) + except ValueError: + return str(path) + if str(relative) == ".": + return "~" + return f"~/{relative}" + + +class UserMessageAttachment(Horizontal): + def __init__(self, attachment: ImageAttachment) -> None: + super().__init__(classes="user-message-attachment-line") + self._attachment = attachment + + def compose(self) -> ComposeResult: + yield NoMarkupStatic( + "└ attached image: ", classes="user-message-attachment-label" + ) + match self._attachment.source: + case FileImageSource(path=path): + yield Link( + _attachment_label(self._attachment), + url=path.as_uri(), + classes="user-message-attachment-link", + ) + case InlineImageSource(): + # Inline images have no file on disk, so there's nothing to link. + yield NoMarkupStatic( + _attachment_label(self._attachment), + classes="user-message-attachment-link", + ) + + class UserMessage(Static): PROMPT_CHAR: ClassVar[str] = ">" SHOW_SEPARATOR: ClassVar[bool] = True @@ -110,34 +150,21 @@ class UserMessage(Static): ) yield NoMarkupStatic(self._content, classes="user-message-content") if self._images: - yield Static( - self._format_attachments_footer(self._images), - classes="user-message-attachments", - markup=True, - ) + with Vertical(classes="user-message-attachments"): + for image in self._images: + yield UserMessageAttachment(image) if self.SHOW_SEPARATOR: yield ExpandingSeparator(classes="user-message-separator") if self._pending: self.add_class("pending") @staticmethod - def _format_attachment_link(att: ImageAttachment) -> str: - match att.source: - case FileImageSource(path=path): - # Quote the URL in Textual [link="..."] markup: the parser stops - # at `:` inside an unquoted tag value, so a raw `file://...` URL - # would raise MarkupError. Textual auto-wires the click to - # webbrowser.open(url), opening the OS default viewer. - return f'[link="{path.as_uri()}"]{escape(att.alias)}[/link]' - case InlineImageSource(): - # Inline images have no file on disk, so there's nothing to link. - return escape(att.alias) + def _attachment_label(attachment: ImageAttachment) -> str: + return _attachment_label(attachment) @staticmethod - def _format_attachments_footer(images: list[ImageAttachment]) -> str: - label = "attached image" if len(images) == 1 else "attached images" - links = ", ".join(UserMessage._format_attachment_link(att) for att in images) - return f"└ {label}: {links}" + def _format_display_path(path: Path) -> str: + return _format_display_path(path) async def set_pending(self, pending: bool) -> None: if pending == self._pending: diff --git a/vibe/cli/textual_ui/widgets/status_message.py b/vibe/cli/textual_ui/widgets/status_message.py index 6e21013..d4001cf 100644 --- a/vibe/cli/textual_ui/widgets/status_message.py +++ b/vibe/cli/textual_ui/widgets/status_message.py @@ -19,7 +19,7 @@ class StatusMessage(SpinnerMixin, NoMarkupStatic): def __init__(self, initial_text: str = "", **kwargs: Any) -> None: self._initial_text = initial_text self._indicator_widget: Static | None = None - self._text_widget: NoMarkupStatic | None = None + self._text_widget: Static | None = None self.success = True self.init_spinner() super().__init__(**kwargs) @@ -64,7 +64,10 @@ class StatusMessage(SpinnerMixin, NoMarkupStatic): self._indicator_widget.add_class("error") self._indicator_widget.remove_class("success") - self._text_widget.update(content) + self._text_widget.update(self._format_text(content)) + + def _format_text(self, content: str) -> str: + return content def get_content(self) -> str: return self._initial_text diff --git a/vibe/cli/textual_ui/widgets/tool_widgets.py b/vibe/cli/textual_ui/widgets/tool_widgets.py index 20c9f84..156d552 100644 --- a/vibe/cli/textual_ui/widgets/tool_widgets.py +++ b/vibe/cli/textual_ui/widgets/tool_widgets.py @@ -18,6 +18,7 @@ from vibe.cli.textual_ui.widgets.diff_rendering import ( locate_snippets_in_file, render_edit_diff, ) +from vibe.cli.textual_ui.widgets.links import LinkStatic, link_markup from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.tools.builtins.ask_user_question import AskUserQuestionResult from vibe.core.tools.builtins.bash import BashArgs, BashResult @@ -25,6 +26,8 @@ from vibe.core.tools.builtins.edit import EditArgs, EditResult from vibe.core.tools.builtins.grep import GrepArgs, GrepResult from vibe.core.tools.builtins.read import ReadArgs, ReadResult from vibe.core.tools.builtins.todo import TodoArgs, TodoResult +from vibe.core.tools.builtins.webfetch import WebFetchResult +from vibe.core.tools.builtins.websearch import WebSearchResult, WebSearchSource from vibe.core.tools.builtins.write_file import WriteFileArgs, WriteFileResult _LINE_NUMBER_PREFIX = re.compile(r"^ *\d+→") @@ -380,6 +383,41 @@ class AskUserQuestionResultWidget(ToolResultWidget[AskUserQuestionResult]): yield from self._footer() +class WebSearchResultWidget(ToolResultWidget[WebSearchResult]): + @staticmethod + def _source_markup(source: WebSearchSource) -> str: + label = source.title or source.url + return link_markup(label, source.url) + + def compose(self) -> ComposeResult: + if not self.result: + yield from self._footer() + return + result = self.result + yield NoMarkupStatic(f"query: {result.query}", classes="tool-result-detail") + if result.answer: + yield from self._yield_truncated_text(f"answer: {result.answer}") + if result.sources: + yield NoMarkupStatic("") + if len(result.sources) > 1: + yield NoMarkupStatic("Sources:", classes="tool-result-detail") + lines = [f" • {self._source_markup(s)}" for s in result.sources] + yield LinkStatic("\n".join(lines), classes="tool-result-detail") + yield from self._footer() + + +class WebFetchResultWidget(ToolResultWidget[WebFetchResult]): + def compose(self) -> ComposeResult: + if not self.result: + yield from self._footer() + return + yield from self._yield_truncated_text(self.result.content) + yield NoMarkupStatic("") + link = link_markup(self.result.url, self.result.url) + yield LinkStatic(link, classes="tool-result-detail") + yield from self._footer() + + APPROVAL_WIDGETS: dict[str, type[ToolApprovalWidget]] = { "bash": BashApprovalWidget, "read": ReadApprovalWidget, @@ -397,8 +435,14 @@ RESULT_WIDGETS: dict[str, type[ToolResultWidget]] = { "grep": GrepResultWidget, "todo": TodoResultWidget, "ask_user_question": AskUserQuestionResultWidget, + "web_search": WebSearchResultWidget, + "web_fetch": WebFetchResultWidget, } +# Tools whose result message text is allowed to contain clickable URLs. +# Opt-in: extend this set when a tool's message becomes URL-shaped. +LINKIFY_RESULT_TOOLS: frozenset[str] = frozenset({"web_fetch"}) + def get_approval_widget(tool_name: str, args: BaseModel) -> ToolApprovalWidget: widget_class = APPROVAL_WIDGETS.get(tool_name, ToolApprovalWidget) diff --git a/vibe/cli/textual_ui/widgets/tools.py b/vibe/cli/textual_ui/widgets/tools.py index f670f40..2c17df6 100644 --- a/vibe/cli/textual_ui/widgets/tools.py +++ b/vibe/cli/textual_ui/widgets/tools.py @@ -1,5 +1,6 @@ from __future__ import annotations +from rich.markup import escape from textual import events from textual.app import ComposeResult from textual.containers import Horizontal, Vertical @@ -10,13 +11,18 @@ from vibe.cli.textual_ui.widgets.collapsible import ( CollapsibleSection, lines_label, ) +from vibe.cli.textual_ui.widgets.links import LinkStatic, linkify_urls_in_text from vibe.cli.textual_ui.widgets.messages import ExpandingBorder from vibe.cli.textual_ui.widgets.no_markup_static import ( NoMarkupStatic, NonSelectableStatic, ) from vibe.cli.textual_ui.widgets.status_message import StatusMessage -from vibe.cli.textual_ui.widgets.tool_widgets import ToolResultWidget, get_result_widget +from vibe.cli.textual_ui.widgets.tool_widgets import ( + LINKIFY_RESULT_TOOLS, + ToolResultWidget, + get_result_widget, +) from vibe.core.tools.ui import ToolCallDisplay, ToolUIDataAdapter from vibe.core.types import ToolCallEvent, ToolResultEvent @@ -47,7 +53,7 @@ class ToolCallMessage(StatusMessage): self._spinner.current_frame(), classes="status-indicator-icon" ) yield self._indicator_widget - self._text_widget = NoMarkupStatic("", classes="status-indicator-text") + self._text_widget = LinkStatic("", classes="status-indicator-text") yield self._text_widget self._suffix_widget = NoMarkupStatic( "", classes="status-indicator-suffix" @@ -83,6 +89,9 @@ class ToolCallMessage(StatusMessage): return adapter.get_call_display(self._event) return ToolCallDisplay(summary=self._tool_name) + def _format_text(self, content: str) -> str: + return escape(content) + def update_event(self, event: ToolCallEvent) -> None: self._event = event self._tool_name = event.tool_name @@ -98,12 +107,15 @@ class ToolCallMessage(StatusMessage): """Stop the spinner while keeping stream row stable to avoid layout jumps.""" super().stop_spinning(success) - def set_result_text(self, text: str, suffix: str = "") -> None: - self._set_text(text, suffix) + def set_result_text( + self, text: str, suffix: str = "", *, linkify: bool = False + ) -> None: + self._set_text(text, suffix, linkify=linkify) - def _set_text(self, text: str, suffix: str) -> None: + def _set_text(self, text: str, suffix: str, *, linkify: bool = False) -> None: if self._text_widget: - self._text_widget.update(text) + content = linkify_urls_in_text(text) if linkify else escape(text) + self._text_widget.update(content) self._update_suffix(suffix) def _update_suffix(self, suffix: str) -> None: @@ -154,7 +166,10 @@ class ToolResultMessage(ClickWithoutDragMixin, Static): success = self._determine_success() self._call_widget.stop_spinning(success=success) result_text, result_suffix = self._get_result_text() - self._call_widget.set_result_text(result_text, result_suffix) + linkify = self._tool_name in LINKIFY_RESULT_TOOLS + self._call_widget.set_result_text( + result_text, result_suffix, linkify=linkify + ) await self._render_result() def _determine_success(self) -> bool: diff --git a/vibe/cli/update_notifier/update.py b/vibe/cli/update_notifier/update.py index 06ad82e..8e1d34e 100644 --- a/vibe/cli/update_notifier/update.py +++ b/vibe/cli/update_notifier/update.py @@ -167,6 +167,7 @@ UPDATE_COMMANDS = ["uv tool upgrade mistral-vibe", "brew upgrade mistral-vibe"] async def do_update() -> bool: + any_succeeded = False for command in UPDATE_COMMANDS: process = await asyncio.create_subprocess_shell( command, @@ -180,8 +181,8 @@ async def do_update() -> bool: await _terminate(process) raise if process.returncode == 0: - return True - return False + any_succeeded = True + return any_succeeded async def _terminate(process: asyncio.subprocess.Process) -> None: diff --git a/vibe/cli/voice_manager/__init__.py b/vibe/cli/voice_manager/__init__.py index 683cd6a..7a4abc9 100644 --- a/vibe/cli/voice_manager/__init__.py +++ b/vibe/cli/voice_manager/__init__.py @@ -1,15 +1,27 @@ from __future__ import annotations -from vibe.cli.voice_manager.voice_manager import VoiceManager +from typing import TYPE_CHECKING + from vibe.cli.voice_manager.voice_manager_port import ( RecordingStartError, VoiceManagerPort, VoiceToggleResult, ) +if TYPE_CHECKING: + from vibe.cli.voice_manager.voice_manager import VoiceManager + __all__ = [ "RecordingStartError", "VoiceManager", "VoiceManagerPort", "VoiceToggleResult", ] + + +def __getattr__(name: str) -> object: + if name == "VoiceManager": + from vibe.cli.voice_manager.voice_manager import VoiceManager + + return VoiceManager + raise AttributeError(name) diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index 83e3729..835a128 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -8,11 +8,13 @@ from enum import StrEnum, auto from functools import wraps from http import HTTPStatus import inspect +import os from pathlib import Path +import shutil import threading from threading import Thread import time -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, cast from uuid import uuid4 from opentelemetry import trace @@ -89,10 +91,7 @@ from vibe.core.tools.base import ( ToolPermission, ToolPermissionError, ) -from vibe.core.tools.connectors import ConnectorRegistry from vibe.core.tools.manager import ToolManager -from vibe.core.tools.mcp import MCPConnectionPool, MCPRegistry -from vibe.core.tools.mcp_sampling import MCPSamplingHandler from vibe.core.tools.permissions import ( ApprovedRule, PermissionContext, @@ -143,17 +142,38 @@ from vibe.core.utils import ( is_user_cancellation_event, ) -try: - from vibe.core.teleport.teleport import TeleportService as _TeleportService - _TELEPORT_AVAILABLE = True -except ImportError: - _TELEPORT_AVAILABLE = False - _TeleportService = None +def _is_git_executable_available() -> bool: + executable = os.environ.get("GIT_PYTHON_GIT_EXECUTABLE") + if not executable: + return shutil.which("git") is not None + + path = Path(executable).expanduser() + if path.is_absolute() or os.sep in executable: + return path.is_file() and os.access(path, os.X_OK) + return shutil.which(executable) is not None + + +_TELEPORT_AVAILABLE = _is_git_executable_available() + + +def _load_teleport_service() -> type[TeleportService]: + try: + from vibe.core.teleport.teleport import TeleportService + except ImportError as e: + raise TeleportError( + "Teleport requires git to be installed. Please install git and try again." + ) from e + return TeleportService + if TYPE_CHECKING: from vibe.core.teleport.teleport import TeleportService from vibe.core.teleport.types import TeleportPushResponseEvent, TeleportYieldEvent + from vibe.core.tools.connectors.connector_registry import ConnectorRegistry + from vibe.core.tools.mcp.pool import MCPConnectionPool + from vibe.core.tools.mcp.registry import MCPRegistry + from vibe.core.tools.mcp_sampling import MCPSamplingHandler class ToolExecutionResponse(StrEnum): @@ -277,6 +297,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 message_observer: Callable[[LLMMessage], None] | None = None, max_turns: int | None = None, max_price: float | None = None, + max_tokens: int | None = None, max_session_tokens: int | None = None, backend: BackendLike | None = None, enable_streaming: bool = False, @@ -305,9 +326,17 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 self._permission_store = permission_store or PermissionStore() - self.mcp_registry = mcp_registry or MCPRegistry() - self._mcp_pool = MCPConnectionPool() - self.connector_registry = self._create_connector_registry() + self.mcp_registry: MCPRegistry | None = ( + mcp_registry + if defer_heavy_init + else mcp_registry or self._create_mcp_registry() + ) + self._mcp_pool: MCPConnectionPool | None = ( + None if defer_heavy_init else self._create_mcp_pool() + ) + self.connector_registry: ConnectorRegistry | None = ( + None if defer_heavy_init else self._create_connector_registry() + ) self.agent_manager = AgentManager( lambda: self._base_config, initial_agent=agent_name, @@ -324,6 +353,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 self.message_observer = message_observer self._max_turns = max_turns self._max_price = max_price + self._max_tokens = max_tokens self._max_session_tokens = max_session_tokens self._plan_session = PlanSession() @@ -331,7 +361,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 self.backend_factory = lambda: backend or self._select_backend() self.backend = self.backend_factory() - self._sampling_handler = MCPSamplingHandler( + self._sampling_handler = self._create_sampling_handler( backend_getter=lambda: self.backend, config_getter=lambda: self.config, metadata_getter=lambda: self._build_backend_metadata( @@ -441,6 +471,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 ``defer_heavy_init=True`` was passed to ``__init__``. """ try: + self._ensure_remote_registries() self.tool_manager.integrate_all(raise_on_mcp_failure=True) self.messages.update_system_prompt(self._build_system_prompt(), notify=True) except Exception as exc: @@ -485,7 +516,8 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 def refresh_config(self) -> None: self._base_config = VibeConfig.load() self.agent_manager.invalidate_config() - self.mcp_registry.sync_active_servers(self.config.mcp_servers) + if self.mcp_registry is not None: + self.mcp_registry.sync_active_servers(self.config.mcp_servers) def _drain_pending_injections(self) -> bool: if not self._pending_injected_messages: @@ -606,8 +638,9 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 task.cancel() with contextlib.suppress(BaseException): await task - with contextlib.suppress(Exception): - await self._mcp_pool.aclose() + if self._mcp_pool is not None: + with contextlib.suppress(Exception): + await self._mcp_pool.aclose() with contextlib.suppress(Exception): await self.backend.__aexit__(None, None, None) with contextlib.suppress(Exception): @@ -627,8 +660,60 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 return None server_url = get_server_url_from_api_base(provider.api_base) + from vibe.core.tools.connectors.connector_registry import ConnectorRegistry + return ConnectorRegistry(api_key=api_key, server_url=server_url) + @staticmethod + def _create_mcp_registry() -> MCPRegistry: + from vibe.core.tools.mcp.registry import MCPRegistry + + return MCPRegistry() + + @staticmethod + def _create_mcp_pool() -> MCPConnectionPool: + from vibe.core.tools.mcp.pool import MCPConnectionPool + + return MCPConnectionPool() + + def _ensure_remote_registries(self) -> None: + if self.mcp_registry is None and self.config.mcp_servers: + self.mcp_registry = self._create_mcp_registry() + self.tool_manager.set_mcp_registry(self.mcp_registry) + + if self._mcp_pool is None and self.config.mcp_servers: + self._mcp_pool = self._create_mcp_pool() + + if self.connector_registry is None: + self.connector_registry = self._create_connector_registry() + self.tool_manager.set_connector_registry(self.connector_registry) + + @staticmethod + def _create_sampling_handler( + *, + backend_getter: Callable[[], BackendLike], + config_getter: Callable[[], VibeConfig], + metadata_getter: Callable[[], dict[str, Any]], + extra_headers_getter: Callable[[], dict[str, str]], + ) -> MCPSamplingHandler: + handler: MCPSamplingHandler | None = None + + async def lazy_handler(context: Any, params: Any) -> Any: + nonlocal handler + if handler is None: + from vibe.core.tools.mcp_sampling import MCPSamplingHandler + + handler = MCPSamplingHandler( + backend_getter=backend_getter, + config_getter=config_getter, + metadata_getter=metadata_getter, + extra_headers_getter=extra_headers_getter, + ) + return await handler(context, params) + + # Only ever invoked as a callable, never attribute-accessed. + return cast("MCPSamplingHandler", lazy_handler) + def _build_system_prompt(self) -> str: return get_universal_system_prompt( self.tool_manager, @@ -730,9 +815,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 ) if self._teleport_service is None: - if _TeleportService is None: - raise TeleportError("_TeleportService is unexpectedly None") - self._teleport_service = _TeleportService( + self._teleport_service = _load_teleport_service()( session_logger=self.session_logger, vibe_code_sessions_base_url=self.config.vibe_code_sessions_base_url, vibe_code_api_key=self.config.vibe_code_api_key, @@ -798,6 +881,9 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 self._max_turns = max_turns self._setup_middleware() + def set_max_tokens(self, max_tokens: int) -> None: + self._max_tokens = max_tokens + def _setup_middleware(self) -> None: """Configure middleware pipeline for this conversation.""" self.middleware_pipeline.clear() @@ -1545,9 +1631,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 return 0 return sum(1 for m in self.messages if m.images) - async def _chat( - self, max_tokens: int | None = None, model_override: ModelConfig | None = None - ) -> LLMChunk: + async def _chat(self, model_override: ModelConfig | None = None) -> LLMChunk: active_model = model_override or self.config.get_active_model() provider = self.config.get_provider_for_model(active_model) backend_metadata = self._build_backend_metadata() @@ -1579,7 +1663,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 tools=available_tools, tool_choice=tool_choice, extra_headers=self._get_extra_headers(provider), - max_tokens=max_tokens, + max_tokens=self._max_tokens, metadata=backend_metadata.model_dump(exclude_none=True), ) end_time = time.perf_counter() @@ -1619,9 +1703,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 f"API error from {provider.name} (model: {active_model.name}): {e}" ) from e - async def _chat_streaming( - self, max_tokens: int | None = None - ) -> AsyncGenerator[LLMChunk]: + async def _chat_streaming(self) -> AsyncGenerator[LLMChunk]: active_model = self.config.get_active_model() provider = self.config.get_active_provider() backend_metadata = self._build_backend_metadata() @@ -1655,7 +1737,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 tools=available_tools, tool_choice=tool_choice, extra_headers=self._get_extra_headers(), - max_tokens=max_tokens, + max_tokens=self._max_tokens, metadata=backend_metadata.model_dump(exclude_none=True), ): if chunk.correlation_id: @@ -1782,6 +1864,10 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 forked = AgentLoop( config=self.base_config.model_copy(deep=True), agent_name=self.agent_profile.name, + max_turns=self._max_turns, + max_price=self._max_price, + max_tokens=self._max_tokens, + max_session_tokens=self._max_session_tokens, enable_streaming=self.enable_streaming, entrypoint_metadata=self.entrypoint_metadata, terminal_emulator=self.terminal_emulator, @@ -1977,6 +2063,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 if max_price is not None: self._max_price = max_price + self._ensure_remote_registries() self.tool_manager = ToolManager( lambda: self.config, mcp_registry=self.mcp_registry, diff --git a/vibe/core/audio_player/__init__.py b/vibe/core/audio_player/__init__.py index ffa5875..376e6aa 100644 --- a/vibe/core/audio_player/__init__.py +++ b/vibe/core/audio_player/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations -from vibe.core.audio_player.audio_player import AudioPlayer +from typing import TYPE_CHECKING + from vibe.core.audio_player.audio_player_port import ( AlreadyPlayingError, AudioBackendUnavailableError, @@ -10,6 +11,9 @@ from vibe.core.audio_player.audio_player_port import ( UnsupportedAudioFormatError, ) +if TYPE_CHECKING: + from vibe.core.audio_player.audio_player import AudioPlayer + __all__ = [ "AlreadyPlayingError", "AudioBackendUnavailableError", @@ -19,3 +23,11 @@ __all__ = [ "NoAudioOutputDeviceError", "UnsupportedAudioFormatError", ] + + +def __getattr__(name: str) -> object: + if name == "AudioPlayer": + from vibe.core.audio_player.audio_player import AudioPlayer + + return AudioPlayer + raise AttributeError(name) diff --git a/vibe/core/audio_recorder/__init__.py b/vibe/core/audio_recorder/__init__.py index f8fa2f9..c048ce0 100644 --- a/vibe/core/audio_recorder/__init__.py +++ b/vibe/core/audio_recorder/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations -from vibe.core.audio_recorder.audio_recorder import AudioRecorder +from typing import TYPE_CHECKING + from vibe.core.audio_recorder.audio_recorder_port import ( AlreadyRecordingError, AudioBackendUnavailableError, @@ -11,6 +12,9 @@ from vibe.core.audio_recorder.audio_recorder_port import ( RecordingMode, ) +if TYPE_CHECKING: + from vibe.core.audio_recorder.audio_recorder import AudioRecorder + __all__ = [ "AlreadyRecordingError", "AudioBackendUnavailableError", @@ -21,3 +25,11 @@ __all__ = [ "NoAudioInputDeviceError", "RecordingMode", ] + + +def __getattr__(name: str) -> object: + if name == "AudioRecorder": + from vibe.core.audio_recorder.audio_recorder import AudioRecorder + + return AudioRecorder + raise AttributeError(name) diff --git a/vibe/core/auth/mcp_oauth.py b/vibe/core/auth/mcp_oauth.py index 73e37b9..1af6768 100644 --- a/vibe/core/auth/mcp_oauth.py +++ b/vibe/core/auth/mcp_oauth.py @@ -22,8 +22,12 @@ from pydantic import AnyUrl, BaseModel, ConfigDict from vibe.core.config import MCPHttp, MCPOAuth, MCPStreamableHttp from vibe.core.utils.http import build_ssl_context +from vibe.core.utils.keyring import ( + delete_api_key_from_keyring, + get_api_key_from_keyring, + set_api_key_in_keyring, +) -_SERVICE: Final = "vibe" _USERNAME_PREFIX: Final = "mcp-oauth" _CLIENT_NAME: Final = "Mistral Vibe" _LOGIN_TIMEOUT_SECONDS: Final = 300.0 @@ -95,16 +99,16 @@ def _kr_username(alias: str, kind: str) -> str: async def _kr_get(username: str) -> str | None: - return await anyio.to_thread.run_sync(keyring.get_password, _SERVICE, username) + return await anyio.to_thread.run_sync(get_api_key_from_keyring, username) async def _kr_set(username: str, value: str) -> None: - await anyio.to_thread.run_sync(keyring.set_password, _SERVICE, username, value) + await anyio.to_thread.run_sync(set_api_key_in_keyring, username, value) async def _kr_delete(username: str) -> None: try: - await anyio.to_thread.run_sync(keyring.delete_password, _SERVICE, username) + await anyio.to_thread.run_sync(delete_api_key_from_keyring, username) except keyring.errors.PasswordDeleteError: pass diff --git a/vibe/core/config/__init__.py b/vibe/core/config/__init__.py index 898db94..944166e 100644 --- a/vibe/core/config/__init__.py +++ b/vibe/core/config/__init__.py @@ -70,7 +70,12 @@ from vibe.core.config.schema import ( WithShallowMerge, WithUnionMerge, ) -from vibe.core.config.types import MISSING_CONFIG_FILE_FINGERPRINT, LayerConfigSnapshot +from vibe.core.config.types import ( + MISSING_BACKING_STORE_DATA_FINGERPRINT, + ConfigChangeCallback, + ConfigChangeEvent, + LayerConfigSnapshot, +) from vibe.core.config.vibe_schema import VibeConfigSchema from vibe.core.prompts import MissingPromptFileError @@ -85,9 +90,11 @@ __all__ = [ "DEFAULT_TTS_MODELS", "DEFAULT_TTS_PROVIDERS", "DEFAULT_VIBE_BASE_URL", - "MISSING_CONFIG_FILE_FINGERPRINT", + "MISSING_BACKING_STORE_DATA_FINGERPRINT", "THINKING_LEVELS", "AddOperationPatch", + "ConfigChangeCallback", + "ConfigChangeEvent", "ConfigDefinitionError", "ConfigFragment", "ConfigLayer", diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py index 26c1a7f..426af47 100644 --- a/vibe/core/config/_settings.py +++ b/vibe/core/config/_settings.py @@ -11,8 +11,6 @@ from typing import Annotated, Any, ClassVar, Literal, get_args from urllib.parse import urljoin from dotenv import dotenv_values -import keyring -from keyring.errors import KeyringError from mistralai.client.models import SpeechOutputFormat from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( DEFAULT_TRACES_EXPORT_PATH, @@ -47,6 +45,7 @@ from vibe.core.prompts import ( ) from vibe.core.types import Backend from vibe.core.utils import configure_ssl_context, get_server_url_from_api_base +from vibe.core.utils.keyring import get_api_key_from_keyring def _strip_bash_pattern_wildcard(pattern: str) -> str: @@ -85,9 +84,6 @@ def load_dotenv_values( environ[key] = value -_KEYRING_SERVICE = "vibe" - - def resolve_api_key(env_key: str) -> str | None: """Resolve an API key value: process/.env environment first, then OS keyring.""" if not env_key: @@ -95,10 +91,7 @@ def resolve_api_key(env_key: str) -> str | None: value = os.environ.get(env_key) if value: return value - try: - return keyring.get_password(_KEYRING_SERVICE, env_key) - except KeyringError: - return None + return get_api_key_from_keyring(env_key) class MissingAPIKeyError(RuntimeError): diff --git a/vibe/core/config/event_bus.py b/vibe/core/config/event_bus.py new file mode 100644 index 0000000..75c7d40 --- /dev/null +++ b/vibe/core/config/event_bus.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from itertools import count + +from vibe.core.config.types import ConfigChangeCallback, ConfigChangeEvent + + +@dataclass(frozen=True, slots=True) +class Subscription: + """Represent a subscription to config change events.""" + + keys: frozenset[str] | None + callback: ConfigChangeCallback + + +class EventBus: + def __init__(self) -> None: + self._subscribers: dict[int, Subscription] = {} + self._subscription_ids = count() + + def subscribe( + self, callback: ConfigChangeCallback, *, keys: set[str] | None = None + ) -> Callable[[], None]: + """Register a listener and return a callable that unsubscribes it. + + keys filters delivery to slash-separated config paths (ancestor and + descendant paths match); None subscribes to every change (wildcard). + """ + subscription_id = next(self._subscription_ids) + frozen_keys = frozenset(keys) if keys is not None else None + self._subscribers[subscription_id] = Subscription( + callback=callback, keys=frozen_keys + ) + + def unsubscribe() -> None: + self._subscribers.pop(subscription_id, None) + + return unsubscribe + + def publish(self, event: ConfigChangeEvent) -> None: + for subscription in list(self._subscribers.values()): + if subscription.keys is None or any( + _key_matches(key, changed) + for key in subscription.keys + for changed in event.changed_keys + ): + subscription.callback(event) + + +def _key_matches(subscription_key: str, changed_key: str) -> bool: + # Match a key against its ancestor or descendant paths: "models" matches + # "models/models" and vice versa, but "model" never matches "models". + return ( + subscription_key == changed_key + or changed_key.startswith(f"{subscription_key}/") + or subscription_key.startswith(f"{changed_key}/") + ) diff --git a/vibe/core/config/layer.py b/vibe/core/config/layer.py index 7e4b238..9674ded 100644 --- a/vibe/core/config/layer.py +++ b/vibe/core/config/layer.py @@ -11,7 +11,7 @@ from pydantic import BaseModel, ConfigDict, ValidationError from vibe.core.config.patch import ConfigPatch from vibe.core.config.types import ( - MISSING_CONFIG_FILE_FINGERPRINT, + MISSING_BACKING_STORE_DATA_FINGERPRINT, ConcurrencyConflictError, ConflictStrategy, LayerConfigSnapshot, @@ -303,7 +303,7 @@ class ConfigLayer[S: BaseModel](ABC): if ( state.data is None or state.fingerprint is None - or state.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT + or state.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT ): raise LayerNotLoadedError(self.name) diff --git a/vibe/core/config/layers/user.py b/vibe/core/config/layers/user.py index 3919275..aea1b7e 100644 --- a/vibe/core/config/layers/user.py +++ b/vibe/core/config/layers/user.py @@ -4,6 +4,7 @@ import os from pathlib import Path import tempfile import tomllib +from typing import ClassVar import tomli_w @@ -20,8 +21,10 @@ class UserConfigLayer(ConfigLayer[RawConfig]): Pass an explicit ``path`` for testing. """ - def __init__(self, *, path: Path | None = None, name: str = "user-toml") -> None: - super().__init__(name=name) + LAYER_NAME: ClassVar[str] = "user-toml" + + def __init__(self, *, path: Path | None = None) -> None: + super().__init__(name=self.LAYER_NAME) self._path = path or (VIBE_HOME.path / "config.toml") async def _check_trust(self) -> bool: diff --git a/vibe/core/config/orchestrator.py b/vibe/core/config/orchestrator.py index 6b99d27..1a4d654 100644 --- a/vibe/core/config/orchestrator.py +++ b/vibe/core/config/orchestrator.py @@ -1,30 +1,57 @@ from __future__ import annotations -from typing import Any +import asyncio +from collections import defaultdict +from collections.abc import Callable + +from jsonpatch import JsonPatchException, apply_patch +from jsonpointer import JsonPointerException +from pydantic import ValidationError from vibe.core.config.builder import ConfigBuilder -from vibe.core.config.layer import ConfigLayer, RawConfig -from vibe.core.config.patch import ConfigPatch +from vibe.core.config.event_bus import EventBus +from vibe.core.config.layer import ConfigLayer, LayerNotLoadedError, RawConfig +from vibe.core.config.layers.user import UserConfigLayer +from vibe.core.config.patch import ConfigPatch, PatchOp from vibe.core.config.schema import ConfigSchema -from vibe.core.config.types import ConflictStrategy +from vibe.core.config.types import ConfigChangeCallback, ConflictStrategy + +DEFAULT_PATCH_LAYER_NAME = UserConfigLayer.LAYER_NAME + + +class ConfigPatchValidationError(Exception): + """Raised when the merged-config preflight rejects a patch.""" + + def __init__(self) -> None: + super().__init__( + "Config patch failed preflight validation against the merged config; " + "fix the patch payload and retry" + ) class ConfigOrchestrator[S: ConfigSchema]: """Single entry point for config management.""" - def __init__(self, builder: ConfigBuilder[S], config: S) -> None: + def __init__( + self, builder: ConfigBuilder[S], config: S, bus: EventBus | None = None + ) -> None: self._builder = builder self._config = config + self._bus = bus if bus is not None else EventBus() @classmethod async def create( - cls, *, schema: type[S], layers: list[ConfigLayer[RawConfig]] + cls, + *, + schema: type[S], + layers: list[ConfigLayer[RawConfig]], + bus: EventBus | None = None, ) -> ConfigOrchestrator[S]: """Build an orchestrator from a schema and an ordered list of layers.""" builder = ConfigBuilder[S](schema) builder.add_layers(layers) config = await builder.build() - instance = cls(builder, config) + instance = cls(builder, config, bus) return instance @property @@ -43,11 +70,88 @@ class ConfigOrchestrator[S: ConfigSchema]: async def apply_patch( self, - patch: ConfigPatch, + operations: list[PatchOp], + reason: str, *, on_conflict: ConflictStrategy = ConflictStrategy.CANCEL, - ) -> None: - raise NotImplementedError("apply_patch() is not implemented (M2)") + ) -> list[BaseException]: + """Apply patch operations layer by layer. - async def subscribe(self, callback: Any) -> None: - raise NotImplementedError("subscribe() is not implemented (M3)") + The merged-config preflight is a cheap sanity check only. Once patching + begins, writes are not atomic across layers. Invalid patch requests + still raise, but per-layer write failures are returned in the result. + """ + if not operations: + return [] + + # Simulate and validate final config + try: + self.config.model_validate( + apply_patch( + self._config.model_dump(), + patch=[operation.to_json_patch() for operation in operations], + in_place=False, + ) + ) + except (JsonPatchException, JsonPointerException, ValidationError) as exc: + raise ConfigPatchValidationError() from exc + + operations_by_layer: dict[str, list[PatchOp]] = defaultdict(list) + for op in operations: + layer_name = ( + op.target_layer_name + if op.target_layer_name is not None + else DEFAULT_PATCH_LAYER_NAME + ) + operations_by_layer[layer_name].append(op) + + tasks = [] + for layer_name, layer_operations in operations_by_layer.items(): + tasks.append( + asyncio.create_task( + self._apply_patch_to_layer( + layer_name=layer_name, + layer_operations=list(layer_operations), + reason=reason, + on_conflict=on_conflict, + ) + ) + ) + results = await asyncio.gather(*tasks, return_exceptions=True) + failures = [r for r in results if isinstance(r, BaseException)] + + await self.reload() + + return failures + + async def _apply_patch_to_layer( + self, + *, + layer_name: str, + layer_operations: list[PatchOp], + reason: str, + on_conflict: ConflictStrategy, + ) -> None: + layer = self.get_layer(layer_name) + if layer.fingerprint is None: + raise LayerNotLoadedError(layer_name) + await layer.apply( + ConfigPatch( + *layer_operations, fingerprint=layer.fingerprint, reason=reason + ), + on_conflict=on_conflict, + ) + + def subscribe( + self, callback: ConfigChangeCallback, *, keys: set[str] | None = None + ) -> Callable[[], None]: + """Register a listener and return a callable that unsubscribes it. + + Args: + callback: Invoked with the event on every matching config change. + keys: Slash-separated config paths to filter on (e.g. {"models/active"}). + A path matches its ancestors and descendants but not partial + segments ("model" never matches "models"). None subscribes to + every change (wildcard). + """ + return self._bus.subscribe(callback, keys=keys) diff --git a/vibe/core/config/patch.py b/vibe/core/config/patch.py index 88b400d..a6d516e 100644 --- a/vibe/core/config/patch.py +++ b/vibe/core/config/patch.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod from typing import Annotated, Any from jsonpointer import JsonPointer, JsonPointerException -from pydantic import AfterValidator, BaseModel, ConfigDict +from pydantic import AfterValidator, BaseModel, ConfigDict, StringConstraints class ConfigPatch: @@ -43,6 +43,9 @@ class _OperationPatch(BaseModel, ABC): return path path: Annotated[str, AfterValidator(_validate_json_pointer)] + target_layer_name: ( + Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] | None + ) = None @abstractmethod def to_json_patch(self) -> dict[str, Any]: diff --git a/vibe/core/config/types.py b/vibe/core/config/types.py index 008fc35..31f3eb7 100644 --- a/vibe/core/config/types.py +++ b/vibe/core/config/types.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum, auto from typing import Annotated, Any @@ -35,9 +36,9 @@ class LayerConfigSnapshot(BaseModel): fingerprint: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] -MISSING_CONFIG_FILE_FINGERPRINT = "vibe:missing-config-file" +MISSING_BACKING_STORE_DATA_FINGERPRINT = "vibe:missing-backing-store-data" EMPTY_CONFIG_SNAPSHOT = LayerConfigSnapshot( - data={}, fingerprint=MISSING_CONFIG_FILE_FINGERPRINT + data={}, fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT ) @@ -49,3 +50,7 @@ class ConfigChangeEvent: before: dict[str, Any] after: dict[str, Any] reason: str + + +type ConfigChangeCallback = Callable[[ConfigChangeEvent], None] +"""Function signature for a callback that receives a config change event.""" diff --git a/vibe/core/config/vibe_schema.py b/vibe/core/config/vibe_schema.py index c27762c..129ef12 100644 --- a/vibe/core/config/vibe_schema.py +++ b/vibe/core/config/vibe_schema.py @@ -1,9 +1,10 @@ from __future__ import annotations +from collections.abc import Callable from pathlib import Path from typing import Annotated, Any -from pydantic import Field +from pydantic import AfterValidator, BeforeValidator, Field, model_validator from vibe.core.agents.models import BuiltinAgentName from vibe.core.config._settings import ( @@ -26,6 +27,7 @@ from vibe.core.config._settings import ( ConnectorConfig, ExperimentsConfig, MCPServer, + MissingAPIKeyError, ModelConfig, ProjectContextConfig, ProviderConfig, @@ -34,6 +36,8 @@ from vibe.core.config._settings import ( TranscribeProviderConfig, TTSModelConfig, TTSProviderConfig, + resolve_api_key, + resolve_theme_name, ) from vibe.core.config.schema import ( ConfigSchema, @@ -42,7 +46,37 @@ from vibe.core.config.schema import ( WithShallowMerge, WithUnionMerge, ) -from vibe.core.prompts import SystemPrompt, UtilityPrompt +from vibe.core.prompts import ( + SystemPrompt, + UtilityPrompt, + load_prompt, + load_system_prompt, +) + + +def _unique_by(key: str) -> Callable[[list[Any]], list[Any]]: + def check(items: list[Any]) -> list[Any]: + seen: set[str] = set() + for item in items: + value = getattr(item, key) + if value in seen: + raise ValueError(f"Duplicate {key} {value!r}; must be unique") + seen.add(value) + return items + + return check + + +def _expand_paths(v: Any) -> list[Path]: + if not v: + return [] + return [Path(p).expanduser().resolve() for p in v] + + +def _normalize_tool_configs(v: Any) -> dict[str, dict[str, Any]]: + if not isinstance(v, dict): + return {} + return {name: cfg if isinstance(cfg, dict) else {} for name, cfg in v.items()} class VibeConfigSchema(ConfigSchema): @@ -51,9 +85,11 @@ class VibeConfigSchema(ConfigSchema): providers: Annotated[list[ProviderConfig], WithUnionMerge(merge_key="name")] = ( Field(default_factory=lambda: list(DEFAULT_PROVIDERS)) ) - models: Annotated[list[ModelConfig], WithUnionMerge(merge_key="alias")] = Field( - default_factory=lambda: list(DEFAULT_MODELS) - ) + models: Annotated[ + list[ModelConfig], + WithUnionMerge(merge_key="alias"), + AfterValidator(_unique_by("alias")), + ] = Field(default_factory=lambda: list(DEFAULT_MODELS)) compaction_model: Annotated[ModelConfig | None, WithReplaceMerge()] = None auto_compact_threshold: Annotated[int, WithReplaceMerge()] = ( DEFAULT_AUTO_COMPACT_THRESHOLD @@ -65,7 +101,9 @@ class VibeConfigSchema(ConfigSchema): list[TranscribeProviderConfig], WithUnionMerge(merge_key="name") ] = Field(default_factory=lambda: list(DEFAULT_TRANSCRIBE_PROVIDERS)) transcribe_models: Annotated[ - list[TranscribeModelConfig], WithUnionMerge(merge_key="alias") + list[TranscribeModelConfig], + WithUnionMerge(merge_key="alias"), + AfterValidator(_unique_by("alias")), ] = Field(default_factory=lambda: list(DEFAULT_TRANSCRIBE_MODELS)) active_tts_model: Annotated[str, WithReplaceMerge()] = ( DEFAULT_ACTIVE_TTS_MODEL_CONFIG.alias @@ -73,15 +111,21 @@ class VibeConfigSchema(ConfigSchema): tts_providers: Annotated[ list[TTSProviderConfig], WithUnionMerge(merge_key="name") ] = Field(default_factory=lambda: list(DEFAULT_TTS_PROVIDERS)) - tts_models: Annotated[list[TTSModelConfig], WithUnionMerge(merge_key="alias")] = ( - Field(default_factory=lambda: list(DEFAULT_TTS_MODELS)) - ) + tts_models: Annotated[ + list[TTSModelConfig], + WithUnionMerge(merge_key="alias"), + AfterValidator(_unique_by("alias")), + ] = Field(default_factory=lambda: list(DEFAULT_TTS_MODELS)) # Tools - tools: Annotated[dict[str, dict[str, Any]], WithShallowMerge()] = Field( - default_factory=dict - ) - tool_paths: Annotated[list[Path], WithConcatMerge()] = Field( + tools: Annotated[ + dict[str, dict[str, Any]], + WithShallowMerge(), + BeforeValidator(_normalize_tool_configs), + ] = Field(default_factory=dict) + tool_paths: Annotated[ + list[Path], WithConcatMerge(), BeforeValidator(_expand_paths) + ] = Field( default_factory=list, description=( "Additional directories or files to explore for custom tools. " @@ -155,7 +199,9 @@ class VibeConfigSchema(ConfigSchema): ) # Skills - skill_paths: Annotated[list[Path], WithConcatMerge()] = Field( + skill_paths: Annotated[ + list[Path], WithConcatMerge(), BeforeValidator(_expand_paths) + ] = Field( default_factory=list, description=( "Additional directories to search for skills. " @@ -199,7 +245,9 @@ class VibeConfigSchema(ConfigSchema): enable_experimental_hooks: Annotated[bool, WithReplaceMerge()] = False # Top-level scalars - theme: Annotated[str, WithReplaceMerge()] = DEFAULT_THEME + theme: Annotated[str, WithReplaceMerge(), BeforeValidator(resolve_theme_name)] = ( + DEFAULT_THEME + ) experiment_overrides: Annotated[dict[str, str], WithReplaceMerge()] = Field( default_factory=dict ) @@ -246,3 +294,90 @@ class VibeConfigSchema(ConfigSchema): experiments: Annotated[ExperimentsConfig, WithReplaceMerge()] = Field( default_factory=ExperimentsConfig ) + + @property + def active_model_config(self) -> ModelConfig: + if model := next( + (m for m in self.models if m.alias == self.active_model), None + ): + return model + raise ValueError( + f"Active model '{self.active_model}' not found in configuration." + ) + + def get_provider_for_model(self, model: ModelConfig) -> ProviderConfig: + if provider := next( + (p for p in self.providers if p.name == model.provider), None + ): + return provider + raise ValueError( + f"Provider '{model.provider}' for model '{model.name}' not found in configuration." + ) + + @property + def active_provider_config(self) -> ProviderConfig: + return self.get_provider_for_model(self.active_model_config) + + @property + def system_prompt(self) -> str: + return load_system_prompt(self.system_prompt_id) + + @property + def compaction_prompt(self) -> str: + return load_prompt( + self.compaction_prompt_id, + setting_name="compaction_prompt_id", + builtins={"compact": UtilityPrompt.COMPACT.path}, + ) + + @model_validator(mode="after") + def _apply_global_auto_compact_threshold(self) -> VibeConfigSchema: + models = [ + model + if "auto_compact_threshold" in model.model_fields_set + else model.model_copy( + update={"auto_compact_threshold": self.auto_compact_threshold} + ) + for model in self.models + ] + object.__setattr__(self, "models", models) + return self + + @model_validator(mode="after") + def _check_compaction_model_provider(self) -> VibeConfigSchema: + if self.compaction_model is None: + return self + + compaction_provider = self.get_provider_for_model(self.compaction_model) + try: + active_provider = self.active_provider_config + except ValueError: + return self + if active_provider.name != compaction_provider.name: + raise ValueError( + f"Compaction model '{self.compaction_model.alias}' uses provider " + f"'{compaction_provider.name}' but active model uses provider " + f"'{active_provider.name}'. They must share the same provider." + ) + return self + + @model_validator(mode="after") + def _check_api_key(self) -> VibeConfigSchema: + try: + provider = self.active_provider_config + api_key_env = provider.api_key_env_var + if api_key_env and not resolve_api_key(api_key_env): + raise MissingAPIKeyError(api_key_env, provider.name) + except ValueError: + pass + return self + + @model_validator(mode="after") + def _check_system_prompt(self) -> VibeConfigSchema: + _ = self.system_prompt + return self + + @model_validator(mode="after") + def _check_compaction_prompt(self) -> VibeConfigSchema: + _ = self.compaction_prompt + return self diff --git a/vibe/core/paths/__init__.py b/vibe/core/paths/__init__.py index a5b3e6b..f274345 100644 --- a/vibe/core/paths/__init__.py +++ b/vibe/core/paths/__init__.py @@ -8,6 +8,7 @@ from vibe.core.paths._local_config_files import ( ) from vibe.core.paths._vibe_home import ( CACHE_FILE, + CONNECTOR_BOOTSTRAP_CACHE_FILE, DEFAULT_TOOL_DIR, GLOBAL_ENV_FILE, HISTORY_FILE, @@ -25,6 +26,7 @@ __all__ = [ "AGENTS_HOME", "AGENTS_MD_FILENAME", "CACHE_FILE", + "CONNECTOR_BOOTSTRAP_CACHE_FILE", "DEFAULT_TOOL_DIR", "GLOBAL_ENV_FILE", "HISTORY_FILE", diff --git a/vibe/core/paths/_vibe_home.py b/vibe/core/paths/_vibe_home.py index edf326a..a4abe79 100644 --- a/vibe/core/paths/_vibe_home.py +++ b/vibe/core/paths/_vibe_home.py @@ -32,6 +32,9 @@ TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs") LOG_FILE = GlobalPath(lambda: VIBE_HOME.path / "logs" / "vibe.log") CACHE_FILE = GlobalPath(lambda: VIBE_HOME.path / "cache.toml") +CONNECTOR_BOOTSTRAP_CACHE_FILE = GlobalPath( + lambda: VIBE_HOME.path / "connector_bootstrap_cache.json" +) HISTORY_FILE = GlobalPath(lambda: VIBE_HOME.path / "vibehistory") PLANS_DIR = GlobalPath(lambda: VIBE_HOME.path / "plans") diff --git a/vibe/core/skills/builtins/vibe.py b/vibe/core/skills/builtins/vibe.py index 1ca1b4d..bb33ecf 100644 --- a/vibe/core/skills/builtins/vibe.py +++ b/vibe/core/skills/builtins/vibe.py @@ -31,6 +31,7 @@ agents, prompts, logs, and session data live here. .env # API keys and credentials (dotenv format) vibehistory # Command history trusted_folders.toml # Trust database for project folders + connector_bootstrap_cache.json # Short-lived connector discovery cache agents/ # Custom agent profiles (*.toml) prompts/ # Custom prompts (*.md) skills/ # User-level skills (each skill is a subdirectory with SKILL.md) @@ -578,7 +579,8 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`. - `/compact` - Compact conversation history by summarizing - `/status` - Display agent statistics - `/voice` - Configure voice settings -- `/mcp` - Display available MCP servers (pass a server name to list its tools) +- `/mcp` - Display MCP servers and connector status; pass a server or connector + name to list its tools or open its auth panel when authentication is required - `/mcp status` - Display MCP auth state (`ok`, `needs_auth`, `static`, `stdio`) - `/mcp login ` - Start OAuth login for an MCP server - `/mcp logout ` - Log out from an MCP server and delete stored OAuth @@ -625,7 +627,7 @@ Image attachments: not added to the conversation. - Snapshotted into `/attachments/.` so that resumed sessions stay reproducible even if the source file is moved. -- Capped at 10 MB per image and 8 images per message. +- Capped at 10 MiB per image and 8 images per message. - Out-of-project paths work via `@/abs/path/to.png` (the picker only suggests project files, but the `@`-parser accepts absolute paths). Drag-and-drop from Finder into Terminal, iTerm2, or Ghostty is @@ -634,9 +636,19 @@ Image attachments: automatically prepends `@` (and quotes paths containing spaces). Non-image paths are pasted verbatim so non-image use cases are not affected. -- Rendered in the chat bubble as a dim footer line linking each - attachment to its snapshot. Clicking opens the file with the OS - default image viewer. +- **Image copy/paste from the clipboard** (**macOS only** for now): + writes the image to `/attachments/clipboard-.png` + (or the system temp dir when no session is active) and inserts an + `@` token at the cursor. Two entry points: + 1. `Ctrl+V` keybinding inside the prompt. + 2. `/paste-image` slash command. + + Uses `osascript` with a TIFF→PNG fallback via `sips`. On Linux and + Windows the binding and the slash command are not registered at all, + so the feature is invisible to users on those platforms. +- Rendered in the chat bubble as one dim `attached image:` footer line + per image, linking each attachment to its snapshot. Clicking opens the + file with the OS default image viewer. ## Input Queue diff --git a/vibe/core/tools/builtins/bash.py b/vibe/core/tools/builtins/bash.py index afaa95f..889d4c3 100644 --- a/vibe/core/tools/builtins/bash.py +++ b/vibe/core/tools/builtins/bash.py @@ -54,6 +54,13 @@ def _extract_commands(command: str) -> list[str]: and child.text is not None ): parts.append(child.text.decode("utf-8")) + # When a command has a heredoc (or other redirect), tree-sitter + # wraps it in a redirected_statement and the redirect is a sibling + # of the command node, not a child. Without this check, + # `python3 << 'EOF'` is extracted as bare `python3` and + # incorrectly blocked by the standalone denylist. + if parts and node.parent and node.parent.type == "redirected_statement": + parts.append("") if parts: commands.append(" ".join(parts)) diff --git a/vibe/core/tools/connectors/__init__.py b/vibe/core/tools/connectors/__init__.py index dcf7505..f05c4b6 100644 --- a/vibe/core/tools/connectors/__init__.py +++ b/vibe/core/tools/connectors/__init__.py @@ -1,9 +1,26 @@ from __future__ import annotations -from vibe.core.tools.connectors.connector_registry import ( - ConnectorAuthAction, - ConnectorRegistry, -) -from vibe.core.tools.connectors.counts import compute_connector_counts +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from vibe.core.tools.connectors.connector_registry import ( + ConnectorAuthAction, + ConnectorRegistry, + ) + from vibe.core.tools.connectors.counts import compute_connector_counts __all__ = ["ConnectorAuthAction", "ConnectorRegistry", "compute_connector_counts"] + + +def __getattr__(name: str) -> Any: + if name in {"ConnectorAuthAction", "ConnectorRegistry"}: + from vibe.core.tools.connectors import connector_registry + + return getattr(connector_registry, name) + + if name == "compute_connector_counts": + from vibe.core.tools.connectors.counts import compute_connector_counts + + return compute_connector_counts + + raise AttributeError(name) diff --git a/vibe/core/tools/connectors/connector_registry.py b/vibe/core/tools/connectors/connector_registry.py index 44c117d..105d008 100644 --- a/vibe/core/tools/connectors/connector_registry.py +++ b/vibe/core/tools/connectors/connector_registry.py @@ -3,13 +3,18 @@ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator from enum import StrEnum +import hashlib +import json +import os import re +import time from typing import TYPE_CHECKING, Any, ClassVar import httpx from mistralai.client import Mistral from vibe.core.logger import logger +from vibe.core.paths import CONNECTOR_BOOTSTRAP_CACHE_FILE from vibe.core.tools.base import ( BaseTool, BaseToolConfig, @@ -17,13 +22,7 @@ from vibe.core.tools.base import ( InvokeContext, ToolError, ) -from vibe.core.tools.mcp.tools import ( - MCPTool, - MCPToolResult, - RemoteTool, - _OpenArgs, - call_tool_http, -) +from vibe.core.tools.remote import MCPTool, MCPToolResult, RemoteTool, _OpenArgs from vibe.core.tools.ui import ToolResultDisplay from vibe.core.types import ToolStreamEvent from vibe.core.utils import run_sync @@ -33,6 +32,19 @@ if TYPE_CHECKING: from vibe.core.types import ToolResultEvent _BOOTSTRAP_TIMEOUT = 30.0 +_BOOTSTRAP_CACHE_TTL_SECONDS = 10 * 60 + + +async def call_tool_http( + url: str, + tool_name: str, + arguments: dict[str, Any], + *, + headers: dict[str, str] | None = None, +) -> MCPToolResult: + from vibe.core.tools.mcp.tools import call_tool_http as call_mcp_tool_http + + return await call_mcp_tool_http(url, tool_name, arguments, headers=headers) class ConnectorAuthAction(StrEnum): @@ -74,6 +86,99 @@ def _connector_tool_to_remote(tool: dict[str, Any]) -> RemoteTool | None: _DEFAULT_BASE_URL = "https://api.mistral.ai" +def _bootstrap_cache_key(api_key: str, server_url: str | None) -> str: + base_url = server_url or _DEFAULT_BASE_URL + return hashlib.sha256(f"{base_url}\0{api_key}".encode()).hexdigest() + + +def _strip_none(value: Any) -> Any: + if isinstance(value, dict): + return { + key: _strip_none(item) for key, item in value.items() if item is not None + } + if isinstance(value, list): + return [_strip_none(item) for item in value] + return value + + +def _read_bootstrap_cache_entries() -> dict[str, Any]: + try: + with CONNECTOR_BOOTSTRAP_CACHE_FILE.path.open(encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def _write_bootstrap_cache_entries(entries: dict[str, Any]) -> None: + cache_path = CONNECTOR_BOOTSTRAP_CACHE_FILE.path + tmp_path = cache_path.with_name(f".{cache_path.name}.{os.getpid()}.tmp") + try: + cache_path.parent.mkdir(parents=True, exist_ok=True) + with tmp_path.open("w", encoding="utf-8") as f: + json.dump(entries, f, separators=(",", ":")) + os.replace(tmp_path, cache_path) + except (OSError, TypeError): + try: + tmp_path.unlink(missing_ok=True) + except OSError: + pass + logger.debug( + "Failed to write connector bootstrap cache file %s", + cache_path, + exc_info=True, + ) + + +def _is_fresh_bootstrap_cache_entry(entry: Any, now: int) -> bool: + if not isinstance(entry, dict): + return False + stored_at = entry.get("stored_at_timestamp") + payload = entry.get("payload") + if not isinstance(stored_at, int) or not isinstance(payload, dict): + return False + if stored_at <= now - _BOOTSTRAP_CACHE_TTL_SECONDS: + return False + return isinstance(payload.get("connectors"), list) + + +def _tool_bootstrap_cache_payload(tool: dict[str, Any]) -> dict[str, Any]: + payload = { + "name": tool.get("name"), + "description": tool.get("description"), + "inputSchema": tool.get("inputSchema"), + } + return _strip_none(payload) + + +def _connector_bootstrap_cache_payload(connector: dict[str, Any]) -> dict[str, Any]: + auth_action = connector.get("auth_action") + status = connector.get("status") or {} + payload: dict[str, Any] = { + "id": connector.get("id"), + "name": connector.get("name"), + "status": {"is_ready": bool(status.get("is_ready", False))}, + "tools": [ + _tool_bootstrap_cache_payload(tool) + for tool in connector.get("tools") or [] + if isinstance(tool, dict) + ], + } + if isinstance(auth_action, dict): + payload["auth_action"] = {"type": auth_action.get("type")} + return _strip_none(payload) + + +def _bootstrap_cache_payload(payload: dict[str, Any]) -> dict[str, Any]: + return { + "connectors": [ + _connector_bootstrap_cache_payload(connector) + for connector in payload.get("connectors") or [] + if isinstance(connector, dict) + ] + } + + def _format_http_status_error( exc: httpx.HTTPStatusError, connector_name: str, connector_id: str ) -> str: @@ -250,6 +355,7 @@ class ConnectorRegistry: def __init__(self, api_key: str, server_url: str | None = None) -> None: self._api_key = api_key self._server_url = server_url + self._bootstrap_cache_key = _bootstrap_cache_key(api_key, server_url) self._cache: dict[str, dict[str, type[BaseTool]]] | None = None self._connector_names: list[str] = [] self._connector_connected: dict[str, bool] = {} @@ -257,19 +363,21 @@ class ConnectorRegistry: self._alias_to_id: dict[str, str] = {} self._discover_lock = asyncio.Lock() - def get_tools(self) -> dict[str, type[BaseTool]]: + def get_tools(self, *, force_refresh: bool = False) -> dict[str, type[BaseTool]]: """Return proxy tool classes for all connectors, using cache when possible.""" - return run_sync(self.get_tools_async()) + return run_sync(self.get_tools_async(force_refresh=force_refresh)) - async def get_tools_async(self) -> dict[str, type[BaseTool]]: + async def get_tools_async( + self, *, force_refresh: bool = False + ) -> dict[str, type[BaseTool]]: """Return proxy tool classes for all connectors, using cache when possible.""" - if self._cache is not None: + if self._cache is not None and not force_refresh: result: dict[str, type[BaseTool]] = {} for tools in self._cache.values(): result.update(tools) return result - return await self._discover_all() + return await self._discover_all(force_refresh=force_refresh) async def _fetch_bootstrap(self) -> dict[str, Any]: base_url = self._server_url or _DEFAULT_BASE_URL @@ -283,6 +391,35 @@ class ConnectorRegistry: response.raise_for_status() return response.json() + def _read_cached_bootstrap(self) -> dict[str, Any] | None: + entry = _read_bootstrap_cache_entries().get(self._bootstrap_cache_key) + if not isinstance(entry, dict): + return None + + stored_at = entry.get("stored_at_timestamp") + payload = entry.get("payload") + if not isinstance(stored_at, int) or not isinstance(payload, dict): + return None + if stored_at <= int(time.time()) - _BOOTSTRAP_CACHE_TTL_SECONDS: + return None + if not isinstance(payload.get("connectors"), list): + return None + return payload + + def _write_cached_bootstrap(self, payload: dict[str, Any]) -> None: + now = int(time.time()) + entries = _read_bootstrap_cache_entries() + entries = { + key: entry + for key, entry in entries.items() + if _is_fresh_bootstrap_cache_entry(entry, now) + } + entries[self._bootstrap_cache_key] = { + "stored_at_timestamp": now, + "payload": _bootstrap_cache_payload(payload), + } + _write_bootstrap_cache_entries(entries) + def _build_tools_for_connector( self, *, @@ -315,18 +452,23 @@ class ConnectorRegistry: ) return tools_map - async def _discover_all(self) -> dict[str, type[BaseTool]]: + async def _discover_all( + self, *, force_refresh: bool = False + ) -> dict[str, type[BaseTool]]: async with self._discover_lock: # Re-check under lock — another coroutine may have finished # discovery while we waited. - if self._cache is not None: + if self._cache is not None and not force_refresh: result: dict[str, type[BaseTool]] = {} for tools in self._cache.values(): result.update(tools) return result + data = None if force_refresh else self._read_cached_bootstrap() try: - data = await self._fetch_bootstrap() + if data is None: + data = await self._fetch_bootstrap() + self._write_cached_bootstrap(data) except Exception: logger.warning("Failed to bootstrap connectors", exc_info=True) self._cache = {} @@ -419,6 +561,7 @@ class ConnectorRegistry: try: data = await self._fetch_bootstrap() fetch_ok = True + self._write_cached_bootstrap(data) for connector in data.get("connectors") or []: if str(connector.get("id")) != connector_id: continue diff --git a/vibe/core/tools/connectors/counts.py b/vibe/core/tools/connectors/counts.py index 7ba0e3d..602f219 100644 --- a/vibe/core/tools/connectors/counts.py +++ b/vibe/core/tools/connectors/counts.py @@ -2,10 +2,9 @@ from __future__ import annotations from typing import TYPE_CHECKING -from vibe.core.tools.connectors.connector_registry import ConnectorRegistry - if TYPE_CHECKING: from vibe.core.config import VibeConfig + from vibe.core.tools.connectors.connector_registry import ConnectorRegistry def compute_connector_counts( diff --git a/vibe/core/tools/manager.py b/vibe/core/tools/manager.py index 80fc579..f8b02e7 100644 --- a/vibe/core/tools/manager.py +++ b/vibe/core/tools/manager.py @@ -9,19 +9,19 @@ from pathlib import Path import re import sys import threading -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypeGuard from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.logger import logger from vibe.core.paths import DEFAULT_TOOL_DIR from vibe.core.tools.base import BaseTool, BaseToolConfig, ToolPermission -from vibe.core.tools.connectors import ConnectorRegistry -from vibe.core.tools.mcp import MCPRegistry -from vibe.core.tools.mcp.tools import MCPTool +from vibe.core.tools.remote import MCPTool from vibe.core.utils import name_matches, run_sync if TYPE_CHECKING: from vibe.core.config import VibeConfig + from vibe.core.tools.connectors.connector_registry import ConnectorRegistry + from vibe.core.tools.mcp.registry import MCPRegistry def _try_canonical_module_name(path: Path) -> str | None: @@ -80,7 +80,7 @@ class ToolManager: ) -> None: self._config_getter = config_getter self._permission_getter = permission_getter - self._mcp_registry = mcp_registry or MCPRegistry() + self._mcp_registry = mcp_registry self._connector_registry = connector_registry self._instances: dict[str, BaseTool] = {} self._search_paths: list[Path] = self._compute_search_paths(self._config) @@ -93,6 +93,21 @@ class ToolManager: if not defer_mcp: self.integrate_all() + def set_mcp_registry(self, mcp_registry: MCPRegistry | None) -> None: + self._mcp_registry = mcp_registry + + def set_connector_registry( + self, connector_registry: ConnectorRegistry | None + ) -> None: + self._connector_registry = connector_registry + + def _get_mcp_registry(self) -> MCPRegistry: + if self._mcp_registry is None: + from vibe.core.tools.mcp.registry import MCPRegistry + + self._mcp_registry = MCPRegistry() + return self._mcp_registry + @property def _config(self) -> VibeConfig: return self._config_getter() @@ -273,7 +288,7 @@ class ToolManager: disabled_sources: set[tuple[str, bool]], per_source_disabled: dict[tuple[str, bool], set[str]], ) -> bool: - if not issubclass(tool_cls, MCPTool): + if not ToolManager._is_remote_tool_class(tool_cls): return False server_name = tool_cls.get_server_name() if server_name is None: @@ -283,6 +298,10 @@ class ToolManager: return True return tool_cls.get_remote_name() in per_source_disabled.get(key, set()) + @staticmethod + def _is_remote_tool_class(tool_cls: type[BaseTool]) -> TypeGuard[type[MCPTool]]: + return issubclass(tool_cls, MCPTool) + def integrate_mcp(self, *, raise_on_failure: bool = False) -> None: """Discover and register MCP tools (sync wrapper). @@ -296,11 +315,12 @@ class ToolManager: if self._mcp_integrated: return if not self._config.mcp_servers: - self._mcp_registry.sync_active_servers([]) + if self._mcp_registry is not None: + self._mcp_registry.sync_active_servers([]) return try: - mcp_tools = await self._mcp_registry.get_tools_async( + mcp_tools = await self._get_mcp_registry().get_tools_async( self._config.mcp_servers ) except Exception as exc: @@ -321,7 +341,7 @@ class ToolManager: stale_keys = [ name for name, cls in self._all_tools.items() - if issubclass(cls, MCPTool) and cls.is_connector() + if self._is_remote_tool_class(cls) and cls.is_connector() ] for key in stale_keys: self._all_tools.pop(key, None) @@ -332,17 +352,17 @@ class ToolManager: stale_keys = [ name for name, cls in self._all_tools.items() - if issubclass(cls, MCPTool) and not cls.is_connector() + if self._is_remote_tool_class(cls) and not cls.is_connector() ] for key in stale_keys: self._all_tools.pop(key, None) self._instances.pop(key, None) - def integrate_connectors(self) -> None: + def integrate_connectors(self, *, force_refresh: bool = False) -> None: """Discover and register connector tools (sync wrapper).""" - run_sync(self.integrate_connectors_async()) + run_sync(self.integrate_connectors_async(force_refresh=force_refresh)) - async def integrate_connectors_async(self) -> None: + async def integrate_connectors_async(self, *, force_refresh: bool = False) -> None: """Discover and register connector tools — canonical implementation. Thread-safe: can be called from the deferred-init background thread. @@ -351,7 +371,9 @@ class ToolManager: return try: - connector_tools = await self._connector_registry.get_tools_async() + connector_tools = await self._connector_registry.get_tools_async( + force_refresh=force_refresh + ) except Exception as exc: logger.warning(f"Connector integration failed: {exc}") with self._lock: @@ -366,28 +388,37 @@ class ToolManager: async def refresh_remote_tools_async(self) -> None: """Force MCP and connector re-discovery for the current config.""" with self._lock: - self._mcp_registry.clear() + if self._mcp_registry is not None: + self._mcp_registry.clear() self._purge_mcp_state() self._mcp_integrated = False self._purge_connector_state() if self._connector_registry is not None: self._connector_registry.clear() - await self._integrate_all_async() + await self._integrate_all_async(force_refresh=True) def refresh_remote_tools(self) -> None: """Sync wrapper for :meth:`refresh_remote_tools_async`.""" run_sync(self.refresh_remote_tools_async()) - def integrate_all(self, *, raise_on_mcp_failure: bool = False) -> None: + def integrate_all( + self, *, raise_on_mcp_failure: bool = False, force_refresh: bool = False + ) -> None: """Discover MCP and connector tools in parallel. Runs both async discovery paths concurrently via ``asyncio.gather`` inside a single ``run_sync`` call. """ - run_sync(self._integrate_all_async(raise_on_mcp_failure=raise_on_mcp_failure)) + run_sync( + self._integrate_all_async( + raise_on_mcp_failure=raise_on_mcp_failure, force_refresh=force_refresh + ) + ) - async def _integrate_all_async(self, *, raise_on_mcp_failure: bool = False) -> None: + async def _integrate_all_async( + self, *, raise_on_mcp_failure: bool = False, force_refresh: bool = False + ) -> None: """Run MCP and connector discovery concurrently. Uses ``return_exceptions=True`` so that a failing MCP server does @@ -395,7 +426,7 @@ class ToolManager: """ mcp_result, connector_result = await asyncio.gather( self._integrate_mcp_async(raise_on_failure=raise_on_mcp_failure), - self.integrate_connectors_async(), + self.integrate_connectors_async(force_refresh=force_refresh), return_exceptions=True, ) diff --git a/vibe/core/tools/mcp/tools.py b/vibe/core/tools/mcp/tools.py index 1f6cbcb..c1dc2d6 100644 --- a/vibe/core/tools/mcp/tools.py +++ b/vibe/core/tools/mcp/tools.py @@ -12,7 +12,7 @@ import threading from typing import TYPE_CHECKING, Any, ClassVar, TextIO import httpx -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, field_validator from mcp import ClientSession from mcp.client.auth import OAuthFlowError @@ -27,7 +27,8 @@ from vibe.core.tools.base import ( ToolError, ) from vibe.core.tools.mcp_sampling import MCPSamplingHandler -from vibe.core.tools.ui import ToolResultDisplay, ToolUIData +from vibe.core.tools.remote import MCPTool, MCPToolResult, RemoteTool, _OpenArgs +from vibe.core.tools.ui import ToolResultDisplay from vibe.core.types import ToolStreamEvent from vibe.core.utils.http import build_ssl_context from vibe.core.utils.io import decode_safe @@ -71,76 +72,6 @@ async def _mcp_stderr_capture() -> AsyncGenerator[TextIO, None]: os.close(w) -class _OpenArgs(BaseModel): - model_config = ConfigDict(extra="allow") - - -class MCPToolResult(BaseModel): - ok: bool = True - server: str - tool: str - text: str | None = None - structured: dict[str, Any] | None = None - - -class MCPTool( - BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState], - ToolUIData[_OpenArgs, MCPToolResult], -): - _server_name: ClassVar[str] = "" - _remote_name: ClassVar[str] = "" - _is_connector: ClassVar[bool] = False - - @classmethod - def get_server_name(cls) -> str | None: - return cls._server_name or None - - @classmethod - def get_remote_name(cls) -> str: - return cls._remote_name or cls.get_name() - - @classmethod - def is_connector(cls) -> bool: - return cls._is_connector - - -class RemoteTool(BaseModel): - model_config = ConfigDict(from_attributes=True) - - name: str - description: str | None = None - input_schema: dict[str, Any] = Field( - default_factory=lambda: {"type": "object", "properties": {}}, - validation_alias="inputSchema", - ) - - @field_validator("name") - @classmethod - def _non_empty_name(cls, v: str) -> str: - if not isinstance(v, str) or not v.strip(): - raise ValueError("MCP tool missing valid 'name'") - return v - - @field_validator("input_schema", mode="before") - @classmethod - def _normalize_schema(cls, v: Any) -> dict[str, Any]: - if v is None: - return {"type": "object", "properties": {}} - if isinstance(v, dict): - return v - dump = getattr(v, "model_dump", None) - if callable(dump): - try: - v = dump() - except Exception: - raise ValueError( - "inputSchema must be a dict or have a valid model_dump method" - ) - if not isinstance(v, dict): - raise ValueError("inputSchema must be a dict") - return v - - @dataclass(frozen=True) class MCPHttpOAuthRuntime: lock: asyncio.Lock diff --git a/vibe/core/tools/remote.py b/vibe/core/tools/remote.py new file mode 100644 index 0000000..8801c23 --- /dev/null +++ b/vibe/core/tools/remote.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState +from vibe.core.tools.ui import ToolUIData + + +class _OpenArgs(BaseModel): + model_config = ConfigDict(extra="allow") + + +class MCPToolResult(BaseModel): + ok: bool = True + server: str + tool: str + text: str | None = None + structured: dict[str, Any] | None = None + + +class MCPTool( + BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState], + ToolUIData[_OpenArgs, MCPToolResult], +): + _server_name: ClassVar[str] = "" + _remote_name: ClassVar[str] = "" + _is_connector: ClassVar[bool] = False + + @classmethod + def get_server_name(cls) -> str | None: + return cls._server_name or None + + @classmethod + def get_remote_name(cls) -> str: + return cls._remote_name or cls.get_name() + + @classmethod + def is_connector(cls) -> bool: + return cls._is_connector + + +class RemoteTool(BaseModel): + model_config = ConfigDict(from_attributes=True) + + name: str + description: str | None = None + input_schema: dict[str, Any] = Field( + default_factory=lambda: {"type": "object", "properties": {}}, + validation_alias="inputSchema", + ) + + @field_validator("name") + @classmethod + def _non_empty_name(cls, v: str) -> str: + if not isinstance(v, str) or not v.strip(): + raise ValueError("MCP tool missing valid 'name'") + return v + + @field_validator("input_schema", mode="before") + @classmethod + def _normalize_schema(cls, v: Any) -> dict[str, Any]: + if v is None: + return {"type": "object", "properties": {}} + if isinstance(v, dict): + return v + dump = getattr(v, "model_dump", None) + if callable(dump): + try: + v = dump() + except Exception: + raise ValueError( + "inputSchema must be a dict or have a valid model_dump method" + ) + if not isinstance(v, dict): + raise ValueError("inputSchema must be a dict") + return v diff --git a/vibe/core/transcribe/__init__.py b/vibe/core/transcribe/__init__.py index 4d2548e..a9f0c4c 100644 --- a/vibe/core/transcribe/__init__.py +++ b/vibe/core/transcribe/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations -from vibe.core.transcribe.factory import make_transcribe_client -from vibe.core.transcribe.mistral_transcribe_client import MistralTranscribeClient +from typing import TYPE_CHECKING + from vibe.core.transcribe.transcribe_client_port import ( TranscribeClientPort, TranscribeDone, @@ -11,6 +11,19 @@ from vibe.core.transcribe.transcribe_client_port import ( TranscribeTextDelta, ) +if TYPE_CHECKING: + from vibe.core.config import TranscribeModelConfig, TranscribeProviderConfig + from vibe.core.transcribe.mistral_transcribe_client import MistralTranscribeClient + + +def make_transcribe_client( + provider: TranscribeProviderConfig, model: TranscribeModelConfig +) -> TranscribeClientPort: + from vibe.core.transcribe.factory import make_transcribe_client as factory + + return factory(provider, model) + + __all__ = [ "MistralTranscribeClient", "TranscribeClientPort", @@ -21,3 +34,13 @@ __all__ = [ "TranscribeTextDelta", "make_transcribe_client", ] + + +def __getattr__(name: str) -> object: + if name == "MistralTranscribeClient": + from vibe.core.transcribe.mistral_transcribe_client import ( + MistralTranscribeClient, + ) + + return MistralTranscribeClient + raise AttributeError(name) diff --git a/vibe/core/tts/__init__.py b/vibe/core/tts/__init__.py index 6f0b4a4..8f0d8e0 100644 --- a/vibe/core/tts/__init__.py +++ b/vibe/core/tts/__init__.py @@ -1,7 +1,28 @@ from __future__ import annotations -from vibe.core.tts.factory import make_tts_client -from vibe.core.tts.mistral_tts_client import MistralTTSClient +from typing import TYPE_CHECKING + from vibe.core.tts.tts_client_port import TTSClientPort, TTSResult +if TYPE_CHECKING: + from vibe.core.config import TTSModelConfig, TTSProviderConfig + from vibe.core.tts.mistral_tts_client import MistralTTSClient + + +def make_tts_client( + provider: TTSProviderConfig, model: TTSModelConfig +) -> TTSClientPort: + from vibe.core.tts.factory import make_tts_client as factory + + return factory(provider, model) + + __all__ = ["MistralTTSClient", "TTSClientPort", "TTSResult", "make_tts_client"] + + +def __getattr__(name: str) -> object: + if name == "MistralTTSClient": + from vibe.core.tts.mistral_tts_client import MistralTTSClient + + return MistralTTSClient + raise AttributeError(name) diff --git a/vibe/core/utils/keyring.py b/vibe/core/utils/keyring.py new file mode 100644 index 0000000..835228f --- /dev/null +++ b/vibe/core/utils/keyring.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import os +import shlex +import subprocess +import sys +from threading import Lock + +import keyring +from keyring.errors import KeyringError, PasswordDeleteError + +_KEYRING_SERVICE = "ai.mistral.vibe" +_LEGACY_KEYRING_SERVICES = ("vibe",) +_DISABLE_KEYRING_ENV_VAR = "VIBE_TEST_DISABLE_KEYRING" +_cache_lock = Lock() +_api_key_cache: dict[str, str | None] = {} +_SECURITY_NOT_FOUND = "could not be found" + + +class _PasswordNotFoundError(KeyringError): + pass + + +def _is_keyring_disabled() -> bool: + return os.environ.get(_DISABLE_KEYRING_ENV_VAR) == "1" + + +def _should_use_macos_security() -> bool: + return sys.platform == "darwin" + + +def _run_security( + args: list[str], *, input_text: str | None = None +) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + ["/usr/bin/security", *args], + input=input_text, + text=True, + capture_output=True, + check=True, + ) + except FileNotFoundError as exc: + raise KeyringError("Can't run macOS Keychain security command") from exc + + +def _is_security_not_found(exc: subprocess.CalledProcessError) -> bool: + output = f"{exc.stdout or ''}\n{exc.stderr or ''}".lower() + return _SECURITY_NOT_FOUND in output + + +def _delete_macos_password(service: str, username: str) -> None: + try: + _run_security(["delete-generic-password", "-s", service, "-a", username]) + except subprocess.CalledProcessError as exc: + if _is_security_not_found(exc): + raise PasswordDeleteError() from exc + raise KeyringError("Can't delete password in macOS Keychain") from exc + + +def _set_password(service: str, username: str, password: str) -> None: + if not _should_use_macos_security(): + keyring.set_password(service, username, password) + return + + try: + _delete_macos_password(service, username) + except PasswordDeleteError: + pass + command = shlex.join([ + "add-generic-password", + "-s", + service, + "-a", + username, + "-w", + password, + "-A", + ]) + try: + _run_security(["-i"], input_text=f"{command}\n") + except subprocess.CalledProcessError as exc: + raise KeyringError("Can't store password in macOS Keychain") from exc + + +def _get_password(service: str, username: str) -> str | None: + if _should_use_macos_security(): + try: + result = _run_security([ + "find-generic-password", + "-s", + service, + "-a", + username, + "-w", + ]) + except subprocess.CalledProcessError as exc: + if _is_security_not_found(exc): + raise _PasswordNotFoundError() from exc + raise KeyringError("Can't get password from macOS Keychain") from exc + return result.stdout.removesuffix("\n") + + return keyring.get_password(service, username) + + +def _delete_password(service: str, username: str) -> None: + if _should_use_macos_security(): + _delete_macos_password(service, username) + return + + keyring.delete_password(service, username) + + +def _migrate_legacy_password(username: str, password: str, legacy_service: str) -> None: + try: + _set_password(_KEYRING_SERVICE, username, password) + except KeyringError: + return + try: + _delete_password(legacy_service, username) + except KeyringError: + pass + + +def _delete_legacy_passwords(username: str) -> None: + for legacy_service in _LEGACY_KEYRING_SERVICES: + try: + _delete_password(legacy_service, username) + except KeyringError: + pass + + +def _get_uncached_password(username: str) -> str | None: + for service in (_KEYRING_SERVICE, *_LEGACY_KEYRING_SERVICES): + try: + api_key = _get_password(service, username) + except _PasswordNotFoundError: + continue + if api_key is None: + continue + if service != _KEYRING_SERVICE: + _migrate_legacy_password(username, api_key, service) + return api_key + return None + + +def get_api_key_from_keyring(env_key: str) -> str | None: + if not env_key: + return None + if _is_keyring_disabled(): + return None + + with _cache_lock: + if env_key in _api_key_cache: + return _api_key_cache[env_key] + + try: + api_key = _get_uncached_password(env_key) + except KeyringError: + return None + + with _cache_lock: + return _api_key_cache.setdefault(env_key, api_key) + + +def set_api_key_in_keyring(env_key: str, api_key: str) -> None: + if _is_keyring_disabled(): + forget_api_key_in_keyring_cache(env_key) + raise KeyringError("keyring disabled") + + try: + _set_password(_KEYRING_SERVICE, env_key, api_key) + except KeyringError: + # Write failed: the keyring has no new value, so drop any stale cache entry. + forget_api_key_in_keyring_cache(env_key) + raise + _delete_legacy_passwords(env_key) + remember_api_key_in_keyring_cache(env_key, api_key) + + +def delete_api_key_from_keyring(env_key: str) -> None: + if _is_keyring_disabled(): + forget_api_key_in_keyring_cache(env_key) + return + + missing_error: PasswordDeleteError | None = None + operation_error: KeyringError | None = None + delete_succeeded = False + try: + for service in (_KEYRING_SERVICE, *_LEGACY_KEYRING_SERVICES): + try: + _delete_password(service, env_key) + delete_succeeded = True + except PasswordDeleteError as exc: + missing_error = missing_error or exc + except KeyringError as exc: + operation_error = operation_error or exc + if operation_error is not None: + raise operation_error + if not delete_succeeded and missing_error is not None: + raise missing_error + finally: + forget_api_key_in_keyring_cache(env_key) + + +def remember_api_key_in_keyring_cache(env_key: str, api_key: str) -> None: + if not env_key: + return + with _cache_lock: + _api_key_cache[env_key] = api_key + + +def forget_api_key_in_keyring_cache(env_key: str) -> None: + if not env_key: + return + with _cache_lock: + _api_key_cache.pop(env_key, None) + + +def clear_api_key_keyring_cache() -> None: + with _cache_lock: + _api_key_cache.clear() diff --git a/vibe/setup/auth/api_key_persistence.py b/vibe/setup/auth/api_key_persistence.py index 74f6efe..971adac 100644 --- a/vibe/setup/auth/api_key_persistence.py +++ b/vibe/setup/auth/api_key_persistence.py @@ -3,17 +3,15 @@ from __future__ import annotations import os from dotenv import set_key, unset_key -import keyring from keyring.errors import KeyringError, NoKeyringError, PasswordDeleteError from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig from vibe.core.logger import logger from vibe.core.paths import GLOBAL_ENV_FILE - -_KEYRING_SERVICE = "vibe" from vibe.core.telemetry.send import TelemetryClient from vibe.core.telemetry.types import EntrypointMetadata from vibe.core.types import Backend +from vibe.core.utils.keyring import delete_api_key_from_keyring, set_api_key_in_keyring def _save_api_key_to_env_file(env_key: str, api_key: str) -> None: @@ -60,7 +58,7 @@ def persist_api_key( except ValueError: return f"env_var_error:{env_key}" try: - keyring.set_password(_KEYRING_SERVICE, env_key, api_key) + set_api_key_in_keyring(env_key, api_key) except KeyringError: try: _save_api_key_to_env_file(env_key, api_key) @@ -93,7 +91,7 @@ def remove_api_key(provider: ProviderConfig) -> None: keyring_error: KeyringError | None = None try: - keyring.delete_password(_KEYRING_SERVICE, env_key) + delete_api_key_from_keyring(env_key) except (NoKeyringError, PasswordDeleteError): # No keyring backend, or nothing stored to remove: both are no-ops for sign-out. pass diff --git a/vibe/setup/auth/auth_state.py b/vibe/setup/auth/auth_state.py index 3f82bf9..e247cfb 100644 --- a/vibe/setup/auth/auth_state.py +++ b/vibe/setup/auth/auth_state.py @@ -7,13 +7,10 @@ import os from pathlib import Path from dotenv import dotenv_values -import keyring -from keyring.errors import KeyringError from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig from vibe.core.paths import GLOBAL_ENV_FILE - -_KEYRING_SERVICE = "vibe" +from vibe.core.utils.keyring import get_api_key_from_keyring class AuthStateKind(StrEnum): @@ -84,10 +81,7 @@ def _capture_auth_env_snapshot( ) -> _AuthEnvSnapshot: resolved_env_path = env_path if env_path is not None else GLOBAL_ENV_FILE.path resolved_environ = environ if environ is not None else os.environ - try: - keyring_has_value = _has_value(keyring.get_password(_KEYRING_SERVICE, env_key)) - except KeyringError: - keyring_has_value = False + keyring_has_value = _has_value(get_api_key_from_keyring(env_key)) return _AuthEnvSnapshot( env_key=env_key, diff --git a/vibe/whats_new.md b/vibe/whats_new.md index b5f494a..e1b361f 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,3 +1,7 @@ +# What's new in v2.18.0 + +- **Clipboard image paste**: Paste images directly from clipboard in the TUI on macOS + # What's new in v2.17.0 - **MCP OAuth login**: Authenticate OAuth-backed MCP servers from the TUI with the new `/mcp login`, `/mcp logout`, and `/mcp status` commands