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.png, b.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 @@
-
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 @@
-
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