Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-05-20 11:39:59 +02:00 committed by GitHub
parent 228f3c65a9
commit f71bfd3b8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 2950 additions and 402 deletions

View file

@ -7,10 +7,12 @@ from unittest.mock import patch
import pytest
from tests.conftest import build_test_vibe_config
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_client import FakeClient
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import ModelConfig, SessionLoggingConfig
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
@ -45,6 +47,43 @@ def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop:
return _create_acp_agent()
@pytest.fixture
def acp_agent_with_session_config(
backend: FakeBackend, temp_session_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> tuple[VibeAcpAgentLoop, FakeClient]:
session_config = SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
)
config = build_test_vibe_config(
active_model="devstral-latest",
models=[
ModelConfig(
name="devstral-latest", provider="mistral", alias="devstral-latest"
)
],
session_logging=session_config,
)
class PatchedAgentLoop(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **{**kwargs, "backend": backend})
self._base_config = config
self.agent_manager.invalidate_config()
monkeypatch.setattr("vibe.acp.acp_agent_loop.AgentLoop", PatchedAgentLoop)
monkeypatch.setattr(VibeAcpAgentLoop, "_load_config", lambda self: config)
monkeypatch.setattr(
"vibe.acp.acp_agent_loop.VibeConfig.load", lambda *args, **kwargs: config
)
vibe_acp_agent = VibeAcpAgentLoop()
client = FakeClient()
vibe_acp_agent.on_connect(client)
client.on_connect(vibe_acp_agent)
return vibe_acp_agent, client
@pytest.fixture
def temp_session_dir(tmp_path: Path) -> Path:
session_dir = tmp_path / "sessions"

View file

@ -463,6 +463,14 @@ class TestSessionUpdates:
== "available_commands_update"
)
title_response_text = await read_response(process)
assert title_response_text is not None
title_response = UpdateJsonRpcNotification.model_validate(
json.loads(title_response_text)
)
assert title_response.params is not None
assert title_response.params.update.session_update == "session_info_update"
text_response = await read_response(process)
assert text_response is not None
response = UpdateJsonRpcNotification.model_validate(

View file

@ -16,6 +16,11 @@ import pytest
from tests import TESTS_ROOT
from tests.e2e.common import ansi_tolerant_pattern
BROWSER_AUTH_NAME = "Sign in through Mistral AI Studio"
BROWSER_AUTH_DESCRIPTION = (
"Sign into Mistral Vibe through your Mistral AI Studio account."
)
class _AcpSmokeClient(Client):
def on_connect(self, conn: Any) -> None:
@ -96,15 +101,22 @@ async def _terminate_process(proc: asyncio.subprocess.Process) -> None:
await proc.wait()
def _build_env(vibe_home_dir: Path, *, include_api_key: bool) -> dict[str, str]:
def _build_env(
vibe_home_dir: Path, *, include_api_key: bool, enable_browser_sign_in: bool = False
) -> dict[str, str]:
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
env["VIBE_HOME"] = str(vibe_home_dir)
vibe_home_dir.mkdir(parents=True, exist_ok=True)
config_lines = ["enable_telemetry = false"]
if enable_browser_sign_in:
config_lines.append("enable_experimental_browser_sign_in = true")
config_file = vibe_home_dir / "config.toml"
if not config_file.exists():
config_file.write_text("enable_telemetry = false\n")
config_file.write_text("\n".join(config_lines) + "\n")
elif enable_browser_sign_in:
config_file.write_text(config_file.read_text() + config_lines[-1] + "\n")
if include_api_key:
env["MISTRAL_API_KEY"] = "mock"
@ -114,17 +126,33 @@ def _build_env(vibe_home_dir: Path, *, include_api_key: bool) -> dict[str, str]:
return env
def _build_client_capabilities(*, terminal_auth: bool = False) -> ClientCapabilities:
if not terminal_auth:
def _build_client_capabilities(
*, terminal_auth: bool = False, delegated_browser_auth: bool = False
) -> ClientCapabilities:
if not terminal_auth and not delegated_browser_auth:
return ClientCapabilities()
return ClientCapabilities(field_meta={"terminal-auth": True})
field_meta: dict[str, bool] = {}
if terminal_auth:
field_meta["terminal-auth"] = True
if delegated_browser_auth:
field_meta["browser-auth-delegated"] = True
return ClientCapabilities(field_meta=field_meta)
async def _connect_and_initialize(
*, vibe_home_dir: Path, include_api_key: bool, terminal_auth: bool = False
*,
vibe_home_dir: Path,
include_api_key: bool,
enable_browser_sign_in: bool = False,
terminal_auth: bool = False,
delegated_browser_auth: bool = False,
) -> tuple[asyncio.subprocess.Process, Any, Any]:
env = _build_env(vibe_home_dir, include_api_key=include_api_key)
env = _build_env(
vibe_home_dir,
include_api_key=include_api_key,
enable_browser_sign_in=enable_browser_sign_in,
)
proc = await _spawn_vibe_acp(env)
try:
@ -136,7 +164,8 @@ async def _connect_and_initialize(
conn.initialize(
protocol_version=PROTOCOL_VERSION,
client_capabilities=_build_client_capabilities(
terminal_auth=terminal_auth
terminal_auth=terminal_auth,
delegated_browser_auth=delegated_browser_auth,
),
client_info=Implementation(
name="pytest-smoke", title="Pytest Smoke", version="0.0.0"
@ -188,18 +217,71 @@ async def test_vibe_acp_bootstraps_default_files(vibe_home_dir: Path) -> None:
@pytest.mark.asyncio
async def test_vibe_acp_initialize_exposes_terminal_auth_when_supported(
vibe_home_dir: Path,
) -> None:
proc, initialize_response, conn = await _connect_and_initialize(
vibe_home_dir=vibe_home_dir, include_api_key=True, terminal_auth=True
async def test_vibe_acp_initialize_exposes_browser_auth(vibe_home_dir: Path) -> None:
proc, initialize_response, _conn = await _connect_and_initialize(
vibe_home_dir=vibe_home_dir, include_api_key=True, enable_browser_sign_in=True
)
try:
assert initialize_response.auth_methods is not None
assert len(initialize_response.auth_methods) == 1
auth_method = initialize_response.auth_methods[0]
assert auth_method.id == "browser-auth"
assert auth_method.name == BROWSER_AUTH_NAME
assert auth_method.description == BROWSER_AUTH_DESCRIPTION
finally:
await _terminate_process(proc)
@pytest.mark.asyncio
async def test_vibe_acp_initialize_exposes_delegated_browser_auth_when_supported(
vibe_home_dir: Path,
) -> None:
proc, initialize_response, _conn = await _connect_and_initialize(
vibe_home_dir=vibe_home_dir,
include_api_key=True,
enable_browser_sign_in=True,
delegated_browser_auth=True,
)
try:
assert initialize_response.auth_methods is not None
assert len(initialize_response.auth_methods) == 2
browser_auth_method = initialize_response.auth_methods[0]
assert browser_auth_method.id == "browser-auth"
assert browser_auth_method.name == BROWSER_AUTH_NAME
assert browser_auth_method.description == BROWSER_AUTH_DESCRIPTION
delegated_browser_auth_method = initialize_response.auth_methods[1]
assert delegated_browser_auth_method.id == "browser-auth-delegated"
assert delegated_browser_auth_method.name == BROWSER_AUTH_NAME
assert delegated_browser_auth_method.description == BROWSER_AUTH_DESCRIPTION
finally:
await _terminate_process(proc)
@pytest.mark.asyncio
async def test_vibe_acp_initialize_exposes_terminal_auth_when_supported(
vibe_home_dir: Path,
) -> None:
proc, initialize_response, _conn = await _connect_and_initialize(
vibe_home_dir=vibe_home_dir,
include_api_key=True,
enable_browser_sign_in=True,
terminal_auth=True,
)
try:
assert initialize_response.auth_methods is not None
assert len(initialize_response.auth_methods) == 2
browser_auth_method = initialize_response.auth_methods[0]
assert browser_auth_method.id == "browser-auth"
assert browser_auth_method.name == BROWSER_AUTH_NAME
assert browser_auth_method.description == BROWSER_AUTH_DESCRIPTION
auth_method = initialize_response.auth_methods[1]
assert auth_method.id == "vibe-setup"
assert auth_method.field_meta is not None
@ -207,11 +289,12 @@ async def test_vibe_acp_initialize_exposes_terminal_auth_when_supported(
assert terminal_auth["label"] == "Mistral Vibe Setup"
assert terminal_auth["command"]
assert terminal_auth["args"]
assert terminal_auth["args"][-1:] == ["--setup"]
finally:
await _terminate_process(proc)
@pytest.mark.timeout(15)
@pytest.mark.timeout(30)
def test_vibe_acp_setup_shows_onboarding_and_exits_on_cancel(
vibe_home_dir: Path,
) -> None:
@ -231,7 +314,7 @@ def test_vibe_acp_setup_shows_onboarding_and_exits_on_cancel(
child.logfile_read = captured
try:
child.expect(ansi_tolerant_pattern("Welcome to Mistral Vibe"), timeout=10)
child.expect(ansi_tolerant_pattern("Welcome to"), timeout=15)
child.sendcontrol("c")
child.expect(pexpect.EOF, timeout=10)
finally:

112
tests/acp/test_acp_title.py Normal file
View file

@ -0,0 +1,112 @@
from __future__ import annotations
from acp.schema import (
EmbeddedResourceContentBlock,
ResourceContentBlock,
TextContentBlock,
TextResourceContents,
)
from vibe.acp.title import acp_blocks_to_title_segments
from vibe.core.session.title_format import MentionSegment, TextSegment
class TestAcpBlocksToTitleSegments:
def test_text_block(self) -> None:
segments = acp_blocks_to_title_segments([
TextContentBlock(type="text", text="hello")
])
assert segments == [TextSegment(text="hello")]
def test_resource_link_with_name(self) -> None:
block = ResourceContentBlock(
type="resource_link", uri="file:///abs/path/foo.py", name="foo.py"
)
segments = acp_blocks_to_title_segments([block])
assert segments == [MentionSegment(name="foo.py")]
def test_resource_link_with_line_range_fragment(self) -> None:
block = ResourceContentBlock(
type="resource_link", uri="file:///abs/path/foo.py#L9-L27", name="foo.py"
)
segments = acp_blocks_to_title_segments([block])
assert segments == [MentionSegment(name="foo.py", start_line=9, end_line=27)]
def test_resource_link_with_single_line_fragment(self) -> None:
block = ResourceContentBlock(
type="resource_link", uri="file:///abs/path/foo.py#L42", name="foo.py"
)
segments = acp_blocks_to_title_segments([block])
assert segments == [MentionSegment(name="foo.py", start_line=42, end_line=None)]
def test_resource_link_falls_back_to_uri_basename_when_no_name(self) -> None:
block = ResourceContentBlock(
type="resource_link", uri="file:///abs/path/bar.txt", name=""
)
segments = acp_blocks_to_title_segments([block])
assert segments == [MentionSegment(name="bar.txt")]
def test_embedded_resource_block_derives_basename_from_uri(self) -> None:
block = EmbeddedResourceContentBlock(
type="resource",
resource=TextResourceContents(
uri="file:///abs/path/script.sh", text="echo hi"
),
)
segments = acp_blocks_to_title_segments([block])
assert segments == [MentionSegment(name="script.sh")]
def test_embedded_resource_with_line_range_fragment(self) -> None:
block = EmbeddedResourceContentBlock(
type="resource",
resource=TextResourceContents(
uri="file:///abs/path/main.py#L1-L20", text="content"
),
)
segments = acp_blocks_to_title_segments([block])
assert segments == [MentionSegment(name="main.py", start_line=1, end_line=20)]
def test_automatic_field_meta_skips_block(self) -> None:
block = EmbeddedResourceContentBlock(
type="resource",
field_meta={"automatic": True},
resource=TextResourceContents(uri="file:///abs/path/auto.py", text="..."),
)
segments = acp_blocks_to_title_segments([block])
assert segments == []
def test_automatic_field_meta_skips_resource_link(self) -> None:
block = ResourceContentBlock(
type="resource_link",
uri="file:///abs/path/auto.py",
name="auto.py",
field_meta={"automatic": True},
)
segments = acp_blocks_to_title_segments([block])
assert segments == []
def test_preserves_order(self) -> None:
blocks = [
TextContentBlock(type="text", text="Look at "),
ResourceContentBlock(
type="resource_link", uri="file:///a/foo.py", name="foo.py"
),
TextContentBlock(type="text", text=" and "),
ResourceContentBlock(
type="resource_link", uri="file:///a/bar.py", name="bar.py"
),
]
segments = acp_blocks_to_title_segments(blocks)
assert segments == [
TextSegment(text="Look at "),
MentionSegment(name="foo.py"),
TextSegment(text=" and "),
MentionSegment(name="bar.py"),
]
def test_unknown_fragment_format_ignored(self) -> None:
block = ResourceContentBlock(
type="resource_link", uri="file:///abs/path/foo.py#section1", name="foo.py"
)
segments = acp_blocks_to_title_segments([block])
assert segments == [MentionSegment(name="foo.py")]

View file

@ -0,0 +1,432 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import pytest
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.acp.exceptions import InternalError, InvalidRequestError
from vibe.core.config import ProviderConfig
from vibe.core.types import Backend
from vibe.setup.auth import (
BrowserSignInAttempt,
BrowserSignInError,
BrowserSignInErrorCode,
)
from vibe.setup.onboarding.context import OnboardingContext
def build_browser_sign_in_attempt(
process_id: str = "process-123",
) -> BrowserSignInAttempt:
return BrowserSignInAttempt(
process_id=process_id,
sign_in_url=f"https://console.mistral.ai/vibe/sign-in/{process_id}",
poll_url=f"https://console.mistral.ai/api/vibe/sign-in/{process_id}",
expires_at=datetime(2026, 4, 23, 12, 0, tzinfo=UTC),
code_verifier="secret-code-verifier",
)
def build_mistral_provider(
*,
api_key_env_var: str = "MISTRAL_API_KEY",
browser_auth_base_url: str = "https://console.mistral.ai",
browser_auth_api_base_url: str = "https://console.mistral.ai/api",
) -> ProviderConfig:
return ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var=api_key_env_var,
browser_auth_base_url=browser_auth_base_url,
browser_auth_api_base_url=browser_auth_api_base_url,
backend=Backend.MISTRAL,
)
def build_unsupported_provider() -> ProviderConfig:
return ProviderConfig(
name="llamacpp",
api_base="http://127.0.0.1:8080/v1",
api_key_env_var="LLAMACPP_API_KEY",
backend=Backend.GENERIC,
)
class MutableOnboardingContextLoader:
def __init__(
self, provider: ProviderConfig, *, enable_browser_sign_in: bool = True
) -> None:
self.provider = provider
self.enable_browser_sign_in = enable_browser_sign_in
def __call__(self) -> OnboardingContext:
return OnboardingContext(
provider=self.provider,
enable_experimental_browser_sign_in=self.enable_browser_sign_in,
)
class FakeBrowserSignInService:
def __init__(
self,
*,
attempt: BrowserSignInAttempt | None = None,
api_key: str = "api-key",
authenticate_error: BrowserSignInError | None = None,
start_error: BrowserSignInError | None = None,
complete_errors: list[BrowserSignInError] | None = None,
complete_error: BrowserSignInError | None = None,
) -> None:
self.attempt = attempt or build_browser_sign_in_attempt()
self.api_key = api_key
self.authenticate_error = authenticate_error
self.start_error = start_error
self.complete_errors = list(complete_errors or [])
if complete_error is not None:
self.complete_errors.append(complete_error)
self.close_count = 0
async def authenticate(self) -> str:
if self.authenticate_error is not None:
raise self.authenticate_error
return self.api_key
async def start_attempt(self) -> BrowserSignInAttempt:
if self.start_error is not None:
raise self.start_error
return self.attempt
async def complete_attempt(self, attempt: BrowserSignInAttempt) -> str:
if self.complete_errors:
raise self.complete_errors.pop(0)
if attempt != self.attempt:
raise AssertionError("Unexpected browser sign-in attempt.")
return self.api_key
async def aclose(self) -> None:
self.close_count += 1
class InMemoryApiKeyPersister:
def __init__(self, result: str = "completed") -> None:
self.result = result
self.saved: list[tuple[ProviderConfig, str]] = []
def persist(self, provider: ProviderConfig, api_key: str) -> str:
self.saved.append((provider, api_key))
return self.result
def build_acp_agent(
*,
provider: ProviderConfig | None = None,
enable_browser_sign_in: bool = True,
browser_sign_in: FakeBrowserSignInService | None = None,
api_key_persister: InMemoryApiKeyPersister | None = None,
) -> tuple[VibeAcpAgentLoop, MutableOnboardingContextLoader, InMemoryApiKeyPersister]:
provider = provider or build_mistral_provider()
browser_sign_in = browser_sign_in or FakeBrowserSignInService()
api_key_persister = api_key_persister or InMemoryApiKeyPersister()
context_loader = MutableOnboardingContextLoader(
provider, enable_browser_sign_in=enable_browser_sign_in
)
return (
VibeAcpAgentLoop(
onboarding_context_loader=context_loader,
browser_sign_in_service_factory=lambda _provider: browser_sign_in,
api_key_persister=api_key_persister.persist,
),
context_loader,
api_key_persister,
)
def require_auth_meta(response: Any, method_id: str) -> dict[str, Any]:
assert response is not None
assert response.field_meta is not None
meta = response.field_meta[method_id]
assert isinstance(meta, dict)
return meta
class TestACPAuthenticate:
@pytest.mark.asyncio
async def test_authenticate_completes_browser_sign_in_and_persists_api_key(
self,
) -> None:
provider = build_mistral_provider()
browser_sign_in = FakeBrowserSignInService(api_key="api-key")
acp_agent_loop, _, api_key_persister = build_acp_agent(
provider=provider, browser_sign_in=browser_sign_in
)
response = await acp_agent_loop.authenticate("browser-auth")
assert require_auth_meta(response, "browser-auth") == {
"persistResult": "completed",
"status": "completed",
}
assert api_key_persister.saved == [(provider, "api-key")]
assert browser_sign_in.close_count == 1
@pytest.mark.asyncio
async def test_authenticate_starts_delegated_browser_sign_in(self) -> None:
attempt = build_browser_sign_in_attempt()
browser_sign_in = FakeBrowserSignInService(attempt=attempt)
acp_agent_loop, _, api_key_persister = build_acp_agent(
browser_sign_in=browser_sign_in
)
response = await acp_agent_loop.authenticate("browser-auth-delegated")
assert require_auth_meta(response, "browser-auth-delegated") == {
"attemptId": "process-123",
"expiresAt": "2026-04-23T12:00:00Z",
"signInUrl": "https://console.mistral.ai/vibe/sign-in/process-123",
}
assert api_key_persister.saved == []
assert browser_sign_in.close_count == 1
@pytest.mark.asyncio
async def test_authenticate_rejects_unsupported_method(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
with pytest.raises(
InvalidRequestError, match="Unsupported auth method: vibe-setup"
):
await acp_agent_loop.authenticate("vibe-setup")
@pytest.mark.asyncio
async def test_authenticate_rejects_browser_sign_in_when_unavailable(self) -> None:
acp_agent_loop = VibeAcpAgentLoop(
onboarding_context_loader=lambda: OnboardingContext(
provider=build_unsupported_provider()
)
)
with pytest.raises(
InvalidRequestError,
match="Browser sign-in is not available for the configured provider.",
):
await acp_agent_loop.authenticate("browser-auth")
@pytest.mark.asyncio
async def test_authenticate_rejects_browser_sign_in_when_flag_disabled(
self,
) -> None:
browser_sign_in = FakeBrowserSignInService(api_key="api-key")
acp_agent_loop, _, api_key_persister = build_acp_agent(
browser_sign_in=browser_sign_in, enable_browser_sign_in=False
)
with pytest.raises(
InvalidRequestError,
match="Browser sign-in is not available for the configured provider.",
):
await acp_agent_loop.authenticate("browser-auth")
assert api_key_persister.saved == []
assert browser_sign_in.close_count == 0
@pytest.mark.asyncio
async def test_authenticate_rejects_delegated_browser_sign_in_when_flag_disabled(
self,
) -> None:
browser_sign_in = FakeBrowserSignInService()
acp_agent_loop, _, api_key_persister = build_acp_agent(
browser_sign_in=browser_sign_in, enable_browser_sign_in=False
)
with pytest.raises(
InvalidRequestError,
match="Browser sign-in is not available for the configured provider.",
):
await acp_agent_loop.authenticate("browser-auth-delegated")
assert api_key_persister.saved == []
assert browser_sign_in.close_count == 0
@pytest.mark.asyncio
async def test_authenticate_surfaces_start_failures(self) -> None:
browser_sign_in = FakeBrowserSignInService(
authenticate_error=BrowserSignInError(
"Failed to start browser sign-in.",
code=BrowserSignInErrorCode.START_FAILED,
)
)
acp_agent_loop, _, _ = build_acp_agent(browser_sign_in=browser_sign_in)
with pytest.raises(InternalError, match="Failed to start browser sign-in."):
await acp_agent_loop.authenticate("browser-auth")
assert browser_sign_in.close_count == 1
@pytest.mark.asyncio
async def test_authenticate_completes_delegated_browser_sign_in_and_persists_api_key(
self,
) -> None:
provider = build_mistral_provider()
attempt = build_browser_sign_in_attempt()
browser_sign_in = FakeBrowserSignInService(attempt=attempt, api_key="api-key")
acp_agent_loop, _, api_key_persister = build_acp_agent(
provider=provider, browser_sign_in=browser_sign_in
)
start_response = await acp_agent_loop.authenticate("browser-auth-delegated")
attempt_id = require_auth_meta(start_response, "browser-auth-delegated")[
"attemptId"
]
response = await acp_agent_loop.authenticate(
"browser-auth-delegated", action="complete", attemptId=attempt_id
)
assert require_auth_meta(response, "browser-auth-delegated") == {
"attemptId": "process-123",
"persistResult": "completed",
"status": "completed",
}
assert api_key_persister.saved == [(provider, "api-key")]
assert browser_sign_in.close_count == 2
@pytest.mark.asyncio
async def test_authenticate_delegated_completion_uses_provider_captured_at_start(
self,
) -> None:
start_provider = build_mistral_provider()
current_provider = build_mistral_provider(
api_key_env_var="OTHER_API_KEY",
browser_auth_base_url="https://example.com",
browser_auth_api_base_url="https://example.com/api",
)
browser_sign_in = FakeBrowserSignInService(api_key="api-key")
acp_agent_loop, context_loader, api_key_persister = build_acp_agent(
provider=start_provider, browser_sign_in=browser_sign_in
)
start_response = await acp_agent_loop.authenticate("browser-auth-delegated")
attempt_id = require_auth_meta(start_response, "browser-auth-delegated")[
"attemptId"
]
context_loader.provider = current_provider
await acp_agent_loop.authenticate(
"browser-auth-delegated", action="complete", attemptId=attempt_id
)
assert api_key_persister.saved == [(start_provider, "api-key")]
@pytest.mark.asyncio
async def test_authenticate_delegated_completion_allows_flag_disabled_after_start(
self,
) -> None:
provider = build_mistral_provider()
browser_sign_in = FakeBrowserSignInService(api_key="api-key")
acp_agent_loop, context_loader, api_key_persister = build_acp_agent(
provider=provider, browser_sign_in=browser_sign_in
)
start_response = await acp_agent_loop.authenticate("browser-auth-delegated")
attempt_id = require_auth_meta(start_response, "browser-auth-delegated")[
"attemptId"
]
context_loader.enable_browser_sign_in = False
await acp_agent_loop.authenticate(
"browser-auth-delegated", action="complete", attemptId=attempt_id
)
assert api_key_persister.saved == [(provider, "api-key")]
@pytest.mark.asyncio
async def test_authenticate_delegated_completion_requires_attempt_id(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
with pytest.raises(
InvalidRequestError, match="Missing browser sign-in attempt ID."
):
await acp_agent_loop.authenticate(
"browser-auth-delegated", action="complete"
)
@pytest.mark.asyncio
async def test_authenticate_delegated_completion_rejects_unknown_attempt_id(
self,
) -> None:
acp_agent_loop, _, _ = build_acp_agent()
with pytest.raises(
InvalidRequestError, match="Unknown browser sign-in attempt: process-123"
):
await acp_agent_loop.authenticate(
"browser-auth-delegated", action="complete", attemptId="process-123"
)
@pytest.mark.asyncio
async def test_authenticate_delegated_completion_surfaces_browser_sign_in_failures(
self,
) -> None:
browser_sign_in = FakeBrowserSignInService(
complete_error=BrowserSignInError(
"Browser sign-in timed out.", code=BrowserSignInErrorCode.TIMED_OUT
)
)
acp_agent_loop, _, api_key_persister = build_acp_agent(
browser_sign_in=browser_sign_in
)
start_response = await acp_agent_loop.authenticate("browser-auth-delegated")
attempt_id = require_auth_meta(start_response, "browser-auth-delegated")[
"attemptId"
]
with pytest.raises(InvalidRequestError, match="Browser sign-in timed out."):
await acp_agent_loop.authenticate(
"browser-auth-delegated", action="complete", attemptId=attempt_id
)
assert api_key_persister.saved == []
assert browser_sign_in.close_count == 2
@pytest.mark.asyncio
@pytest.mark.parametrize(
"error_code",
[BrowserSignInErrorCode.EXCHANGE_FAILED, BrowserSignInErrorCode.POLL_FAILED],
)
async def test_authenticate_delegated_completion_keeps_retryable_attempts(
self, error_code: BrowserSignInErrorCode
) -> None:
provider = build_mistral_provider()
browser_sign_in = FakeBrowserSignInService(
api_key="api-key",
complete_errors=[
BrowserSignInError(
"Transient browser sign-in failure.", code=error_code
)
],
)
acp_agent_loop, _, api_key_persister = build_acp_agent(
provider=provider, browser_sign_in=browser_sign_in
)
start_response = await acp_agent_loop.authenticate("browser-auth-delegated")
attempt_id = require_auth_meta(start_response, "browser-auth-delegated")[
"attemptId"
]
with pytest.raises(
InvalidRequestError, match="Transient browser sign-in failure."
):
await acp_agent_loop.authenticate(
"browser-auth-delegated", action="complete", attemptId=attempt_id
)
retry_response = await acp_agent_loop.authenticate(
"browser-auth-delegated", action="complete", attemptId=attempt_id
)
assert require_auth_meta(retry_response, "browser-auth-delegated") == {
"attemptId": attempt_id,
"persistResult": "completed",
"status": "completed",
}
assert api_key_persister.saved == [(provider, "api-key")]
assert browser_sign_in.close_count == 3

View file

@ -8,7 +8,8 @@ import pytest
from tests.mock.utils import collect_result
from vibe.acp.tools.builtins.bash import AcpBashState, Bash
from vibe.core.tools.base import ToolError
from vibe.acp.tools.events import ToolTerminalOpenedEvent
from vibe.core.tools.base import InvokeContext, ToolError
from vibe.core.tools.builtins.bash import BashArgs, BashResult, BashToolConfig
@ -105,9 +106,7 @@ def acp_bash_tool(mock_client: MockClient) -> Bash:
config = BashToolConfig()
# Use model_construct to bypass Pydantic validation for testing
state = AcpBashState.model_construct(
client=mock_client,
session_id="test_session_123",
tool_call_id="test_tool_call_456",
client=mock_client, session_id="test_session_123"
)
return Bash(config_getter=lambda: config, state=state)
@ -156,7 +155,7 @@ class TestAcpBashExecution:
tool = Bash(
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -176,7 +175,7 @@ class TestAcpBashExecution:
tool = Bash(
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -196,7 +195,7 @@ class TestAcpBashExecution:
tool = Bash(
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -213,9 +212,7 @@ class TestAcpBashExecution:
async def test_run_without_client(self) -> None:
tool = Bash(
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=None, session_id="test_session", tool_call_id="test_call"
),
state=AcpBashState.model_construct(client=None, session_id="test_session"),
)
args = BashArgs(command="test")
@ -232,9 +229,7 @@ class TestAcpBashExecution:
mock_client = MockClient()
tool = Bash(
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id=None, tool_call_id="test_call"
),
state=AcpBashState.model_construct(client=mock_client, session_id=None),
)
args = BashArgs(command="test")
@ -256,7 +251,7 @@ class TestAcpBashExecution:
tool = Bash(
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -283,7 +278,7 @@ class TestAcpBashTimeout:
tool = Bash(
config_getter=lambda: BashToolConfig(default_timeout=30),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -312,7 +307,7 @@ class TestAcpBashTimeout:
tool = Bash(
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -324,61 +319,56 @@ class TestAcpBashTimeout:
assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"
class TestAcpBashEmbedding:
class TestAcpBashTerminalOpenedEvent:
@pytest.mark.asyncio
async def test_run_with_embedding(self, mock_client: MockClient) -> None:
tool = Bash(
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
await collect_result(tool.run(args))
assert mock_client._session_update_called
@pytest.mark.asyncio
async def test_run_embedding_without_tool_call_id(
async def test_run_yields_terminal_opened_event(
self, mock_client: MockClient
) -> None:
tool = Bash(
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id=None
client=mock_client, session_id="test_session"
),
)
args = BashArgs(command="test")
await collect_result(tool.run(args))
events: list[ToolTerminalOpenedEvent] = []
async for item in tool.run(args, InvokeContext(tool_call_id="test_call")):
if isinstance(item, ToolTerminalOpenedEvent):
events.append(item)
# Embedding should be skipped when tool_call_id is None
assert not mock_client._session_update_called
assert len(events) == 1
assert events[0].terminal_id == mock_client._terminal_handle.id
assert events[0].tool_call_id == "test_call"
assert events[0].tool_name == "bash"
class TestAcpBashConcurrentInvocations:
@pytest.mark.asyncio
async def test_run_embedding_handles_exception(
self, mock_client: MockClient
) -> None:
# Make session_update raise an exception
async def failing_session_update(session_id: str, update, **kwargs) -> None:
raise RuntimeError("Session update failed")
mock_client.session_update = failing_session_update
async def test_concurrent_invocations_yield_distinct_tool_call_ids(self) -> None:
mock_client = MockClient(MockTerminalHandle(terminal_id="t", wait_delay=0.05))
tool = Bash(
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
args = BashArgs(command="test")
# Should not raise, embedding failure is silently ignored
result = await collect_result(tool.run(args))
async def run_and_collect_ids(tool_call_id: str) -> list[str]:
ids: list[str] = []
async for item in tool.run(
BashArgs(command="echo hi"), InvokeContext(tool_call_id=tool_call_id)
):
if isinstance(item, ToolTerminalOpenedEvent):
ids.append(item.tool_call_id)
return ids
assert result is not None
assert result.stdout == "test output"
results = await asyncio.gather(
run_and_collect_ids("T1"), run_and_collect_ids("T2")
)
assert results[0] == ["T1"]
assert results[1] == ["T2"]
class TestAcpBashConfig:
@ -395,7 +385,7 @@ class TestAcpBashConfig:
tool = Bash(
config_getter=lambda: BashToolConfig(default_timeout=30),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -425,7 +415,7 @@ class TestAcpBashCleanup:
tool = Bash(
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -457,7 +447,7 @@ class TestAcpBashCleanup:
tool = Bash(
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -483,7 +473,7 @@ class TestAcpBashCleanup:
tool = Bash(
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)

View file

@ -14,11 +14,42 @@ from acp.schema import (
import pytest
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.config import ProviderConfig
from vibe.core.types import Backend
from vibe.setup.onboarding.context import OnboardingContext
BROWSER_AUTH_NAME = "Sign in through Mistral AI Studio"
BROWSER_AUTH_DESCRIPTION = (
"Sign into Mistral Vibe through your Mistral AI Studio account."
)
def build_mistral_provider() -> ProviderConfig:
return ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
browser_auth_base_url="https://console.mistral.ai",
browser_auth_api_base_url="https://console.mistral.ai/api",
backend=Backend.MISTRAL,
)
def build_acp_agent_loop(
*, provider: ProviderConfig | None = None, enable_browser_sign_in: bool = True
) -> VibeAcpAgentLoop:
return VibeAcpAgentLoop(
onboarding_context_loader=lambda: OnboardingContext(
provider=provider or build_mistral_provider(),
enable_experimental_browser_sign_in=enable_browser_sign_in,
)
)
class TestACPInitialize:
@pytest.mark.asyncio
async def test_initialize(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
async def test_initialize(self) -> None:
acp_agent_loop = build_acp_agent_loop()
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
assert response.protocol_version == PROTOCOL_VERSION
@ -34,16 +65,20 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.10.0"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.10.1"
)
assert response.auth_methods == []
assert response.auth_methods is not None
assert len(response.auth_methods) == 1
auth_method = response.auth_methods[0]
assert auth_method.id == "browser-auth"
assert auth_method.name == BROWSER_AUTH_NAME
assert auth_method.description == BROWSER_AUTH_DESCRIPTION
@pytest.mark.asyncio
async def test_initialize_with_terminal_auth(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
async def test_initialize_with_terminal_auth(self) -> None:
"""Test initialize with terminal-auth capabilities to check it was included."""
acp_agent_loop = build_acp_agent_loop()
client_capabilities = ClientCapabilities(field_meta={"terminal-auth": True})
response = await acp_agent_loop.initialize(
protocol_version=PROTOCOL_VERSION, client_capabilities=client_capabilities
@ -62,12 +97,18 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.10.0"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.10.1"
)
assert response.auth_methods is not None
assert len(response.auth_methods) == 1
auth_method = response.auth_methods[0]
assert len(response.auth_methods) == 2
browser_auth_method = response.auth_methods[0]
assert browser_auth_method.id == "browser-auth"
assert browser_auth_method.name == BROWSER_AUTH_NAME
assert browser_auth_method.description == BROWSER_AUTH_DESCRIPTION
auth_method = response.auth_methods[1]
assert auth_method.id == "vibe-setup"
assert auth_method.name == "Register your API Key"
assert auth_method.description == "Register your API Key inside Mistral Vibe"
@ -76,4 +117,55 @@ class TestACPInitialize:
terminal_auth_meta = auth_method.field_meta["terminal-auth"]
assert "command" in terminal_auth_meta
assert "args" in terminal_auth_meta
assert terminal_auth_meta["args"][-1:] == ["--setup"]
assert terminal_auth_meta["label"] == "Mistral Vibe Setup"
@pytest.mark.asyncio
async def test_initialize_with_delegated_browser_auth(self) -> None:
acp_agent_loop = build_acp_agent_loop()
client_capabilities = ClientCapabilities(
field_meta={"browser-auth-delegated": True}
)
response = await acp_agent_loop.initialize(
protocol_version=PROTOCOL_VERSION, client_capabilities=client_capabilities
)
assert response.auth_methods is not None
assert len(response.auth_methods) == 2
browser_auth_method = response.auth_methods[0]
assert browser_auth_method.id == "browser-auth"
assert browser_auth_method.name == BROWSER_AUTH_NAME
assert browser_auth_method.description == BROWSER_AUTH_DESCRIPTION
delegated_browser_auth_method = response.auth_methods[1]
assert delegated_browser_auth_method.id == "browser-auth-delegated"
assert delegated_browser_auth_method.name == BROWSER_AUTH_NAME
assert delegated_browser_auth_method.description == BROWSER_AUTH_DESCRIPTION
@pytest.mark.asyncio
async def test_initialize_omits_browser_auth_when_experimental_flag_disabled(
self,
) -> None:
acp_agent_loop = build_acp_agent_loop(enable_browser_sign_in=False)
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
assert response.auth_methods == []
@pytest.mark.asyncio
async def test_initialize_omits_browser_auth_when_provider_unsupported(
self,
) -> None:
acp_agent_loop = build_acp_agent_loop(
provider=ProviderConfig(
name="llamacpp",
api_base="http://127.0.0.1:8080/v1",
api_key_env_var="LLAMACPP_API_KEY",
backend=Backend.GENERIC,
)
)
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
assert response.auth_methods == []

View file

@ -74,7 +74,6 @@ def acp_read_file_tool(
state = AcpReadFileState.model_construct(
client=mock_client, # type: ignore[arg-type]
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
return ReadFile(config_getter=lambda: config, state=state)
@ -99,7 +98,6 @@ class TestAcpReadFileExecution:
assert result.content == "line 1\nline 2\nline 3"
assert result.lines_read == 3
assert mock_client._read_text_file_called
assert mock_client._session_update_called
# Verify read_text_file was called correctly
params = mock_client._last_read_params
@ -118,7 +116,7 @@ class TestAcpReadFileExecution:
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -141,7 +139,7 @@ class TestAcpReadFileExecution:
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -164,7 +162,7 @@ class TestAcpReadFileExecution:
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -189,7 +187,7 @@ class TestAcpReadFileExecution:
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -209,7 +207,7 @@ class TestAcpReadFileExecution:
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
client=None, session_id="test_session", tool_call_id="test_call"
client=None, session_id="test_session"
),
)
@ -232,9 +230,7 @@ class TestAcpReadFileExecution:
mock_client = MockClient()
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
client=mock_client, session_id=None, tool_call_id="test_call"
),
state=AcpReadFileState.model_construct(client=mock_client, session_id=None),
)
args = ReadFileArgs(path=str(test_file))

View file

@ -81,9 +81,7 @@ def acp_search_replace_tool(
monkeypatch.chdir(tmp_path)
config = SearchReplaceConfig()
state = AcpSearchReplaceState.model_construct(
client=mock_client,
session_id="test_session_123",
tool_call_id="test_tool_call_456",
client=mock_client, session_id="test_session_123"
)
return SearchReplace(config_getter=lambda: config, state=state)
@ -116,7 +114,6 @@ class TestAcpSearchReplaceExecution:
assert result.blocks_applied == 1
assert mock_client._read_text_file_called
assert mock_client._write_text_file_called
assert mock_client._session_update_called
# Verify read_text_file was called correctly
read_params = mock_client._last_read_params
@ -141,7 +138,7 @@ class TestAcpSearchReplaceExecution:
tool = SearchReplace(
config_getter=lambda: config,
state=AcpSearchReplaceState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -171,7 +168,7 @@ class TestAcpSearchReplaceExecution:
tool = SearchReplace(
config_getter=lambda: SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -202,7 +199,7 @@ class TestAcpSearchReplaceExecution:
tool = SearchReplace(
config_getter=lambda: SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -245,7 +242,7 @@ class TestAcpSearchReplaceExecution:
tool = SearchReplace(
config_getter=lambda: SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
client=client, session_id=session_id, tool_call_id="test_call"
client=client, session_id=session_id
),
)

View file

@ -0,0 +1,92 @@
from __future__ import annotations
from pathlib import Path
from acp.schema import ResourceContentBlock, SessionInfoUpdate, TextContentBlock
import pytest
from tests.stubs.fake_client import FakeClient
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
def _info_updates(client: FakeClient) -> list:
return [
notification.update
for notification in client._session_updates
if isinstance(notification.update, SessionInfoUpdate)
]
class TestAcpAutoTitleOnPrompt:
@pytest.mark.asyncio
async def test_emits_session_info_update_on_first_prompt(
self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient]
) -> None:
acp_agent, client = acp_agent_with_session_config
new_session = await acp_agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
assert new_session is not None
await acp_agent.prompt(
session_id=new_session.session_id,
prompt=[
TextContentBlock(type="text", text="Refactor "),
ResourceContentBlock(
type="resource_link", uri="file:///abs/auth.py", name="auth.py"
),
TextContentBlock(type="text", text=" please"),
],
)
updates = _info_updates(client)
assert len(updates) == 1
assert updates[0].title == "Refactor @auth.py please"
assert updates[0].updated_at is None
@pytest.mark.asyncio
async def test_no_event_on_second_prompt(
self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient]
) -> None:
acp_agent, client = acp_agent_with_session_config
new_session = await acp_agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
assert new_session is not None
await acp_agent.prompt(
session_id=new_session.session_id,
prompt=[TextContentBlock(type="text", text="first")],
)
assert len(_info_updates(client)) == 1
await acp_agent.prompt(
session_id=new_session.session_id,
prompt=[TextContentBlock(type="text", text="second")],
)
assert len(_info_updates(client)) == 1
@pytest.mark.asyncio
async def test_skips_automatic_resource_in_title(
self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient]
) -> None:
acp_agent, client = acp_agent_with_session_config
new_session = await acp_agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
assert new_session is not None
await acp_agent.prompt(
session_id=new_session.session_id,
prompt=[
TextContentBlock(type="text", text="What does this do?"),
ResourceContentBlock(
type="resource_link",
uri="file:///abs/open_in_editor.py",
name="open_in_editor.py",
field_meta={"automatic": True},
),
],
)
updates = _info_updates(client)
assert len(updates) == 1
assert updates[0].title == "What does this do?"

View file

@ -7,50 +7,10 @@ from acp.schema import SessionInfoUpdate
import pytest
import tomli_w
from tests.conftest import build_test_vibe_config, get_base_config
from tests.stubs.fake_backend import FakeBackend
from tests.conftest import get_base_config
from tests.stubs.fake_client import FakeClient
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.acp.exceptions import InternalError, InvalidRequestError, SessionNotFoundError
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import ModelConfig, SessionLoggingConfig
@pytest.fixture
def acp_agent_with_session_config(
backend: FakeBackend, temp_session_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> tuple[VibeAcpAgentLoop, FakeClient]:
session_config = SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
)
config = build_test_vibe_config(
active_model="devstral-latest",
models=[
ModelConfig(
name="devstral-latest", provider="mistral", alias="devstral-latest"
)
],
session_logging=session_config,
)
class PatchedAgentLoop(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **{**kwargs, "backend": backend})
self._base_config = config
self.agent_manager.invalidate_config()
monkeypatch.setattr("vibe.acp.acp_agent_loop.AgentLoop", PatchedAgentLoop)
monkeypatch.setattr(VibeAcpAgentLoop, "_load_config", lambda self: config)
monkeypatch.setattr(
"vibe.acp.acp_agent_loop.VibeConfig.load", lambda *args, **kwargs: config
)
vibe_acp_agent = VibeAcpAgentLoop()
client = FakeClient()
vibe_acp_agent.on_connect(client)
client.on_connect(vibe_acp_agent)
return vibe_acp_agent, client
class TestSessionSetTitle:

View file

@ -54,9 +54,7 @@ def acp_write_file_tool(
monkeypatch.chdir(tmp_path)
config = WriteFileConfig()
state = AcpWriteFileState.model_construct(
client=mock_client,
session_id="test_session_123",
tool_call_id="test_tool_call_456",
client=mock_client, session_id="test_session_123"
)
return WriteFile(config_getter=lambda: config, state=state)
@ -81,7 +79,6 @@ class TestAcpWriteFileExecution:
assert result.bytes_written == len(b"Hello, world!")
assert result.file_existed is False
assert mock_client._write_text_file_called
assert mock_client._session_update_called
# Verify write_text_file was called correctly
params = mock_client._last_write_params
@ -97,7 +94,7 @@ class TestAcpWriteFileExecution:
tool = WriteFile(
config_getter=lambda: WriteFileConfig(),
state=AcpWriteFileState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -114,7 +111,6 @@ class TestAcpWriteFileExecution:
assert result.bytes_written == len(b"New content")
assert result.file_existed is True
assert mock_client._write_text_file_called
assert mock_client._session_update_called
# Verify write_text_file was called correctly
params = mock_client._last_write_params
@ -132,7 +128,7 @@ class TestAcpWriteFileExecution:
tool = WriteFile(
config_getter=lambda: WriteFileConfig(),
state=AcpWriteFileState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
client=mock_client, session_id="test_session"
),
)
@ -151,7 +147,7 @@ class TestAcpWriteFileExecution:
tool = WriteFile(
config_getter=lambda: WriteFileConfig(),
state=AcpWriteFileState.model_construct(
client=None, session_id="test_session", tool_call_id="test_call"
client=None, session_id="test_session"
),
)
@ -173,7 +169,7 @@ class TestAcpWriteFileExecution:
tool = WriteFile(
config_getter=lambda: WriteFileConfig(),
state=AcpWriteFileState.model_construct(
client=mock_client, session_id=None, tool_call_id="test_call"
client=mock_client, session_id=None
),
)

View file

@ -2,7 +2,11 @@ from __future__ import annotations
from pathlib import Path
from vibe.core.autocompletion.path_prompt import build_path_prompt_payload
from vibe.core.autocompletion.path_prompt import (
build_path_prompt_payload,
build_title_segments,
)
from vibe.core.session.title_format import MentionSegment, TextSegment
def test_deduplicates_same_file_mentioned_twice(tmp_path: Path) -> None:
@ -16,3 +20,58 @@ def test_deduplicates_same_file_mentioned_twice(tmp_path: Path) -> None:
assert len(payload.resources) == 1
assert payload.resources[0].path == readme
assert len(payload.all_resources) == 2
class TestBuildTitleSegments:
def test_empty_message(self) -> None:
assert build_title_segments("") == []
def test_plain_text_no_mentions(self) -> None:
segments = build_title_segments("hello world")
assert segments == [TextSegment(text="hello world")]
def test_matched_file_mention_uses_basename(self, tmp_path: Path) -> None:
nested = tmp_path / "src" / "auth"
nested.mkdir(parents=True)
target = nested / "foo.py"
target.write_text("x", encoding="utf-8")
segments = build_title_segments(
"Refactor @src/auth/foo.py please", base_dir=tmp_path
)
assert segments == [
TextSegment(text="Refactor "),
MentionSegment(name="foo.py"),
TextSegment(text=" please"),
]
def test_unmatched_mention_stays_as_text(self, tmp_path: Path) -> None:
segments = build_title_segments("Look at @nope.py here", base_dir=tmp_path)
assert segments == [TextSegment(text="Look at @nope.py here")]
def test_folder_mention_uses_basename(self, tmp_path: Path) -> None:
folder = tmp_path / "components"
folder.mkdir()
segments = build_title_segments("Update @components", base_dir=tmp_path)
assert segments == [
TextSegment(text="Update "),
MentionSegment(name="components"),
]
def test_multiple_mentions_keep_text_in_between(self, tmp_path: Path) -> None:
(tmp_path / "a.py").write_text("", encoding="utf-8")
(tmp_path / "b.py").write_text("", encoding="utf-8")
segments = build_title_segments("@a.py vs @b.py", base_dir=tmp_path)
assert segments == [
MentionSegment(name="a.py"),
TextSegment(text=" vs "),
MentionSegment(name="b.py"),
]
def test_mention_with_no_surrounding_text(self, tmp_path: Path) -> None:
(tmp_path / "only.py").write_text("", encoding="utf-8")
segments = build_title_segments("@only.py", base_dir=tmp_path)
assert segments == [MentionSegment(name="only.py")]

View file

@ -99,7 +99,7 @@ def build_sign_in_process(
f"https://console.mistral.ai/codestral/cli/authenticate#{fragment}"
),
poll_url=(
f"https://api.mistral.ai/api/vibe/sign-in/poll/poll-token-{process_id}"
f"https://console.mistral.ai/api/vibe/sign-in/poll/poll-token-{process_id}"
),
expires_at=now + timedelta(minutes=5),
)

View file

@ -34,7 +34,7 @@ TEST_SIGN_IN_URL = "https://console.mistral.ai/codestral/cli/authenticate#" + ur
"complete_token": f"complete-token-{TEST_PROCESS_ID}",
"state": f"state-{TEST_PROCESS_ID}",
})
TEST_POLL_URL = "https://api.mistral.ai/api/vibe/sign-in/poll/poll-token-process-1"
TEST_POLL_URL = "https://console.mistral.ai/api/vibe/sign-in/poll/poll-token-process-1"
def build_code_challenge(verifier: str) -> str:
@ -94,6 +94,50 @@ async def test_authenticate_returns_api_key_after_pending_poll() -> None:
assert gateway.exchange_requests[0].exchange_token == "exchange-1"
@pytest.mark.asyncio
async def test_start_attempt_returns_attempt_without_opening_browser() -> None:
opened_urls: list[str] = []
gateway, service = build_test_service(
poll_results=[], open_browser=lambda url: opened_urls.append(url) or True
)
attempt = await service.start_attempt()
assert opened_urls == []
assert attempt.process_id == TEST_PROCESS_ID
assert attempt.sign_in_url == TEST_SIGN_IN_URL
assert attempt.poll_url == TEST_POLL_URL
assert attempt.expires_at == build_sign_in_process(TEST_NOW).expires_at
assert gateway.exchange_requests == []
assert gateway.code_challenges == [build_code_challenge(attempt.code_verifier)]
@pytest.mark.asyncio
async def test_complete_attempt_returns_api_key_without_opening_browser() -> None:
opened_urls: list[str] = []
statuses: list[BrowserSignInStatus] = []
gateway, service = build_test_service(
poll_results=[
BrowserSignInPollResult(status="pending"),
BrowserSignInPollResult(status="completed", exchange_token="exchange-1"),
],
open_browser=lambda url: opened_urls.append(url) or True,
)
attempt = await service.start_attempt()
api_key = await service.complete_attempt(attempt, status_callback=statuses.append)
assert api_key == "sk-browser-key"
assert opened_urls == []
assert statuses == [
BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN,
BrowserSignInStatus.EXCHANGING,
BrowserSignInStatus.COMPLETED,
]
assert gateway.polled_urls == [TEST_POLL_URL, TEST_POLL_URL]
assert gateway.exchange_requests[0].exchange_token == "exchange-1"
@pytest.mark.asyncio
async def test_authenticate_raises_when_polling_expires() -> None:
opened_urls: list[str] = []

View file

@ -15,9 +15,9 @@ from vibe.setup.auth import (
HttpBrowserSignInGateway,
)
AUTH_ORIGIN = "https://api.mistral.ai"
AUTH_ORIGIN = "https://console.mistral.ai"
AUTH_BROWSER_BASE_URL = "https://console.mistral.ai"
AUTH_API_BASE_URL = AUTH_ORIGIN
AUTH_API_BASE_URL = "https://console.mistral.ai/api"
TEST_PROCESS_ID = "process-1"
TEST_COMPLETE_TOKEN = "complete-token-1"
TEST_STATE = "state-1"
@ -46,7 +46,7 @@ def build_sign_in_url(
def build_poll_url(
*, poll_token: str = TEST_POLL_TOKEN, api_base_url: str = AUTH_API_BASE_URL
) -> str:
return f"{api_base_url}/api/vibe/sign-in/poll/{poll_token}"
return f"{api_base_url}/vibe/sign-in/poll/{poll_token}"
@asynccontextmanager
@ -72,7 +72,7 @@ async def test_http_api_creates_process_with_pkce_payload() -> None:
def handler(request: httpx.Request) -> httpx.Response:
nonlocal captured_body
assert request.url.path == "/vibe/sign-in"
assert request.url.path == "/api/vibe/sign-in"
captured_body = request.content.decode("utf-8")
return httpx.Response(
200,
@ -129,7 +129,7 @@ async def test_http_api_exchanges_token_for_api_key() -> None:
def handler(request: httpx.Request) -> httpx.Response:
nonlocal captured_body
assert request.url.path == "/vibe/sign-in/process-1/exchange"
assert request.url.path == "/api/vibe/sign-in/process-1/exchange"
captured_body = request.content.decode("utf-8")
return httpx.Response(200, json={"api_key": "sk-browser-key"})
@ -142,6 +142,28 @@ async def test_http_api_exchanges_token_for_api_key() -> None:
assert '"code_verifier":"verifier-1"' in captured_body
@pytest.mark.asyncio
async def test_http_api_logs_exchange_failure_status_and_detail_without_secrets(
caplog: pytest.LogCaptureFixture,
) -> None:
exchange_token = "exchange-token-secret"
code_verifier = "verifier-secret"
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/vibe/sign-in/process-1/exchange"
return httpx.Response(400, json={"detail": "Invalid exchange token."})
async with build_gateway(handler) as gateway:
with caplog.at_level(logging.WARNING, logger="vibe"):
with pytest.raises(BrowserSignInError, match="exchange browser sign-in"):
await gateway.exchange("process-1", exchange_token, code_verifier)
assert "status_code=400" in caplog.text
assert "response_detail=Invalid exchange token." in caplog.text
assert exchange_token not in caplog.text
assert code_verifier not in caplog.text
@pytest.mark.asyncio
async def test_http_api_translates_transport_errors() -> None:
def handler(request: httpx.Request) -> httpx.Response:
@ -232,31 +254,35 @@ async def test_http_api_accepts_poll_url_under_configured_api_base_url() -> None
@pytest.mark.asyncio
async def test_http_api_accepts_poll_url_under_configured_api_base_path() -> None:
async def test_http_api_accepts_backend_poll_url_under_custom_configured_api_base_path() -> (
None
):
now = datetime(2026, 3, 16, tzinfo=UTC)
browser_base_url = "https://browser-auth.example/sign-in"
api_base_url = "https://browser-auth.example/custom/api"
poll_url = f"{api_base_url}/backend-owned-poll/{TEST_POLL_TOKEN}"
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/v1/vibe/sign-in"
assert request.url.path == "/custom/api/vibe/sign-in"
return httpx.Response(
200,
json={
"process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(),
"poll_url": build_poll_url(
poll_token=TEST_POLL_TOKEN, api_base_url="https://api.mistral.ai/v1"
),
"sign_in_url": build_sign_in_url(base_url=browser_base_url),
"poll_url": poll_url,
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
async with build_gateway(
handler,
origin="https://api.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
origin="https://browser-auth.example",
browser_base_url=browser_base_url,
api_base_url=api_base_url,
) as gateway:
process = await gateway.create_process("challenge-123")
assert process.poll_url == build_poll_url(api_base_url="https://api.mistral.ai/v1")
assert process.poll_url == poll_url
@pytest.mark.asyncio
@ -273,7 +299,9 @@ async def test_http_api_accepts_same_origin_urls_with_explicit_default_https_por
"sign_in_url": build_sign_in_url(
base_url="https://console.mistral.ai:443"
),
"poll_url": build_poll_url(api_base_url="https://api.mistral.ai:443"),
"poll_url": build_poll_url(
api_base_url="https://console.mistral.ai:443/api"
),
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
@ -284,7 +312,9 @@ async def test_http_api_accepts_same_origin_urls_with_explicit_default_https_por
assert process.sign_in_url == build_sign_in_url(
base_url="https://console.mistral.ai:443"
)
assert process.poll_url == build_poll_url(api_base_url="https://api.mistral.ai:443")
assert process.poll_url == build_poll_url(
api_base_url="https://console.mistral.ai:443/api"
)
@pytest.mark.asyncio
@ -299,8 +329,8 @@ async def test_http_api_accepts_poll_url_without_explicit_default_https_port_whe
async with build_gateway(
handler,
origin="https://api.mistral.ai:443",
api_base_url="https://api.mistral.ai:443",
origin="https://console.mistral.ai:443",
api_base_url="https://console.mistral.ai:443/api",
) as gateway:
result = await gateway.poll(build_poll_url())
@ -365,15 +395,13 @@ async def test_http_api_rejects_sign_in_url_outside_browser_base_path_after_norm
"sign_in_url": build_sign_in_url(
base_url="https://console.mistral.ai/v1/.."
),
"poll_url": build_poll_url(api_base_url="https://api.mistral.ai/v1"),
"poll_url": build_poll_url(),
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
async with build_gateway(
handler,
browser_base_url="https://console.mistral.ai/v1",
api_base_url="https://api.mistral.ai/v1",
handler, browser_base_url="https://console.mistral.ai/v1"
) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
await gateway.create_process("challenge-123")
@ -395,15 +423,13 @@ async def test_http_api_rejects_sign_in_url_with_encoded_dot_segments_outside_br
"sign_in_url": build_sign_in_url(
base_url="https://console.mistral.ai/v1/%2e%2e"
),
"poll_url": build_poll_url(api_base_url="https://api.mistral.ai/v1"),
"poll_url": build_poll_url(),
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
async with build_gateway(
handler,
browser_base_url="https://console.mistral.ai/v1",
api_base_url="https://api.mistral.ai/v1",
handler, browser_base_url="https://console.mistral.ai/v1"
) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
await gateway.create_process("challenge-123")
@ -449,7 +475,7 @@ async def test_http_api_rejects_returned_poll_url_outside_api_base_path_after_no
),
"poll_url": build_poll_url(
poll_token=TEST_POLL_TOKEN,
api_base_url="https://api.mistral.ai/v1/..",
api_base_url="https://console.mistral.ai/api/..",
),
"expires_at": _iso(now + timedelta(minutes=5)),
},
@ -457,8 +483,7 @@ async def test_http_api_rejects_returned_poll_url_outside_api_base_path_after_no
async with build_gateway(
handler,
origin="https://api.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
origin="https://console.mistral.ai",
browser_base_url="https://console.mistral.ai/v1",
) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
@ -483,7 +508,7 @@ async def test_http_api_rejects_returned_poll_url_with_encoded_dot_segments_outs
),
"poll_url": build_poll_url(
poll_token=TEST_POLL_TOKEN,
api_base_url="https://api.mistral.ai/v1/%2e%2e",
api_base_url="https://console.mistral.ai/api/%2e%2e",
),
"expires_at": _iso(now + timedelta(minutes=5)),
},
@ -491,8 +516,7 @@ async def test_http_api_rejects_returned_poll_url_with_encoded_dot_segments_outs
async with build_gateway(
handler,
origin="https://api.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
origin="https://console.mistral.ai",
browser_base_url="https://console.mistral.ai/v1",
) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
@ -504,15 +528,14 @@ async def test_http_api_rejects_returned_poll_url_with_encoded_dot_segments_outs
@pytest.mark.asyncio
async def test_http_api_rejects_poll_url_outside_api_base_path() -> None:
async with build_gateway(
lambda _: httpx.Response(200),
origin="https://api.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
lambda _: httpx.Response(200, json={"status": "completed"}),
origin="https://console.mistral.ai",
) as gateway:
with pytest.raises(
BrowserSignInError, match="status could not be retrieved"
) as err:
await gateway.poll(
"https://api.mistral.ai/v1evil/api/vibe/sign-in/poll/poll-token-1"
"https://console.mistral.ai/apievil/vibe/sign-in/poll/poll-token-1"
)
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
@ -523,15 +546,14 @@ async def test_http_api_rejects_poll_url_outside_api_base_path_after_normalizati
None
):
async with build_gateway(
lambda _: httpx.Response(200),
origin="https://api.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
lambda _: httpx.Response(200, json={"status": "completed"}),
origin="https://console.mistral.ai",
) as gateway:
with pytest.raises(
BrowserSignInError, match="status could not be retrieved"
) as err:
await gateway.poll(
"https://api.mistral.ai/v1/../api/vibe/sign-in/poll/poll-token-1"
"https://console.mistral.ai/api/../evil/sign-in/poll/poll-token-1"
)
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
@ -547,7 +569,7 @@ async def test_http_api_translates_invalid_returned_poll_url_port() -> None:
json={
"process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(),
"poll_url": "https://api.mistral.ai:99999/api/vibe/sign-in/poll/poll-token-1",
"poll_url": "https://console.mistral.ai:99999/api/vibe/sign-in/poll/poll-token-1",
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
@ -566,7 +588,7 @@ async def test_http_api_assigns_poll_failed_code_on_invalid_poll_url_port() -> N
BrowserSignInError, match="status could not be retrieved"
) as err:
await gateway.poll(
"https://api.mistral.ai:99999/api/vibe/sign-in/poll/poll-token-1"
"https://console.mistral.ai:99999/api/vibe/sign-in/poll/poll-token-1"
)
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
@ -639,16 +661,15 @@ async def test_http_api_does_not_log_poll_secret_on_poll_url_validation_failure(
poll_token = "poll-token-secret"
async with build_gateway(
lambda _: httpx.Response(200),
origin="https://api.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
lambda _: httpx.Response(200, json={"status": "completed"}),
origin="https://console.mistral.ai",
) as gateway:
with caplog.at_level(logging.WARNING, logger="vibe"):
with pytest.raises(
BrowserSignInError, match="status could not be retrieved"
):
await gateway.poll(
f"https://api.mistral.ai/v1evil/api/vibe/sign-in/poll/{poll_token}"
f"https://console.mistral.ai/apievil/sign-in/poll/{poll_token}"
)
assert poll_token not in caplog.text

View file

@ -0,0 +1,74 @@
from __future__ import annotations
from vibe.core.session.title_format import (
MAX_TITLE_LENGTH,
MentionSegment,
TextSegment,
format_session_title,
)
class TestFormatSessionTitle:
def test_empty_returns_empty(self) -> None:
assert format_session_title([]) == ""
def test_only_whitespace_returns_empty(self) -> None:
assert format_session_title([TextSegment(text=" \n\t ")]) == ""
def test_plain_text_passthrough(self) -> None:
assert (
format_session_title([TextSegment(text="Refactor the auth")])
== "Refactor the auth"
)
def test_mention_no_lines(self) -> None:
assert format_session_title([MentionSegment(name="foo.py")]) == "@foo.py"
def test_mention_with_start_line_only(self) -> None:
assert (
format_session_title([MentionSegment(name="foo.py", start_line=12)])
== "@foo.py:12"
)
def test_mention_with_line_range(self) -> None:
assert (
format_session_title([
MentionSegment(name="foo.py", start_line=9, end_line=27)
])
== "@foo.py:9-27"
)
def test_mixed_text_and_mention(self) -> None:
segments = [
TextSegment(text="Refactor "),
MentionSegment(name="auth.py"),
TextSegment(text=" please"),
]
assert format_session_title(segments) == "Refactor @auth.py please"
def test_collapses_whitespace(self) -> None:
segments = [TextSegment(text="line one\n\nline two\t\nline three")]
assert format_session_title(segments) == "line one line two line three"
def test_strips_outer_whitespace(self) -> None:
assert (
format_session_title([TextSegment(text=" hello world ")]) == "hello world"
)
def test_truncates_beyond_max(self) -> None:
long_text = "a" * (MAX_TITLE_LENGTH + 20)
result = format_session_title([TextSegment(text=long_text)])
assert result == "a" * MAX_TITLE_LENGTH + ""
def test_no_truncation_at_or_below_max(self) -> None:
text = "a" * MAX_TITLE_LENGTH
assert format_session_title([TextSegment(text=text)]) == text
def test_truncation_can_cut_inside_mention(self) -> None:
segments = [
TextSegment(text="x" * (MAX_TITLE_LENGTH - 3)),
MentionSegment(name="long-filename.py", start_line=5, end_line=10),
]
result = format_session_title(segments)
assert len(result) == MAX_TITLE_LENGTH + 1
assert result.endswith("")

View file

@ -0,0 +1,322 @@
from __future__ import annotations
from typing import Annotated, Any
from pydantic import Field, ValidationError
import pytest
from vibe.core.config.builder import ConfigBuilder
from vibe.core.config.layer import ConfigLayer, RawConfig
from vibe.core.config.schema import (
ConfigFragment,
ConfigSchema,
WithConcatMerge,
WithConflictMerge,
WithReplaceMerge,
WithShallowMerge,
WithUnionMerge,
)
from vibe.core.utils.merge import MergeConflictError
class FakeLayer(ConfigLayer[RawConfig]):
def __init__(self, *, name: str, data: dict[str, Any]) -> None:
super().__init__(name=name)
self._data = data
async def _check_trust(self) -> bool:
return True
async def _read_config(self) -> dict[str, Any]:
return dict(self._data)
class UntrustedFakeLayer(FakeLayer):
async def _check_trust(self) -> bool:
return False
class InnerFragment(ConfigFragment):
value: Annotated[str, WithReplaceMerge()] = "default"
items: Annotated[list[str], WithConcatMerge()] = Field(default_factory=list)
class SampleSchema(ConfigSchema):
name: Annotated[str, WithReplaceMerge()] = "unnamed"
tags: Annotated[list[str], WithConcatMerge()] = Field(default_factory=list)
entries: Annotated[list[dict[str, str]], WithUnionMerge(merge_key="id")] = Field(
default_factory=list
)
inner: InnerFragment = Field(default_factory=InnerFragment)
@pytest.mark.asyncio
async def test_replace_strategy_higher_layer_wins() -> None:
builder = ConfigBuilder(SampleSchema)
builder.add_layer(FakeLayer(name="low", data={"name": "low-name"}))
builder.add_layer(FakeLayer(name="high", data={"name": "high-name"}))
config = await builder.build()
assert config.name == "high-name"
@pytest.mark.asyncio
async def test_concat_strategy_appends_lists() -> None:
builder = ConfigBuilder(SampleSchema)
builder.add_layer(FakeLayer(name="base", data={"tags": ["a", "b"]}))
builder.add_layer(FakeLayer(name="extra", data={"tags": ["c"]}))
config = await builder.build()
assert config.tags == ["a", "b", "c"]
@pytest.mark.asyncio
async def test_union_strategy_merges_by_key() -> None:
builder = ConfigBuilder(SampleSchema)
builder.add_layer(
FakeLayer(
name="base",
data={"entries": [{"id": "1", "v": "old"}, {"id": "2", "v": "keep"}]},
)
)
builder.add_layer(
FakeLayer(name="override", data={"entries": [{"id": "1", "v": "new"}]})
)
config = await builder.build()
assert config.entries == [{"id": "1", "v": "new"}, {"id": "2", "v": "keep"}]
@pytest.mark.asyncio
async def test_fragment_recursion() -> None:
builder = ConfigBuilder(SampleSchema)
builder.add_layer(
FakeLayer(
name="layer1", data={"inner": {"value": "from-layer1", "items": ["x"]}}
)
)
builder.add_layer(FakeLayer(name="layer2", data={"inner": {"items": ["y"]}}))
config = await builder.build()
assert config.inner.value == "from-layer1"
assert config.inner.items == ["x", "y"]
@pytest.mark.asyncio
async def test_untrusted_layer_skipped() -> None:
builder = ConfigBuilder(SampleSchema)
builder.add_layer(FakeLayer(name="trusted", data={"name": "good"}))
builder.add_layer(UntrustedFakeLayer(name="untrusted", data={"name": "bad"}))
config = await builder.build()
assert config.name == "good"
@pytest.mark.asyncio
async def test_empty_layers_uses_schema_defaults() -> None:
builder = ConfigBuilder(SampleSchema)
config = await builder.build()
assert config.name == "unnamed"
assert config.tags == []
assert config.inner.value == "default"
@pytest.mark.asyncio
async def test_single_layer_partial_data() -> None:
builder = ConfigBuilder(SampleSchema)
builder.add_layer(FakeLayer(name="partial", data={"name": "custom"}))
config = await builder.build()
assert config.name == "custom"
assert config.tags == []
assert config.inner.value == "default"
# --- MERGE (shallow dict merge) ---
class MergeSchema(ConfigSchema):
settings: Annotated[dict[str, Any], WithShallowMerge()] = Field(
default_factory=dict
)
@pytest.mark.asyncio
async def test_shallow_merge_combines_dicts() -> None:
builder = ConfigBuilder(MergeSchema)
builder.add_layer(FakeLayer(name="base", data={"settings": {"a": 1, "b": 2}}))
builder.add_layer(FakeLayer(name="override", data={"settings": {"b": 99, "c": 3}}))
config = await builder.build()
assert config.settings == {"a": 1, "b": 99, "c": 3}
@pytest.mark.asyncio
async def test_shallow_merge_single_layer() -> None:
builder = ConfigBuilder(MergeSchema)
builder.add_layer(FakeLayer(name="only", data={"settings": {"x": 1}}))
config = await builder.build()
assert config.settings == {"x": 1}
# --- CONFLICT ---
class ConflictSchema(ConfigSchema):
unique_id: Annotated[str, WithConflictMerge()] = ""
@pytest.mark.asyncio
async def test_conflict_single_layer_succeeds() -> None:
builder = ConfigBuilder(ConflictSchema)
builder.add_layer(FakeLayer(name="only", data={"unique_id": "abc"}))
config = await builder.build()
assert config.unique_id == "abc"
@pytest.mark.asyncio
async def test_conflict_two_layers_raises() -> None:
builder = ConfigBuilder(ConflictSchema)
builder.add_layer(FakeLayer(name="first", data={"unique_id": "abc"}))
builder.add_layer(FakeLayer(name="second", data={"unique_id": "def"}))
with pytest.raises(MergeConflictError):
await builder.build()
# --- 4-layer merge with mixed strategies ---
class FourLayerSchema(ConfigSchema):
active_model: Annotated[str, WithReplaceMerge()] = "default"
tags: Annotated[list[str], WithConcatMerge()] = Field(default_factory=list)
models: Annotated[list[dict[str, str]], WithUnionMerge(merge_key="alias")] = Field(
default_factory=list
)
settings: Annotated[dict[str, Any], WithShallowMerge()] = Field(
default_factory=dict
)
@pytest.mark.asyncio
async def test_four_layers_with_mixed_strategies() -> None:
builder = ConfigBuilder(FourLayerSchema)
builder.add_layer(
FakeLayer(
name="defaults",
data={
"active_model": "model-a",
"tags": ["core"],
"models": [{"alias": "m1", "provider": "p1"}],
"settings": {"timeout": 30},
},
)
)
builder.add_layer(
FakeLayer(
name="user",
data={
"active_model": "model-b",
"tags": ["user"],
"models": [{"alias": "m2", "provider": "p2"}],
"settings": {"timeout": 60, "debug": True},
},
)
)
builder.add_layer(
FakeLayer(
name="project",
data={
"tags": ["project"],
"models": [{"alias": "m1", "provider": "p1-override"}],
},
)
)
builder.add_layer(FakeLayer(name="cli", data={"active_model": "model-d"}))
config = await builder.build()
assert config.active_model == "model-d"
assert config.tags == ["core", "user", "project"]
assert config.models == [
{"alias": "m1", "provider": "p1-override"},
{"alias": "m2", "provider": "p2"},
]
assert config.settings == {"timeout": 60, "debug": True}
# --- Validation errors ---
class StrictSchema(ConfigSchema):
count: Annotated[int, WithReplaceMerge()]
name: Annotated[str, WithReplaceMerge()] = "default"
@pytest.mark.asyncio
async def test_validation_error_on_missing_required_field() -> None:
builder = ConfigBuilder(StrictSchema)
builder.add_layer(FakeLayer(name="incomplete", data={"name": "hello"}))
with pytest.raises(ValidationError, match="count"):
await builder.build()
@pytest.mark.asyncio
async def test_validation_error_on_wrong_type() -> None:
builder = ConfigBuilder(StrictSchema)
builder.add_layer(
FakeLayer(name="bad-type", data={"count": "not-a-number", "name": "hello"})
)
with pytest.raises(ValidationError):
await builder.build()
# --- Fragment defaults ---
@pytest.mark.asyncio
async def test_fragment_defaults_when_no_layer_provides() -> None:
builder = ConfigBuilder(SampleSchema)
config = await builder.build()
assert config.inner.value == "default"
assert config.inner.items == []
# --- Edge cases ---
@pytest.mark.asyncio
async def test_concat_with_empty_list_from_one_layer() -> None:
builder = ConfigBuilder(SampleSchema)
builder.add_layer(FakeLayer(name="empty", data={"tags": []}))
builder.add_layer(FakeLayer(name="full", data={"tags": ["a"]}))
config = await builder.build()
assert config.tags == ["a"]
@pytest.mark.asyncio
async def test_union_with_empty_list_from_one_layer() -> None:
builder = ConfigBuilder(SampleSchema)
builder.add_layer(FakeLayer(name="empty", data={"entries": []}))
builder.add_layer(FakeLayer(name="full", data={"entries": [{"id": "1", "v": "a"}]}))
config = await builder.build()
assert config.entries == [{"id": "1", "v": "a"}]
@pytest.mark.asyncio
async def test_all_layers_untrusted_uses_defaults() -> None:
builder = ConfigBuilder(SampleSchema)
builder.add_layer(UntrustedFakeLayer(name="u1", data={"name": "bad1"}))
builder.add_layer(UntrustedFakeLayer(name="u2", data={"name": "bad2"}))
config = await builder.build()
assert config.name == "unnamed"
class NullableSchema(ConfigSchema):
value: Annotated[str | None, WithReplaceMerge()] = "default"
@pytest.mark.asyncio
async def test_replace_none_means_absent_so_base_wins() -> None:
builder = ConfigBuilder(NullableSchema)
builder.add_layer(FakeLayer(name="base", data={"value": "hello"}))
builder.add_layer(FakeLayer(name="nullifier", data={"value": None}))
config = await builder.build()
# None is treated as "not provided" by MergeStrategy, so base wins
assert config.value == "hello"

View file

@ -0,0 +1,86 @@
from __future__ import annotations
from typing import Annotated, Any
from pydantic import ValidationError
import pytest
from vibe.core.config.layer import ConfigLayer, RawConfig
from vibe.core.config.orchestrator import ConfigOrchestrator
from vibe.core.config.schema import ConfigSchema, WithReplaceMerge
class FakeLayer(ConfigLayer[RawConfig]):
def __init__(self, *, name: str, data: dict[str, Any]) -> None:
super().__init__(name=name)
self._data = data
async def _check_trust(self) -> bool:
return True
async def _read_config(self) -> dict[str, Any]:
return dict(self._data)
class SimpleSchema(ConfigSchema):
value: Annotated[str, WithReplaceMerge()] = "default"
@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"
@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])
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=[])
with pytest.raises(KeyError, match="unknown"):
orch.get_layer("unknown")
@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])
assert orch.config.value == "original"
layer._data = {"value": "updated"}
await orch.reload()
assert orch.config.value == "updated"
@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])
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=[])
assert orch.config.origin_of("nonexistent") is 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({})
@pytest.mark.asyncio
async def test_subscribe_raises_not_implemented() -> None:
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
with pytest.raises(NotImplementedError, match="M3"):
await orch.subscribe(lambda: None)

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from pathlib import Path
from typing import Annotated
from pydantic import Field, ValidationError
import pytest
from vibe.core.config.schema import (
ConfigFragment,
ConfigSchema,
WithReplaceMerge,
WithUnionMerge,
)
class ModelsFragment(ConfigFragment):
active_model: Annotated[str, WithReplaceMerge()] = "default-model"
models: Annotated[list[dict[str, str]], WithUnionMerge(merge_key="alias")] = Field(
default_factory=list
)
providers: Annotated[list[dict[str, str]], WithUnionMerge(merge_key="name")] = (
Field(default_factory=list)
)
class MinimalSchema(ConfigSchema):
models: ModelsFragment = Field(default_factory=ModelsFragment)
@pytest.mark.asyncio
async def test_toml_to_typed_config_end_to_end(tmp_working_directory: Path) -> None:
toml_path = tmp_working_directory / "config.toml"
toml_path.write_text(
"""\
[models]
active_model = "mistral-large"
[[models.models]]
alias = "mistral-large"
provider = "mistral"
[[models.providers]]
name = "mistral"
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")
orchestrator = await ConfigOrchestrator.create(schema=MinimalSchema, layers=[layer])
assert orchestrator.config.models.active_model == "mistral-large"
assert orchestrator.config.models.models == [
{"alias": "mistral-large", "provider": "mistral"}
]
# Immutability (frozen schema)
with pytest.raises(ValidationError, match="frozen"):
orchestrator.config.models = ModelsFragment() # type: ignore[misc]
# Reload picks up changes
toml_path.write_text(
"""\
[models]
active_model = "codestral"
"""
)
await orchestrator.reload()
assert orchestrator.config.models.active_model == "codestral"

View file

@ -0,0 +1,96 @@
from __future__ import annotations
from pathlib import Path
import pytest
from vibe.core.config.layer import LayerImplementationError
from vibe.core.config.layers.user import UserConfigLayer
@pytest.mark.asyncio
async def test_reads_toml_file(tmp_working_directory: Path) -> None:
path = tmp_working_directory / "config.toml"
path.write_text('active_model = "mistral-large"\ncount = 42\n')
layer = UserConfigLayer(path=path, name="user-toml")
data = await layer.load()
assert data.model_extra == {"active_model": "mistral-large", "count": 42}
@pytest.mark.asyncio
async def test_always_trusted(tmp_working_directory: Path) -> None:
path = tmp_working_directory / "config.toml"
path.write_text('key = "value"\n')
layer = UserConfigLayer(path=path, name="user-toml")
assert layer.is_trusted is None
data = await layer.load()
assert layer.is_trusted is True
assert data.model_extra == {"key": "value"}
@pytest.mark.asyncio
async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None:
path = tmp_working_directory / "nonexistent.toml"
layer = UserConfigLayer(path=path, name="user-toml")
data = await layer.load()
assert data.model_extra == {}
@pytest.mark.asyncio
async def test_apply_raises_not_implemented(tmp_working_directory: Path) -> None:
path = tmp_working_directory / "config.toml"
layer = UserConfigLayer(path=path, name="user-toml")
with pytest.raises(NotImplementedError, match="M2"):
await layer.apply({"op": "set"})
@pytest.mark.asyncio
async def test_nested_toml_structure(tmp_working_directory: Path) -> None:
path = tmp_working_directory / "config.toml"
path.write_text("""\
[models]
active_model = "test"
[[models.items]]
alias = "a"
provider = "p"
""")
layer = UserConfigLayer(path=path, name="user-toml")
data = await layer.load()
assert data.model_extra == {
"models": {"active_model": "test", "items": [{"alias": "a", "provider": "p"}]}
}
@pytest.mark.asyncio
async def test_invalid_toml_raises(tmp_working_directory: Path) -> None:
path = tmp_working_directory / "bad.toml"
path.write_text("this is not valid = = = toml [[[")
layer = UserConfigLayer(path=path, name="user-toml")
with pytest.raises(LayerImplementationError, match="_read_config"):
await layer.load()
@pytest.mark.asyncio
async def test_force_reload_reads_fresh_data(tmp_working_directory: Path) -> None:
path = tmp_working_directory / "config.toml"
path.write_text('value = "first"\n')
layer = UserConfigLayer(path=path, name="user-toml")
data1 = await layer.load()
assert data1.model_extra == {"value": "first"}
path.write_text('value = "second"\n')
data2 = await layer.load(force=True)
assert data2.model_extra == {"value": "second"}
@pytest.mark.asyncio
async def test_empty_toml_file(tmp_working_directory: Path) -> None:
path = tmp_working_directory / "empty.toml"
path.write_text("")
layer = UserConfigLayer(path=path, name="user-toml")
data = await layer.load()
assert data.model_extra == {}

View file

@ -3,7 +3,6 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable
from pathlib import Path
from types import SimpleNamespace
from typing import cast
import pytest
@ -31,9 +30,10 @@ from vibe.setup.auth import (
BrowserSignInService,
BrowserSignInStatus,
)
from vibe.setup.auth.api_key_persistence import persist_api_key
import vibe.setup.onboarding as onboarding_module
from vibe.setup.onboarding import OnboardingApp
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen, persist_api_key
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
from vibe.setup.onboarding.screens.auth_method import AuthMethodScreen
from vibe.setup.onboarding.screens.browser_sign_in import (
ERROR_HINT,
@ -43,7 +43,7 @@ from vibe.setup.onboarding.screens.browser_sign_in import (
)
CONSOLE_URL = "https://console.mistral.ai"
API_URL = "https://api.mistral.ai"
BROWSER_AUTH_API_URL = "https://console.mistral.ai/api"
async def _wait_for(
@ -95,7 +95,7 @@ def _build_browser_onboarding_app(
return OnboardingApp(
config=_build_onboarding_config(
browser_auth_base_url=CONSOLE_URL,
browser_auth_api_base_url=API_URL,
browser_auth_api_base_url=BROWSER_AUTH_API_URL,
enable_experimental_browser_sign_in=True,
),
browser_sign_in_service_factory=browser_sign_in_service_factory,
@ -237,7 +237,8 @@ async def test_ui_hides_browser_sign_in_when_experimental_flag_is_disabled() ->
)
app = OnboardingApp(
config=_build_onboarding_config(
browser_auth_base_url=CONSOLE_URL, browser_auth_api_base_url=API_URL
browser_auth_base_url=CONSOLE_URL,
browser_auth_api_base_url=BROWSER_AUTH_API_URL,
),
browser_sign_in_service_factory=browser_sign_in_service_factory,
)
@ -257,7 +258,7 @@ async def test_ui_supports_browser_sign_in_when_experimental_flag_is_enabled() -
app = OnboardingApp(
config=_build_onboarding_config(
browser_auth_base_url=CONSOLE_URL,
browser_auth_api_base_url=API_URL,
browser_auth_api_base_url=BROWSER_AUTH_API_URL,
enable_experimental_browser_sign_in=True,
),
browser_sign_in_service_factory=browser_sign_in_service_factory,
@ -276,7 +277,7 @@ async def test_ui_offers_browser_sign_in_for_renamed_mistral_provider() -> None:
provider_name="customer-mistral",
backend=Backend.MISTRAL,
browser_auth_base_url=CONSOLE_URL,
browser_auth_api_base_url=API_URL,
browser_auth_api_base_url=BROWSER_AUTH_API_URL,
enable_experimental_browser_sign_in=True,
)
)
@ -344,7 +345,7 @@ async def test_ui_browser_sign_in_falls_back_to_mistral_env_var_when_missing() -
provider_name="custom-mistral",
api_key_env_var="",
browser_auth_base_url=CONSOLE_URL,
browser_auth_api_base_url=API_URL,
browser_auth_api_base_url=BROWSER_AUTH_API_URL,
enable_experimental_browser_sign_in=True,
),
browser_sign_in_service_factory=browser_sign_in_service_factory,
@ -683,7 +684,7 @@ async def test_ui_falls_back_to_default_onboarding_context_with_invalid_active_m
'api_base = "https://api.mistral.ai/v1"',
'api_key_env_var = "MISTRAL_API_KEY"',
'browser_auth_base_url = "https://console.mistral.ai"',
'browser_auth_api_base_url = "https://api.mistral.ai"',
'browser_auth_api_base_url = "https://console.mistral.ai/api"',
'backend = "mistral"',
"",
"[[models]]",
@ -732,11 +733,9 @@ def test_api_key_screen_uses_mistral_fallback_for_context_without_env_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"vibe.setup.onboarding.screens.api_key.OnboardingContext.load",
lambda: SimpleNamespace(
provider=ProviderConfig(
name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
)
"vibe.setup.auth.api_key_persistence._load_onboarding_provider",
lambda: ProviderConfig(
name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
),
)
@ -778,7 +777,7 @@ def test_persist_api_key_sends_onboarding_telemetry_with_entrypoint_metadata(
provider = ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_base="https://inference.mistral.test/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)

View file

@ -204,6 +204,84 @@ class TestSessionLoggerTitleManagement:
assert logger.session_metadata.end_time == "2026-01-01T10:00:00+00:00"
def test_set_initial_auto_title_applies_when_no_title_set(
self, session_config: SessionLoggingConfig
) -> None:
logger = SessionLogger(session_config, "test-session-123")
applied = logger.set_initial_auto_title("Pretty title")
assert applied is True
assert logger.session_metadata is not None
assert logger.session_metadata.title == "Pretty title"
assert logger.session_metadata.title_source == "auto"
def test_set_initial_auto_title_noop_when_title_already_set(
self, session_config: SessionLoggingConfig
) -> None:
logger = SessionLogger(session_config, "test-session-123")
logger.set_title("Manual title")
applied = logger.set_initial_auto_title("Pretty title")
assert applied is False
assert logger.session_metadata is not None
assert logger.session_metadata.title == "Manual title"
assert logger.session_metadata.title_source == "manual"
def test_set_initial_auto_title_noop_when_prior_auto_title_set(
self, session_config: SessionLoggingConfig
) -> None:
logger = SessionLogger(session_config, "test-session-123")
logger.set_initial_auto_title("First title")
applied = logger.set_initial_auto_title("Second title")
assert applied is False
assert logger.session_metadata is not None
assert logger.session_metadata.title == "First title"
def test_set_initial_auto_title_rejects_blank(
self, session_config: SessionLoggingConfig
) -> None:
logger = SessionLogger(session_config, "test-session-123")
applied = logger.set_initial_auto_title(" ")
assert applied is False
assert logger.session_metadata is not None
assert logger.session_metadata.title is None
def test_needs_initial_auto_title_true_when_no_title(
self, session_config: SessionLoggingConfig
) -> None:
logger = SessionLogger(session_config, "test-session-123")
assert logger.needs_initial_auto_title() is True
def test_needs_initial_auto_title_false_after_set_initial_auto_title(
self, session_config: SessionLoggingConfig
) -> None:
logger = SessionLogger(session_config, "test-session-123")
logger.set_initial_auto_title("Pretty title")
assert logger.needs_initial_auto_title() is False
def test_needs_initial_auto_title_false_after_manual_set_title(
self, session_config: SessionLoggingConfig
) -> None:
logger = SessionLogger(session_config, "test-session-123")
logger.set_title("Manual title")
assert logger.needs_initial_auto_title() is False
def test_needs_initial_auto_title_false_when_disabled(
self, disabled_session_config: SessionLoggingConfig
) -> None:
logger = SessionLogger(disabled_session_config, "test-session-123")
assert logger.needs_initial_auto_title() is False
class TestSessionLoggerSaveInteraction:
@pytest.mark.asyncio
@ -544,6 +622,47 @@ class TestSessionLoggerSaveInteraction:
assert metadata["title"] == expected_title
assert metadata["title_source"] == "auto"
@pytest.mark.asyncio
async def test_save_interaction_preserves_preset_auto_title(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
assert logger.session_metadata is not None
logger.set_initial_auto_title("Pretty @foo.py title")
messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(
role=Role.user, content="path: file:///abs/foo.py\ncontent: ..."
),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
stats = AgentStats(
steps=1, session_prompt_tokens=10, session_completion_tokens=20
)
await logger.save_interaction(
messages=messages,
stats=stats,
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
assert logger.session_dir is not None
metadata_file = logger.session_dir / "meta.json"
with open(metadata_file) as f:
metadata = json.load(f)
assert metadata["title"] == "Pretty @foo.py title"
assert metadata["title_source"] == "auto"
@pytest.mark.asyncio
async def test_save_interaction_preserves_manual_title(
self,

View file

@ -0,0 +1,83 @@
from __future__ import annotations
from pathlib import Path
import pytest
from tests.conftest import build_test_agent_loop, build_test_vibe_config
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig
from vibe.core.types import BaseEvent, SessionTitleUpdatedEvent, UserMessageEvent
def _make_agent_loop(tmp_path: Path) -> AgentLoop:
session_logging = SessionLoggingConfig(
save_dir=str(tmp_path / "sessions"), session_prefix="session", enabled=True
)
config = build_test_vibe_config(session_logging=session_logging)
backend = FakeBackend([
[mock_llm_chunk(content="ok")],
[mock_llm_chunk(content="ok")],
])
return build_test_agent_loop(config=config, backend=backend)
async def _collect(loop: AgentLoop, prompt: str, **kwargs) -> list[BaseEvent]:
return [ev async for ev in loop.act(prompt, **kwargs)]
class TestAgentLoopAutoTitleEvent:
@pytest.mark.asyncio
async def test_emits_event_on_first_user_message(self, tmp_path: Path) -> None:
loop = _make_agent_loop(tmp_path)
events = await _collect(loop, "rendered prompt", auto_title="Pretty title")
title_events = [e for e in events if isinstance(e, SessionTitleUpdatedEvent)]
assert len(title_events) == 1
assert title_events[0].title == "Pretty title"
@pytest.mark.asyncio
async def test_event_fires_after_user_message_event(self, tmp_path: Path) -> None:
loop = _make_agent_loop(tmp_path)
events = await _collect(loop, "rendered", auto_title="Pretty")
indices = {
type(e).__name__: i
for i, e in enumerate(events)
if isinstance(e, (UserMessageEvent, SessionTitleUpdatedEvent))
}
assert indices["UserMessageEvent"] < indices["SessionTitleUpdatedEvent"]
@pytest.mark.asyncio
async def test_no_event_on_second_message(self, tmp_path: Path) -> None:
loop = _make_agent_loop(tmp_path)
await _collect(loop, "first", auto_title="First title")
events = await _collect(loop, "second", auto_title="Second title")
title_events = [e for e in events if isinstance(e, SessionTitleUpdatedEvent)]
assert title_events == []
@pytest.mark.asyncio
async def test_no_event_when_auto_title_is_none(self, tmp_path: Path) -> None:
loop = _make_agent_loop(tmp_path)
events = await _collect(loop, "rendered", auto_title=None)
title_events = [e for e in events if isinstance(e, SessionTitleUpdatedEvent)]
assert title_events == []
@pytest.mark.asyncio
async def test_no_event_when_session_logging_disabled(self, tmp_path: Path) -> None:
config = build_test_vibe_config()
backend = FakeBackend(mock_llm_chunk(content="ok"))
loop = build_test_agent_loop(config=config, backend=backend)
events = await _collect(loop, "rendered", auto_title="Pretty title")
title_events = [e for e in events if isinstance(e, SessionTitleUpdatedEvent)]
assert title_events == []