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

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