v2.18.1 (#856)
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:
parent
e607ccbb00
commit
ed5b7192e6
33 changed files with 1456 additions and 125 deletions
|
|
@ -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
|
||||
|
|
|
|||
26
tests/cli/test_input_kinds.py
Normal file
26
tests/cli/test_input_kinds.py
Normal 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
|
||||
212
tests/cli/test_mcp_add_command.py
Normal file
212
tests/cli/test_mcp_add_command.py
Normal 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
|
||||
)
|
||||
38
tests/cli/test_ui_exit_command.py
Normal file
38
tests/cli/test_ui_exit_command.py
Normal 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 == []
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 == [
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
195
tests/core/test_mcp_settings.py
Normal file
195
tests/core/test_mcp_settings.py
Normal 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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue