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

@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.10.1] - 2026-05-20
### Added
- Pretty session titles that format `@mention` syntax for human-readable display
- Auto-emit `SessionInfoUpdate` on the first prompt so IDE session pickers reflect the title immediately
- End-to-end layered config read path with `TomlFileLayer`, `ConfigBuilder`, and `ConfigOrchestrator`
### Fixed
- MCP menu no longer shows `D`/`E` shortcuts in the detail view when there are no tools to enable or disable
- Parallel `bash` tool calls in ACP each render their own live terminal instead of racing on shared state
## [2.10.0] - 2026-05-19 ## [2.10.0] - 2026-05-19
### Added ### Added

View file

@ -1,7 +1,7 @@
id = "mistral-vibe" id = "mistral-vibe"
name = "Mistral Vibe" name = "Mistral Vibe"
description = "Mistral's open-source coding assistant" description = "Mistral's open-source coding assistant"
version = "2.10.0" version = "2.10.1"
schema_version = 1 schema_version = 1
authors = ["Mistral AI"] authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe" repository = "https://github.com/mistralai/mistral-vibe"
@ -11,21 +11,21 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg" icon = "./icons/mistral_vibe.svg"
[agent_servers.mistral-vibe.targets.darwin-aarch64] [agent_servers.mistral-vibe.targets.darwin-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.10.0/vibe-acp-darwin-aarch64-2.10.0.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.10.1/vibe-acp-darwin-aarch64-2.10.1.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64] [agent_servers.mistral-vibe.targets.darwin-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.10.0/vibe-acp-darwin-x86_64-2.10.0.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.10.1/vibe-acp-darwin-x86_64-2.10.1.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64] [agent_servers.mistral-vibe.targets.linux-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.10.0/vibe-acp-linux-aarch64-2.10.0.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.10.1/vibe-acp-linux-aarch64-2.10.1.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64] [agent_servers.mistral-vibe.targets.linux-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.10.0/vibe-acp-linux-x86_64-2.10.0.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.10.1/vibe-acp-linux-x86_64-2.10.1.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-x86_64] [agent_servers.mistral-vibe.targets.windows-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.10.0/vibe-acp-windows-x86_64-2.10.0.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.10.1/vibe-acp-windows-x86_64-2.10.1.zip"
cmd = "./vibe-acp.exe" cmd = "./vibe-acp.exe"

View file

@ -1,6 +1,6 @@
[project] [project]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.10.0" version = "2.10.1"
description = "Minimal CLI coding agent by Mistral" description = "Minimal CLI coding agent by Mistral"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"

View file

@ -7,10 +7,12 @@ from unittest.mock import patch
import pytest import pytest
from tests.conftest import build_test_vibe_config
from tests.stubs.fake_backend import FakeBackend from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_client import FakeClient from tests.stubs.fake_client import FakeClient
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agent_loop import AgentLoop from vibe.core.agent_loop import AgentLoop
from vibe.core.config import ModelConfig, SessionLoggingConfig
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
@ -45,6 +47,43 @@ def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop:
return _create_acp_agent() 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 @pytest.fixture
def temp_session_dir(tmp_path: Path) -> Path: def temp_session_dir(tmp_path: Path) -> Path:
session_dir = tmp_path / "sessions" session_dir = tmp_path / "sessions"

View file

@ -463,6 +463,14 @@ class TestSessionUpdates:
== "available_commands_update" == "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) text_response = await read_response(process)
assert text_response is not None assert text_response is not None
response = UpdateJsonRpcNotification.model_validate( response = UpdateJsonRpcNotification.model_validate(

View file

@ -16,6 +16,11 @@ import pytest
from tests import TESTS_ROOT from tests import TESTS_ROOT
from tests.e2e.common import ansi_tolerant_pattern 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): class _AcpSmokeClient(Client):
def on_connect(self, conn: Any) -> None: def on_connect(self, conn: Any) -> None:
@ -96,15 +101,22 @@ async def _terminate_process(proc: asyncio.subprocess.Process) -> None:
await proc.wait() 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 = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1" env["PYTHONUNBUFFERED"] = "1"
env["VIBE_HOME"] = str(vibe_home_dir) env["VIBE_HOME"] = str(vibe_home_dir)
vibe_home_dir.mkdir(parents=True, exist_ok=True) 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" config_file = vibe_home_dir / "config.toml"
if not config_file.exists(): 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: if include_api_key:
env["MISTRAL_API_KEY"] = "mock" 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 return env
def _build_client_capabilities(*, terminal_auth: bool = False) -> ClientCapabilities: def _build_client_capabilities(
if not terminal_auth: *, terminal_auth: bool = False, delegated_browser_auth: bool = False
) -> ClientCapabilities:
if not terminal_auth and not delegated_browser_auth:
return ClientCapabilities() 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( 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]: ) -> 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) proc = await _spawn_vibe_acp(env)
try: try:
@ -136,7 +164,8 @@ async def _connect_and_initialize(
conn.initialize( conn.initialize(
protocol_version=PROTOCOL_VERSION, protocol_version=PROTOCOL_VERSION,
client_capabilities=_build_client_capabilities( client_capabilities=_build_client_capabilities(
terminal_auth=terminal_auth terminal_auth=terminal_auth,
delegated_browser_auth=delegated_browser_auth,
), ),
client_info=Implementation( client_info=Implementation(
name="pytest-smoke", title="Pytest Smoke", version="0.0.0" 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 @pytest.mark.asyncio
async def test_vibe_acp_initialize_exposes_terminal_auth_when_supported( async def test_vibe_acp_initialize_exposes_browser_auth(vibe_home_dir: Path) -> None:
vibe_home_dir: Path, proc, initialize_response, _conn = await _connect_and_initialize(
) -> None: vibe_home_dir=vibe_home_dir, include_api_key=True, enable_browser_sign_in=True
proc, initialize_response, conn = await _connect_and_initialize(
vibe_home_dir=vibe_home_dir, include_api_key=True, terminal_auth=True
) )
try: try:
assert initialize_response.auth_methods is not None assert initialize_response.auth_methods is not None
assert len(initialize_response.auth_methods) == 1 assert len(initialize_response.auth_methods) == 1
auth_method = initialize_response.auth_methods[0] 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.id == "vibe-setup"
assert auth_method.field_meta is not None 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["label"] == "Mistral Vibe Setup"
assert terminal_auth["command"] assert terminal_auth["command"]
assert terminal_auth["args"] assert terminal_auth["args"]
assert terminal_auth["args"][-1:] == ["--setup"]
finally: finally:
await _terminate_process(proc) await _terminate_process(proc)
@pytest.mark.timeout(15) @pytest.mark.timeout(30)
def test_vibe_acp_setup_shows_onboarding_and_exits_on_cancel( def test_vibe_acp_setup_shows_onboarding_and_exits_on_cancel(
vibe_home_dir: Path, vibe_home_dir: Path,
) -> None: ) -> None:
@ -231,7 +314,7 @@ def test_vibe_acp_setup_shows_onboarding_and_exits_on_cancel(
child.logfile_read = captured child.logfile_read = captured
try: 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.sendcontrol("c")
child.expect(pexpect.EOF, timeout=10) child.expect(pexpect.EOF, timeout=10)
finally: 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 tests.mock.utils import collect_result
from vibe.acp.tools.builtins.bash import AcpBashState, Bash 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 from vibe.core.tools.builtins.bash import BashArgs, BashResult, BashToolConfig
@ -105,9 +106,7 @@ def acp_bash_tool(mock_client: MockClient) -> Bash:
config = BashToolConfig() config = BashToolConfig()
# Use model_construct to bypass Pydantic validation for testing # Use model_construct to bypass Pydantic validation for testing
state = AcpBashState.model_construct( state = AcpBashState.model_construct(
client=mock_client, client=mock_client, session_id="test_session_123"
session_id="test_session_123",
tool_call_id="test_tool_call_456",
) )
return Bash(config_getter=lambda: config, state=state) return Bash(config_getter=lambda: config, state=state)
@ -156,7 +155,7 @@ class TestAcpBashExecution:
tool = Bash( tool = Bash(
config_getter=lambda: BashToolConfig(), config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct( 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( tool = Bash(
config_getter=lambda: BashToolConfig(), config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct( 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( tool = Bash(
config_getter=lambda: BashToolConfig(), config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct( 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: async def test_run_without_client(self) -> None:
tool = Bash( tool = Bash(
config_getter=lambda: BashToolConfig(), config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct( state=AcpBashState.model_construct(client=None, session_id="test_session"),
client=None, session_id="test_session", tool_call_id="test_call"
),
) )
args = BashArgs(command="test") args = BashArgs(command="test")
@ -232,9 +229,7 @@ class TestAcpBashExecution:
mock_client = MockClient() mock_client = MockClient()
tool = Bash( tool = Bash(
config_getter=lambda: BashToolConfig(), config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct( state=AcpBashState.model_construct(client=mock_client, session_id=None),
client=mock_client, session_id=None, tool_call_id="test_call"
),
) )
args = BashArgs(command="test") args = BashArgs(command="test")
@ -256,7 +251,7 @@ class TestAcpBashExecution:
tool = Bash( tool = Bash(
config_getter=lambda: BashToolConfig(), config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct( 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( tool = Bash(
config_getter=lambda: BashToolConfig(default_timeout=30), config_getter=lambda: BashToolConfig(default_timeout=30),
state=AcpBashState.model_construct( 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( tool = Bash(
config_getter=lambda: BashToolConfig(), config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct( 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'" assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"
class TestAcpBashEmbedding: class TestAcpBashTerminalOpenedEvent:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_with_embedding(self, mock_client: MockClient) -> None: async def test_run_yields_terminal_opened_event(
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(
self, mock_client: MockClient self, mock_client: MockClient
) -> None: ) -> None:
tool = Bash( tool = Bash(
config_getter=lambda: BashToolConfig(), config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct( 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") 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 len(events) == 1
assert not mock_client._session_update_called 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 @pytest.mark.asyncio
async def test_run_embedding_handles_exception( async def test_concurrent_invocations_yield_distinct_tool_call_ids(self) -> None:
self, mock_client: MockClient mock_client = MockClient(MockTerminalHandle(terminal_id="t", wait_delay=0.05))
) -> 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
tool = Bash( tool = Bash(
config_getter=lambda: BashToolConfig(), config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct( 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") async def run_and_collect_ids(tool_call_id: str) -> list[str]:
# Should not raise, embedding failure is silently ignored ids: list[str] = []
result = await collect_result(tool.run(args)) 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 results = await asyncio.gather(
assert result.stdout == "test output" run_and_collect_ids("T1"), run_and_collect_ids("T2")
)
assert results[0] == ["T1"]
assert results[1] == ["T2"]
class TestAcpBashConfig: class TestAcpBashConfig:
@ -395,7 +385,7 @@ class TestAcpBashConfig:
tool = Bash( tool = Bash(
config_getter=lambda: BashToolConfig(default_timeout=30), config_getter=lambda: BashToolConfig(default_timeout=30),
state=AcpBashState.model_construct( 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( tool = Bash(
config_getter=lambda: BashToolConfig(), config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct( 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( tool = Bash(
config_getter=lambda: BashToolConfig(), config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct( 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( tool = Bash(
config_getter=lambda: BashToolConfig(), config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct( 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 import pytest
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop 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: class TestACPInitialize:
@pytest.mark.asyncio @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) response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
assert response.protocol_version == PROTOCOL_VERSION assert response.protocol_version == PROTOCOL_VERSION
@ -34,16 +65,20 @@ class TestACPInitialize:
), ),
) )
assert response.agent_info == Implementation( 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 @pytest.mark.asyncio
async def test_initialize_with_terminal_auth( async def test_initialize_with_terminal_auth(self) -> None:
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
"""Test initialize with terminal-auth capabilities to check it was included.""" """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}) client_capabilities = ClientCapabilities(field_meta={"terminal-auth": True})
response = await acp_agent_loop.initialize( response = await acp_agent_loop.initialize(
protocol_version=PROTOCOL_VERSION, client_capabilities=client_capabilities protocol_version=PROTOCOL_VERSION, client_capabilities=client_capabilities
@ -62,12 +97,18 @@ class TestACPInitialize:
), ),
) )
assert response.agent_info == Implementation( 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 response.auth_methods is not None
assert len(response.auth_methods) == 1 assert len(response.auth_methods) == 2
auth_method = response.auth_methods[0]
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.id == "vibe-setup"
assert auth_method.name == "Register your API Key" assert auth_method.name == "Register your API Key"
assert auth_method.description == "Register your API Key inside Mistral Vibe" 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"] terminal_auth_meta = auth_method.field_meta["terminal-auth"]
assert "command" in terminal_auth_meta assert "command" in terminal_auth_meta
assert "args" 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" 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( state = AcpReadFileState.model_construct(
client=mock_client, # type: ignore[arg-type] client=mock_client, # type: ignore[arg-type]
session_id="test_session_123", session_id="test_session_123",
tool_call_id="test_tool_call_456",
) )
return ReadFile(config_getter=lambda: config, state=state) 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.content == "line 1\nline 2\nline 3"
assert result.lines_read == 3 assert result.lines_read == 3
assert mock_client._read_text_file_called assert mock_client._read_text_file_called
assert mock_client._session_update_called
# Verify read_text_file was called correctly # Verify read_text_file was called correctly
params = mock_client._last_read_params params = mock_client._last_read_params
@ -118,7 +116,7 @@ class TestAcpReadFileExecution:
tool = ReadFile( tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(), config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct( 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( tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(), config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct( 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( tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(), config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct( 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( tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(), config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct( 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( tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(), config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct( 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() mock_client = MockClient()
tool = ReadFile( tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(), config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct( state=AcpReadFileState.model_construct(client=mock_client, session_id=None),
client=mock_client, session_id=None, tool_call_id="test_call"
),
) )
args = ReadFileArgs(path=str(test_file)) args = ReadFileArgs(path=str(test_file))

View file

@ -81,9 +81,7 @@ def acp_search_replace_tool(
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
config = SearchReplaceConfig() config = SearchReplaceConfig()
state = AcpSearchReplaceState.model_construct( state = AcpSearchReplaceState.model_construct(
client=mock_client, client=mock_client, session_id="test_session_123"
session_id="test_session_123",
tool_call_id="test_tool_call_456",
) )
return SearchReplace(config_getter=lambda: config, state=state) return SearchReplace(config_getter=lambda: config, state=state)
@ -116,7 +114,6 @@ class TestAcpSearchReplaceExecution:
assert result.blocks_applied == 1 assert result.blocks_applied == 1
assert mock_client._read_text_file_called assert mock_client._read_text_file_called
assert mock_client._write_text_file_called assert mock_client._write_text_file_called
assert mock_client._session_update_called
# Verify read_text_file was called correctly # Verify read_text_file was called correctly
read_params = mock_client._last_read_params read_params = mock_client._last_read_params
@ -141,7 +138,7 @@ class TestAcpSearchReplaceExecution:
tool = SearchReplace( tool = SearchReplace(
config_getter=lambda: config, config_getter=lambda: config,
state=AcpSearchReplaceState.model_construct( 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( tool = SearchReplace(
config_getter=lambda: SearchReplaceConfig(), config_getter=lambda: SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct( 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( tool = SearchReplace(
config_getter=lambda: SearchReplaceConfig(), config_getter=lambda: SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct( 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( tool = SearchReplace(
config_getter=lambda: SearchReplaceConfig(), config_getter=lambda: SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct( 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 pytest
import tomli_w import tomli_w
from tests.conftest import build_test_vibe_config, get_base_config from tests.conftest import get_base_config
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_client import FakeClient from tests.stubs.fake_client import FakeClient
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.acp.exceptions import InternalError, InvalidRequestError, SessionNotFoundError 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: class TestSessionSetTitle:

View file

@ -54,9 +54,7 @@ def acp_write_file_tool(
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
config = WriteFileConfig() config = WriteFileConfig()
state = AcpWriteFileState.model_construct( state = AcpWriteFileState.model_construct(
client=mock_client, client=mock_client, session_id="test_session_123"
session_id="test_session_123",
tool_call_id="test_tool_call_456",
) )
return WriteFile(config_getter=lambda: config, state=state) return WriteFile(config_getter=lambda: config, state=state)
@ -81,7 +79,6 @@ class TestAcpWriteFileExecution:
assert result.bytes_written == len(b"Hello, world!") assert result.bytes_written == len(b"Hello, world!")
assert result.file_existed is False assert result.file_existed is False
assert mock_client._write_text_file_called assert mock_client._write_text_file_called
assert mock_client._session_update_called
# Verify write_text_file was called correctly # Verify write_text_file was called correctly
params = mock_client._last_write_params params = mock_client._last_write_params
@ -97,7 +94,7 @@ class TestAcpWriteFileExecution:
tool = WriteFile( tool = WriteFile(
config_getter=lambda: WriteFileConfig(), config_getter=lambda: WriteFileConfig(),
state=AcpWriteFileState.model_construct( 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.bytes_written == len(b"New content")
assert result.file_existed is True assert result.file_existed is True
assert mock_client._write_text_file_called assert mock_client._write_text_file_called
assert mock_client._session_update_called
# Verify write_text_file was called correctly # Verify write_text_file was called correctly
params = mock_client._last_write_params params = mock_client._last_write_params
@ -132,7 +128,7 @@ class TestAcpWriteFileExecution:
tool = WriteFile( tool = WriteFile(
config_getter=lambda: WriteFileConfig(), config_getter=lambda: WriteFileConfig(),
state=AcpWriteFileState.model_construct( 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( tool = WriteFile(
config_getter=lambda: WriteFileConfig(), config_getter=lambda: WriteFileConfig(),
state=AcpWriteFileState.model_construct( 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( tool = WriteFile(
config_getter=lambda: WriteFileConfig(), config_getter=lambda: WriteFileConfig(),
state=AcpWriteFileState.model_construct( 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 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: 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 len(payload.resources) == 1
assert payload.resources[0].path == readme assert payload.resources[0].path == readme
assert len(payload.all_resources) == 2 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}" f"https://console.mistral.ai/codestral/cli/authenticate#{fragment}"
), ),
poll_url=( 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), 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}", "complete_token": f"complete-token-{TEST_PROCESS_ID}",
"state": f"state-{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: 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" 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 @pytest.mark.asyncio
async def test_authenticate_raises_when_polling_expires() -> None: async def test_authenticate_raises_when_polling_expires() -> None:
opened_urls: list[str] = [] opened_urls: list[str] = []

View file

@ -15,9 +15,9 @@ from vibe.setup.auth import (
HttpBrowserSignInGateway, HttpBrowserSignInGateway,
) )
AUTH_ORIGIN = "https://api.mistral.ai" AUTH_ORIGIN = "https://console.mistral.ai"
AUTH_BROWSER_BASE_URL = "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_PROCESS_ID = "process-1"
TEST_COMPLETE_TOKEN = "complete-token-1" TEST_COMPLETE_TOKEN = "complete-token-1"
TEST_STATE = "state-1" TEST_STATE = "state-1"
@ -46,7 +46,7 @@ def build_sign_in_url(
def build_poll_url( def build_poll_url(
*, poll_token: str = TEST_POLL_TOKEN, api_base_url: str = AUTH_API_BASE_URL *, poll_token: str = TEST_POLL_TOKEN, api_base_url: str = AUTH_API_BASE_URL
) -> str: ) -> 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 @asynccontextmanager
@ -72,7 +72,7 @@ async def test_http_api_creates_process_with_pkce_payload() -> None:
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
nonlocal captured_body 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") captured_body = request.content.decode("utf-8")
return httpx.Response( return httpx.Response(
200, 200,
@ -129,7 +129,7 @@ async def test_http_api_exchanges_token_for_api_key() -> None:
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
nonlocal captured_body 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") captured_body = request.content.decode("utf-8")
return httpx.Response(200, json={"api_key": "sk-browser-key"}) 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 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 @pytest.mark.asyncio
async def test_http_api_translates_transport_errors() -> None: async def test_http_api_translates_transport_errors() -> None:
def handler(request: httpx.Request) -> httpx.Response: 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 @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) 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: 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( return httpx.Response(
200, 200,
json={ json={
"process_id": TEST_PROCESS_ID, "process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(), "sign_in_url": build_sign_in_url(base_url=browser_base_url),
"poll_url": build_poll_url( "poll_url": poll_url,
poll_token=TEST_POLL_TOKEN, api_base_url="https://api.mistral.ai/v1"
),
"expires_at": _iso(now + timedelta(minutes=5)), "expires_at": _iso(now + timedelta(minutes=5)),
}, },
) )
async with build_gateway( async with build_gateway(
handler, handler,
origin="https://api.mistral.ai", origin="https://browser-auth.example",
api_base_url="https://api.mistral.ai/v1", browser_base_url=browser_base_url,
api_base_url=api_base_url,
) as gateway: ) as gateway:
process = await gateway.create_process("challenge-123") 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 @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( "sign_in_url": build_sign_in_url(
base_url="https://console.mistral.ai:443" 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)), "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( assert process.sign_in_url == build_sign_in_url(
base_url="https://console.mistral.ai:443" 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 @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( async with build_gateway(
handler, handler,
origin="https://api.mistral.ai:443", origin="https://console.mistral.ai:443",
api_base_url="https://api.mistral.ai:443", api_base_url="https://console.mistral.ai:443/api",
) as gateway: ) as gateway:
result = await gateway.poll(build_poll_url()) 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( "sign_in_url": build_sign_in_url(
base_url="https://console.mistral.ai/v1/.." 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)), "expires_at": _iso(now + timedelta(minutes=5)),
}, },
) )
async with build_gateway( async with build_gateway(
handler, handler, browser_base_url="https://console.mistral.ai/v1"
browser_base_url="https://console.mistral.ai/v1",
api_base_url="https://api.mistral.ai/v1",
) as gateway: ) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err: with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
await gateway.create_process("challenge-123") 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( "sign_in_url": build_sign_in_url(
base_url="https://console.mistral.ai/v1/%2e%2e" 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)), "expires_at": _iso(now + timedelta(minutes=5)),
}, },
) )
async with build_gateway( async with build_gateway(
handler, handler, browser_base_url="https://console.mistral.ai/v1"
browser_base_url="https://console.mistral.ai/v1",
api_base_url="https://api.mistral.ai/v1",
) as gateway: ) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err: with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
await gateway.create_process("challenge-123") 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_url": build_poll_url(
poll_token=TEST_POLL_TOKEN, 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)), "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( async with build_gateway(
handler, handler,
origin="https://api.mistral.ai", origin="https://console.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
browser_base_url="https://console.mistral.ai/v1", browser_base_url="https://console.mistral.ai/v1",
) as gateway: ) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err: 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_url": build_poll_url(
poll_token=TEST_POLL_TOKEN, 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)), "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( async with build_gateway(
handler, handler,
origin="https://api.mistral.ai", origin="https://console.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
browser_base_url="https://console.mistral.ai/v1", browser_base_url="https://console.mistral.ai/v1",
) as gateway: ) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err: 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 @pytest.mark.asyncio
async def test_http_api_rejects_poll_url_outside_api_base_path() -> None: async def test_http_api_rejects_poll_url_outside_api_base_path() -> None:
async with build_gateway( async with build_gateway(
lambda _: httpx.Response(200), lambda _: httpx.Response(200, json={"status": "completed"}),
origin="https://api.mistral.ai", origin="https://console.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
) as gateway: ) as gateway:
with pytest.raises( with pytest.raises(
BrowserSignInError, match="status could not be retrieved" BrowserSignInError, match="status could not be retrieved"
) as err: ) as err:
await gateway.poll( 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 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 None
): ):
async with build_gateway( async with build_gateway(
lambda _: httpx.Response(200), lambda _: httpx.Response(200, json={"status": "completed"}),
origin="https://api.mistral.ai", origin="https://console.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
) as gateway: ) as gateway:
with pytest.raises( with pytest.raises(
BrowserSignInError, match="status could not be retrieved" BrowserSignInError, match="status could not be retrieved"
) as err: ) as err:
await gateway.poll( 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 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={ json={
"process_id": TEST_PROCESS_ID, "process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(), "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)), "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" BrowserSignInError, match="status could not be retrieved"
) as err: ) as err:
await gateway.poll( 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 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" poll_token = "poll-token-secret"
async with build_gateway( async with build_gateway(
lambda _: httpx.Response(200), lambda _: httpx.Response(200, json={"status": "completed"}),
origin="https://api.mistral.ai", origin="https://console.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
) as gateway: ) as gateway:
with caplog.at_level(logging.WARNING, logger="vibe"): with caplog.at_level(logging.WARNING, logger="vibe"):
with pytest.raises( with pytest.raises(
BrowserSignInError, match="status could not be retrieved" BrowserSignInError, match="status could not be retrieved"
): ):
await gateway.poll( 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 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 import asyncio
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from typing import cast from typing import cast
import pytest import pytest
@ -31,9 +30,10 @@ from vibe.setup.auth import (
BrowserSignInService, BrowserSignInService,
BrowserSignInStatus, BrowserSignInStatus,
) )
from vibe.setup.auth.api_key_persistence import persist_api_key
import vibe.setup.onboarding as onboarding_module import vibe.setup.onboarding as onboarding_module
from vibe.setup.onboarding import OnboardingApp 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.auth_method import AuthMethodScreen
from vibe.setup.onboarding.screens.browser_sign_in import ( from vibe.setup.onboarding.screens.browser_sign_in import (
ERROR_HINT, ERROR_HINT,
@ -43,7 +43,7 @@ from vibe.setup.onboarding.screens.browser_sign_in import (
) )
CONSOLE_URL = "https://console.mistral.ai" 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( async def _wait_for(
@ -95,7 +95,7 @@ def _build_browser_onboarding_app(
return OnboardingApp( return OnboardingApp(
config=_build_onboarding_config( config=_build_onboarding_config(
browser_auth_base_url=CONSOLE_URL, 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, enable_experimental_browser_sign_in=True,
), ),
browser_sign_in_service_factory=browser_sign_in_service_factory, 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( app = OnboardingApp(
config=_build_onboarding_config( 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, 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( app = OnboardingApp(
config=_build_onboarding_config( config=_build_onboarding_config(
browser_auth_base_url=CONSOLE_URL, 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, enable_experimental_browser_sign_in=True,
), ),
browser_sign_in_service_factory=browser_sign_in_service_factory, 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", provider_name="customer-mistral",
backend=Backend.MISTRAL, backend=Backend.MISTRAL,
browser_auth_base_url=CONSOLE_URL, 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, 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", provider_name="custom-mistral",
api_key_env_var="", api_key_env_var="",
browser_auth_base_url=CONSOLE_URL, 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, enable_experimental_browser_sign_in=True,
), ),
browser_sign_in_service_factory=browser_sign_in_service_factory, 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_base = "https://api.mistral.ai/v1"',
'api_key_env_var = "MISTRAL_API_KEY"', 'api_key_env_var = "MISTRAL_API_KEY"',
'browser_auth_base_url = "https://console.mistral.ai"', '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"', 'backend = "mistral"',
"", "",
"[[models]]", "[[models]]",
@ -732,11 +733,9 @@ def test_api_key_screen_uses_mistral_fallback_for_context_without_env_key(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
monkeypatch.setattr( monkeypatch.setattr(
"vibe.setup.onboarding.screens.api_key.OnboardingContext.load", "vibe.setup.auth.api_key_persistence._load_onboarding_provider",
lambda: SimpleNamespace( lambda: ProviderConfig(
provider=ProviderConfig( name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
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( provider = ProviderConfig(
name="mistral", name="mistral",
api_base="https://api.mistral.ai/v1", api_base="https://inference.mistral.test/v1",
api_key_env_var="MISTRAL_API_KEY", api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL, backend=Backend.MISTRAL,
) )

View file

@ -204,6 +204,84 @@ class TestSessionLoggerTitleManagement:
assert logger.session_metadata.end_time == "2026-01-01T10:00:00+00:00" 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: class TestSessionLoggerSaveInteraction:
@pytest.mark.asyncio @pytest.mark.asyncio
@ -544,6 +622,47 @@ class TestSessionLoggerSaveInteraction:
assert metadata["title"] == expected_title assert metadata["title"] == expected_title
assert metadata["title_source"] == "auto" 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 @pytest.mark.asyncio
async def test_save_interaction_preserves_manual_title( async def test_save_interaction_preserves_manual_title(
self, 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 == []

4
uv.lock generated
View file

@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
[options] [options]
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer = "2026-05-13T08:56:52.756107Z"
exclude-newer-span = "P7D" exclude-newer-span = "P7D"
[options.exclude-newer-package] [options.exclude-newer-package]
@ -820,7 +820,7 @@ wheels = [
[[package]] [[package]]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.10.0" version = "2.10.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "agent-client-protocol" }, { name = "agent-client-protocol" },

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
VIBE_ROOT = Path(__file__).parent VIBE_ROOT = Path(__file__).parent
__version__ = "2.10.0" __version__ = "2.10.1"

View file

@ -3,12 +3,14 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import AsyncGenerator, Callable from collections.abc import AsyncGenerator, Callable
from contextlib import aclosing from contextlib import aclosing
from dataclasses import dataclass
from datetime import UTC
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
import signal import signal
import sys import sys
from typing import Any, cast, override from typing import Any, Protocol, cast, override
from uuid import uuid4 from uuid import uuid4
from acp import ( from acp import (
@ -57,6 +59,7 @@ from acp.schema import (
SetSessionConfigOptionResponse, SetSessionConfigOptionResponse,
SseMcpServer, SseMcpServer,
TerminalAuthMethod, TerminalAuthMethod,
TerminalToolCallContent,
TextContentBlock, TextContentBlock,
TextResourceContents, TextResourceContents,
ToolCallProgress, ToolCallProgress,
@ -83,7 +86,9 @@ from vibe.acp.exceptions import (
UnauthenticatedError, UnauthenticatedError,
) )
from vibe.acp.session import AcpSessionLoop from vibe.acp.session import AcpSessionLoop
from vibe.acp.title import acp_blocks_to_title_segments
from vibe.acp.tools.base import BaseAcpTool from vibe.acp.tools.base import BaseAcpTool
from vibe.acp.tools.events import ToolTerminalOpenedEvent
from vibe.acp.tools.session_update import ( from vibe.acp.tools.session_update import (
resolve_kind, resolve_kind,
tool_call_session_update, tool_call_session_update,
@ -112,6 +117,7 @@ from vibe.core.agents.models import CHAT as CHAT_AGENT, BuiltinAgentName
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import ( from vibe.core.config import (
MissingAPIKeyError, MissingAPIKeyError,
ProviderConfig,
SessionLoggingConfig, SessionLoggingConfig,
VibeConfig, VibeConfig,
load_dotenv_values, load_dotenv_values,
@ -129,6 +135,7 @@ from vibe.core.session.saved_sessions import (
update_saved_session_title_at_path, update_saved_session_title_at_path,
) )
from vibe.core.session.session_loader import SessionLoader from vibe.core.session.session_loader import SessionLoader
from vibe.core.session.title_format import format_session_title
from vibe.core.skills.manager import SkillManager from vibe.core.skills.manager import SkillManager
from vibe.core.telemetry.build_metadata import build_entrypoint_metadata from vibe.core.telemetry.build_metadata import build_entrypoint_metadata
from vibe.core.telemetry.send import TelemetryClient from vibe.core.telemetry.send import TelemetryClient
@ -146,6 +153,7 @@ from vibe.core.types import (
RateLimitError as CoreRateLimitError, RateLimitError as CoreRateLimitError,
ReasoningEvent, ReasoningEvent,
Role, Role,
SessionTitleUpdatedEvent,
ToolCallEvent, ToolCallEvent,
ToolResultEvent, ToolResultEvent,
ToolStreamEvent, ToolStreamEvent,
@ -155,6 +163,18 @@ from vibe.core.utils import (
ConversationLimitException, ConversationLimitException,
get_user_cancellation_message, get_user_cancellation_message,
) )
from vibe.setup.auth import (
BrowserSignInAttempt,
BrowserSignInError,
BrowserSignInErrorCode,
BrowserSignInService,
HttpBrowserSignInGateway,
)
from vibe.setup.auth.api_key_persistence import (
persist_api_key,
resolve_api_key_provider,
)
from vibe.setup.onboarding.context import OnboardingContext
logger = logging.getLogger("vibe") logger = logging.getLogger("vibe")
@ -215,13 +235,123 @@ def _resolved_user_message_id(client_message_id: str | None) -> str:
return str(uuid4()) return str(uuid4())
@dataclass(frozen=True)
class PendingBrowserSignInAttempt:
attempt: BrowserSignInAttempt
provider: ProviderConfig
RETRYABLE_BROWSER_SIGN_IN_COMPLETION_ERRORS = {
BrowserSignInErrorCode.EXCHANGE_FAILED,
BrowserSignInErrorCode.POLL_FAILED,
}
OnboardingContextLoader = Callable[[], OnboardingContext]
ApiKeyPersister = Callable[[ProviderConfig, str], str]
class BrowserSignInServiceAdapter(Protocol):
async def authenticate(self) -> str: ...
async def start_attempt(self) -> BrowserSignInAttempt: ...
async def complete_attempt(self, attempt: BrowserSignInAttempt) -> str: ...
async def aclose(self) -> None: ...
BrowserSignInServiceFactory = Callable[[ProviderConfig], BrowserSignInServiceAdapter]
class VibeAcpAgentLoop(AcpAgent): class VibeAcpAgentLoop(AcpAgent):
client: Client client: Client
def __init__(self) -> None: def __init__(
self,
*,
onboarding_context_loader: OnboardingContextLoader | None = None,
browser_sign_in_service_factory: BrowserSignInServiceFactory | None = None,
api_key_persister: ApiKeyPersister = persist_api_key,
) -> None:
self.sessions: dict[str, AcpSessionLoop] = {} self.sessions: dict[str, AcpSessionLoop] = {}
self.client_capabilities: ClientCapabilities | None = None self.client_capabilities: ClientCapabilities | None = None
self.client_info: Implementation | None = None self.client_info: Implementation | None = None
self._pending_browser_sign_in_attempts: dict[
str, PendingBrowserSignInAttempt
] = {}
self._load_onboarding_context = (
onboarding_context_loader or OnboardingContext.load
)
self._browser_sign_in_service_factory = (
browser_sign_in_service_factory or self._build_browser_sign_in_service
)
self._persist_api_key = api_key_persister
def _build_browser_auth_method(
self, context: OnboardingContext, method_id: str
) -> AuthMethodAgent | None:
if not context.browser_sign_in_enabled:
return None
return AuthMethodAgent(
id=method_id,
name="Sign in through Mistral AI Studio",
description="Sign into Mistral Vibe through your Mistral AI Studio account.",
)
def _build_terminal_auth_method(
self, command: str, args: list[str]
) -> TerminalAuthMethod:
return TerminalAuthMethod(
type="terminal",
id="vibe-setup",
name="Register your API Key",
description="Register your API Key inside Mistral Vibe",
field_meta={
"terminal-auth": {
"command": command,
"args": args,
"label": "Mistral Vibe Setup",
}
},
)
def _build_browser_sign_in_service(
self, provider: ProviderConfig | None = None
) -> BrowserSignInService:
provider = provider or self._load_onboarding_context().provider
if not provider.supports_browser_sign_in:
raise InvalidRequestError(
"Browser sign-in is not available for the configured provider."
)
browser_base_url = provider.browser_auth_base_url
api_base_url = provider.browser_auth_api_base_url
if browser_base_url is None or api_base_url is None:
raise ConfigurationError("Browser sign-in requires both browser auth URLs.")
return BrowserSignInService(
HttpBrowserSignInGateway(
browser_base_url=browser_base_url, api_base_url=api_base_url
)
)
def _load_enabled_browser_sign_in_context(self) -> OnboardingContext:
context = self._load_onboarding_context()
if not context.browser_sign_in_enabled:
raise InvalidRequestError(
"Browser sign-in is not available for the configured provider."
)
return context
def _supports_delegated_browser_auth(self) -> bool:
return bool(
self.client_capabilities
and self.client_capabilities.field_meta
and self.client_capabilities.field_meta.get("browser-auth-delegated")
is True
)
@override @override
async def initialize( async def initialize(
@ -256,25 +386,21 @@ class VibeAcpAgentLoop(AcpAgent):
and self.client_capabilities.field_meta.get("terminal-auth") is True and self.client_capabilities.field_meta.get("terminal-auth") is True
) )
auth_methods: list[EnvVarAuthMethod | TerminalAuthMethod | AuthMethodAgent] = ( context = self._load_onboarding_context()
[
TerminalAuthMethod( auth_methods: list[EnvVarAuthMethod | TerminalAuthMethod | AuthMethodAgent] = []
type="terminal", if browser_auth_method := self._build_browser_auth_method(
id="vibe-setup", context, "browser-auth"
name="Register your API Key", ):
description="Register your API Key inside Mistral Vibe", auth_methods.append(browser_auth_method)
field_meta={ if self._supports_delegated_browser_auth():
"terminal-auth": { delegated_browser_auth_method = self._build_browser_auth_method(
"command": command, context, "browser-auth-delegated"
"args": args, )
"label": "Mistral Vibe Setup", if delegated_browser_auth_method is not None:
} auth_methods.append(delegated_browser_auth_method)
}, if supports_terminal_auth:
) auth_methods.append(self._build_terminal_auth_method(command, args))
]
if supports_terminal_auth
else []
)
response = InitializeResponse( response = InitializeResponse(
agent_capabilities=AgentCapabilities( agent_capabilities=AgentCapabilities(
@ -298,11 +424,116 @@ class VibeAcpAgentLoop(AcpAgent):
) )
return response return response
async def _authenticate_browser_auth(self, **kwargs: Any) -> AuthenticateResponse:
action = kwargs.get("action")
if action not in {None, "start"}:
raise InvalidRequestError(f"Unsupported browser auth action: {action}")
provider = self._load_enabled_browser_sign_in_context().provider
browser_sign_in = self._browser_sign_in_service_factory(provider)
try:
api_key = await browser_sign_in.authenticate()
except BrowserSignInError as e:
raise InternalError(str(e)) from e
finally:
await browser_sign_in.aclose()
persist_result = self._persist_api_key(
resolve_api_key_provider(provider), api_key
)
return AuthenticateResponse(
field_meta={
"browser-auth": {"persistResult": persist_result, "status": "completed"}
}
)
async def _start_delegated_browser_auth(self) -> AuthenticateResponse:
provider = self._load_enabled_browser_sign_in_context().provider
browser_sign_in = self._browser_sign_in_service_factory(provider)
try:
attempt = await browser_sign_in.start_attempt()
except BrowserSignInError as e:
raise InternalError(str(e)) from e
finally:
await browser_sign_in.aclose()
self._pending_browser_sign_in_attempts[attempt.process_id] = (
PendingBrowserSignInAttempt(attempt=attempt, provider=provider)
)
return AuthenticateResponse(
field_meta={
"browser-auth-delegated": {
"attemptId": attempt.process_id,
"expiresAt": attempt.expires_at.astimezone(UTC)
.isoformat()
.replace("+00:00", "Z"),
"signInUrl": attempt.sign_in_url,
}
}
)
async def _complete_delegated_browser_auth(
self, **kwargs: Any
) -> AuthenticateResponse:
attempt_id = kwargs.get("attemptId") or kwargs.get("attempt_id")
if not isinstance(attempt_id, str) or not attempt_id:
raise InvalidRequestError("Missing browser sign-in attempt ID.")
pending_attempt = self._pending_browser_sign_in_attempts.get(attempt_id)
if pending_attempt is None:
raise InvalidRequestError(f"Unknown browser sign-in attempt: {attempt_id}")
browser_sign_in = self._browser_sign_in_service_factory(
pending_attempt.provider
)
try:
api_key = await browser_sign_in.complete_attempt(pending_attempt.attempt)
except BrowserSignInError as e:
if e.code not in RETRYABLE_BROWSER_SIGN_IN_COMPLETION_ERRORS:
self._pending_browser_sign_in_attempts.pop(attempt_id, None)
raise InvalidRequestError(str(e)) from e
finally:
await browser_sign_in.aclose()
self._pending_browser_sign_in_attempts.pop(attempt_id, None)
persist_result = self._persist_api_key(
resolve_api_key_provider(pending_attempt.provider), api_key
)
return AuthenticateResponse(
field_meta={
"browser-auth-delegated": {
"attemptId": attempt_id,
"persistResult": persist_result,
"status": "completed",
}
}
)
async def _authenticate_delegated_browser_auth(
self, **kwargs: Any
) -> AuthenticateResponse:
action = kwargs.get("action", "start")
if action not in {"start", "complete"}:
raise InvalidRequestError(
f"Unsupported delegated browser auth action: {action}"
)
if action == "start":
return await self._start_delegated_browser_auth()
return await self._complete_delegated_browser_auth(**kwargs)
@override @override
async def authenticate( async def authenticate(
self, method_id: str, **kwargs: Any self, method_id: str, **kwargs: Any
) -> AuthenticateResponse | None: ) -> AuthenticateResponse | None:
raise NotImplementedMethodError("authenticate") if method_id == "browser-auth":
return await self._authenticate_browser_auth(**kwargs)
if method_id == "browser-auth-delegated":
return await self._authenticate_delegated_browser_auth(**kwargs)
raise InvalidRequestError(f"Unsupported auth method: {method_id}")
def _build_entrypoint_metadata(self) -> EntrypointMetadata: def _build_entrypoint_metadata(self) -> EntrypointMetadata:
return build_entrypoint_metadata( return build_entrypoint_metadata(
@ -834,9 +1065,15 @@ class VibeAcpAgentLoop(AcpAgent):
) )
text_prompt = SkillManager.build_skill_prompt(text_prompt, skill) text_prompt = SkillManager.build_skill_prompt(text_prompt, skill)
auto_title: str | None = None
if session.agent_loop.session_logger.needs_initial_auto_title():
auto_title = (
format_session_title(acp_blocks_to_title_segments(prompt)) or None
)
async def agent_loop_task() -> None: async def agent_loop_task() -> None:
async for update in self._run_agent_loop( async for update in self._run_agent_loop(
session, text_prompt, resolved_message_id session, text_prompt, resolved_message_id, auto_title=auto_title
): ):
await self.client.session_update(session_id=session.id, update=update) await self.client.session_update(session_id=session.id, update=update)
@ -945,15 +1182,29 @@ class VibeAcpAgentLoop(AcpAgent):
return await handler(session, text_prompt, message_id) return await handler(session, text_prompt, message_id)
async def _run_agent_loop( async def _run_agent_loop(
self, session: AcpSessionLoop, prompt: str, client_message_id: str | None = None self,
session: AcpSessionLoop,
prompt: str,
client_message_id: str | None = None,
*,
auto_title: str | None = None,
) -> AsyncGenerator[SessionUpdate | UsageUpdate]: ) -> AsyncGenerator[SessionUpdate | UsageUpdate]:
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd()) rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
async with aclosing( async with aclosing(
session.agent_loop.act(rendered_prompt, client_message_id=client_message_id) session.agent_loop.act(
rendered_prompt,
client_message_id=client_message_id,
auto_title=auto_title,
)
) as events: ) as events:
async for event in events: async for event in events:
if isinstance(event, AssistantEvent): if isinstance(event, SessionTitleUpdatedEvent):
await self._emit_session_info_update(
session.id, title=event.title, updated_at=None
)
elif isinstance(event, AssistantEvent):
yield AgentMessageChunk( yield AgentMessageChunk(
session_update="agent_message_chunk", session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=event.content), content=TextContentBlock(type="text", text=event.content),
@ -973,7 +1224,6 @@ class VibeAcpAgentLoop(AcpAgent):
tool_manager=session.agent_loop.tool_manager, tool_manager=session.agent_loop.tool_manager,
client=self.client, client=self.client,
session_id=session.id, session_id=session.id,
tool_call_id=event.tool_call_id,
) )
session_update = tool_call_session_update(event) session_update = tool_call_session_update(event)
@ -986,6 +1236,22 @@ class VibeAcpAgentLoop(AcpAgent):
yield session_update yield session_update
self._send_usage_update(session) self._send_usage_update(session)
elif isinstance(event, ToolTerminalOpenedEvent):
# bash yielded the terminal id; surface it as an
# in_progress update carrying the terminal block.
yield ToolCallProgress(
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
status="in_progress",
kind=resolve_kind(event.tool_name),
content=[
TerminalToolCallContent(
type="terminal", terminal_id=event.terminal_id
)
],
field_meta={"tool_name": event.tool_name},
)
elif isinstance(event, ToolStreamEvent): elif isinstance(event, ToolStreamEvent):
yield ToolCallProgress( yield ToolCallProgress(
session_update="tool_call_update", session_update="tool_call_update",

69
vibe/acp/title.py Normal file
View file

@ -0,0 +1,69 @@
from __future__ import annotations
from pathlib import Path
import re
from urllib.parse import SplitResult, urlsplit
from acp.helpers import ContentBlock
from vibe.core.session.title_format import MentionSegment, TextSegment, TitleSegment
_LINE_FRAGMENT_RE = re.compile(r"^L(\d+)(?:-L(\d+))?$")
def acp_blocks_to_title_segments(blocks: list[ContentBlock]) -> list[TitleSegment]:
segments: list[TitleSegment] = []
for block in blocks:
if block.field_meta and block.field_meta.get("automatic"):
continue
if (segment := _block_to_segment(block)) is not None:
segments.append(segment)
return segments
def _block_to_segment(block: ContentBlock) -> TitleSegment | None:
match block.type:
case "text":
return TextSegment(text=block.text)
case "resource":
base, start, end = _parse_line_range_fragment(block.resource.uri)
name = _basename_from_uri(base)
if not name:
return None
return MentionSegment(name=name, start_line=start, end_line=end)
case "resource_link":
base, start, end = _parse_line_range_fragment(block.uri)
name = block.name or _basename_from_uri(base)
if not name:
return None
return MentionSegment(name=name, start_line=start, end_line=end)
case _:
return None
def _safe_urlsplit(uri: str) -> SplitResult | None:
try:
return urlsplit(uri)
except ValueError:
return None
def _parse_line_range_fragment(uri: str) -> tuple[str, int | None, int | None]:
parts = _safe_urlsplit(uri)
if parts is None or not parts.fragment:
return uri, None, None
match = _LINE_FRAGMENT_RE.match(parts.fragment)
if match is None:
return uri, None, None
start = int(match.group(1))
end = int(match.group(2)) if match.group(2) is not None else None
base_uri = parts._replace(fragment="").geturl()
return base_uri, start, end
def _basename_from_uri(uri: str) -> str:
parts = _safe_urlsplit(uri)
if parts is None:
return ""
path = parts.path if parts.scheme else uri
return Path(path).name

View file

@ -4,12 +4,8 @@ from abc import abstractmethod
from typing import Annotated, cast from typing import Annotated, cast
from acp import Client from acp import Client
from acp.helpers import ToolCallContentVariant
from acp.schema import ToolCallProgress
from pydantic import BaseModel, ConfigDict, Field, SkipValidation from pydantic import BaseModel, ConfigDict, Field, SkipValidation
from vibe.acp.tools.session_update import resolve_kind
from vibe.core.logger import logger
from vibe.core.tools.base import BaseTool, ToolError from vibe.core.tools.base import BaseTool, ToolError
from vibe.core.tools.manager import ToolManager from vibe.core.tools.manager import ToolManager
@ -21,9 +17,6 @@ class AcpToolState(BaseModel):
default=None, description="ACP Client" default=None, description="ACP Client"
) )
session_id: str | None = Field(default=None, description="Current ACP session ID") session_id: str | None = Field(default=None, description="Current ACP session ID")
tool_call_id: str | None = Field(
default=None, description="Current ACP tool call ID"
)
class BaseAcpTool[ToolState: AcpToolState](BaseTool): class BaseAcpTool[ToolState: AcpToolState](BaseTool):
@ -37,23 +30,17 @@ class BaseAcpTool[ToolState: AcpToolState](BaseTool):
@classmethod @classmethod
def update_tool_state( def update_tool_state(
cls, cls, *, tool_manager: ToolManager, client: Client | None, session_id: str | None
*,
tool_manager: ToolManager,
client: Client | None,
session_id: str | None,
tool_call_id: str | None,
) -> None: ) -> None:
tool_instance = cls.get_tool_instance(cls.get_name(), tool_manager) tool_instance = cls.get_tool_instance(cls.get_name(), tool_manager)
tool_instance.state.client = client tool_instance.state.client = client
tool_instance.state.session_id = session_id tool_instance.state.session_id = session_id
tool_instance.state.tool_call_id = tool_call_id
@classmethod @classmethod
@abstractmethod @abstractmethod
def _get_tool_state_class(cls) -> type[ToolState]: ... def _get_tool_state_class(cls) -> type[ToolState]: ...
def _load_state(self) -> tuple[Client, str, str | None]: def _load_state(self) -> tuple[Client, str]:
if self.state.client is None: if self.state.client is None:
raise ToolError( raise ToolError(
"Client not available in tool state. This tool can only be used within an ACP session." "Client not available in tool state. This tool can only be used within an ACP session."
@ -63,27 +50,4 @@ class BaseAcpTool[ToolState: AcpToolState](BaseTool):
"Session ID not available in tool state. This tool can only be used within an ACP session." "Session ID not available in tool state. This tool can only be used within an ACP session."
) )
return self.state.client, self.state.session_id, self.state.tool_call_id return self.state.client, self.state.session_id
async def _send_in_progress_session_update(
self, content: list[ToolCallContentVariant] | None = None
) -> None:
client, session_id, tool_call_id = self._load_state()
if tool_call_id is None:
return
tool_name = self.get_name()
try:
await client.session_update(
session_id=session_id,
update=ToolCallProgress(
session_update="tool_call_update",
tool_call_id=tool_call_id,
status="in_progress",
kind=resolve_kind(tool_name),
content=content,
field_meta={"tool_name": tool_name},
),
)
except Exception as e:
logger.error(f"Failed to update session: {e!r}")

View file

@ -6,7 +6,6 @@ from pathlib import Path
from acp.schema import ( from acp.schema import (
ContentToolCallContent, ContentToolCallContent,
TerminalToolCallContent,
TextContentBlock, TextContentBlock,
ToolCallProgress, ToolCallProgress,
ToolCallStart, ToolCallStart,
@ -15,6 +14,7 @@ from acp.schema import (
from vibe import VIBE_ROOT from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool from vibe.acp.tools.base import AcpToolState, BaseAcpTool
from vibe.acp.tools.events import ToolTerminalOpenedEvent
from vibe.acp.tools.session_update import resolve_kind from vibe.acp.tools.session_update import resolve_kind
from vibe.core.logger import logger from vibe.core.logger import logger
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
@ -37,7 +37,7 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
async def run( async def run(
self, args: BashArgs, ctx: InvokeContext | None = None self, args: BashArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | BashResult, None]: ) -> AsyncGenerator[ToolStreamEvent | BashResult, None]:
client, session_id, _ = self._load_state() client, session_id = self._load_state()
timeout = args.timeout or self.config.default_timeout timeout = args.timeout or self.config.default_timeout
max_bytes = self.config.max_output_bytes max_bytes = self.config.max_output_bytes
@ -54,11 +54,14 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
terminal_id = terminal.terminal_id terminal_id = terminal.terminal_id
await self._send_in_progress_session_update([
TerminalToolCallContent(type="terminal", terminal_id=terminal_id)
])
try: try:
if ctx is not None:
yield ToolTerminalOpenedEvent(
tool_name=self.get_name(),
tool_call_id=ctx.tool_call_id,
terminal_id=terminal_id,
)
exit_response = await self._wait_for_terminal_exit( exit_response = await self._wait_for_terminal_exit(
terminal_id=terminal_id, timeout=timeout, command=args.command terminal_id=terminal_id, timeout=timeout, command=args.command
) )
@ -93,7 +96,7 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
async def _wait_for_terminal_exit( async def _wait_for_terminal_exit(
self, terminal_id: str, timeout: int, command: str self, terminal_id: str, timeout: int, command: str
) -> WaitForTerminalExitResponse: ) -> WaitForTerminalExitResponse:
client, session_id, _ = self._load_state() client, session_id = self._load_state()
try: try:
return await asyncio.wait_for( return await asyncio.wait_for(

View file

@ -114,13 +114,11 @@ class ReadFile(
) )
async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult: async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
client, session_id, _ = self._load_state() client, session_id = self._load_state()
line = args.offset + 1 if args.offset > 0 else None line = args.offset + 1 if args.offset > 0 else None
limit = args.limit limit = args.limit
await self._send_in_progress_session_update()
try: try:
response = await client.read_text_file( response = await client.read_text_file(
session_id=session_id, path=str(file_path), line=line, limit=limit session_id=session_id, path=str(file_path), line=line, limit=limit

View file

@ -44,9 +44,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
return AcpSearchReplaceState return AcpSearchReplaceState
async def _read_file(self, file_path: Path) -> ReadSafeResult: async def _read_file(self, file_path: Path) -> ReadSafeResult:
client, session_id, _ = self._load_state() client, session_id = self._load_state()
await self._send_in_progress_session_update()
try: try:
response = await client.read_text_file( response = await client.read_text_file(
@ -74,7 +72,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
async def _write_file( async def _write_file(
self, file_path: Path, content: str, encoding: str, newline: str self, file_path: Path, content: str, encoding: str, newline: str
) -> None: ) -> None:
client, session_id, _ = self._load_state() client, session_id = self._load_state()
try: try:
await client.write_text_file( await client.write_text_file(

View file

@ -41,9 +41,7 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
return AcpWriteFileState return AcpWriteFileState
async def _write_file(self, args: WriteFileArgs, file_path: Path) -> None: async def _write_file(self, args: WriteFileArgs, file_path: Path) -> None:
client, session_id, _ = self._load_state() client, session_id = self._load_state()
await self._send_in_progress_session_update()
try: try:
await client.write_text_file( await client.write_text_file(

8
vibe/acp/tools/events.py Normal file
View file

@ -0,0 +1,8 @@
from __future__ import annotations
from vibe.core.types import ToolStreamEvent
class ToolTerminalOpenedEvent(ToolStreamEvent):
message: str = ""
terminal_id: str

View file

@ -123,7 +123,10 @@ from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents import AgentProfile from vibe.core.agents import AgentProfile
from vibe.core.audio_player.audio_player import AudioPlayer from vibe.core.audio_player.audio_player import AudioPlayer
from vibe.core.audio_recorder import AudioRecorder from vibe.core.audio_recorder import AudioRecorder
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.autocompletion.path_prompt_adapter import render_path_prompt from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig from vibe.core.config import VibeConfig
from vibe.core.data_retention import DATA_RETENTION_MESSAGE from vibe.core.data_retention import DATA_RETENTION_MESSAGE
@ -140,6 +143,7 @@ from vibe.core.session.resume_sessions import (
) )
from vibe.core.session.saved_sessions import update_saved_session_title_at_path from vibe.core.session.saved_sessions import update_saved_session_title_at_path
from vibe.core.session.session_loader import SessionLoader from vibe.core.session.session_loader import SessionLoader
from vibe.core.session.title_format import format_session_title
from vibe.core.skills.manager import SkillManager from vibe.core.skills.manager import SkillManager
from vibe.core.teleport.telemetry import send_teleport_early_failure_telemetry from vibe.core.teleport.telemetry import send_teleport_early_failure_telemetry
from vibe.core.teleport.types import ( from vibe.core.teleport.types import (
@ -957,7 +961,7 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_slash_command_used(skill.name, "skill") self.agent_loop.telemetry_client.send_slash_command_used(skill.name, "skill")
prompt = SkillManager.build_skill_prompt(user_input, skill) prompt = SkillManager.build_skill_prompt(user_input, skill)
await self._handle_user_message(prompt) await self._handle_user_message(prompt, title_source=user_input)
return True return True
@staticmethod @staticmethod
@ -1146,7 +1150,9 @@ class VibeApp(App): # noqa: PLR0904
return "\n\n".join(sections) return "\n\n".join(sections)
async def _handle_user_message(self, message: str) -> None: async def _handle_user_message(
self, message: str, *, title_source: str | None = None
) -> None:
if self._remote_manager.is_active: if self._remote_manager.is_active:
await self._handle_remote_user_message(message) await self._handle_remote_user_message(message)
return return
@ -1165,7 +1171,7 @@ class VibeApp(App): # noqa: PLR0904
await self._remote_manager.stop_stream() await self._remote_manager.stop_stream()
await self._remove_loading_widget() await self._remove_loading_widget()
self._agent_task = asyncio.create_task( self._agent_task = asyncio.create_task(
self._handle_agent_loop_turn(message) self._handle_agent_loop_turn(message, title_source=title_source)
) )
async def _handle_remote_user_message(self, message: str) -> None: async def _handle_remote_user_message(self, message: str) -> None:
@ -1366,7 +1372,9 @@ class VibeApp(App): # noqa: PLR0904
event, loading_widget=self._loading_widget event, loading_widget=self._loading_widget
) )
async def _handle_agent_loop_turn(self, prompt: str) -> None: async def _handle_agent_loop_turn(
self, prompt: str, *, title_source: str | None = None
) -> None:
self._agent_running = True self._agent_running = True
await self._remove_loading_widget() await self._remove_loading_widget()
@ -1393,10 +1401,22 @@ class VibeApp(App): # noqa: PLR0904
message_id=message_id, message_id=message_id,
) )
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd()) rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
auto_title: str | None = None
if self.agent_loop.session_logger.needs_initial_auto_title():
auto_title = (
format_session_title(
build_title_segments(
title_source or prompt, base_dir=Path.cwd()
)
)
or None
)
self._narrator_manager.cancel() self._narrator_manager.cancel()
self._narrator_manager.on_turn_start(rendered_prompt) self._narrator_manager.on_turn_start(rendered_prompt)
async with aclosing( async with aclosing(
self.agent_loop.act(rendered_prompt, client_message_id=message_id) self.agent_loop.act(
rendered_prompt, client_message_id=message_id, auto_title=auto_title
)
) as events: ) as events:
await self._handle_agent_loop_events(events) await self._handle_agent_loop_events(events)
except asyncio.CancelledError: except asyncio.CancelledError:

View file

@ -33,6 +33,7 @@ from vibe.core.types import (
PlanReviewEndedEvent, PlanReviewEndedEvent,
PlanReviewRequestedEvent, PlanReviewRequestedEvent,
ReasoningEvent, ReasoningEvent,
SessionTitleUpdatedEvent,
ToolCallEvent, ToolCallEvent,
ToolResultEvent, ToolResultEvent,
ToolStreamEvent, ToolStreamEvent,
@ -116,6 +117,8 @@ class EventHandler:
case AgentProfileChangedEvent(): case AgentProfileChangedEvent():
if self.on_profile_changed: if self.on_profile_changed:
self.on_profile_changed() self.on_profile_changed()
case SessionTitleUpdatedEvent():
pass
case UserMessageEvent(): case UserMessageEvent():
await self.finalize_streaming() await self.finalize_streaming()
if self.is_remote: if self.is_remote:

View file

@ -71,6 +71,7 @@ _LIST_VIEW_HELP_AUTH = (
_DETAIL_VIEW_HELP = ( _DETAIL_VIEW_HELP = (
"↑↓ Navigate D Disable E Enable Backspace Back R Refresh Esc Close" "↑↓ Navigate D Disable E Enable Backspace Back R Refresh Esc Close"
) )
_DETAIL_VIEW_HELP_NO_TOOLS = "↑↓ Navigate Backspace Back R Refresh Esc Close"
class MCPApp(Container): class MCPApp(Container):
@ -211,7 +212,16 @@ class MCPApp(Container):
return return
self._status_message = "Refreshing..." self._status_message = "Refreshing..."
help = _DETAIL_VIEW_HELP if self._viewing_server else _LIST_VIEW_HELP_TOOLS if self._viewing_server:
tools_source = (
self._index.connector_tools
if self._viewing_kind == MCPSourceKind.CONNECTOR
else self._index.server_tools
)
all_tools = tools_source.get(self._viewing_server, [])
help = _DETAIL_VIEW_HELP if all_tools else _DETAIL_VIEW_HELP_NO_TOOLS
else:
help = _LIST_VIEW_HELP_TOOLS
self._set_help_text(help) self._set_help_text(help)
self._refreshing = True self._refreshing = True
@ -516,9 +526,11 @@ class MCPApp(Container):
self.query_one("#mcp-title", NoMarkupStatic).update( self.query_one("#mcp-title", NoMarkupStatic).update(
f"{title_prefix}: {server_name}" f"{title_prefix}: {server_name}"
) )
self._set_help_text(_DETAIL_VIEW_HELP)
tools_source = index.connector_tools if is_connector else index.server_tools tools_source = index.connector_tools if is_connector else index.server_tools
all_tools = sorted(tools_source.get(server_name, []), key=lambda t: t[0]) all_tools = sorted(tools_source.get(server_name, []), key=lambda t: t[0])
self._set_help_text(
_DETAIL_VIEW_HELP if all_tools else _DETAIL_VIEW_HELP_NO_TOOLS
)
if not all_tools: if not all_tools:
if ( if (
is_connector is_connector

View file

@ -113,6 +113,7 @@ from vibe.core.types import (
RateLimitError, RateLimitError,
ReasoningEvent, ReasoningEvent,
Role, Role,
SessionTitleUpdatedEvent,
ToolCall, ToolCall,
ToolCallEvent, ToolCallEvent,
ToolResultEvent, ToolResultEvent,
@ -629,7 +630,11 @@ class AgentLoop: # noqa: PLR0904
@requires_init @requires_init
async def act( async def act(
self, msg: str, client_message_id: str | None = None self,
msg: str,
client_message_id: str | None = None,
*,
auto_title: str | None = None,
) -> AsyncGenerator[BaseEvent, None]: ) -> AsyncGenerator[BaseEvent, None]:
self._clean_message_history() self._clean_message_history()
self.rewind_manager.create_checkpoint() self.rewind_manager.create_checkpoint()
@ -639,7 +644,7 @@ class AgentLoop: # noqa: PLR0904
model_name = None model_name = None
async with agent_span(model=model_name, session_id=self.session_id): async with agent_span(model=model_name, session_id=self.session_id):
async for event in self._conversation_loop( async for event in self._conversation_loop(
msg, client_message_id=client_message_id msg, client_message_id=client_message_id, auto_title=auto_title
): ):
yield event yield event
@ -848,7 +853,11 @@ class AgentLoop: # noqa: PLR0904
return headers return headers
async def _conversation_loop( # noqa: PLR0912 async def _conversation_loop( # noqa: PLR0912
self, user_msg: str, client_message_id: str | None = None self,
user_msg: str,
client_message_id: str | None = None,
*,
auto_title: str | None = None,
) -> AsyncGenerator[BaseEvent]: ) -> AsyncGenerator[BaseEvent]:
user_message = LLMMessage( user_message = LLMMessage(
role=Role.user, content=user_msg, message_id=client_message_id role=Role.user, content=user_msg, message_id=client_message_id
@ -862,6 +871,11 @@ class AgentLoop: # noqa: PLR0904
yield UserMessageEvent(content=user_msg, message_id=user_message.message_id) yield UserMessageEvent(content=user_msg, message_id=user_message.message_id)
if auto_title is not None and self.session_logger.set_initial_auto_title(
auto_title
):
yield SessionTitleUpdatedEvent(title=auto_title)
if self._hooks_manager: if self._hooks_manager:
self._hooks_manager.reset_retry_count() self._hooks_manager.reset_retry_count()

View file

@ -4,6 +4,8 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from vibe.core.session.title_format import MentionSegment, TextSegment, TitleSegment
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class PathResource: class PathResource:
@ -107,3 +109,35 @@ def _dedupe_resources(resources: list[PathResource]) -> list[PathResource]:
seen.add(resource.path) seen.add(resource.path)
unique.append(resource) unique.append(resource)
return unique return unique
def build_title_segments(
message: str, *, base_dir: Path | None = None
) -> list[TitleSegment]:
if not message:
return []
resolved_base = (base_dir or Path.cwd()).resolve()
segments: list[TitleSegment] = []
text_buf: list[str] = []
pos = 0
def flush_text() -> None:
if text_buf:
segments.append(TextSegment(text="".join(text_buf)))
text_buf.clear()
while pos < len(message):
if _is_path_anchor(message, pos):
candidate, new_pos = _extract_candidate(message, pos + 1)
if candidate and (resource := _to_resource(candidate, resolved_base)):
flush_text()
segments.append(MentionSegment(name=resource.path.name))
pos = new_pos
continue
text_buf.append(message[pos])
pos += 1
flush_text()
return segments

122
vibe/core/config/builder.py Normal file
View file

@ -0,0 +1,122 @@
from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from vibe.core.config.layer import (
ConfigLayer,
EmptyLayerError,
RawConfig,
UntrustedLayerError,
)
from vibe.core.config.schema import ConfigFragment, ConfigSchema, MergeFieldMetadata
@dataclass(frozen=True, slots=True)
class _LayerData:
name: str
data: dict[str, Any]
class ConfigBuilder[S: ConfigSchema]:
"""Collects layers and merges them into an immutable Config[S]."""
def __init__(self, schema: type[S]) -> None:
self._schema = schema
self._layers: list[ConfigLayer[RawConfig]] = []
self._lock = asyncio.Lock()
def add_layer(self, layer: ConfigLayer[RawConfig]) -> None:
self._layers.append(layer)
def add_layers(self, layers: list[ConfigLayer[RawConfig]]) -> None:
self._layers.extend(layers)
@property
def layers(self) -> list[ConfigLayer[RawConfig]]:
return self._layers
async def build(self, force_load: bool = False) -> S:
"""Merge all layers and return a validated schema.
Untrusted and empty layers are skipped.
Pass ``force_load=True`` to bypass caching.
"""
async with self._lock:
internal_layers = self._layers.copy()
layer_dicts: list[_LayerData] = []
for layer in internal_layers:
try:
data = await layer.load(force=force_load)
raw = data.model_dump()
if raw:
layer_dicts.append(_LayerData(name=layer.name, data=raw))
except (UntrustedLayerError, EmptyLayerError):
continue
merged, origins = self._merge_fields(self._schema, layer_dicts)
return self._schema(origins=origins, **merged)
def _merge_fields(
self, schema: type[S], layer_dicts: list[_LayerData]
) -> tuple[dict[str, Any], dict[str, Any]]:
accumulated: dict[str, Any] = defaultdict(dict)
origins: dict[str, Any] = {}
for ld in layer_dicts:
for key, value in ld.data.items():
if key not in schema.model_fields:
continue
field_info = schema.model_fields[key]
annotation = field_info.annotation
if annotation is None:
continue
is_fragment = isinstance(annotation, type) and issubclass(
annotation, ConfigFragment
)
if is_fragment:
if not isinstance(value, dict):
continue
for fragment_key, fragment_value in value.items():
if fragment_key not in annotation.model_fields:
continue
fragment_field = annotation.model_fields[fragment_key]
fragment_meta = MergeFieldMetadata.from_field(fragment_field)
if fragment_meta is None:
continue
accumulated[key][fragment_key] = (
fragment_meta.merge_strategy.apply(
accumulated[key].get(fragment_key),
fragment_value,
key_fn=self._make_key_fn(fragment_meta),
)
)
continue
meta = MergeFieldMetadata.from_field(field_info)
if meta is None:
continue
accumulated[key] = meta.merge_strategy.apply(
accumulated.get(key), value, key_fn=self._make_key_fn(meta)
)
return accumulated, origins
def _make_key_fn(
self, merge_field_meta: MergeFieldMetadata
) -> Callable[[Any], str] | None:
merge_key = merge_field_meta.merge_key
if merge_key is None:
return None
return lambda item: item[merge_key]

View file

@ -0,0 +1,5 @@
from __future__ import annotations
from vibe.core.config.layers.user import UserConfigLayer
__all__ = ["UserConfigLayer"]

View file

@ -0,0 +1,32 @@
from __future__ import annotations
from pathlib import Path
import tomllib
from typing import Any
from vibe.core.config.layer import ConfigLayer, RawConfig
from vibe.core.paths._vibe_home import VIBE_HOME
class UserConfigLayer(ConfigLayer[RawConfig]):
"""Reads the user-level TOML config file. Always trusted.
Defaults to ``~/.vibe/config.toml`` (via VIBE_HOME).
Pass an explicit ``path`` for testing.
"""
def __init__(self, *, path: Path | None = None, name: str = "user-toml") -> None:
super().__init__(name=name)
self._path = path or (VIBE_HOME.path / "config.toml")
async def _check_trust(self) -> bool:
return True
async def _read_config(self) -> dict[str, Any]:
if not self._path.exists():
return {}
with self._path.open("rb") as f:
return tomllib.load(f)
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
raise NotImplementedError("UserConfigLayer.apply() is not implemented (M2)")

View file

@ -0,0 +1,46 @@
from __future__ import annotations
from typing import Any
from vibe.core.config.builder import ConfigBuilder
from vibe.core.config.layer import ConfigLayer, RawConfig
from vibe.core.config.schema import ConfigSchema
class ConfigOrchestrator[S: ConfigSchema]:
"""Single entry point for config management."""
def __init__(self, builder: ConfigBuilder[S], config: S) -> None:
self._builder = builder
self._config = config
@classmethod
async def create(
cls, *, schema: type[S], layers: list[ConfigLayer[RawConfig]]
) -> ConfigOrchestrator[S]:
"""Build an orchestrator from a schema and an ordered list of layers."""
builder = ConfigBuilder[S](schema)
builder.add_layers(layers)
config = await builder.build()
instance = cls(builder, config)
return instance
@property
def config(self) -> S:
return self._config
def get_layer(self, name: str) -> ConfigLayer[RawConfig]:
for layer in self._builder.layers:
if layer.name == name:
return layer
raise KeyError(f"No layer named {name!r}")
async def reload(self) -> None:
"""Force-reload all layers and atomically replace the config snapshot."""
self._config = await self._builder.build(force_load=True)
async def apply_patch(self, patch: Any) -> None:
raise NotImplementedError("apply_patch() is not implemented (M2)")
async def subscribe(self, callback: Any) -> None:
raise NotImplementedError("subscribe() is not implemented (M3)")

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict, PrivateAttr
from pydantic.fields import FieldInfo from pydantic.fields import FieldInfo
from vibe.core.utils.merge import MergeStrategy from vibe.core.utils.merge import MergeStrategy
@ -19,11 +19,25 @@ class ConfigDefinitionError(TypeError):
class ConfigSchema(BaseModel): class ConfigSchema(BaseModel):
"""Base for composite config schemas composed of fragments and merge-aware fields.""" """Base for composite config schemas composed of fragments and merge-aware fields."""
model_config = ConfigDict(frozen=True)
_origins: dict[str, str] = PrivateAttr(default_factory=dict)
def __init__(
self, *, origins: dict[str, str] | None = None, **data: object
) -> None:
super().__init__(**data)
if origins:
self._origins = origins
def origin_of(self, key: str) -> str | None:
return self._origins.get(key)
@classmethod @classmethod
def __pydantic_on_complete__(cls) -> None: def __pydantic_on_complete__(cls) -> None:
super().__pydantic_on_complete__() super().__pydantic_on_complete__()
if cls.__name__ == "ConfigSchema" and cls.__module__ == __name__: if not cls.model_fields:
return return
for field_name, field_info in cls.model_fields.items(): for field_name, field_info in cls.model_fields.items():
@ -50,6 +64,8 @@ class ConfigSchema(BaseModel):
class ConfigFragment(BaseModel): class ConfigFragment(BaseModel):
"""Base for domain config groups with merge-aware top-level fields.""" """Base for domain config groups with merge-aware top-level fields."""
model_config = ConfigDict(frozen=True)
@classmethod @classmethod
def __pydantic_on_complete__(cls) -> None: def __pydantic_on_complete__(cls) -> None:
super().__pydantic_on_complete__() super().__pydantic_on_complete__()

View file

@ -0,0 +1,8 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class ConfigOrigin:
layer_name: str

View file

@ -18,6 +18,7 @@ from vibe.core.session.session_loader import (
METADATA_FILENAME, METADATA_FILENAME,
SessionLoader, SessionLoader,
) )
from vibe.core.session.title_format import MAX_TITLE_LENGTH
from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
from vibe.core.utils import is_windows, utc_now from vibe.core.utils import is_windows, utc_now
from vibe.core.utils.io import read_safe_async from vibe.core.utils.io import read_safe_async
@ -145,7 +146,7 @@ class SessionLogger:
title_source="auto", title_source="auto",
) )
def _get_title(self, messages: Sequence[LLMMessage]) -> str: def _fallback_title_from_messages(self, messages: Sequence[LLMMessage]) -> str:
first_user_message = None first_user_message = None
for message in messages: for message in messages:
if message.role == Role.user: if message.role == Role.user:
@ -153,14 +154,12 @@ class SessionLogger:
break break
if first_user_message is None: if first_user_message is None:
title = "Untitled session" return "Untitled session"
else:
MAX_TITLE_LENGTH = 50
text = str(first_user_message.content)
title = text[:MAX_TITLE_LENGTH]
if len(text) > MAX_TITLE_LENGTH:
title += ""
text = str(first_user_message.content)
title = text[:MAX_TITLE_LENGTH]
if len(text) > MAX_TITLE_LENGTH:
title += ""
return title return title
def _set_title_state( def _set_title_state(
@ -183,14 +182,28 @@ class SessionLogger:
self._set_title_state(normalized_title, source="manual") self._set_title_state(normalized_title, source="manual")
def needs_initial_auto_title(self) -> bool:
return self.session_metadata is not None and self.session_metadata.title is None
def set_initial_auto_title(self, title: str) -> bool:
if not self.needs_initial_auto_title():
return False
normalized_title = title.strip()
if not normalized_title:
return False
self._set_title_state(normalized_title, source="auto")
return True
def _resolve_title(self, messages: Sequence[LLMMessage]) -> str | None: def _resolve_title(self, messages: Sequence[LLMMessage]) -> str | None:
if self.session_metadata is None: if self.session_metadata is None:
return self._get_title(messages) return self._fallback_title_from_messages(messages)
if self.session_metadata.title_source == "manual": if self.session_metadata.title is not None:
return self.session_metadata.title return self.session_metadata.title
title = self._get_title(messages) title = self._fallback_title_from_messages(messages)
self._set_title_state(title, source="auto") self._set_title_state(title, source="auto")
return title return title

View file

@ -0,0 +1,51 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
import re
MAX_TITLE_LENGTH = 50
ELLIPSIS = ""
_WHITESPACE_RE = re.compile(r"\s+")
@dataclass(frozen=True, slots=True)
class TextSegment:
text: str
@dataclass(frozen=True, slots=True)
class MentionSegment:
name: str
start_line: int | None = None
end_line: int | None = None
TitleSegment = TextSegment | MentionSegment
def format_session_title(segments: Sequence[TitleSegment]) -> str:
parts = [_render_segment(s) for s in segments]
joined = "".join(parts)
collapsed = _WHITESPACE_RE.sub(" ", joined).strip()
if not collapsed:
return ""
if len(collapsed) > MAX_TITLE_LENGTH:
return collapsed[:MAX_TITLE_LENGTH] + ELLIPSIS
return collapsed
def _render_segment(segment: TitleSegment) -> str:
match segment:
case TextSegment(text=text):
return text
case MentionSegment(name=name, start_line=start, end_line=end):
if start is not None and end is not None:
return f"@{name}:{start}-{end}"
if start is not None:
return f"@{name}:{start}"
return f"@{name}"

View file

@ -456,6 +456,10 @@ class AgentProfileChangedEvent(BaseEvent):
agent_name: str agent_name: str
class SessionTitleUpdatedEvent(BaseEvent):
title: str
class OutputFormat(StrEnum): class OutputFormat(StrEnum):
TEXT = auto() TEXT = auto()
JSON = auto() JSON = auto()

View file

@ -1,6 +1,10 @@
from __future__ import annotations from __future__ import annotations
from vibe.setup.auth.browser_sign_in import BrowserSignInService, BrowserSignInStatus from vibe.setup.auth.browser_sign_in import (
BrowserSignInAttempt,
BrowserSignInService,
BrowserSignInStatus,
)
from vibe.setup.auth.browser_sign_in_gateway import ( from vibe.setup.auth.browser_sign_in_gateway import (
BrowserSignInError, BrowserSignInError,
BrowserSignInErrorCode, BrowserSignInErrorCode,
@ -11,6 +15,7 @@ from vibe.setup.auth.browser_sign_in_gateway import (
from vibe.setup.auth.http_browser_sign_in_gateway import HttpBrowserSignInGateway from vibe.setup.auth.http_browser_sign_in_gateway import HttpBrowserSignInGateway
__all__ = [ __all__ = [
"BrowserSignInAttempt",
"BrowserSignInError", "BrowserSignInError",
"BrowserSignInErrorCode", "BrowserSignInErrorCode",
"BrowserSignInGateway", "BrowserSignInGateway",

View file

@ -0,0 +1,64 @@
from __future__ import annotations
import os
from dotenv import set_key
from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig
from vibe.core.paths import GLOBAL_ENV_FILE
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.telemetry.types import EntrypointMetadata
from vibe.core.types import Backend
def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
set_key(GLOBAL_ENV_FILE.path, env_key, api_key)
def _get_mistral_provider() -> ProviderConfig:
return next(
provider for provider in DEFAULT_PROVIDERS if provider.name == "mistral"
)
def _load_onboarding_provider() -> ProviderConfig:
from vibe.setup.onboarding.context import OnboardingContext
return OnboardingContext.load().provider
def resolve_api_key_provider(provider: ProviderConfig | None = None) -> ProviderConfig:
resolved_provider = provider or _load_onboarding_provider()
if resolved_provider.api_key_env_var:
return resolved_provider
return _get_mistral_provider()
def persist_api_key(
provider: ProviderConfig,
api_key: str,
*,
entrypoint_metadata: EntrypointMetadata | None = None,
) -> str:
env_key = provider.api_key_env_var
if not env_key:
return "env_var_error:<empty>"
try:
os.environ[env_key] = api_key
except ValueError:
return f"env_var_error:{env_key}"
try:
_save_api_key_to_env_file(env_key, api_key)
except (OSError, ValueError) as err:
return f"save_error:{err}"
if provider.backend == Backend.MISTRAL:
try:
telemetry = TelemetryClient(
config_getter=VibeConfig,
entrypoint_metadata_getter=lambda: entrypoint_metadata,
)
telemetry.send_onboarding_api_key_added()
except Exception:
pass
return "completed"

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from enum import StrEnum from enum import StrEnum
import hashlib import hashlib
@ -13,7 +14,6 @@ from vibe.setup.auth.browser_sign_in_gateway import (
BrowserSignInError, BrowserSignInError,
BrowserSignInErrorCode, BrowserSignInErrorCode,
BrowserSignInGateway, BrowserSignInGateway,
BrowserSignInProcess,
) )
@ -30,6 +30,15 @@ SleepFn = Callable[[float], Awaitable[None]]
NowFn = Callable[[], datetime] NowFn = Callable[[], datetime]
@dataclass(frozen=True)
class BrowserSignInAttempt:
process_id: str
sign_in_url: str
poll_url: str
expires_at: datetime
code_verifier: str
class BrowserSignInService: class BrowserSignInService:
_max_consecutive_poll_failures = 3 _max_consecutive_poll_failures = 3
@ -51,38 +60,55 @@ class BrowserSignInService:
async def aclose(self) -> None: async def aclose(self) -> None:
await self._gateway.aclose() await self._gateway.aclose()
async def authenticate(self, status_callback: StatusCallback | None = None) -> str: async def start_attempt(self) -> BrowserSignInAttempt:
verifier, challenge = _generate_pkce_pair() verifier, challenge = _generate_pkce_pair()
process = await self._gateway.create_process(challenge) process = await self._gateway.create_process(challenge)
self._emit(status_callback, BrowserSignInStatus.OPENING_BROWSER) return BrowserSignInAttempt(
self._open_browser_or_raise(process.sign_in_url) process_id=process.process_id,
sign_in_url=process.sign_in_url,
poll_url=process.poll_url,
expires_at=process.expires_at,
code_verifier=verifier,
)
async def complete_attempt(
self,
attempt: BrowserSignInAttempt,
status_callback: StatusCallback | None = None,
) -> str:
self._emit(status_callback, BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN) self._emit(status_callback, BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN)
exchange_token = await self._wait_for_completion(process) exchange_token = await self._wait_for_completion(attempt)
self._emit(status_callback, BrowserSignInStatus.EXCHANGING) self._emit(status_callback, BrowserSignInStatus.EXCHANGING)
api_key = await self._gateway.exchange( api_key = await self._gateway.exchange(
process.process_id, exchange_token, verifier attempt.process_id, exchange_token, attempt.code_verifier
) )
self._emit(status_callback, BrowserSignInStatus.COMPLETED) self._emit(status_callback, BrowserSignInStatus.COMPLETED)
return api_key return api_key
async def _wait_for_completion(self, process: BrowserSignInProcess) -> str: async def authenticate(self, status_callback: StatusCallback | None = None) -> str:
attempt = await self.start_attempt()
self._emit(status_callback, BrowserSignInStatus.OPENING_BROWSER)
self._open_browser_or_raise(attempt.sign_in_url)
return await self.complete_attempt(attempt, status_callback=status_callback)
async def _wait_for_completion(self, attempt: BrowserSignInAttempt) -> str:
consecutive_poll_failures = 0 consecutive_poll_failures = 0
while self._now() < process.expires_at: while self._now() < attempt.expires_at:
try: try:
payload = await self._gateway.poll(process.poll_url) payload = await self._gateway.poll(attempt.poll_url)
except BrowserSignInError as err: except BrowserSignInError as err:
if err.code is not BrowserSignInErrorCode.POLL_FAILED: if err.code is not BrowserSignInErrorCode.POLL_FAILED:
raise raise
consecutive_poll_failures += 1 consecutive_poll_failures += 1
if consecutive_poll_failures >= self._max_consecutive_poll_failures: if consecutive_poll_failures >= self._max_consecutive_poll_failures:
raise raise
await self._sleep_until_next_poll_or_timeout(process.expires_at) await self._sleep_until_next_poll_or_timeout(attempt.expires_at)
continue continue
consecutive_poll_failures = 0 consecutive_poll_failures = 0
match payload.status: match payload.status:
case "pending": case "pending":
await self._sleep_until_next_poll_or_timeout(process.expires_at) await self._sleep_until_next_poll_or_timeout(attempt.expires_at)
case "completed": case "completed":
if payload.exchange_token: if payload.exchange_token:
return payload.exchange_token return payload.exchange_token

View file

@ -188,9 +188,21 @@ class HttpBrowserSignInGateway(BrowserSignInGateway):
json={"exchange_token": exchange_token, "code_verifier": code_verifier}, json={"exchange_token": exchange_token, "code_verifier": code_verifier},
) )
except httpx.HTTPError as err: except httpx.HTTPError as err:
logger.warning(
"Browser sign-in exchange request failed for api_base_url=%s: %s",
self._api_base_url,
err,
exc_info=True,
)
raise BrowserSignInError(message, code=code) from err raise BrowserSignInError(message, code=code) from err
if not response.is_success: if not response.is_success:
logger.warning(
"Browser sign-in exchange request returned status_code=%s for api_base_url=%s response_detail=%s",
response.status_code,
self._api_base_url,
_build_safe_response_error_detail(response),
)
raise BrowserSignInError(message, code=code) raise BrowserSignInError(message, code=code)
raw_payload = _response_json_or_raise(response, message=message, code=code) raw_payload = _response_json_or_raise(response, message=message, code=code)
@ -298,6 +310,29 @@ def _response_json_or_raise(
return dict(payload) return dict(payload)
def _build_safe_response_error_detail(response: httpx.Response) -> str:
try:
payload = response.json()
except ValueError:
return "unavailable"
if not isinstance(payload, Mapping):
return "unavailable"
for key in ("detail", "message", "error"):
value = payload.get(key)
if isinstance(value, str):
return _truncate_log_value(value)
return "unavailable"
def _truncate_log_value(value: str, *, max_length: int = 200) -> str:
if len(value) <= max_length:
return value
return f"{value[: max_length - 3]}..."
def _build_safe_url_log_details(value: object) -> str: def _build_safe_url_log_details(value: object) -> str:
if not isinstance(value, str): if not isinstance(value, str):
return "unavailable" return "unavailable"

View file

@ -1,9 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os
from typing import ClassVar from typing import ClassVar
from dotenv import set_key
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.binding import Binding, BindingType from textual.binding import Binding, BindingType
from textual.containers import Center, Horizontal, Vertical from textual.containers import Center, Horizontal, Vertical
@ -13,13 +11,13 @@ from textual.widgets import Input, Link, Static
from vibe.cli.clipboard import copy_selection_to_clipboard from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig from vibe.core.config import ProviderConfig
from vibe.core.paths import GLOBAL_ENV_FILE
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.telemetry.types import EntrypointMetadata from vibe.core.telemetry.types import EntrypointMetadata
from vibe.core.types import Backend from vibe.setup.auth.api_key_persistence import (
persist_api_key,
resolve_api_key_provider,
)
from vibe.setup.onboarding.base import OnboardingScreen from vibe.setup.onboarding.base import OnboardingScreen
from vibe.setup.onboarding.context import OnboardingContext
PROVIDER_HELP = { PROVIDER_HELP = {
"mistral": ("https://console.mistral.ai/codestral/cli", "Mistral AI Studio") "mistral": ("https://console.mistral.ai/codestral/cli", "Mistral AI Studio")
@ -29,55 +27,6 @@ CONFIG_DOCS_URL = (
) )
def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
set_key(GLOBAL_ENV_FILE.path, env_key, api_key)
def persist_api_key(
provider: ProviderConfig,
api_key: str,
*,
entrypoint_metadata: EntrypointMetadata | None = None,
) -> str:
env_key = provider.api_key_env_var
if not env_key:
return "env_var_error:<empty>"
try:
os.environ[env_key] = api_key
except ValueError:
return f"env_var_error:{env_key}"
try:
_save_api_key_to_env_file(env_key, api_key)
except (OSError, ValueError) as err:
return f"save_error:{err}"
if provider.backend == Backend.MISTRAL:
try:
telemetry = TelemetryClient(
config_getter=VibeConfig,
entrypoint_metadata_getter=lambda: entrypoint_metadata,
)
telemetry.send_onboarding_api_key_added()
except Exception:
pass
return "completed"
def _get_mistral_provider() -> ProviderConfig:
return next(
provider for provider in DEFAULT_PROVIDERS if provider.name == "mistral"
)
def _resolve_onboarding_provider(
provider: ProviderConfig | None = None,
) -> ProviderConfig:
resolved_provider = provider or OnboardingContext.load().provider
if resolved_provider.api_key_env_var:
return resolved_provider
return _get_mistral_provider()
class ApiKeyScreen(OnboardingScreen): class ApiKeyScreen(OnboardingScreen):
BINDINGS: ClassVar[list[BindingType]] = [ BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "cancel", "Cancel", show=False), Binding("ctrl+c", "cancel", "Cancel", show=False),
@ -93,7 +42,7 @@ class ApiKeyScreen(OnboardingScreen):
entrypoint_metadata: EntrypointMetadata | None = None, entrypoint_metadata: EntrypointMetadata | None = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self.provider = _resolve_onboarding_provider(provider) self.provider = resolve_api_key_provider(provider)
self._entrypoint_metadata = entrypoint_metadata self._entrypoint_metadata = entrypoint_metadata
def _compose_provider_link(self, provider_name: str) -> ComposeResult: def _compose_provider_link(self, provider_name: str) -> ComposeResult:

View file

@ -22,11 +22,11 @@ from vibe.setup.auth import (
BrowserSignInService, BrowserSignInService,
BrowserSignInStatus, BrowserSignInStatus,
) )
from vibe.setup.onboarding.base import OnboardingScreen from vibe.setup.auth.api_key_persistence import (
from vibe.setup.onboarding.screens.api_key import (
_resolve_onboarding_provider,
persist_api_key, persist_api_key,
resolve_api_key_provider,
) )
from vibe.setup.onboarding.base import OnboardingScreen
PENDING_HINT = "Press M to enter API key manually · Esc to cancel" PENDING_HINT = "Press M to enter API key manually · Esc to cancel"
ERROR_HINT = "Press R to retry · Press M to enter API key manually · Esc to cancel" ERROR_HINT = "Press R to retry · Press M to enter API key manually · Esc to cancel"
@ -215,7 +215,7 @@ class BrowserSignInScreen(OnboardingScreen):
self._worker = None self._worker = None
self.app.exit( self.app.exit(
persist_api_key( persist_api_key(
_resolve_onboarding_provider(self.provider), resolve_api_key_provider(self.provider),
api_key, api_key,
entrypoint_metadata=self._entrypoint_metadata, entrypoint_metadata=self._entrypoint_metadata,
) )