Co-authored-by: Charles-Edouard Cady <charles.edouard.cady@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Sylvain Afchain <safchain@gmail.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-06-29 17:14:25 +02:00 committed by GitHub
parent e607ccbb00
commit ed5b7192e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1456 additions and 125 deletions

View file

@ -72,7 +72,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.0"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.1"
)
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.18.0"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.1"
)
assert response.auth_methods is not None

View file

@ -0,0 +1,26 @@
from __future__ import annotations
import pytest
from vibe.cli.commands import CommandRegistry
from vibe.cli.textual_ui.widgets.chat_input.input_kinds import (
Prompt,
SlashCommand,
classify,
)
def _classify(value: str) -> object:
return classify(value, commands=CommandRegistry(), expand_skill=lambda _value: None)
@pytest.mark.parametrize("alias", ["/exit", "exit", "quit", ":q", ":quit"])
def test_classify_treats_bare_exit_synonyms_as_slash_command(alias: str) -> None:
assert isinstance(_classify(alias), SlashCommand)
@pytest.mark.parametrize("value", ["exit the function early", "quit your job"])
def test_classify_keeps_bare_synonym_with_trailing_text_as_prompt(value: str) -> None:
result = _classify(value)
assert isinstance(result, Prompt)
assert result.text == value

View file

@ -0,0 +1,212 @@
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from tests.conftest import build_test_vibe_app, build_test_vibe_config
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.mcp_commands import parse_mcp_add_args, parse_mcp_subcommand
from vibe.cli.textual_ui.widgets.messages import ErrorMessage, UserCommandMessage
from vibe.core.config import MCPHttp, MCPOAuth, MCPStreamableHttp, VibeConfig
def _capture_mounted_widgets(
app: VibeApp, monkeypatch: pytest.MonkeyPatch
) -> list[object]:
mounted_widgets: list[object] = []
async def mount_and_scroll(widget: object, after: object | None = None) -> None:
mounted_widgets.append(widget)
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
return mounted_widgets
def test_parse_mcp_add_args_accepts_url_and_options() -> None:
args = parse_mcp_add_args(
"https://mcp.example.com/mcp --name docs --scope read --scope write "
"--transport http --no-login"
)
assert args.url == "https://mcp.example.com/mcp"
assert args.name == "docs"
assert args.scopes == ["read", "write"]
assert args.transport == "http"
assert args.login is False
def test_parse_mcp_add_args_defaults_to_login() -> None:
args = parse_mcp_add_args("https://mcp.example.com/mcp")
assert args.transport == "streamable-http"
assert args.login is True
@pytest.mark.parametrize(
"raw_args",
[
"",
"https://mcp.example.com/mcp extra",
"https://mcp.example.com/mcp --unknown",
"https://mcp.example.com/mcp --name",
"https://mcp.example.com/mcp --name a --name b",
"https://mcp.example.com/mcp --scope",
"https://mcp.example.com/mcp --login",
"https://mcp.example.com/mcp --transport",
"https://mcp.example.com/mcp --transport sse",
"https://mcp.example.com/mcp --transport http --transport streamable-http",
"'unterminated",
],
)
def test_parse_mcp_add_args_rejects_invalid_args(raw_args: str) -> None:
with pytest.raises(ValueError):
parse_mcp_add_args(raw_args)
def test_parse_mcp_subcommand_recognizes_supported_subcommands() -> None:
parsed = parse_mcp_subcommand("add https://mcp.linear.app/mcp --no-login")
assert parsed is not None
assert parsed.name == "add"
assert parsed.args == "https://mcp.linear.app/mcp --no-login"
def test_parse_mcp_subcommand_ignores_unknown_subcommands() -> None:
assert parse_mcp_subcommand("tools linear") is None
@pytest.mark.asyncio
async def test_mcp_add_saves_oauth_server_and_prints_next_steps(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
await app._mcp_add("https://mcp.linear.app/mcp --no-login")
server = VibeConfig.load().mcp_servers[0]
assert isinstance(server, MCPStreamableHttp)
assert server.name == "linear"
assert isinstance(server.auth, MCPOAuth)
assert server.auth.scopes == []
assert any(
isinstance(widget, UserCommandMessage)
and "Added OAuth MCP server `linear`." in widget._content
and "/mcp login linear" in widget._content
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_mcp_add_saves_name_and_scopes(monkeypatch: pytest.MonkeyPatch) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
await app._mcp_add(
"https://mcp.example.com/mcp --name docs --scope read --scope write --no-login"
)
server = VibeConfig.load().mcp_servers[0]
assert isinstance(server, MCPStreamableHttp)
assert server.name == "docs"
assert isinstance(server.auth, MCPOAuth)
assert server.auth.scopes == ["read", "write"]
assert any(
isinstance(widget, UserCommandMessage)
and "Added OAuth MCP server `docs`." in widget._content
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_mcp_add_saves_http_transport(monkeypatch: pytest.MonkeyPatch) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
_capture_mounted_widgets(app, monkeypatch)
await app._mcp_add("https://mcp.example.com/mcp --transport http --no-login")
server = VibeConfig.load().mcp_servers[0]
assert isinstance(server, MCPHttp)
assert server.transport == "http"
assert isinstance(server.auth, MCPOAuth)
@pytest.mark.asyncio
async def test_mcp_add_delegates_to_login_by_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
login = AsyncMock()
monkeypatch.setattr(app, "_mcp_login", login)
await app._mcp_add("https://mcp.linear.app/mcp")
login.assert_awaited_once_with("linear")
assert any(
isinstance(widget, UserCommandMessage)
and "Starting OAuth login..." in widget._content
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_mcp_add_no_login_skips_login(monkeypatch: pytest.MonkeyPatch) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
_capture_mounted_widgets(app, monkeypatch)
login = AsyncMock()
monkeypatch.setattr(app, "_mcp_login", login)
await app._mcp_add("https://mcp.linear.app/mcp --no-login")
login.assert_not_awaited()
@pytest.mark.asyncio
async def test_mcp_add_mounts_parser_errors(monkeypatch: pytest.MonkeyPatch) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
await app._mcp_add("https://mcp.example.com/mcp --unknown")
assert any(
isinstance(widget, ErrorMessage)
and widget._error == "Unknown /mcp add option: --unknown"
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_mcp_add_help_states_oauth_only(monkeypatch: pytest.MonkeyPatch) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
await app._mcp_add("--help")
assert any(
isinstance(widget, UserCommandMessage)
and "Usage: /mcp add <url>" in widget._content
and "--transport <http|streamable-http>" in widget._content
and "OAuth-only shortcut" in widget._content
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_mcp_subcommand_handler_recognizes_add(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
handled = await app._maybe_handle_mcp_subcommand(
"add https://mcp.linear.app/mcp --no-login"
)
assert handled is True
assert any(
isinstance(widget, UserCommandMessage)
and "Added OAuth MCP server `linear`." in widget._content
for widget in mounted_widgets
)

View file

@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Any
import pytest
from tests.conftest import build_test_vibe_app
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.messages import SlashCommandMessage, UserMessage
@pytest.mark.parametrize("alias", ["/exit", "exit", "quit", ":q", ":quit"])
@pytest.mark.asyncio
async def test_exit_synonym_runs_exit_handler_and_is_not_sent_as_prompt(
alias: str, monkeypatch: pytest.MonkeyPatch
) -> None:
vibe_app = build_test_vibe_app()
async with vibe_app.run_test() as pilot:
await pilot.pause(0.1)
calls: list[str] = []
async def _record_exit(**_kwargs: Any) -> None:
calls.append(alias)
monkeypatch.setattr(vibe_app, "_exit_app", _record_exit)
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.post_message(ChatInputContainer.Submitted(alias))
await pilot.pause(0.2)
assert calls == [alias]
prompts = [
m
for m in vibe_app.query(UserMessage)
if not isinstance(m, SlashCommandMessage)
]
assert prompts == []

View file

@ -1,10 +1,13 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
import signal
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from textual.app import WINDOWS
from tests.conftest import build_test_vibe_app
from vibe.cli.textual_ui.app import VibeApp, _run_app_with_cleanup
@ -249,3 +252,35 @@ async def test_run_app_with_cleanup_runs_cleanup_when_run_async_raises(
await _run_app_with_cleanup(app)
cleanup.assert_awaited_once()
def test_force_quit_delegates_to_private(app: VibeApp) -> None:
with patch.object(app, "_force_quit") as private:
app._force_quit()
private.assert_called_once_with()
@pytest.mark.skipif(WINDOWS, reason="SIGTERM handlers are not installed on Windows")
@pytest.mark.asyncio
async def test_run_app_with_cleanup_sigterm_triggers_force_quit(app: VibeApp) -> None:
captured: list[Callable[[], None]] = []
loop = asyncio.get_running_loop()
def capture_add(sig: int, callback: Callable[[], None], *args: object) -> None:
captured.append(callback)
async def fake_run_async() -> None:
captured[0]()
with (
patch.object(loop, "add_signal_handler", side_effect=capture_add),
patch.object(loop, "remove_signal_handler") as remove_handler,
patch.object(app, "run_async", side_effect=fake_run_async),
patch.object(app, "shutdown_cleanup", new_callable=AsyncMock),
patch.object(app, "_force_quit") as force_quit,
):
await _run_app_with_cleanup(app)
force_quit.assert_called_once_with()
remove_handler.assert_any_call(signal.SIGTERM)

View file

@ -18,8 +18,14 @@ from vibe.core.config.layer import (
RawConfig,
)
from vibe.core.config.layers.environment import EnvironmentLayer
from vibe.core.config.layers.overrides import OverridesLayer
from vibe.core.config.layers.project import ProjectConfigLayer
from vibe.core.config.layers.user import UserConfigLayer
from vibe.core.config.orchestrator import ConfigOrchestrator, ConfigPatchValidationError
from vibe.core.config.orchestrator import (
ConfigOrchestrator,
ConfigPatchValidationError,
DefaultLayerResolutionError,
)
from vibe.core.config.patch import (
AddOperationPatch,
RemoveOperationPatch,
@ -36,6 +42,7 @@ from vibe.core.config.types import (
ConfigChangeEvent,
LayerConfigSnapshot,
)
from vibe.core.trusted_folders import trusted_folders_manager
class FakeLayer(ConfigLayer[RawConfig]):
@ -143,6 +150,12 @@ class RoutingSchema(ConfigSchema):
default_agent: Annotated[str, WithReplaceMerge()] = "default-agent"
class CliRoutingSchema(ConfigSchema):
active_model: Annotated[str, WithReplaceMerge()] = "default-model"
default_agent: Annotated[str, WithReplaceMerge()] = "default-agent"
enabled_tools: Annotated[list[str], WithConcatMerge()] = Field(default_factory=list)
class RequiredPairSchema(ConfigSchema):
first: Annotated[str, WithReplaceMerge()]
second: Annotated[str, WithReplaceMerge()]
@ -157,23 +170,33 @@ def assert_single_failure[E: BaseException](
return failure
def unused_default_layer() -> ConfigLayer[RawConfig]:
return FakeLayer(name="unused-default", data={})
@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])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
assert orch.config.model_dump() == {"value": "hello"}
@pytest.mark.asyncio
async def test_get_layer_returns_named_layer() -> None:
layer = FakeLayer(name="my-layer", data={})
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
assert orch.get_layer("my-layer") is layer
@pytest.mark.asyncio
async def test_get_layer_unknown_raises() -> None:
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[], default_layer_resolver=unused_default_layer
)
with pytest.raises(KeyError, match="No layer named 'unknown'"):
orch.get_layer("unknown")
@ -181,7 +204,9 @@ async def test_get_layer_unknown_raises() -> None:
@pytest.mark.asyncio
async def test_reload_picks_up_changes() -> None:
layer = FakeLayer(name="mutable", data={"value": "original"})
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
assert orch.config.value == "original"
layer._data = {"value": "updated"}
@ -192,21 +217,27 @@ async def test_reload_picks_up_changes() -> None:
@pytest.mark.asyncio
async def test_config_is_immutable() -> None:
layer = FakeLayer(name="test", data={"value": "hello"})
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
with pytest.raises(ValidationError, match="frozen"):
orch.config.value = "changed" # type: ignore[misc]
@pytest.mark.asyncio
async def test_origin_of_missing_key_returns_none() -> None:
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[], default_layer_resolver=unused_default_layer
)
assert orch.config.origin_of("nonexistent") is None
@pytest.mark.asyncio
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])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
result = await orch.apply_patch([], reason="no-op")
@ -216,7 +247,9 @@ async def test_apply_patch_empty_operations_is_noop() -> None:
@pytest.mark.asyncio
async def test_apply_patch_rejects_invalid_schema_result_before_routing() -> None:
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[], default_layer_resolver=unused_default_layer
)
with pytest.raises(ConfigPatchValidationError) as exc_info:
await orch.apply_patch(
[ReplaceOperationPatch(path="/value", value={"invalid": "shape"})],
@ -229,25 +262,12 @@ async def test_apply_patch_rejects_invalid_schema_result_before_routing() -> Non
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])
layer = NormalizingWritableLayer(name="user-toml", data={})
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
result = await orch.apply_patch(
[
@ -265,11 +285,11 @@ async def test_apply_patch_unknown_explicit_target_returns_failure() -> None:
@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])
async def test_apply_patch_uses_default_layer_resolver_and_reloads_config() -> None:
layer = NormalizingWritableLayer(name="user-toml", data={})
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
result = await orch.apply_patch(
[AddOperationPatch(path="/value", value="updated")], reason="test update"
@ -280,6 +300,19 @@ async def test_apply_patch_falls_back_to_default_user_layer_and_reloads_config()
assert layer._data == {"value": "updated-normalized"}
@pytest.mark.asyncio
async def test_set_field_uses_default_layer_resolver() -> None:
layer = NormalizingWritableLayer(name="user-toml", data={})
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
result = await orch.set_field("/value", "updated", reason="set field")
assert result == []
assert orch.config.value == "updated-normalized"
@pytest.mark.asyncio
async def test_apply_patch_returns_layer_save_error_after_other_layer_commits() -> None:
first_layer = FieldWritableLayer(
@ -287,7 +320,9 @@ async def test_apply_patch_returns_layer_save_error_after_other_layer_commits()
)
second_layer = FailingSaveLayer(name="second-layer", data={"second": "two"})
orch = await ConfigOrchestrator.create(
schema=MultiValueSchema, layers=[first_layer, second_layer]
schema=MultiValueSchema,
layers=[first_layer, second_layer],
default_layer_resolver=lambda: first_layer,
)
result = await orch.apply_patch(
@ -325,7 +360,9 @@ 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])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
result = await orch.apply_patch(
[
@ -344,7 +381,9 @@ 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]
schema=SimpleSchema,
layers=[loaded_layer, target_layer],
default_layer_resolver=lambda: loaded_layer,
)
await target_layer.invalidate_cache()
@ -374,7 +413,9 @@ async def test_apply_patch_applies_layers_in_parallel() -> None:
barrier=barrier,
)
orch = await ConfigOrchestrator.create(
schema=MultiValueSchema, layers=[first_layer, second_layer]
schema=MultiValueSchema,
layers=[first_layer, second_layer],
default_layer_resolver=lambda: first_layer,
)
result = await orch.apply_patch(
@ -412,7 +453,11 @@ deprecated_setting = true
)
user_layer = UserConfigLayer(path=toml_path)
orch = await ConfigOrchestrator.create(schema=ToolSchema, layers=[user_layer])
orch = await ConfigOrchestrator.create(
schema=ToolSchema,
layers=[user_layer],
default_layer_resolver=lambda: user_layer,
)
result = await orch.apply_patch(
[
@ -437,22 +482,26 @@ deprecated_setting = true
@pytest.mark.asyncio
async def test_apply_patch_fallback_to_user_layer_fails_when_user_file_is_missing(
async def test_apply_patch_creates_user_file_when_it_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])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema,
layers=[user_layer],
default_layer_resolver=lambda: 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"
assert result == []
with toml_path.open("rb") as file:
assert tomllib.load(file) == {"value": "created-later"}
assert orch.config.value == "created-later"
@pytest.mark.asyncio
@ -466,7 +515,9 @@ async def test_apply_patch_end_to_end_falls_back_to_user_layer_when_no_target_is
user_layer = UserConfigLayer(path=toml_path)
environment_layer = EnvironmentLayer(schema=RoutingSchema)
orch = await ConfigOrchestrator.create(
schema=RoutingSchema, layers=[user_layer, environment_layer]
schema=RoutingSchema,
layers=[user_layer, environment_layer],
default_layer_resolver=lambda: user_layer,
)
result = await orch.apply_patch(
@ -498,7 +549,9 @@ async def test_apply_patch_end_to_end_respects_explicit_target_layer(
user_layer = UserConfigLayer(path=toml_path)
environment_layer = EnvironmentLayer(schema=RoutingSchema)
orch = await ConfigOrchestrator.create(
schema=RoutingSchema, layers=[user_layer, environment_layer]
schema=RoutingSchema,
layers=[user_layer, environment_layer],
default_layer_resolver=lambda: user_layer,
)
result = await orch.apply_patch(
@ -521,10 +574,117 @@ async def test_apply_patch_end_to_end_respects_explicit_target_layer(
assert orch.config.default_agent == "accept-edits"
@pytest.mark.asyncio
async def test_apply_patch_returns_failure_when_resolver_returns_unknown_layer() -> (
None
):
loaded_layer = RawWritableLayer(name="loaded", data={})
unknown_layer = RawWritableLayer(name="unknown", data={})
orch = await ConfigOrchestrator.create(
schema=SimpleSchema,
layers=[loaded_layer],
default_layer_resolver=lambda: unknown_layer,
)
with pytest.raises(DefaultLayerResolutionError, match="unknown layer 'unknown'"):
await orch.apply_patch(
[AddOperationPatch(path="/value", value="updated")],
reason="unknown resolver target",
)
assert orch.config.value == "default"
@pytest.mark.asyncio
async def test_apply_patch_explicit_target_does_not_resolve_default_layer() -> None:
layer = RawWritableLayer(name="target", data={"value": "original"})
orch = await ConfigOrchestrator.create(
schema=SimpleSchema,
layers=[layer],
default_layer_resolver=lambda: (_ for _ in ()).throw(
AssertionError("resolver should not be called")
),
)
result = await orch.apply_patch(
[
ReplaceOperationPatch(
path="/value", value="updated", target_layer_name="target"
)
],
reason="explicit target only",
)
assert result == []
assert orch.config.value == "updated"
@pytest.mark.asyncio
async def test_apply_patch_end_to_end_routes_default_writes_to_project_layer(
monkeypatch: pytest.MonkeyPatch, tmp_working_directory: Path
) -> None:
workspace = tmp_working_directory / "workspace"
workspace.mkdir()
project_config_path = workspace / ".vibe" / "config.toml"
project_config_path.parent.mkdir(parents=True, exist_ok=True)
project_config_path.write_text('default_agent = "plan"\n', encoding="utf-8")
user_config_path = tmp_working_directory / "user.toml"
user_config_path.write_text('default_agent = "accept-edits"\n', encoding="utf-8")
trusted_folders_manager.add_trusted(project_config_path.parent)
monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model")
user_layer = UserConfigLayer(path=user_config_path)
project_layer = ProjectConfigLayer(path=workspace)
environment_layer = EnvironmentLayer(schema=CliRoutingSchema)
overrides_layer = OverridesLayer(data={"enabled_tools": ["read"]})
def resolve_default_layer() -> ConfigLayer[RawConfig]:
if project_layer.is_file_discovered:
return project_layer
return user_layer
orch = await ConfigOrchestrator.create(
schema=CliRoutingSchema,
layers=[user_layer, project_layer, environment_layer, overrides_layer],
default_layer_resolver=resolve_default_layer,
)
assert project_layer.is_file_discovered is True
assert orch.config.active_model == "env-model"
assert orch.config.default_agent == "plan"
assert orch.config.enabled_tools == ["read"]
result = await orch.apply_patch(
[
AddOperationPatch(path="/active_model", value="persisted-in-project-file"),
ReplaceOperationPatch(path="/default_agent", value="auto-approve"),
],
reason="update runtime defaults",
)
failure = assert_single_failure(result, NotImplementedError)
assert str(failure) == "ProjectConfigLayer patch persistence is not implemented yet"
with project_config_path.open("rb") as file:
assert tomllib.load(file) == {"default_agent": "plan"}
with user_config_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 == "plan"
assert orch.config.enabled_tools == ["read"]
@pytest.mark.asyncio
async def test_subscribe_registers_on_the_bus() -> None:
bus = EventBus()
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[], bus=bus)
orch = await ConfigOrchestrator.create(
schema=SimpleSchema,
layers=[],
default_layer_resolver=unused_default_layer,
bus=bus,
)
received: list[ConfigChangeEvent] = []
orch.subscribe(received.append)

View file

@ -50,7 +50,9 @@ api_base = "https://api.mistral.ai/v1"
from vibe.core.config.orchestrator import ConfigOrchestrator
layer = UserConfigLayer(path=toml_path)
orchestrator = await ConfigOrchestrator.create(schema=MinimalSchema, layers=[layer])
orchestrator = await ConfigOrchestrator.create(
schema=MinimalSchema, layers=[layer], default_layer_resolver=lambda: layer
)
assert orchestrator.config.models.active_model == "mistral-large"
assert orchestrator.config.models.models == [

View file

@ -357,6 +357,35 @@ def test_delete_attempts_legacy_service_when_current_service_raises(
assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES]
def _raise_unloadable_backend(*_args: str) -> None:
# Mirrors keyring.get_keyring() failing because PYTHON_KEYRING_BACKEND points
# at a backend module absent from Vibe's venv (e.g. flyte._keyring.file).
raise ModuleNotFoundError("No module named 'flyte'")
def test_get_returns_none_when_backend_module_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(keyring, "get_password", _raise_unloadable_backend)
assert get_api_key_from_keyring("CUSTOM_API_KEY") is None
def test_set_converts_backend_import_error_to_keyring_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(keyring, "set_password", _raise_unloadable_backend)
with pytest.raises(KeyringError):
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
def test_delete_converts_backend_import_error_to_keyring_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(keyring, "delete_password", _raise_unloadable_backend)
with pytest.raises(KeyringError):
delete_api_key_from_keyring("CUSTOM_API_KEY")
def test_delete_prefers_operation_error_over_missing_entry(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View file

@ -190,6 +190,18 @@ class TestKeyringTokenStorage:
assert "api_key_env" in msg
assert exc_info.value.server_alias == "linear"
def test_unloadable_backend_raises_headless(self) -> None:
# PYTHON_KEYRING_BACKEND can point at a backend module that is absent from
# Vibe's isolated venv (e.g. flyte._keyring.file in Flyte/Slurm envs), so
# get_keyring() raises ModuleNotFoundError instead of returning a backend.
with patch(
"vibe.core.auth.mcp_oauth.keyring.get_keyring",
side_effect=ModuleNotFoundError("No module named 'flyte'"),
):
with pytest.raises(MCPOAuthHeadlessError) as exc_info:
KeyringTokenStorage(alias="notion")
assert exc_info.value.server_alias == "notion"
class TestFingerprint:
def test_compute_stable_across_scope_order(

View file

@ -0,0 +1,195 @@
from __future__ import annotations
from pathlib import Path
import tomllib
import pytest
from tests.conftest import build_test_vibe_config
from vibe.core.config import MCPHttp, MCPOAuth, MCPStreamableHttp, VibeConfig
from vibe.core.tools.mcp_settings import (
MCPServerAddError,
parse_mcp_add_transport,
persist_oauth_mcp_server,
)
def _persisted_servers(config_dir: Path) -> list[dict]:
with (config_dir / "config.toml").open("rb") as file:
return tomllib.load(file).get("mcp_servers", [])
@pytest.mark.parametrize(
("url", "expected_url"),
[
("https://mcp.example.com/mcp", "https://mcp.example.com/mcp"),
("http://localhost:8000/mcp", "http://localhost:8000/mcp"),
("http://127.0.0.1:8000/mcp", "http://127.0.0.1:8000/mcp"),
("https://example.com:443/mcp?tenant=a", "https://example.com/mcp?tenant=a"),
],
)
def test_add_oauth_mcp_server_accepts_supported_urls(
url: str, expected_url: str, config_dir: Path
) -> None:
result = persist_oauth_mcp_server(build_test_vibe_config(), url=url)
assert result.created is True
assert result.url == expected_url
assert _persisted_servers(config_dir)[0]["url"] == expected_url
@pytest.mark.parametrize(
"url",
[
"mcp.example.com/mcp",
"ftp://mcp.example.com/mcp",
"https:///mcp",
"http://mcp.example.com/mcp",
"https://mcp.example.com/mcp#section",
],
)
def test_add_oauth_mcp_server_rejects_invalid_urls(url: str) -> None:
with pytest.raises(MCPServerAddError):
persist_oauth_mcp_server(build_test_vibe_config(), url=url)
@pytest.mark.parametrize(
("url", "expected_name"),
[
("https://mcp.linear.app/mcp", "linear"),
("https://example.com/mcp", "example"),
("https://mcp.example.com/notion/mcp", "example"),
("https://mcp.localhost/notion/mcp", "notion"),
],
)
def test_add_oauth_mcp_server_generates_alias(url: str, expected_name: str) -> None:
result = persist_oauth_mcp_server(build_test_vibe_config(), url=url)
assert result.name == expected_name
def test_add_oauth_mcp_server_suffixes_generated_alias_collision(
config_dir: Path,
) -> None:
config = build_test_vibe_config(
mcp_servers=[
MCPStreamableHttp(
name="linear",
transport="streamable-http",
url="https://other.example.com/mcp",
)
]
)
result = persist_oauth_mcp_server(config, url="https://mcp.linear.app/mcp")
assert result.name == "linear_2"
assert _persisted_servers(config_dir)[0]["name"] == "linear_2"
def test_add_oauth_mcp_server_is_idempotent_for_existing_url(config_dir: Path) -> None:
first = persist_oauth_mcp_server(
build_test_vibe_config(), url="https://mcp.linear.app/mcp"
)
second = persist_oauth_mcp_server(
VibeConfig.load(), url="https://mcp.linear.app:443/mcp/"
)
assert first.created is True
assert second.created is False
assert second.name == "linear"
assert len(_persisted_servers(config_dir)) == 1
def test_add_oauth_mcp_server_rejects_active_static_url_match() -> None:
config = build_test_vibe_config(
mcp_servers=[
MCPStreamableHttp(
name="docs",
transport="streamable-http",
url="https://mcp.example.com/mcp",
)
]
)
with pytest.raises(MCPServerAddError, match="`docs` with static auth"):
persist_oauth_mcp_server(config, url="https://mcp.example.com/mcp")
def test_add_oauth_mcp_server_rejects_persisted_static_url_match() -> None:
VibeConfig.save_updates({
"mcp_servers": [
{
"name": "docs",
"transport": "streamable-http",
"url": "https://mcp.example.com/mcp",
}
]
})
with pytest.raises(MCPServerAddError, match="`docs` with static auth"):
persist_oauth_mcp_server(
build_test_vibe_config(), url="https://mcp.example.com/mcp"
)
def test_add_oauth_mcp_server_rejects_existing_url_with_different_name() -> None:
persist_oauth_mcp_server(build_test_vibe_config(), url="https://mcp.linear.app/mcp")
with pytest.raises(MCPServerAddError, match="already configured as `linear`"):
persist_oauth_mcp_server(
VibeConfig.load(), url="https://mcp.linear.app/mcp", name="other"
)
def test_add_oauth_mcp_server_rejects_explicit_alias_collision() -> None:
config = build_test_vibe_config(
mcp_servers=[
MCPStreamableHttp(
name="linear",
transport="streamable-http",
url="https://other.example.com/mcp",
)
]
)
with pytest.raises(MCPServerAddError, match="name `linear` is already configured"):
persist_oauth_mcp_server(
config, url="https://mcp.example.com/mcp", name="linear"
)
def test_add_oauth_mcp_server_persists_loadable_oauth_config() -> None:
persist_oauth_mcp_server(
build_test_vibe_config(),
url="https://mcp.example.com/mcp",
name="docs",
scopes=["read", "write"],
)
server = VibeConfig.load().mcp_servers[0]
assert isinstance(server, MCPStreamableHttp)
assert server.name == "docs"
assert isinstance(server.auth, MCPOAuth)
assert server.auth.scopes == ["read", "write"]
def test_add_oauth_mcp_server_persists_http_transport() -> None:
persist_oauth_mcp_server(
build_test_vibe_config(),
url="https://mcp.example.com/mcp",
name="docs",
transport="http",
)
server = VibeConfig.load().mcp_servers[0]
assert isinstance(server, MCPHttp)
assert server.transport == "http"
assert isinstance(server.auth, MCPOAuth)
def test_parse_mcp_add_transport_rejects_unsupported_transport() -> None:
with pytest.raises(MCPServerAddError, match="http, streamable-http"):
parse_mcp_add_transport("sse")

View file

@ -188,6 +188,24 @@ async def test_find_file_result_is_cached(tmp_working_directory: Path) -> None:
assert layer._config_file_path is cached_path
@pytest.mark.asyncio
async def test_is_file_discovered_reflects_cached_discovery_state(
tmp_working_directory: Path,
) -> None:
trusted_folders_manager.add_trusted(tmp_working_directory)
config_path = tmp_working_directory / ".vibe" / "config.toml"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text('active_model = "project-model"\n')
layer = ProjectConfigLayer(path=tmp_working_directory)
assert layer.is_file_discovered is False
await layer.load()
assert layer.is_file_discovered is True
@pytest.mark.asyncio
async def test_config_file_added_after_first_search_is_not_detected(
tmp_working_directory: Path,

View file

@ -58,7 +58,7 @@ async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None:
@pytest.mark.asyncio
async def test_apply_raises_when_file_does_not_exist(
async def test_apply_creates_file_when_it_does_not_exist(
tmp_working_directory: Path,
) -> None:
path = tmp_working_directory / random_config_file_name()
@ -67,15 +67,16 @@ async def test_apply_raises_when_file_does_not_exist(
await layer.load()
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_BACKING_STORE_DATA_FINGERPRINT,
)
await layer.apply(
ConfigPatch(
AddOperationPatch(path="/active_model", value="mistral-large"),
fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT,
)
)
assert not path.exists()
with path.open("rb") as file:
assert tomllib.load(file) == {"active_model": "mistral-large"}
assert layer.fingerprint == create_file_fingerprint(file)
@pytest.mark.asyncio
@ -224,7 +225,7 @@ async def test_apply_raises_when_cache_is_invalidated(
@pytest.mark.asyncio
async def test_apply_raises_when_parent_directory_does_not_exist(
async def test_apply_creates_parent_directory_when_it_does_not_exist(
tmp_working_directory: Path,
) -> None:
path = tmp_working_directory / "nested" / random_config_file_name()
@ -232,15 +233,15 @@ async def test_apply_raises_when_parent_directory_does_not_exist(
await layer.load()
with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"):
await layer.apply(
ConfigPatch(
AddOperationPatch(path="/active_model", value="mistral-large"),
fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT,
)
await layer.apply(
ConfigPatch(
AddOperationPatch(path="/active_model", value="mistral-large"),
fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT,
)
)
assert not path.exists()
with path.open("rb") as file:
assert tomllib.load(file) == {"active_model": "mistral-large"}
@pytest.mark.asyncio

View file

@ -56,7 +56,7 @@ provider = "mistral"
layer = UserConfigLayer(path=toml_path)
orchestrator = await ConfigOrchestrator[VibeConfig].create(
schema=VibeConfig, layers=[layer]
schema=VibeConfig, layers=[layer], default_layer_resolver=lambda: layer
)
config = orchestrator.config