v2.10.1 (#702)
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:
parent
228f3c65a9
commit
f71bfd3b8c
57 changed files with 2950 additions and 402 deletions
|
|
@ -7,10 +7,12 @@ from unittest.mock import patch
|
|||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_client import FakeClient
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import ModelConfig, SessionLoggingConfig
|
||||
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
|
||||
|
||||
|
||||
|
|
@ -45,6 +47,43 @@ def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop:
|
|||
return _create_acp_agent()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent_with_session_config(
|
||||
backend: FakeBackend, temp_session_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> tuple[VibeAcpAgentLoop, FakeClient]:
|
||||
session_config = SessionLoggingConfig(
|
||||
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
|
||||
)
|
||||
config = build_test_vibe_config(
|
||||
active_model="devstral-latest",
|
||||
models=[
|
||||
ModelConfig(
|
||||
name="devstral-latest", provider="mistral", alias="devstral-latest"
|
||||
)
|
||||
],
|
||||
session_logging=session_config,
|
||||
)
|
||||
|
||||
class PatchedAgentLoop(AgentLoop):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **{**kwargs, "backend": backend})
|
||||
self._base_config = config
|
||||
self.agent_manager.invalidate_config()
|
||||
|
||||
monkeypatch.setattr("vibe.acp.acp_agent_loop.AgentLoop", PatchedAgentLoop)
|
||||
monkeypatch.setattr(VibeAcpAgentLoop, "_load_config", lambda self: config)
|
||||
monkeypatch.setattr(
|
||||
"vibe.acp.acp_agent_loop.VibeConfig.load", lambda *args, **kwargs: config
|
||||
)
|
||||
|
||||
vibe_acp_agent = VibeAcpAgentLoop()
|
||||
client = FakeClient()
|
||||
vibe_acp_agent.on_connect(client)
|
||||
client.on_connect(vibe_acp_agent)
|
||||
|
||||
return vibe_acp_agent, client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_session_dir(tmp_path: Path) -> Path:
|
||||
session_dir = tmp_path / "sessions"
|
||||
|
|
|
|||
|
|
@ -463,6 +463,14 @@ class TestSessionUpdates:
|
|||
== "available_commands_update"
|
||||
)
|
||||
|
||||
title_response_text = await read_response(process)
|
||||
assert title_response_text is not None
|
||||
title_response = UpdateJsonRpcNotification.model_validate(
|
||||
json.loads(title_response_text)
|
||||
)
|
||||
assert title_response.params is not None
|
||||
assert title_response.params.update.session_update == "session_info_update"
|
||||
|
||||
text_response = await read_response(process)
|
||||
assert text_response is not None
|
||||
response = UpdateJsonRpcNotification.model_validate(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ import pytest
|
|||
from tests import TESTS_ROOT
|
||||
from tests.e2e.common import ansi_tolerant_pattern
|
||||
|
||||
BROWSER_AUTH_NAME = "Sign in through Mistral AI Studio"
|
||||
BROWSER_AUTH_DESCRIPTION = (
|
||||
"Sign into Mistral Vibe through your Mistral AI Studio account."
|
||||
)
|
||||
|
||||
|
||||
class _AcpSmokeClient(Client):
|
||||
def on_connect(self, conn: Any) -> None:
|
||||
|
|
@ -96,15 +101,22 @@ async def _terminate_process(proc: asyncio.subprocess.Process) -> None:
|
|||
await proc.wait()
|
||||
|
||||
|
||||
def _build_env(vibe_home_dir: Path, *, include_api_key: bool) -> dict[str, str]:
|
||||
def _build_env(
|
||||
vibe_home_dir: Path, *, include_api_key: bool, enable_browser_sign_in: bool = False
|
||||
) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
env["VIBE_HOME"] = str(vibe_home_dir)
|
||||
|
||||
vibe_home_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_lines = ["enable_telemetry = false"]
|
||||
if enable_browser_sign_in:
|
||||
config_lines.append("enable_experimental_browser_sign_in = true")
|
||||
config_file = vibe_home_dir / "config.toml"
|
||||
if not config_file.exists():
|
||||
config_file.write_text("enable_telemetry = false\n")
|
||||
config_file.write_text("\n".join(config_lines) + "\n")
|
||||
elif enable_browser_sign_in:
|
||||
config_file.write_text(config_file.read_text() + config_lines[-1] + "\n")
|
||||
|
||||
if include_api_key:
|
||||
env["MISTRAL_API_KEY"] = "mock"
|
||||
|
|
@ -114,17 +126,33 @@ def _build_env(vibe_home_dir: Path, *, include_api_key: bool) -> dict[str, str]:
|
|||
return env
|
||||
|
||||
|
||||
def _build_client_capabilities(*, terminal_auth: bool = False) -> ClientCapabilities:
|
||||
if not terminal_auth:
|
||||
def _build_client_capabilities(
|
||||
*, terminal_auth: bool = False, delegated_browser_auth: bool = False
|
||||
) -> ClientCapabilities:
|
||||
if not terminal_auth and not delegated_browser_auth:
|
||||
return ClientCapabilities()
|
||||
|
||||
return ClientCapabilities(field_meta={"terminal-auth": True})
|
||||
field_meta: dict[str, bool] = {}
|
||||
if terminal_auth:
|
||||
field_meta["terminal-auth"] = True
|
||||
if delegated_browser_auth:
|
||||
field_meta["browser-auth-delegated"] = True
|
||||
return ClientCapabilities(field_meta=field_meta)
|
||||
|
||||
|
||||
async def _connect_and_initialize(
|
||||
*, vibe_home_dir: Path, include_api_key: bool, terminal_auth: bool = False
|
||||
*,
|
||||
vibe_home_dir: Path,
|
||||
include_api_key: bool,
|
||||
enable_browser_sign_in: bool = False,
|
||||
terminal_auth: bool = False,
|
||||
delegated_browser_auth: bool = False,
|
||||
) -> tuple[asyncio.subprocess.Process, Any, Any]:
|
||||
env = _build_env(vibe_home_dir, include_api_key=include_api_key)
|
||||
env = _build_env(
|
||||
vibe_home_dir,
|
||||
include_api_key=include_api_key,
|
||||
enable_browser_sign_in=enable_browser_sign_in,
|
||||
)
|
||||
proc = await _spawn_vibe_acp(env)
|
||||
|
||||
try:
|
||||
|
|
@ -136,7 +164,8 @@ async def _connect_and_initialize(
|
|||
conn.initialize(
|
||||
protocol_version=PROTOCOL_VERSION,
|
||||
client_capabilities=_build_client_capabilities(
|
||||
terminal_auth=terminal_auth
|
||||
terminal_auth=terminal_auth,
|
||||
delegated_browser_auth=delegated_browser_auth,
|
||||
),
|
||||
client_info=Implementation(
|
||||
name="pytest-smoke", title="Pytest Smoke", version="0.0.0"
|
||||
|
|
@ -188,18 +217,71 @@ async def test_vibe_acp_bootstraps_default_files(vibe_home_dir: Path) -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vibe_acp_initialize_exposes_terminal_auth_when_supported(
|
||||
vibe_home_dir: Path,
|
||||
) -> None:
|
||||
proc, initialize_response, conn = await _connect_and_initialize(
|
||||
vibe_home_dir=vibe_home_dir, include_api_key=True, terminal_auth=True
|
||||
async def test_vibe_acp_initialize_exposes_browser_auth(vibe_home_dir: Path) -> None:
|
||||
proc, initialize_response, _conn = await _connect_and_initialize(
|
||||
vibe_home_dir=vibe_home_dir, include_api_key=True, enable_browser_sign_in=True
|
||||
)
|
||||
|
||||
try:
|
||||
assert initialize_response.auth_methods is not None
|
||||
assert len(initialize_response.auth_methods) == 1
|
||||
|
||||
auth_method = initialize_response.auth_methods[0]
|
||||
assert auth_method.id == "browser-auth"
|
||||
assert auth_method.name == BROWSER_AUTH_NAME
|
||||
assert auth_method.description == BROWSER_AUTH_DESCRIPTION
|
||||
finally:
|
||||
await _terminate_process(proc)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vibe_acp_initialize_exposes_delegated_browser_auth_when_supported(
|
||||
vibe_home_dir: Path,
|
||||
) -> None:
|
||||
proc, initialize_response, _conn = await _connect_and_initialize(
|
||||
vibe_home_dir=vibe_home_dir,
|
||||
include_api_key=True,
|
||||
enable_browser_sign_in=True,
|
||||
delegated_browser_auth=True,
|
||||
)
|
||||
|
||||
try:
|
||||
assert initialize_response.auth_methods is not None
|
||||
assert len(initialize_response.auth_methods) == 2
|
||||
|
||||
browser_auth_method = initialize_response.auth_methods[0]
|
||||
assert browser_auth_method.id == "browser-auth"
|
||||
assert browser_auth_method.name == BROWSER_AUTH_NAME
|
||||
assert browser_auth_method.description == BROWSER_AUTH_DESCRIPTION
|
||||
|
||||
delegated_browser_auth_method = initialize_response.auth_methods[1]
|
||||
assert delegated_browser_auth_method.id == "browser-auth-delegated"
|
||||
assert delegated_browser_auth_method.name == BROWSER_AUTH_NAME
|
||||
assert delegated_browser_auth_method.description == BROWSER_AUTH_DESCRIPTION
|
||||
finally:
|
||||
await _terminate_process(proc)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vibe_acp_initialize_exposes_terminal_auth_when_supported(
|
||||
vibe_home_dir: Path,
|
||||
) -> None:
|
||||
proc, initialize_response, _conn = await _connect_and_initialize(
|
||||
vibe_home_dir=vibe_home_dir,
|
||||
include_api_key=True,
|
||||
enable_browser_sign_in=True,
|
||||
terminal_auth=True,
|
||||
)
|
||||
|
||||
try:
|
||||
assert initialize_response.auth_methods is not None
|
||||
assert len(initialize_response.auth_methods) == 2
|
||||
|
||||
browser_auth_method = initialize_response.auth_methods[0]
|
||||
assert browser_auth_method.id == "browser-auth"
|
||||
assert browser_auth_method.name == BROWSER_AUTH_NAME
|
||||
assert browser_auth_method.description == BROWSER_AUTH_DESCRIPTION
|
||||
|
||||
auth_method = initialize_response.auth_methods[1]
|
||||
assert auth_method.id == "vibe-setup"
|
||||
assert auth_method.field_meta is not None
|
||||
|
||||
|
|
@ -207,11 +289,12 @@ async def test_vibe_acp_initialize_exposes_terminal_auth_when_supported(
|
|||
assert terminal_auth["label"] == "Mistral Vibe Setup"
|
||||
assert terminal_auth["command"]
|
||||
assert terminal_auth["args"]
|
||||
assert terminal_auth["args"][-1:] == ["--setup"]
|
||||
finally:
|
||||
await _terminate_process(proc)
|
||||
|
||||
|
||||
@pytest.mark.timeout(15)
|
||||
@pytest.mark.timeout(30)
|
||||
def test_vibe_acp_setup_shows_onboarding_and_exits_on_cancel(
|
||||
vibe_home_dir: Path,
|
||||
) -> None:
|
||||
|
|
@ -231,7 +314,7 @@ def test_vibe_acp_setup_shows_onboarding_and_exits_on_cancel(
|
|||
child.logfile_read = captured
|
||||
|
||||
try:
|
||||
child.expect(ansi_tolerant_pattern("Welcome to Mistral Vibe"), timeout=10)
|
||||
child.expect(ansi_tolerant_pattern("Welcome to"), timeout=15)
|
||||
child.sendcontrol("c")
|
||||
child.expect(pexpect.EOF, timeout=10)
|
||||
finally:
|
||||
|
|
|
|||
112
tests/acp/test_acp_title.py
Normal file
112
tests/acp/test_acp_title.py
Normal 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")]
|
||||
432
tests/acp/test_authenticate.py
Normal file
432
tests/acp/test_authenticate.py
Normal 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
|
||||
|
|
@ -8,7 +8,8 @@ import pytest
|
|||
|
||||
from tests.mock.utils import collect_result
|
||||
from vibe.acp.tools.builtins.bash import AcpBashState, Bash
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.acp.tools.events import ToolTerminalOpenedEvent
|
||||
from vibe.core.tools.base import InvokeContext, ToolError
|
||||
from vibe.core.tools.builtins.bash import BashArgs, BashResult, BashToolConfig
|
||||
|
||||
|
||||
|
|
@ -105,9 +106,7 @@ def acp_bash_tool(mock_client: MockClient) -> Bash:
|
|||
config = BashToolConfig()
|
||||
# Use model_construct to bypass Pydantic validation for testing
|
||||
state = AcpBashState.model_construct(
|
||||
client=mock_client,
|
||||
session_id="test_session_123",
|
||||
tool_call_id="test_tool_call_456",
|
||||
client=mock_client, session_id="test_session_123"
|
||||
)
|
||||
return Bash(config_getter=lambda: config, state=state)
|
||||
|
||||
|
|
@ -156,7 +155,7 @@ class TestAcpBashExecution:
|
|||
tool = Bash(
|
||||
config_getter=lambda: BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -176,7 +175,7 @@ class TestAcpBashExecution:
|
|||
tool = Bash(
|
||||
config_getter=lambda: BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -196,7 +195,7 @@ class TestAcpBashExecution:
|
|||
tool = Bash(
|
||||
config_getter=lambda: BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -213,9 +212,7 @@ class TestAcpBashExecution:
|
|||
async def test_run_without_client(self) -> None:
|
||||
tool = Bash(
|
||||
config_getter=lambda: BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
client=None, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
state=AcpBashState.model_construct(client=None, session_id="test_session"),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
|
|
@ -232,9 +229,7 @@ class TestAcpBashExecution:
|
|||
mock_client = MockClient()
|
||||
tool = Bash(
|
||||
config_getter=lambda: BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
client=mock_client, session_id=None, tool_call_id="test_call"
|
||||
),
|
||||
state=AcpBashState.model_construct(client=mock_client, session_id=None),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
|
|
@ -256,7 +251,7 @@ class TestAcpBashExecution:
|
|||
tool = Bash(
|
||||
config_getter=lambda: BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -283,7 +278,7 @@ class TestAcpBashTimeout:
|
|||
tool = Bash(
|
||||
config_getter=lambda: BashToolConfig(default_timeout=30),
|
||||
state=AcpBashState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -312,7 +307,7 @@ class TestAcpBashTimeout:
|
|||
tool = Bash(
|
||||
config_getter=lambda: BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -324,61 +319,56 @@ class TestAcpBashTimeout:
|
|||
assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"
|
||||
|
||||
|
||||
class TestAcpBashEmbedding:
|
||||
class TestAcpBashTerminalOpenedEvent:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_embedding(self, mock_client: MockClient) -> None:
|
||||
tool = Bash(
|
||||
config_getter=lambda: BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
await collect_result(tool.run(args))
|
||||
|
||||
assert mock_client._session_update_called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_embedding_without_tool_call_id(
|
||||
async def test_run_yields_terminal_opened_event(
|
||||
self, mock_client: MockClient
|
||||
) -> None:
|
||||
tool = Bash(
|
||||
config_getter=lambda: BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id=None
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
await collect_result(tool.run(args))
|
||||
events: list[ToolTerminalOpenedEvent] = []
|
||||
async for item in tool.run(args, InvokeContext(tool_call_id="test_call")):
|
||||
if isinstance(item, ToolTerminalOpenedEvent):
|
||||
events.append(item)
|
||||
|
||||
# Embedding should be skipped when tool_call_id is None
|
||||
assert not mock_client._session_update_called
|
||||
assert len(events) == 1
|
||||
assert events[0].terminal_id == mock_client._terminal_handle.id
|
||||
assert events[0].tool_call_id == "test_call"
|
||||
assert events[0].tool_name == "bash"
|
||||
|
||||
|
||||
class TestAcpBashConcurrentInvocations:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_embedding_handles_exception(
|
||||
self, mock_client: MockClient
|
||||
) -> None:
|
||||
# Make session_update raise an exception
|
||||
async def failing_session_update(session_id: str, update, **kwargs) -> None:
|
||||
raise RuntimeError("Session update failed")
|
||||
|
||||
mock_client.session_update = failing_session_update
|
||||
|
||||
async def test_concurrent_invocations_yield_distinct_tool_call_ids(self) -> None:
|
||||
mock_client = MockClient(MockTerminalHandle(terminal_id="t", wait_delay=0.05))
|
||||
tool = Bash(
|
||||
config_getter=lambda: BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="test")
|
||||
# Should not raise, embedding failure is silently ignored
|
||||
result = await collect_result(tool.run(args))
|
||||
async def run_and_collect_ids(tool_call_id: str) -> list[str]:
|
||||
ids: list[str] = []
|
||||
async for item in tool.run(
|
||||
BashArgs(command="echo hi"), InvokeContext(tool_call_id=tool_call_id)
|
||||
):
|
||||
if isinstance(item, ToolTerminalOpenedEvent):
|
||||
ids.append(item.tool_call_id)
|
||||
return ids
|
||||
|
||||
assert result is not None
|
||||
assert result.stdout == "test output"
|
||||
results = await asyncio.gather(
|
||||
run_and_collect_ids("T1"), run_and_collect_ids("T2")
|
||||
)
|
||||
|
||||
assert results[0] == ["T1"]
|
||||
assert results[1] == ["T2"]
|
||||
|
||||
|
||||
class TestAcpBashConfig:
|
||||
|
|
@ -395,7 +385,7 @@ class TestAcpBashConfig:
|
|||
tool = Bash(
|
||||
config_getter=lambda: BashToolConfig(default_timeout=30),
|
||||
state=AcpBashState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -425,7 +415,7 @@ class TestAcpBashCleanup:
|
|||
tool = Bash(
|
||||
config_getter=lambda: BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -457,7 +447,7 @@ class TestAcpBashCleanup:
|
|||
tool = Bash(
|
||||
config_getter=lambda: BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -483,7 +473,7 @@ class TestAcpBashCleanup:
|
|||
tool = Bash(
|
||||
config_getter=lambda: BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,42 @@ from acp.schema import (
|
|||
import pytest
|
||||
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.types import Backend
|
||||
from vibe.setup.onboarding.context import OnboardingContext
|
||||
|
||||
BROWSER_AUTH_NAME = "Sign in through Mistral AI Studio"
|
||||
BROWSER_AUTH_DESCRIPTION = (
|
||||
"Sign into Mistral Vibe through your Mistral AI Studio account."
|
||||
)
|
||||
|
||||
|
||||
def build_mistral_provider() -> ProviderConfig:
|
||||
return ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
browser_auth_base_url="https://console.mistral.ai",
|
||||
browser_auth_api_base_url="https://console.mistral.ai/api",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
|
||||
|
||||
def build_acp_agent_loop(
|
||||
*, provider: ProviderConfig | None = None, enable_browser_sign_in: bool = True
|
||||
) -> VibeAcpAgentLoop:
|
||||
return VibeAcpAgentLoop(
|
||||
onboarding_context_loader=lambda: OnboardingContext(
|
||||
provider=provider or build_mistral_provider(),
|
||||
enable_experimental_browser_sign_in=enable_browser_sign_in,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestACPInitialize:
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
|
||||
async def test_initialize(self) -> None:
|
||||
acp_agent_loop = build_acp_agent_loop()
|
||||
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
|
||||
|
||||
assert response.protocol_version == PROTOCOL_VERSION
|
||||
|
|
@ -34,16 +65,20 @@ class TestACPInitialize:
|
|||
),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.10.0"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.10.1"
|
||||
)
|
||||
|
||||
assert response.auth_methods == []
|
||||
assert response.auth_methods is not None
|
||||
assert len(response.auth_methods) == 1
|
||||
auth_method = response.auth_methods[0]
|
||||
assert auth_method.id == "browser-auth"
|
||||
assert auth_method.name == BROWSER_AUTH_NAME
|
||||
assert auth_method.description == BROWSER_AUTH_DESCRIPTION
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_with_terminal_auth(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
async def test_initialize_with_terminal_auth(self) -> None:
|
||||
"""Test initialize with terminal-auth capabilities to check it was included."""
|
||||
acp_agent_loop = build_acp_agent_loop()
|
||||
client_capabilities = ClientCapabilities(field_meta={"terminal-auth": True})
|
||||
response = await acp_agent_loop.initialize(
|
||||
protocol_version=PROTOCOL_VERSION, client_capabilities=client_capabilities
|
||||
|
|
@ -62,12 +97,18 @@ class TestACPInitialize:
|
|||
),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.10.0"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.10.1"
|
||||
)
|
||||
|
||||
assert response.auth_methods is not None
|
||||
assert len(response.auth_methods) == 1
|
||||
auth_method = response.auth_methods[0]
|
||||
assert len(response.auth_methods) == 2
|
||||
|
||||
browser_auth_method = response.auth_methods[0]
|
||||
assert browser_auth_method.id == "browser-auth"
|
||||
assert browser_auth_method.name == BROWSER_AUTH_NAME
|
||||
assert browser_auth_method.description == BROWSER_AUTH_DESCRIPTION
|
||||
|
||||
auth_method = response.auth_methods[1]
|
||||
assert auth_method.id == "vibe-setup"
|
||||
assert auth_method.name == "Register your API Key"
|
||||
assert auth_method.description == "Register your API Key inside Mistral Vibe"
|
||||
|
|
@ -76,4 +117,55 @@ class TestACPInitialize:
|
|||
terminal_auth_meta = auth_method.field_meta["terminal-auth"]
|
||||
assert "command" in terminal_auth_meta
|
||||
assert "args" in terminal_auth_meta
|
||||
assert terminal_auth_meta["args"][-1:] == ["--setup"]
|
||||
assert terminal_auth_meta["label"] == "Mistral Vibe Setup"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_with_delegated_browser_auth(self) -> None:
|
||||
acp_agent_loop = build_acp_agent_loop()
|
||||
client_capabilities = ClientCapabilities(
|
||||
field_meta={"browser-auth-delegated": True}
|
||||
)
|
||||
response = await acp_agent_loop.initialize(
|
||||
protocol_version=PROTOCOL_VERSION, client_capabilities=client_capabilities
|
||||
)
|
||||
|
||||
assert response.auth_methods is not None
|
||||
assert len(response.auth_methods) == 2
|
||||
|
||||
browser_auth_method = response.auth_methods[0]
|
||||
assert browser_auth_method.id == "browser-auth"
|
||||
assert browser_auth_method.name == BROWSER_AUTH_NAME
|
||||
assert browser_auth_method.description == BROWSER_AUTH_DESCRIPTION
|
||||
|
||||
delegated_browser_auth_method = response.auth_methods[1]
|
||||
assert delegated_browser_auth_method.id == "browser-auth-delegated"
|
||||
assert delegated_browser_auth_method.name == BROWSER_AUTH_NAME
|
||||
assert delegated_browser_auth_method.description == BROWSER_AUTH_DESCRIPTION
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_omits_browser_auth_when_experimental_flag_disabled(
|
||||
self,
|
||||
) -> None:
|
||||
acp_agent_loop = build_acp_agent_loop(enable_browser_sign_in=False)
|
||||
|
||||
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
|
||||
|
||||
assert response.auth_methods == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_omits_browser_auth_when_provider_unsupported(
|
||||
self,
|
||||
) -> None:
|
||||
acp_agent_loop = build_acp_agent_loop(
|
||||
provider=ProviderConfig(
|
||||
name="llamacpp",
|
||||
api_base="http://127.0.0.1:8080/v1",
|
||||
api_key_env_var="LLAMACPP_API_KEY",
|
||||
backend=Backend.GENERIC,
|
||||
)
|
||||
)
|
||||
|
||||
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
|
||||
|
||||
assert response.auth_methods == []
|
||||
|
|
|
|||
|
|
@ -74,7 +74,6 @@ def acp_read_file_tool(
|
|||
state = AcpReadFileState.model_construct(
|
||||
client=mock_client, # type: ignore[arg-type]
|
||||
session_id="test_session_123",
|
||||
tool_call_id="test_tool_call_456",
|
||||
)
|
||||
return ReadFile(config_getter=lambda: config, state=state)
|
||||
|
||||
|
|
@ -99,7 +98,6 @@ class TestAcpReadFileExecution:
|
|||
assert result.content == "line 1\nline 2\nline 3"
|
||||
assert result.lines_read == 3
|
||||
assert mock_client._read_text_file_called
|
||||
assert mock_client._session_update_called
|
||||
|
||||
# Verify read_text_file was called correctly
|
||||
params = mock_client._last_read_params
|
||||
|
|
@ -118,7 +116,7 @@ class TestAcpReadFileExecution:
|
|||
tool = ReadFile(
|
||||
config_getter=lambda: ReadFileToolConfig(),
|
||||
state=AcpReadFileState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -141,7 +139,7 @@ class TestAcpReadFileExecution:
|
|||
tool = ReadFile(
|
||||
config_getter=lambda: ReadFileToolConfig(),
|
||||
state=AcpReadFileState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -164,7 +162,7 @@ class TestAcpReadFileExecution:
|
|||
tool = ReadFile(
|
||||
config_getter=lambda: ReadFileToolConfig(),
|
||||
state=AcpReadFileState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -189,7 +187,7 @@ class TestAcpReadFileExecution:
|
|||
tool = ReadFile(
|
||||
config_getter=lambda: ReadFileToolConfig(),
|
||||
state=AcpReadFileState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -209,7 +207,7 @@ class TestAcpReadFileExecution:
|
|||
tool = ReadFile(
|
||||
config_getter=lambda: ReadFileToolConfig(),
|
||||
state=AcpReadFileState.model_construct(
|
||||
client=None, session_id="test_session", tool_call_id="test_call"
|
||||
client=None, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -232,9 +230,7 @@ class TestAcpReadFileExecution:
|
|||
mock_client = MockClient()
|
||||
tool = ReadFile(
|
||||
config_getter=lambda: ReadFileToolConfig(),
|
||||
state=AcpReadFileState.model_construct(
|
||||
client=mock_client, session_id=None, tool_call_id="test_call"
|
||||
),
|
||||
state=AcpReadFileState.model_construct(client=mock_client, session_id=None),
|
||||
)
|
||||
|
||||
args = ReadFileArgs(path=str(test_file))
|
||||
|
|
|
|||
|
|
@ -81,9 +81,7 @@ def acp_search_replace_tool(
|
|||
monkeypatch.chdir(tmp_path)
|
||||
config = SearchReplaceConfig()
|
||||
state = AcpSearchReplaceState.model_construct(
|
||||
client=mock_client,
|
||||
session_id="test_session_123",
|
||||
tool_call_id="test_tool_call_456",
|
||||
client=mock_client, session_id="test_session_123"
|
||||
)
|
||||
return SearchReplace(config_getter=lambda: config, state=state)
|
||||
|
||||
|
|
@ -116,7 +114,6 @@ class TestAcpSearchReplaceExecution:
|
|||
assert result.blocks_applied == 1
|
||||
assert mock_client._read_text_file_called
|
||||
assert mock_client._write_text_file_called
|
||||
assert mock_client._session_update_called
|
||||
|
||||
# Verify read_text_file was called correctly
|
||||
read_params = mock_client._last_read_params
|
||||
|
|
@ -141,7 +138,7 @@ class TestAcpSearchReplaceExecution:
|
|||
tool = SearchReplace(
|
||||
config_getter=lambda: config,
|
||||
state=AcpSearchReplaceState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -171,7 +168,7 @@ class TestAcpSearchReplaceExecution:
|
|||
tool = SearchReplace(
|
||||
config_getter=lambda: SearchReplaceConfig(),
|
||||
state=AcpSearchReplaceState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -202,7 +199,7 @@ class TestAcpSearchReplaceExecution:
|
|||
tool = SearchReplace(
|
||||
config_getter=lambda: SearchReplaceConfig(),
|
||||
state=AcpSearchReplaceState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -245,7 +242,7 @@ class TestAcpSearchReplaceExecution:
|
|||
tool = SearchReplace(
|
||||
config_getter=lambda: SearchReplaceConfig(),
|
||||
state=AcpSearchReplaceState.model_construct(
|
||||
client=client, session_id=session_id, tool_call_id="test_call"
|
||||
client=client, session_id=session_id
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
92
tests/acp/test_session_auto_title.py
Normal file
92
tests/acp/test_session_auto_title.py
Normal 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?"
|
||||
|
|
@ -7,50 +7,10 @@ from acp.schema import SessionInfoUpdate
|
|||
import pytest
|
||||
import tomli_w
|
||||
|
||||
from tests.conftest import build_test_vibe_config, get_base_config
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.conftest import get_base_config
|
||||
from tests.stubs.fake_client import FakeClient
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.acp.exceptions import InternalError, InvalidRequestError, SessionNotFoundError
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import ModelConfig, SessionLoggingConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent_with_session_config(
|
||||
backend: FakeBackend, temp_session_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> tuple[VibeAcpAgentLoop, FakeClient]:
|
||||
session_config = SessionLoggingConfig(
|
||||
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
|
||||
)
|
||||
config = build_test_vibe_config(
|
||||
active_model="devstral-latest",
|
||||
models=[
|
||||
ModelConfig(
|
||||
name="devstral-latest", provider="mistral", alias="devstral-latest"
|
||||
)
|
||||
],
|
||||
session_logging=session_config,
|
||||
)
|
||||
|
||||
class PatchedAgentLoop(AgentLoop):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **{**kwargs, "backend": backend})
|
||||
self._base_config = config
|
||||
self.agent_manager.invalidate_config()
|
||||
|
||||
monkeypatch.setattr("vibe.acp.acp_agent_loop.AgentLoop", PatchedAgentLoop)
|
||||
monkeypatch.setattr(VibeAcpAgentLoop, "_load_config", lambda self: config)
|
||||
monkeypatch.setattr(
|
||||
"vibe.acp.acp_agent_loop.VibeConfig.load", lambda *args, **kwargs: config
|
||||
)
|
||||
|
||||
vibe_acp_agent = VibeAcpAgentLoop()
|
||||
client = FakeClient()
|
||||
vibe_acp_agent.on_connect(client)
|
||||
client.on_connect(vibe_acp_agent)
|
||||
|
||||
return vibe_acp_agent, client
|
||||
|
||||
|
||||
class TestSessionSetTitle:
|
||||
|
|
|
|||
|
|
@ -54,9 +54,7 @@ def acp_write_file_tool(
|
|||
monkeypatch.chdir(tmp_path)
|
||||
config = WriteFileConfig()
|
||||
state = AcpWriteFileState.model_construct(
|
||||
client=mock_client,
|
||||
session_id="test_session_123",
|
||||
tool_call_id="test_tool_call_456",
|
||||
client=mock_client, session_id="test_session_123"
|
||||
)
|
||||
return WriteFile(config_getter=lambda: config, state=state)
|
||||
|
||||
|
|
@ -81,7 +79,6 @@ class TestAcpWriteFileExecution:
|
|||
assert result.bytes_written == len(b"Hello, world!")
|
||||
assert result.file_existed is False
|
||||
assert mock_client._write_text_file_called
|
||||
assert mock_client._session_update_called
|
||||
|
||||
# Verify write_text_file was called correctly
|
||||
params = mock_client._last_write_params
|
||||
|
|
@ -97,7 +94,7 @@ class TestAcpWriteFileExecution:
|
|||
tool = WriteFile(
|
||||
config_getter=lambda: WriteFileConfig(),
|
||||
state=AcpWriteFileState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -114,7 +111,6 @@ class TestAcpWriteFileExecution:
|
|||
assert result.bytes_written == len(b"New content")
|
||||
assert result.file_existed is True
|
||||
assert mock_client._write_text_file_called
|
||||
assert mock_client._session_update_called
|
||||
|
||||
# Verify write_text_file was called correctly
|
||||
params = mock_client._last_write_params
|
||||
|
|
@ -132,7 +128,7 @@ class TestAcpWriteFileExecution:
|
|||
tool = WriteFile(
|
||||
config_getter=lambda: WriteFileConfig(),
|
||||
state=AcpWriteFileState.model_construct(
|
||||
client=mock_client, session_id="test_session", tool_call_id="test_call"
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -151,7 +147,7 @@ class TestAcpWriteFileExecution:
|
|||
tool = WriteFile(
|
||||
config_getter=lambda: WriteFileConfig(),
|
||||
state=AcpWriteFileState.model_construct(
|
||||
client=None, session_id="test_session", tool_call_id="test_call"
|
||||
client=None, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -173,7 +169,7 @@ class TestAcpWriteFileExecution:
|
|||
tool = WriteFile(
|
||||
config_getter=lambda: WriteFileConfig(),
|
||||
state=AcpWriteFileState.model_construct(
|
||||
client=mock_client, session_id=None, tool_call_id="test_call"
|
||||
client=mock_client, session_id=None
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue