Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Cyprien <courtot.c@gmail.com>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com>
Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: josephine-delas <57808586+josephine-delas@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Laure Hugo 2026-06-25 16:08:45 +02:00 committed by GitHub
parent 725d3a56ce
commit e607ccbb00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
242 changed files with 7372 additions and 1974 deletions

View file

@ -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}`);

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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",

View file

@ -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),

View file

@ -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,

View file

@ -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"

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -42,6 +42,7 @@ def acp_agent_with_session_config(
),
],
session_logging=session_config,
include_project_context=True,
)
class PatchedAgentLoop(AgentLoop):

View file

@ -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"

View file

@ -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

View file

@ -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)

View file

@ -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"]

View file

@ -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
)

View file

@ -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"])

View file

@ -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": []})
)

View file

@ -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
)

View file

@ -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
)

View file

@ -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)

View file

@ -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")]

View file

@ -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:

View file

@ -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

View file

@ -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],
)

View file

@ -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,
)

View file

@ -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"]

View file

@ -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"

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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 [],

View file

@ -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}}
)

View file

@ -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(

View file

@ -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)

View file

@ -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

View file

@ -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",
)

View file

@ -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:

View file

@ -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,
)

View file

@ -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"):

View file

@ -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,
)

View file

@ -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)

View file

@ -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")

View file

@ -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

View file

@ -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)

View file

@ -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"

View file

@ -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]],

View file

@ -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

View file

@ -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%)"

View file

@ -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

View file

@ -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: @")

View file

@ -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)

View file

@ -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

View file

@ -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:

View file

@ -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,

View file

@ -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"

View file

@ -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))

View file

@ -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,
)

View file

@ -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)

View file

@ -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"]

View file

@ -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]

View file

@ -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"

614
tests/core/test_keyring.py Normal file
View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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 == ""

View file

@ -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

View file

@ -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 == {}

View file

@ -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

View file

@ -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)

View file

@ -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),

View file

@ -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)

View file

@ -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:

View file

@ -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(

View file

@ -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

View file

@ -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 {

View file

@ -130,7 +130,7 @@
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="0" y="459.2" textLength="1464" clip-path="url(#terminal-line-18)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r4" x="0" y="483.6" textLength="158.6" clip-path="url(#terminal-line-19)">/test/workdir</text><text class="terminal-r4" x="1256.6" y="483.6" textLength="207.4" clip-path="url(#terminal-line-19)">0%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r4" x="0" y="483.6" textLength="158.6" clip-path="url(#terminal-line-19)">/test/workdir</text><text class="terminal-r4" x="1244.4" y="483.6" textLength="219.6" clip-path="url(#terminal-line-19)">0/200k&#160;tokens&#160;(0%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Before After
Before After

View file

@ -170,7 +170,7 @@
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="0" y="703.2" textLength="1464" clip-path="url(#terminal-line-28)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r4" x="0" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r4" x="1256.6" y="727.6" textLength="207.4" clip-path="url(#terminal-line-29)">0%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r4" x="0" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r4" x="1244.4" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">0/200k&#160;tokens&#160;(0%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

@ -195,7 +195,7 @@
</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">6%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1220" y="874" textLength="244" clip-path="url(#terminal-line-35)">12k/200k&#160;tokens&#160;(6%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

@ -194,7 +194,7 @@
</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1244.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k&#160;tokens&#160;(0%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

@ -201,7 +201,7 @@
</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r11" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r11" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r11" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r11" x="1244.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k&#160;tokens&#160;(0%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -194,7 +194,7 @@
</text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r1" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1000.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k&#160;tokens&#160;(0%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

@ -198,7 +198,7 @@
</text><text class="terminal-r1" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r1" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r8" x="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓&#160;Navigate&#160;&#160;Enter&#160;Select/Toggle&#160;&#160;Esc&#160;Exit</text><text class="terminal-r1" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r1" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r8" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r8" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r8" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r8" x="1000.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k&#160;tokens&#160;(0%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

@ -198,7 +198,7 @@
</text><text class="terminal-r1" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r1" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r8" x="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓&#160;Navigate&#160;&#160;Enter&#160;Select/Toggle&#160;&#160;Esc&#160;Exit</text><text class="terminal-r1" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r1" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r8" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r8" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r8" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r8" x="1000.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k&#160;tokens&#160;(0%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

@ -198,7 +198,7 @@
</text><text class="terminal-r1" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r1" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r8" x="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓&#160;Navigate&#160;&#160;Enter&#160;Select/Toggle&#160;&#160;Esc&#160;Exit</text><text class="terminal-r1" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r1" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r8" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r8" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r8" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r8" x="1000.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k&#160;tokens&#160;(0%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

@ -195,7 +195,7 @@
</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1244.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k&#160;tokens&#160;(0%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -195,7 +195,7 @@
</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1244.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k&#160;tokens&#160;(0%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -196,7 +196,7 @@
</text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r1" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r6" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r6" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r6" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r6" x="1000.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k&#160;tokens&#160;(0%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -194,7 +194,7 @@
</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1244.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k&#160;tokens&#160;(0%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

@ -197,7 +197,7 @@
</text><text class="terminal-r1" x="24.4" y="800.8" textLength="866.2" clip-path="url(#terminal-line-32)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r1" x="24.4" y="825.2" textLength="866.2" clip-path="url(#terminal-line-33)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r1" x="0" y="849.6" textLength="890.6" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────│</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r2" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r2" x="671" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text><text class="terminal-r1" x="878.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"></text>
</text><text class="terminal-r2" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r2" x="658.8" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k&#160;tokens&#160;(0%)</text><text class="terminal-r1" x="878.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"></text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before After
Before After

View file

@ -196,7 +196,7 @@
</text><text class="terminal-r1" x="24.4" y="800.8" textLength="866.2" clip-path="url(#terminal-line-32)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r1" x="24.4" y="825.2" textLength="866.2" clip-path="url(#terminal-line-33)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r1" x="0" y="849.6" textLength="890.6" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────│</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r2" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r2" x="671" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text><text class="terminal-r1" x="878.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"></text>
</text><text class="terminal-r2" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r2" x="658.8" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k&#160;tokens&#160;(0%)</text><text class="terminal-r1" x="878.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"></text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before After
Before After

View file

@ -216,7 +216,7 @@
</text><text class="terminal-r1" x="0" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)"></text><text class="terminal-r1" x="1451.8" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)"></text><text class="terminal-r1" x="1464" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)">
</text><text class="terminal-r1" x="0" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)"></text><text class="terminal-r10" x="24.4" y="922.8" textLength="451.4" clip-path="url(#terminal-line-37)">↑↓&#160;navigate&#160;&#160;Enter&#160;select&#160;&#160;ESC&#160;reject</text><text class="terminal-r1" x="1451.8" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)"></text><text class="terminal-r1" x="1464" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)">
</text><text class="terminal-r1" x="0" y="947.2" textLength="1464" clip-path="url(#terminal-line-38)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)">
</text><text class="terminal-r10" x="0" y="971.6" textLength="158.6" clip-path="url(#terminal-line-39)">/test/workdir</text><text class="terminal-r10" x="1256.6" y="971.6" textLength="207.4" clip-path="url(#terminal-line-39)">0%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r10" x="0" y="971.6" textLength="158.6" clip-path="url(#terminal-line-39)">/test/workdir</text><text class="terminal-r10" x="1244.4" y="971.6" textLength="219.6" clip-path="url(#terminal-line-39)">0/200k&#160;tokens&#160;(0%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Before After
Before After

View file

@ -175,7 +175,7 @@
</text><text class="terminal-r1" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r9" x="24.4" y="678.8" textLength="451.4" clip-path="url(#terminal-line-27)">↑↓&#160;navigate&#160;&#160;Enter&#160;select&#160;&#160;ESC&#160;reject</text><text class="terminal-r1" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="0" y="703.2" textLength="1220" clip-path="url(#terminal-line-28)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r9" x="0" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r9" x="1012.6" y="727.6" textLength="207.4" clip-path="url(#terminal-line-29)">0%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r9" x="0" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r9" x="1000.4" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">0/200k&#160;tokens&#160;(0%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

View file

@ -174,7 +174,7 @@
</text><text class="terminal-r1" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r5" x="24.4" y="678.8" textLength="451.4" clip-path="url(#terminal-line-27)">↑↓&#160;navigate&#160;&#160;Enter&#160;select&#160;&#160;ESC&#160;reject</text><text class="terminal-r1" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="0" y="703.2" textLength="1220" clip-path="url(#terminal-line-28)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r5" x="0" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r5" x="1012.6" y="727.6" textLength="207.4" clip-path="url(#terminal-line-29)">0%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r5" x="0" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r5" x="1000.4" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">0/200k&#160;tokens&#160;(0%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before After
Before After

View file

@ -177,7 +177,7 @@
</text><text class="terminal-r1" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r5" x="24.4" y="678.8" textLength="451.4" clip-path="url(#terminal-line-27)">↑↓&#160;navigate&#160;&#160;Enter&#160;select&#160;&#160;ESC&#160;reject</text><text class="terminal-r1" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="0" y="703.2" textLength="1220" clip-path="url(#terminal-line-28)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r5" x="0" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r5" x="1012.6" y="727.6" textLength="207.4" clip-path="url(#terminal-line-29)">0%&#160;of&#160;200k&#160;tokens</text>
</text><text class="terminal-r5" x="0" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r5" x="1000.4" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">0/200k&#160;tokens&#160;(0%)</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

Some files were not shown because too many files have changed in this diff Show more