Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Corentin André <corentin.andre@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com>
Co-authored-by: Peter Evers <pevers90@gmail.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: MichisGitIsKing <MichisGitIsKing@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-05-19 11:56:25 +02:00 committed by GitHub
parent 626f905186
commit 228f3c65a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
158 changed files with 7235 additions and 916 deletions

View file

@ -34,7 +34,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.6"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.10.0"
)
assert response.auth_methods == []
@ -62,7 +62,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.6"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.10.0"
)
assert response.auth_methods is not None

View file

@ -47,12 +47,6 @@ class TestACPNewSession:
cwd=str(Path.cwd()), mcp_servers=[]
)
new_session_events = [
e for e in telemetry_events if e.get("event_name") == "vibe.new_session"
]
assert len(new_session_events) == 1
assert new_session_events[0]["properties"]["entrypoint"] == "acp"
assert session_response.session_id is not None
acp_session = next(
(
@ -63,6 +57,17 @@ class TestACPNewSession:
None,
)
assert acp_session is not None
# Telemetry now fires from the background warm-up worker once
# `wait_until_ready` joins both MCP and experiments. Awaiting it here
# forces emission before assertions.
await acp_session.agent_loop.wait_until_ready()
new_session_events = [
e for e in telemetry_events if e.get("event_name") == "vibe.new_session"
]
assert len(new_session_events) == 1
assert new_session_events[0]["properties"]["entrypoint"] == "acp"
assert (
acp_session.agent_loop.session_logger.session_id
== session_response.session_id

View file

@ -74,6 +74,31 @@ class TestTelemetryNotification:
assert props["file_extensions"] == {".py": 1}
assert props["message_id"] == "msg-abc"
@pytest.mark.asyncio
async def test_user_rating_feedback_dispatches_telemetry(
self, acp_agent_loop: VibeAcpAgentLoop, telemetry_events: list[dict[str, Any]]
) -> None:
session = await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
telemetry_events.clear()
await acp_agent_loop.ext_notification(
"telemetry/send",
{
"event": "vibe.user_rating_feedback",
"session_id": session.session_id,
"properties": {"rating": 1},
},
)
rating_events = [
e
for e in telemetry_events
if e["event_name"] == "vibe.user_rating_feedback"
]
assert len(rating_events) == 1
props = rating_events[0]["properties"]
assert props["rating"] == 1
@pytest.mark.asyncio
async def test_raises_on_invalid_params(
self, acp_agent_loop: VibeAcpAgentLoop

View file

@ -139,7 +139,7 @@ class TestPrepareRequest:
{"role": "user", "content": "Hi"},
]
def test_consecutive_user_messages_are_merged(self, adapter, provider):
def test_consecutive_user_messages_are_preserved(self, adapter, provider):
payload = _prepare(
adapter,
provider,
@ -148,7 +148,10 @@ class TestPrepareRequest:
LLMMessage(role=Role.user, content="Again"),
],
)
assert payload["input"] == [{"role": "user", "content": "Hi\n\nAgain"}]
assert payload["input"] == [
{"role": "user", "content": "Hi"},
{"role": "user", "content": "Again"},
]
def test_multiple_system_messages_are_preserved(self, adapter, provider):
payload = _prepare(

View file

@ -1,7 +1,8 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from urllib.parse import urlencode
from vibe.setup.auth import (
@ -11,6 +12,7 @@ from vibe.setup.auth import (
BrowserSignInPollResult,
BrowserSignInProcess,
)
from vibe.setup.auth.browser_sign_in import BrowserSignInService
@dataclass
@ -42,6 +44,7 @@ class StubBrowserSignInGateway(BrowserSignInGateway):
self.polled_urls: list[str] = []
self.exchange_requests: list[ExchangeRequestPayload] = []
self.closed = False
self.poll_calls = 0
self.process_number = 0
async def create_process(self, code_challenge: str) -> BrowserSignInProcess:
@ -55,10 +58,10 @@ class StubBrowserSignInGateway(BrowserSignInGateway):
async def poll(self, poll_url: str) -> BrowserSignInPollResult:
self.polled_urls.append(poll_url)
self.poll_calls += 1
if not self._poll_results:
msg = "StubBrowserSignInGateway requires scripted poll results."
raise AssertionError(msg)
result = self._poll_results.pop(0)
if isinstance(result, BrowserSignInError):
raise result
@ -109,5 +112,80 @@ def build_poll_failed_error() -> BrowserSignInError:
)
def build_poll_results_from_outcomes(
outcomes: list[str],
) -> tuple[
list[BrowserSignInProcess], list[BrowserSignInPollResult | BrowserSignInError]
]:
processes: list[BrowserSignInProcess] = []
poll_results: list[BrowserSignInPollResult | BrowserSignInError] = []
now = datetime(2026, 3, 16, tzinfo=UTC)
for process_index, outcome in enumerate(outcomes, start=1):
process_id = f"process-{process_index}"
processes.append(build_sign_in_process(now, process_id=process_id))
match outcome:
case "completed":
poll_results.extend([
BrowserSignInPollResult(status="pending"),
BrowserSignInPollResult(
status="completed", exchange_token=f"exchange-{process_id}"
),
])
case "expired":
poll_results.append(BrowserSignInPollResult(status="expired"))
case "poll_failed":
poll_results.extend([
BrowserSignInPollResult(status="pending"),
build_poll_failed_error(),
build_poll_failed_error(),
build_poll_failed_error(),
])
case _:
msg = f"Unsupported browser sign-in outcome: {outcome}"
raise AssertionError(msg)
return processes, poll_results
async def noop_sleep(_: float) -> None:
return None
def build_browser_sign_in_service_factory(
outcomes: list[str],
*,
exchange_result: str = "sk-browser-onboarding-test-key",
open_browser: Callable[[str], bool] | None = None,
sleep: Callable[[float], Awaitable[None]] = noop_sleep,
now: Callable[[], datetime] | None = None,
) -> tuple[
StubBrowserSignInGateway,
Callable[[], BrowserSignInService],
list[BrowserSignInService],
]:
processes, poll_results = build_poll_results_from_outcomes(outcomes)
gateway = StubBrowserSignInGateway(
processes=processes, poll_results=poll_results, exchange_result=exchange_result
)
created_services: list[BrowserSignInService] = []
def build_service() -> BrowserSignInService:
service = BrowserSignInService(
gateway,
open_browser=open_browser or (lambda _: True),
sleep=sleep,
now=now
or (
lambda: (
datetime(2026, 3, 16, tzinfo=UTC)
+ timedelta(seconds=gateway.poll_calls)
)
),
poll_interval=0,
)
created_services.append(service)
return service
return gateway, build_service, created_services

View file

@ -23,6 +23,7 @@ from vibe.setup.auth import (
BrowserSignInPollResult,
BrowserSignInProcess,
BrowserSignInService,
BrowserSignInStatus,
)
TEST_NOW = datetime(2026, 3, 16, tzinfo=UTC)
@ -68,7 +69,7 @@ def build_test_service(
@pytest.mark.asyncio
async def test_authenticate_returns_api_key_after_pending_poll() -> None:
opened_urls: list[str] = []
statuses: list[str] = []
statuses: list[BrowserSignInStatus] = []
gateway, service = build_test_service(
poll_results=[
BrowserSignInPollResult(status="pending"),
@ -84,10 +85,10 @@ async def test_authenticate_returns_api_key_after_pending_poll() -> None:
assert api_key == "sk-browser-key"
assert opened_urls == [TEST_SIGN_IN_URL]
assert statuses == [
"opening_browser",
"waiting_for_browser_sign_in",
"exchanging",
"completed",
BrowserSignInStatus.OPENING_BROWSER,
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"

View file

@ -4,6 +4,7 @@ import httpx
import pytest
import respx
from tests.conftest import build_test_vibe_config
from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIGatewayError,
@ -11,6 +12,7 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIPlanType,
WhoAmIResponse,
)
from vibe.core.config import DEFAULT_CONSOLE_BASE_URL
@pytest.mark.asyncio
@ -170,3 +172,53 @@ async def test_return_unknown_plan_on_unsupported_plan_type(
plan_name="INDIVIDUAL",
prompt_switching_to_pro_plan=False,
)
@pytest.mark.asyncio
async def test_gateway_calls_custom_console_base_url_from_config(
respx_mock: respx.MockRouter,
) -> None:
custom_url = "https://custom-console.example.com"
config = build_test_vibe_config(console_base_url=custom_url)
route = respx_mock.get(f"{custom_url}/api/vibe/whoami").mock(
return_value=httpx.Response(
200,
json={
"plan_type": "CHAT",
"plan_name": "INDIVIDUAL",
"prompt_switching_to_pro_plan": False,
},
)
)
gateway = HttpWhoAmIGateway(base_url=config.console_base_url)
response = await gateway.whoami("api-key")
assert route.called
assert response.plan_type == "CHAT"
@pytest.mark.asyncio
async def test_gateway_uses_default_console_url_when_not_configured(
respx_mock: respx.MockRouter,
) -> None:
config = build_test_vibe_config()
assert config.console_base_url == DEFAULT_CONSOLE_BASE_URL
route = respx_mock.get(f"{DEFAULT_CONSOLE_BASE_URL}/api/vibe/whoami").mock(
return_value=httpx.Response(
200,
json={
"plan_type": "CHAT",
"plan_name": "INDIVIDUAL",
"prompt_switching_to_pro_plan": False,
},
)
)
gateway = HttpWhoAmIGateway(base_url=config.console_base_url)
await gateway.whoami("api-key")
assert route.called

View file

@ -0,0 +1,78 @@
from __future__ import annotations
from unittest.mock import patch
from pydantic import BaseModel
import pytest
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
from vibe.core.config import VibeConfig
_TEST_GRACE_PERIOD_S = 0.5
class FakeArgs(BaseModel):
command: str = "echo hello"
@pytest.fixture
def approval_app(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"vibe.cli.textual_ui.widgets.approval_app._INPUT_GRACE_PERIOD_S",
_TEST_GRACE_PERIOD_S,
)
config = VibeConfig()
app = ApprovalApp(tool_name="bash", tool_args=FakeArgs(), config=config)
app._mount_time = 100.0
return app
class TestGracePeriod:
def test_actions_ignored_within_grace_period(self, approval_app: ApprovalApp):
with (
patch("vibe.cli.textual_ui.widgets.approval_app.time") as mock_time,
patch.object(approval_app, "post_message") as posted,
):
mock_time.monotonic.return_value = 100.0 + _TEST_GRACE_PERIOD_S - 0.01
assert approval_app.is_within_grace_period()
approval_app.action_select()
approval_app.action_select_1()
approval_app.action_select_2()
approval_app.action_select_3()
approval_app.action_reject()
posted.assert_not_called()
def test_actions_post_messages_after_grace_period(self, approval_app: ApprovalApp):
with (
patch("vibe.cli.textual_ui.widgets.approval_app.time") as mock_time,
patch.object(approval_app, "post_message") as posted,
):
mock_time.monotonic.return_value = 100.0 + _TEST_GRACE_PERIOD_S + 0.01
assert not approval_app.is_within_grace_period()
approval_app.action_select_1()
approval_app.action_reject()
assert posted.call_count == 2
assert isinstance(
posted.call_args_list[0].args[0], ApprovalApp.ApprovalGranted
)
assert isinstance(
posted.call_args_list[1].args[0], ApprovalApp.ApprovalRejected
)
def test_arrow_keys_work_during_grace_period(self, approval_app: ApprovalApp):
with (
patch("vibe.cli.textual_ui.widgets.approval_app.time") as mock_time,
patch.object(approval_app, "_update_options"),
):
mock_time.monotonic.return_value = 100.0 + 0.01
assert approval_app.is_within_grace_period()
assert approval_app.selected_option == 0
approval_app.action_move_down()
assert approval_app.selected_option == 1
approval_app.action_move_up()
assert approval_app.selected_option == 0

View file

@ -21,6 +21,7 @@ def _make_args(**overrides: object) -> argparse.Namespace:
"agent": "default",
"setup": False,
"workdir": None,
"add_dir": [],
"trust": False,
"teleport": False,
"continue_session": False,

View file

@ -0,0 +1,111 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from textual import events
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
Choice,
Question,
)
_TEST_GRACE_PERIOD_S = 0.5
@pytest.fixture
def question_app(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"vibe.cli.textual_ui.widgets.question_app._INPUT_GRACE_PERIOD_S",
_TEST_GRACE_PERIOD_S,
)
args = AskUserQuestionArgs(
questions=[
Question(
question="Pick one",
header="Pick",
options=[Choice(label="A"), Choice(label="B")],
)
]
)
app = QuestionApp(args)
app._mount_time = 100.0
return app
class TestQuestionAppGracePeriod:
def test_select_and_cancel_ignored_within_grace_period(
self, question_app: QuestionApp
):
with (
patch("vibe.cli.textual_ui.widgets.question_app.time") as mock_time,
patch.object(question_app, "post_message") as posted,
):
mock_time.monotonic.return_value = 100.0 + _TEST_GRACE_PERIOD_S - 0.01
assert question_app.is_within_grace_period()
question_app.action_select()
question_app.action_cancel()
posted.assert_not_called()
def test_cancel_posts_message_after_grace_period(self, question_app: QuestionApp):
with (
patch("vibe.cli.textual_ui.widgets.question_app.time") as mock_time,
patch.object(question_app, "post_message") as posted,
):
mock_time.monotonic.return_value = 100.0 + _TEST_GRACE_PERIOD_S + 0.01
question_app.action_cancel()
posted.assert_called_once()
assert isinstance(posted.call_args.args[0], QuestionApp.Cancelled)
def test_navigation_works_during_grace_period(self, question_app: QuestionApp):
with patch("vibe.cli.textual_ui.widgets.question_app.time") as mock_time:
mock_time.monotonic.return_value = 100.0 + 0.01
assert question_app.is_within_grace_period()
assert question_app.selected_option == 0
question_app.action_move_down()
assert question_app.selected_option == 1
question_app.action_move_up()
assert question_app.selected_option == 0
def test_number_key_consumed_but_not_acted_within_grace_period(
self, question_app: QuestionApp
):
with (
patch("vibe.cli.textual_ui.widgets.question_app.time") as mock_time,
patch.object(question_app, "post_message") as posted,
):
mock_time.monotonic.return_value = 100.0 + _TEST_GRACE_PERIOD_S - 0.01
event = MagicMock(spec=events.Key)
event.character = "1"
handled = question_app._handle_number_key(event)
assert handled is True
event.stop.assert_called_once()
event.prevent_default.assert_called_once()
posted.assert_not_called()
def test_number_key_selects_option_after_grace_period(
self, question_app: QuestionApp
):
with (
patch("vibe.cli.textual_ui.widgets.question_app.time") as mock_time,
patch.object(question_app, "post_message") as posted,
):
mock_time.monotonic.return_value = 100.0 + _TEST_GRACE_PERIOD_S + 0.01
event = MagicMock(spec=events.Key)
event.character = "1"
handled = question_app._handle_number_key(event)
assert handled is True
assert question_app.selected_option == 0
posted.assert_called_once()
assert isinstance(posted.call_args.args[0], QuestionApp.Answered)

View file

@ -0,0 +1,35 @@
from __future__ import annotations
import pytest
from vibe.cli.textual_ui.app import _TYPING_DEBOUNCE_ENV_VAR, _resolve_typing_debounce_s
_TEST_DEFAULT_DEBOUNCE_MS = 1000
@pytest.fixture(autouse=True)
def _restore_default_debounce(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"vibe.cli.textual_ui.app._DEFAULT_TYPING_DEBOUNCE_MS", _TEST_DEFAULT_DEBOUNCE_MS
)
class TestTypingDebounceEnvVar:
@pytest.mark.parametrize(
("env_value", "expected_s"), [("500", 0.5), ("2000", 2.0), ("0", 0.0)]
)
def test_env_var_override(
self, env_value: str, expected_s: float, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setenv(_TYPING_DEBOUNCE_ENV_VAR, env_value)
assert _resolve_typing_debounce_s() == expected_s
@pytest.mark.parametrize("env_value", [None, "not-a-number", "-100"])
def test_falls_back_to_default(
self, env_value: str | None, monkeypatch: pytest.MonkeyPatch
):
if env_value is None:
monkeypatch.delenv(_TYPING_DEBOUNCE_ENV_VAR, raising=False)
else:
monkeypatch.setenv(_TYPING_DEBOUNCE_ENV_VAR, env_value)
assert _resolve_typing_debounce_s() == _TEST_DEFAULT_DEBOUNCE_MS / 1000

View file

@ -3,13 +3,14 @@ from __future__ import annotations
import pytest
from vibe.cli.textual_ui.session_exit import print_session_resume_message
from vibe.core.config import SessionLoggingConfig
from vibe.core.types import AgentStats
def test_print_session_resume_message_skips_output_without_session_id(
capsys: pytest.CaptureFixture[str],
) -> None:
print_session_resume_message(None, AgentStats())
print_session_resume_message(None, AgentStats(), SessionLoggingConfig())
assert capsys.readouterr().out == ""
@ -20,6 +21,7 @@ def test_print_session_resume_message_prints_resume_commands_and_usage(
print_session_resume_message(
"12345678-1234-1234-1234-123456789abc",
AgentStats(session_prompt_tokens=14_867, session_completion_tokens=6),
SessionLoggingConfig(),
)
assert capsys.readouterr().out == (
@ -34,7 +36,7 @@ def test_print_session_resume_message_prints_resume_commands_and_usage(
def test_print_session_resume_message_prints_zero_usage_for_resumed_run_without_llm_activity(
capsys: pytest.CaptureFixture[str],
) -> None:
print_session_resume_message("12345678", AgentStats())
print_session_resume_message("12345678", AgentStats(), SessionLoggingConfig())
assert capsys.readouterr().out == (
"\n"

View file

@ -164,6 +164,18 @@ def _disable_feedback_bar(monkeypatch: pytest.MonkeyPatch) -> None:
)
@pytest.fixture(autouse=True)
def _disable_input_grace_periods(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"vibe.cli.textual_ui.widgets.approval_app._INPUT_GRACE_PERIOD_S", 0
)
monkeypatch.setattr(
"vibe.cli.textual_ui.widgets.question_app._INPUT_GRACE_PERIOD_S", 0
)
monkeypatch.setattr("vibe.cli.textual_ui.app._DEFAULT_TYPING_DEBOUNCE_MS", 0)
monkeypatch.delenv("VIBE_TYPING_GRACE_PERIOD_MS", raising=False)
@pytest.fixture(autouse=True)
def telemetry_events(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []

View file

View file

@ -0,0 +1,116 @@
from __future__ import annotations
import logging
import httpx
import pytest
import respx
from vibe.core.experiments._constants import build_eval_url
from vibe.core.experiments.active import ExperimentName
from vibe.core.experiments.client import RemoteEvalClient
from vibe.core.experiments.models import ExperimentAttributes
_TEST_API_HOST = "https://growthbook.test"
_TEST_CLIENT_KEY = "sdk-test"
_TEST_EVAL_URL = build_eval_url(_TEST_API_HOST, _TEST_CLIENT_KEY)
assert _TEST_EVAL_URL is not None
def _attrs() -> ExperimentAttributes:
return ExperimentAttributes(
userId="hashed",
entrypoint="cli",
agent_version="1.2.3",
client_name="vibe",
client_version="1.2.3",
os="darwin",
)
def _make_client() -> RemoteEvalClient:
return RemoteEvalClient.from_settings(_TEST_API_HOST, _TEST_CLIENT_KEY)
@pytest.mark.asyncio
@respx.mock
async def test_evaluate_happy_path() -> None:
payload = {
"features": {
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "cli_v2", "tracks": []}],
}
}
}
route = respx.post(_TEST_EVAL_URL).mock(
return_value=httpx.Response(200, json=payload)
)
client = _make_client()
response = await client.evaluate(_attrs())
await client.aclose()
assert route.called
request_body = route.calls.last.request.read()
assert b'"userId":"hashed"' in request_body
assert b'"forcedVariations":{}' in request_body
assert b'"forcedFeatures":[]' in request_body
assert response is not None
assert (
response.features[ExperimentName.SYSTEM_PROMPT.value].resolved_value()
== "cli_v2"
)
@pytest.fixture
def _silence_vibe_logger(caplog: pytest.LogCaptureFixture) -> None:
# Failure-path tests legitimately emit WARNING logs. Silence the vibe
# logger so they don't leak into ~/.vibe/logs/vibe.log (the file handler
# is bound to the real path at module import, before VIBE_HOME is
# monkeypatched in the conftest fixture).
caplog.set_level(logging.CRITICAL, logger="vibe")
@pytest.mark.asyncio
@respx.mock
async def test_evaluate_returns_none_on_5xx(_silence_vibe_logger: None) -> None:
respx.post(_TEST_EVAL_URL).mock(return_value=httpx.Response(500, text="oops"))
client = _make_client()
assert await client.evaluate(_attrs()) is None
await client.aclose()
@pytest.mark.asyncio
@respx.mock
async def test_evaluate_returns_none_on_network_error(
_silence_vibe_logger: None,
) -> None:
respx.post(_TEST_EVAL_URL).mock(side_effect=httpx.ConnectError("nope"))
client = _make_client()
assert await client.evaluate(_attrs()) is None
await client.aclose()
@pytest.mark.asyncio
@respx.mock
async def test_evaluate_returns_none_on_invalid_json(
_silence_vibe_logger: None,
) -> None:
respx.post(_TEST_EVAL_URL).mock(return_value=httpx.Response(200, text="not json"))
client = _make_client()
assert await client.evaluate(_attrs()) is None
await client.aclose()
@pytest.mark.asyncio
async def test_evaluate_skips_request_when_url_unset() -> None:
client = RemoteEvalClient.from_settings(api_host="", client_key="sdk-x")
assert await client.evaluate(_attrs()) is None
await client.aclose()
@pytest.mark.asyncio
async def test_aclose_is_idempotent() -> None:
client = _make_client()
await client.aclose()
await client.aclose()

View file

@ -0,0 +1,427 @@
from __future__ import annotations
from typing import Any
import pytest
from vibe.core.experiments.active import DEFAULT_VARIANTS, ExperimentName
from vibe.core.experiments.client import RemoteEvalClient
from vibe.core.experiments.manager import ExperimentManager, hash_api_key
from vibe.core.experiments.models import EvalResponse, ExperimentAttributes
class _StubClient(RemoteEvalClient):
def __init__(self, response: EvalResponse | None) -> None:
self._response = response
self.calls: list[ExperimentAttributes] = []
async def evaluate(self, attributes: ExperimentAttributes) -> EvalResponse | None:
self.calls.append(attributes)
return self._response
async def aclose(self) -> None:
pass
def _attrs() -> ExperimentAttributes:
return ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
)
def _response(features: dict[str, Any]) -> EvalResponse:
return EvalResponse.model_validate({"features": features})
def test_hash_api_key_is_stable_and_anonymous() -> None:
a = hash_api_key("sk-abc")
b = hash_api_key("sk-abc")
assert a == b
assert "sk-" not in a
assert len(a) == 32
def test_hash_api_key_differs_per_key() -> None:
assert hash_api_key("sk-abc") != hash_api_key("sk-def")
@pytest.mark.asyncio
async def test_get_variant_returns_default_when_uninitialized() -> None:
manager = ExperimentManager(client=_StubClient(None))
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "cli"
@pytest.mark.asyncio
async def test_get_variant_or_none_returns_none_when_unassigned() -> None:
manager = ExperimentManager(client=_StubClient(None))
assert manager.get_variant_or_none(ExperimentName.SYSTEM_PROMPT) is None
@pytest.mark.asyncio
async def test_get_variant_or_none_returns_override() -> None:
manager = ExperimentManager(
client=_StubClient(None), overrides={ExperimentName.SYSTEM_PROMPT.value: "lean"}
)
assert manager.get_variant_or_none(ExperimentName.SYSTEM_PROMPT) == "lean"
@pytest.mark.asyncio
async def test_get_variant_or_none_returns_resolved_value() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "explore", "tracks": []}],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.get_variant_or_none(ExperimentName.SYSTEM_PROMPT) == "explore"
@pytest.mark.asyncio
async def test_get_variant_returns_resolved_value() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "cli_v2", "tracks": []}],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "cli_v2"
@pytest.mark.asyncio
async def test_overrides_take_precedence_over_remote() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "cli_v2", "tracks": []}],
}
})
manager = ExperimentManager(
client=_StubClient(response),
overrides={ExperimentName.SYSTEM_PROMPT.value: "forced"},
)
await manager.initialize(_attrs())
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "forced"
@pytest.mark.asyncio
async def test_get_variant_falls_back_when_remote_returns_no_match() -> None:
response = _response({"some_other_feature": {"defaultValue": True}})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "cli"
@pytest.mark.asyncio
async def test_assignments_uses_resolved_variant_value() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": ExperimentName.SYSTEM_PROMPT.value},
"result": {
"key": "1",
"variationId": 1,
"inExperiment": True,
},
}
],
}
],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assignments = manager.assignments()
assert assignments == {ExperimentName.SYSTEM_PROMPT.value: "cli_v2"}
@pytest.mark.asyncio
async def test_assignments_prefers_track_result_value() -> None:
# When the GrowthBook payload carries an explicit per-arm value, it wins
# over the rule's force value — it's the most precise label for the user.
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": ExperimentName.SYSTEM_PROMPT.value},
"result": {
"key": "1",
"variationId": 1,
"value": "cli_v3",
"inExperiment": True,
},
}
],
}
],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.assignments() == {ExperimentName.SYSTEM_PROMPT.value: "cli_v3"}
@pytest.mark.asyncio
async def test_assignments_keys_on_feature_id_when_experiment_key_differs() -> None:
# GrowthBook feature ID and experiment key can diverge. Telemetry must
# key on the feature ID (matches ExperimentName) so overrides, lookups
# and downstream analysis stay consistent.
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": "vibe-code-cli-system-prompt"},
"result": {
"key": "1",
"variationId": 1,
"inExperiment": True,
"featureId": ExperimentName.SYSTEM_PROMPT.value,
},
}
],
}
],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assignments = manager.assignments()
assert assignments == {ExperimentName.SYSTEM_PROMPT.value: "cli_v2"}
assert "vibe-code-cli-system-prompt" not in assignments
@pytest.mark.asyncio
async def test_initialize_does_nothing_on_failed_eval() -> None:
manager = ExperimentManager(client=_StubClient(None))
await manager.initialize(_attrs())
assert manager.assignments() == {}
for name in ExperimentName:
assert manager.get_variant(name) == DEFAULT_VARIANTS[name]
def test_experiment_attributes_default_custom_system_prompt_to_false() -> None:
attrs = ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
)
assert attrs.custom_system_prompt is False
assert attrs.model_dump(exclude_none=True)["custom_system_prompt"] is False
def test_experiment_attributes_serializes_custom_system_prompt() -> None:
attrs = ExperimentAttributes(
userId="x",
entrypoint="cli",
agent_version="0",
os="darwin",
custom_system_prompt=True,
)
assert attrs.model_dump(exclude_none=True)["custom_system_prompt"] is True
def test_export_state_returns_none_before_initialize() -> None:
manager = ExperimentManager(client=_StubClient(None))
assert manager.export_state() is None
@pytest.mark.asyncio
async def test_export_state_returns_response_after_initialize() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "cli_v2", "tracks": []}],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.export_state() == response
def test_hydrate_does_not_call_client() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "cli_v2", "tracks": []}],
}
})
stub = _StubClient(response)
manager = ExperimentManager(client=stub)
manager.hydrate(response)
assert stub.calls == []
def test_hydrate_makes_get_variant_match_initialized_manager() -> None:
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": ExperimentName.SYSTEM_PROMPT.value},
"result": {
"key": "1",
"variationId": 1,
"inExperiment": True,
},
}
],
}
],
}
})
hydrated = ExperimentManager(client=_StubClient(None))
hydrated.hydrate(response)
assert hydrated.get_variant(ExperimentName.SYSTEM_PROMPT) == "cli_v2"
assert hydrated.assignments() == {ExperimentName.SYSTEM_PROMPT.value: "cli_v2"}
@pytest.mark.asyncio
async def test_initialize_twice_replaces_response() -> None:
# Session reset re-runs initialize_experiments, which calls manager.initialize
# again. The second response must replace the first so the new session sees
# the up-to-date variant assignment.
first = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "explore", "tracks": []}],
}
})
second = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "lean", "tracks": []}],
}
})
class _ScriptedClient(RemoteEvalClient):
def __init__(self, responses: list[EvalResponse]) -> None:
self._responses = responses
self.call_count = 0
async def evaluate(
self, attributes: ExperimentAttributes
) -> EvalResponse | None:
response = self._responses[self.call_count]
self.call_count += 1
return response
async def aclose(self) -> None:
pass
client = _ScriptedClient([first, second])
manager = ExperimentManager(client=client)
await manager.initialize(_attrs())
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "explore"
await manager.initialize(_attrs())
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "lean"
assert client.call_count == 2
@pytest.mark.asyncio
async def test_assignments_excludes_tracks_not_in_experiment() -> None:
# Holdouts and forced overrides come back with inExperiment=False.
# We must NOT report them as active experiment participants — that would
# pollute downstream A/B analysis since the assignment map IS the
# exposure record (no separate exposure event).
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli",
"tracks": [
{
"experiment": {"key": ExperimentName.SYSTEM_PROMPT.value},
"result": {
"key": "0",
"variationId": 0,
"inExperiment": False,
},
}
],
}
],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.assignments() == {}
@pytest.mark.asyncio
async def test_assignments_excludes_tracks_with_missing_in_experiment() -> None:
# Defensive: if the proxy returns a track without inExperiment, treat it
# as "not confirmed in the experiment" rather than silently counting it.
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": ExperimentName.SYSTEM_PROMPT.value},
"result": {"key": "1", "variationId": 1},
}
],
}
],
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
assert manager.assignments() == {}
@pytest.mark.asyncio
async def test_initialize_drops_unknown_features() -> None:
# GrowthBook returns every feature defined in the org. Only the ones
# listed in ExperimentName should survive into manager state and the
# persisted snapshot.
response = _response({
ExperimentName.SYSTEM_PROMPT.value: {"defaultValue": "cli"},
"unrelated_org_flag": {"defaultValue": True},
"neko": {"defaultValue": False},
"gbdemo-checkout-layout": {"defaultValue": "dev"},
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(_attrs())
snapshot = manager.export_state()
assert snapshot is not None
assert set(snapshot.features.keys()) == {ExperimentName.SYSTEM_PROMPT.value}
def test_hydrate_drops_unknown_features() -> None:
# When resuming a session saved by an older vibe version, the snapshot
# may contain experiments that have since been retired. Filter them on
# hydrate so the in-memory state matches the current ExperimentName.
legacy_response = _response({
ExperimentName.SYSTEM_PROMPT.value: {"defaultValue": "cli"},
"retired_experiment": {"defaultValue": "control"},
})
manager = ExperimentManager(client=_StubClient(None))
manager.hydrate(legacy_response)
snapshot = manager.export_state()
assert snapshot is not None
assert set(snapshot.features.keys()) == {ExperimentName.SYSTEM_PROMPT.value}

View file

@ -0,0 +1,68 @@
from __future__ import annotations
from vibe.core.experiments.active import ExperimentName
from vibe.core.experiments.models import EvalResponse, FeatureDefinition, FeatureRule
class TestFeatureDefinition:
def test_resolved_value_returns_force_when_present(self) -> None:
feature = FeatureDefinition(
defaultValue="cli", rules=[FeatureRule(force="cli_v2")]
)
assert feature.resolved_value() == "cli_v2"
def test_resolved_value_falls_back_to_default(self) -> None:
feature = FeatureDefinition(defaultValue="cli")
assert feature.resolved_value() == "cli"
def test_resolved_value_picks_first_rule_with_force(self) -> None:
feature = FeatureDefinition(
defaultValue="cli",
rules=[FeatureRule(force=None), FeatureRule(force="cli_v2")],
)
assert feature.resolved_value() == "cli_v2"
class TestEvalResponse:
def test_parses_real_proxy_payload(self) -> None:
# A reduced version of the live response captured during dev probing.
sp_key = ExperimentName.SYSTEM_PROMPT.value
raw = {
"features": {
"rbac_enabled": {"defaultValue": True},
sp_key: {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {
"key": sp_key,
"variations": [{}, "cli_v2"],
},
"result": {
"key": "1",
"variationId": 1,
"value": "cli_v2",
"inExperiment": True,
"hashAttribute": "id",
"hashValue": "abc",
},
}
],
}
],
},
},
"experiments": [],
"dateUpdated": "2026-05-11T13:29:48.394Z",
}
response = EvalResponse.model_validate(raw)
assert response.features["rbac_enabled"].defaultValue is True
sp = response.features[sp_key]
assert sp.resolved_value() == "cli_v2"
track = sp.rules[0].tracks[0]
assert track.experiment.key == sp_key
assert track.result.variationId == 1
assert track.result.inExperiment is True

View file

@ -0,0 +1,168 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from tests.conftest import build_test_vibe_config
from vibe.core.agents import AgentManager
from vibe.core.experiments.active import ExperimentName
from vibe.core.experiments.client import RemoteEvalClient
from vibe.core.experiments.manager import ExperimentManager
from vibe.core.experiments.models import EvalResponse, ExperimentAttributes
from vibe.core.session.session_loader import SessionLoader
from vibe.core.skills.manager import SkillManager
from vibe.core.system_prompt import get_universal_system_prompt
from vibe.core.tools.manager import ToolManager
from vibe.core.types import SessionMetadata
class _StubClient(RemoteEvalClient):
def __init__(self, response: EvalResponse | None) -> None:
self._response = response
self.calls: list[ExperimentAttributes] = []
async def evaluate(self, attributes: ExperimentAttributes) -> EvalResponse | None:
self.calls.append(attributes)
return self._response
async def aclose(self) -> None:
pass
def _attrs() -> ExperimentAttributes:
return ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
)
def _response_forcing(variant: str) -> EvalResponse:
return EvalResponse.model_validate({
"features": {
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [
{
"force": variant,
"tracks": [
{
"experiment": {
"key": ExperimentName.SYSTEM_PROMPT.value
},
"result": {
"key": "1",
"variationId": 1,
"inExperiment": True,
},
}
],
}
],
}
}
})
@pytest.mark.asyncio
async def test_resume_round_trip_preserves_variant() -> None:
response = _response_forcing("explore")
fresh = ExperimentManager(client=_StubClient(response))
await fresh.initialize(_attrs())
persisted_state = fresh.export_state()
assert persisted_state is not None
serialized = persisted_state.model_dump_json()
rehydrated_response = EvalResponse.model_validate_json(serialized)
stub = _StubClient(None)
resumed = ExperimentManager(client=stub)
resumed.hydrate(rehydrated_response)
assert resumed.get_variant(ExperimentName.SYSTEM_PROMPT) == "explore"
assert resumed.assignments() == fresh.assignments()
assert stub.calls == []
def test_resume_old_session_without_experiments_field_falls_back_to_defaults(
tmp_path: Path,
) -> None:
legacy_metadata = {
"session_id": "legacy",
"start_time": "2025-01-01T00:00:00+00:00",
"end_time": None,
"git_commit": None,
"git_branch": None,
"environment": {"working_directory": str(tmp_path)},
"username": "tester",
"loops": [],
}
meta_path = tmp_path / "meta.json"
meta_path.write_text(json.dumps(legacy_metadata))
metadata = SessionLoader.load_metadata(tmp_path)
assert metadata.experiments is None
def test_resume_overrides_still_win_over_hydrated_state() -> None:
response = _response_forcing("explore")
manager = ExperimentManager(
client=_StubClient(None), overrides={ExperimentName.SYSTEM_PROMPT.value: "lean"}
)
manager.hydrate(response)
assert manager.get_variant(ExperimentName.SYSTEM_PROMPT) == "lean"
def test_session_metadata_round_trips_experiments_field() -> None:
response = _response_forcing("explore")
metadata = SessionMetadata(
session_id="s",
start_time="2025-01-01T00:00:00+00:00",
end_time=None,
git_commit=None,
git_branch=None,
environment={"working_directory": "/tmp"},
username="tester",
experiments=response,
)
serialized = metadata.model_dump_json()
restored = SessionMetadata.model_validate_json(serialized)
assert restored.experiments is not None
assert restored.experiments == response
def _build_managers(config):
return (
ToolManager(lambda: config),
SkillManager(lambda: config),
AgentManager(lambda: config),
)
@pytest.mark.asyncio
async def test_graduated_experiment_with_deleted_variant_file_falls_back() -> None:
config = build_test_vibe_config(
system_prompt_id="cli",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
)
response = _response_forcing("removed_after_graduation_2025_07")
manager = ExperimentManager(client=_StubClient(None))
manager.hydrate(response)
tool_manager, skill_manager, agent_manager = _build_managers(config)
prompt = get_universal_system_prompt(
tool_manager, config, skill_manager, agent_manager, experiment_manager=manager
)
default_prompt = get_universal_system_prompt(
tool_manager, config, skill_manager, agent_manager, experiment_manager=None
)
assert prompt == default_prompt

View file

@ -0,0 +1,208 @@
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from vibe.core.experiments.client import RemoteEvalClient
from vibe.core.experiments.manager import ExperimentManager
from vibe.core.experiments.models import EvalResponse, ExperimentAttributes
from vibe.core.experiments.session import (
hydrate_experiments_from_session,
initialize_experiments,
)
class _StubClient(RemoteEvalClient):
def __init__(self, response: EvalResponse | None) -> None:
self._response = response
async def evaluate(self, attributes: ExperimentAttributes) -> EvalResponse | None:
return self._response
async def aclose(self) -> None:
pass
def _make_config(
*, enable_telemetry: bool = True, enable_experiments: bool = True
) -> Any:
config = MagicMock()
config.enable_telemetry = enable_telemetry
config.experiments.enable = enable_experiments
return config
@pytest.mark.asyncio
async def test_initialize_returns_false_when_telemetry_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
persist = AsyncMock()
session_logger = MagicMock()
session_logger.persist_experiments = persist
manager = ExperimentManager(client=_StubClient(None))
result = await initialize_experiments(
config=_make_config(enable_telemetry=False),
manager=manager,
session_logger=session_logger,
entrypoint_metadata=None,
)
assert result is False
persist.assert_not_called()
@pytest.mark.asyncio
async def test_initialize_returns_false_when_experiments_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# `experiments.enable=False` must short-circuit even when telemetry is on
# — it is the user's opt-out for being assigned to A/B tests without
# disabling all telemetry.
persist = AsyncMock()
session_logger = MagicMock()
session_logger.persist_experiments = persist
manager = ExperimentManager(client=_StubClient(None))
result = await initialize_experiments(
config=_make_config(enable_experiments=False),
manager=manager,
session_logger=session_logger,
entrypoint_metadata=None,
)
assert result is False
persist.assert_not_called()
@pytest.mark.asyncio
async def test_initialize_returns_false_when_no_mistral_provider(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"vibe.core.experiments.session.get_mistral_provider_and_api_key",
lambda _config: None,
)
persist = AsyncMock()
session_logger = MagicMock()
session_logger.persist_experiments = persist
manager = ExperimentManager(client=_StubClient(None))
result = await initialize_experiments(
config=_make_config(),
manager=manager,
session_logger=session_logger,
entrypoint_metadata=None,
)
assert result is False
persist.assert_not_called()
@pytest.mark.asyncio
async def test_initialize_returns_false_when_remote_eval_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Regression: even if telemetry is enabled and a Mistral key is set,
# a failed remote eval (returns None) leaves manager state empty —
# the helper must NOT report success, so the caller skips the
# unnecessary system prompt refresh.
monkeypatch.setattr(
"vibe.core.experiments.session.get_mistral_provider_and_api_key",
lambda _config: (MagicMock(), "fake-key"),
)
monkeypatch.setattr(
"vibe.core.experiments.session._build_attributes",
lambda *_args, **_kwargs: ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
),
)
persist = AsyncMock()
session_logger = MagicMock()
session_logger.persist_experiments = persist
manager = ExperimentManager(client=_StubClient(None))
result = await initialize_experiments(
config=_make_config(),
manager=manager,
session_logger=session_logger,
entrypoint_metadata=None,
)
assert result is False
persist.assert_not_called()
@pytest.mark.asyncio
async def test_initialize_returns_true_and_persists_when_remote_eval_succeeds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"vibe.core.experiments.session.get_mistral_provider_and_api_key",
lambda _config: (MagicMock(), "fake-key"),
)
monkeypatch.setattr(
"vibe.core.experiments.session._build_attributes",
lambda *_args, **_kwargs: ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
),
)
persist = AsyncMock()
session_logger = MagicMock()
session_logger.persist_experiments = persist
response = EvalResponse.model_validate({
"features": {"vibe_cli_system_prompt": {"defaultValue": "cli"}}
})
manager = ExperimentManager(client=_StubClient(response))
result = await initialize_experiments(
config=_make_config(),
manager=manager,
session_logger=session_logger,
entrypoint_metadata=None,
)
assert result is True
persist.assert_awaited_once()
@pytest.mark.asyncio
async def test_hydrate_returns_false_when_telemetry_disabled() -> None:
session_logger = MagicMock()
response = EvalResponse.model_validate({
"features": {"vibe_cli_system_prompt": {"defaultValue": "cli"}}
})
session_logger.session_metadata.experiments = response
manager = ExperimentManager(client=_StubClient(None))
result = await hydrate_experiments_from_session(
config=_make_config(enable_telemetry=False),
manager=manager,
session_logger=session_logger,
)
assert result is False
assert manager.export_state() is None
@pytest.mark.asyncio
async def test_hydrate_returns_false_when_experiments_disabled() -> None:
# Regression: without this gate, a user who flipped experiments.enable
# to False between sessions would still resume into a hydrated variant.
session_logger = MagicMock()
response = EvalResponse.model_validate({
"features": {"vibe_cli_system_prompt": {"defaultValue": "cli"}}
})
session_logger.session_metadata.experiments = response
manager = ExperimentManager(client=_StubClient(None))
result = await hydrate_experiments_from_session(
config=_make_config(enable_experiments=False),
manager=manager,
session_logger=session_logger,
)
assert result is False
assert manager.export_state() is None

View file

@ -0,0 +1,186 @@
from __future__ import annotations
import pytest
from tests.conftest import build_test_vibe_config
from vibe.core.agents import AgentManager
from vibe.core.experiments.active import ExperimentName
from vibe.core.experiments.client import RemoteEvalClient
from vibe.core.experiments.manager import ExperimentManager
from vibe.core.experiments.models import EvalResponse, ExperimentAttributes
from vibe.core.prompts import load_system_prompt
from vibe.core.skills.manager import SkillManager
from vibe.core.system_prompt import get_universal_system_prompt
from vibe.core.tools.manager import ToolManager
class _StubClient(RemoteEvalClient):
def __init__(self, response: EvalResponse | None) -> None:
self._response = response
async def evaluate(self, attributes: ExperimentAttributes) -> EvalResponse | None:
return self._response
async def aclose(self) -> None:
pass
def _build_managers(config):
return (
ToolManager(lambda: config),
SkillManager(lambda: config),
AgentManager(lambda: config),
)
@pytest.mark.asyncio
async def test_system_prompt_uses_assigned_variant() -> None:
config = build_test_vibe_config(
system_prompt_id="cli",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
)
response = EvalResponse.model_validate({
"features": {
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "tests", "tracks": []}],
}
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(
ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
)
)
tool_manager, skill_manager, agent_manager = _build_managers(config)
prompt = get_universal_system_prompt(
tool_manager, config, skill_manager, agent_manager, experiment_manager=manager
)
assert prompt.startswith("You are Vibe, a super useful programming assistant.")
@pytest.mark.asyncio
async def test_system_prompt_falls_back_to_default_when_variant_unknown() -> None:
config = build_test_vibe_config(
system_prompt_id="cli",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
)
response = EvalResponse.model_validate({
"features": {
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "nonexistent_prompt", "tracks": []}],
}
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(
ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
)
)
tool_manager, skill_manager, agent_manager = _build_managers(config)
prompt = get_universal_system_prompt(
tool_manager, config, skill_manager, agent_manager, experiment_manager=manager
)
# Falls back to the default `cli` system prompt.
assert "You are Mistral Vibe" in prompt or "You are Vibe" in prompt
def test_system_prompt_uses_default_when_no_manager() -> None:
config = build_test_vibe_config(
system_prompt_id="cli",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
)
tool_manager, skill_manager, agent_manager = _build_managers(config)
prompt = get_universal_system_prompt(
tool_manager, config, skill_manager, agent_manager
)
assert "You are Mistral Vibe" in prompt or "You are Vibe" in prompt
def test_system_prompt_honors_user_config_when_manager_uninitialized() -> None:
config = build_test_vibe_config(
system_prompt_id="lean",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
)
manager = ExperimentManager(client=_StubClient(None))
tool_manager, skill_manager, agent_manager = _build_managers(config)
prompt = get_universal_system_prompt(
tool_manager, config, skill_manager, agent_manager, experiment_manager=manager
)
assert prompt == load_system_prompt("lean")
@pytest.mark.asyncio
async def test_system_prompt_honors_user_config_when_no_remote_assignment() -> None:
config = build_test_vibe_config(
system_prompt_id="lean",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
)
response = EvalResponse.model_validate({
"features": {"some_other_feature": {"defaultValue": True}}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(
ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
)
)
tool_manager, skill_manager, agent_manager = _build_managers(config)
prompt = get_universal_system_prompt(
tool_manager, config, skill_manager, agent_manager, experiment_manager=manager
)
assert prompt == load_system_prompt("lean")
@pytest.mark.asyncio
async def test_user_config_overrides_assigned_experiment_variant() -> None:
config = build_test_vibe_config(
system_prompt_id="lean",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
)
response = EvalResponse.model_validate({
"features": {
ExperimentName.SYSTEM_PROMPT.value: {
"defaultValue": "cli",
"rules": [{"force": "explore", "tracks": []}],
}
}
})
manager = ExperimentManager(client=_StubClient(response))
await manager.initialize(
ExperimentAttributes(
userId="x", entrypoint="cli", agent_version="0", os="darwin"
)
)
tool_manager, skill_manager, agent_manager = _build_managers(config)
prompt = get_universal_system_prompt(
tool_manager, config, skill_manager, agent_manager, experiment_manager=manager
)
assert prompt == load_system_prompt("lean")
assert prompt != load_system_prompt("explore")

View file

@ -0,0 +1,31 @@
from __future__ import annotations
from tests.conftest import build_test_vibe_config
from vibe.core.experiments.active import ExperimentName
from vibe.core.telemetry.send import TelemetryClient
def test_build_client_event_metadata_omits_experiments_when_empty() -> None:
config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(
config_getter=lambda: config, experiments_getter=lambda: {}
)
metadata = client.build_client_event_metadata()
assert "experiments" not in metadata
def test_build_client_event_metadata_includes_experiments_when_present() -> None:
config = build_test_vibe_config(enable_telemetry=True)
sp_key = ExperimentName.SYSTEM_PROMPT.value
client = TelemetryClient(
config_getter=lambda: config, experiments_getter=lambda: {sp_key: "1"}
)
metadata = client.build_client_event_metadata()
assert metadata["experiments"] == {sp_key: "1"}
def test_build_client_event_metadata_works_without_getter() -> None:
config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
metadata = client.build_client_event_metadata()
assert "experiments" not in metadata

View file

@ -0,0 +1,123 @@
from __future__ import annotations
from pathlib import Path
import pytest
from vibe.core.config import SessionLoggingConfig
from vibe.core.session import last_session_pointer
@pytest.fixture
def session_logging(tmp_path: Path) -> SessionLoggingConfig:
return SessionLoggingConfig(save_dir=str(tmp_path))
def _set_tty(monkeypatch: pytest.MonkeyPatch, key: str | None) -> None:
monkeypatch.setattr(last_session_pointer, "current_tty_key", lambda: key)
def test_load_returns_none_when_no_tty(
session_logging: SessionLoggingConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
_set_tty(monkeypatch, None)
assert last_session_pointer.load(session_logging) is None
def test_load_returns_none_when_no_pointer_written(
session_logging: SessionLoggingConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
_set_tty(monkeypatch, "ttys001")
assert last_session_pointer.load(session_logging) is None
def test_record_then_load_round_trip(
session_logging: SessionLoggingConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
_set_tty(monkeypatch, "ttys001")
last_session_pointer.record(session_logging, "abc-123")
assert last_session_pointer.load(session_logging) == "abc-123"
def test_record_skips_when_no_tty(
session_logging: SessionLoggingConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
_set_tty(monkeypatch, None)
last_session_pointer.record(session_logging, "abc-123")
pointer_dir = Path(session_logging.save_dir) / last_session_pointer.POINTER_DIR_NAME
assert not pointer_dir.exists()
def test_record_skips_when_logging_disabled(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
disabled = SessionLoggingConfig(save_dir=str(tmp_path), enabled=False)
_set_tty(monkeypatch, "ttys001")
last_session_pointer.record(disabled, "abc-123")
assert not (tmp_path / last_session_pointer.POINTER_DIR_NAME).exists()
def test_pointers_are_per_tty(
session_logging: SessionLoggingConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
_set_tty(monkeypatch, "ttys001")
last_session_pointer.record(session_logging, "session-a")
_set_tty(monkeypatch, "ttys002")
last_session_pointer.record(session_logging, "session-b")
assert last_session_pointer.load(session_logging) == "session-b"
_set_tty(monkeypatch, "ttys001")
assert last_session_pointer.load(session_logging) == "session-a"
def test_record_ignores_empty_session_id(
session_logging: SessionLoggingConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
_set_tty(monkeypatch, "ttys001")
last_session_pointer.record(session_logging, None)
last_session_pointer.record(session_logging, "")
assert last_session_pointer.load(session_logging) is None
def test_current_tty_key_returns_none_when_ttyname_is_unavailable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delattr(last_session_pointer.os, "ttyname", raising=False)
assert last_session_pointer.current_tty_key() is None
def _patch_windows(monkeypatch: pytest.MonkeyPatch, hwnd: int) -> None:
import ctypes
from types import SimpleNamespace
fake_windll = SimpleNamespace(
kernel32=SimpleNamespace(GetConsoleWindow=lambda: hwnd)
)
monkeypatch.setattr(last_session_pointer.sys, "platform", "win32")
monkeypatch.setattr(ctypes, "windll", fake_windll, raising=False)
def test_current_tty_key_uses_console_hwnd_on_windows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_windows(monkeypatch, hwnd=12345)
assert last_session_pointer.current_tty_key() == "conhost-12345"
def test_current_tty_key_falls_back_to_wt_session(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_windows(monkeypatch, hwnd=0)
monkeypatch.setenv("WT_SESSION", "abcd-1234")
assert last_session_pointer.current_tty_key() == "wt-abcd-1234"
def test_current_tty_key_falls_back_to_ppid_on_windows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_windows(monkeypatch, hwnd=0)
monkeypatch.delenv("WT_SESSION", raising=False)
monkeypatch.setattr(last_session_pointer.os, "getppid", lambda: 4242)
assert last_session_pointer.current_tty_key() == "ppid-4242"

389
tests/core/test_add_dir.py Normal file
View file

@ -0,0 +1,389 @@
from __future__ import annotations
from pathlib import Path
import pytest
from vibe.core.config.harness_files import (
HarnessFilesManager,
get_harness_files_manager,
init_harness_files_manager,
reset_harness_files_manager,
)
from vibe.core.paths import AGENTS_MD_FILENAME
from vibe.core.tools.utils import is_path_within_workdir
from vibe.core.trusted_folders import trusted_folders_manager
class TestHarnessFilesManagerAdditionalDirs:
def test_default_additional_dirs_empty(self) -> None:
mgr = HarnessFilesManager(sources=("user",))
assert mgr._additional_dirs == ()
def test_init_with_additional_dirs(self, tmp_path: Path) -> None:
d1 = tmp_path / "extra1"
d1.mkdir()
d2 = tmp_path / "extra2"
d2.mkdir()
reset_harness_files_manager()
init_harness_files_manager("user", "project", additional_dirs=[d1, d2])
mgr = get_harness_files_manager()
# init normalizes to resolved paths so the symlink/relative-vs-absolute
# spellings of the same dir compare equal on reinit.
assert mgr._additional_dirs == (d1.resolve(), d2.resolve())
def test_reinit_idempotent_when_paths_resolve_equal(self, tmp_path: Path) -> None:
target = tmp_path / "target"
target.mkdir()
link = tmp_path / "link"
link.symlink_to(target, target_is_directory=True)
reset_harness_files_manager()
init_harness_files_manager("user", "project", additional_dirs=[target])
# Second call with the symlink spelling must NOT raise — both resolve
# to the same dir.
init_harness_files_manager("user", "project", additional_dirs=[link])
def test_init_without_additional_dirs(self) -> None:
reset_harness_files_manager()
init_harness_files_manager("user", "project")
mgr = get_harness_files_manager()
assert mgr._additional_dirs == ()
def test_reinit_with_same_additional_dirs_is_idempotent(
self, tmp_path: Path
) -> None:
d = tmp_path / "extra"
d.mkdir()
reset_harness_files_manager()
init_harness_files_manager("user", "project", additional_dirs=[d])
# Same sources + same additional_dirs: no-op, no raise.
init_harness_files_manager("user", "project", additional_dirs=[d])
def test_reinit_with_different_additional_dirs_raises(self, tmp_path: Path) -> None:
d1 = tmp_path / "extra1"
d1.mkdir()
d2 = tmp_path / "extra2"
d2.mkdir()
reset_harness_files_manager()
init_harness_files_manager("user", "project", additional_dirs=[d1])
with pytest.raises(RuntimeError, match="different configuration"):
init_harness_files_manager("user", "project", additional_dirs=[d2])
class TestAdditionalDirsDiscovery:
def test_discovers_tools_from_additional_dir(self, tmp_path: Path) -> None:
extra = tmp_path / "extra_project"
tools_dir = extra / ".vibe" / "tools"
tools_dir.mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
assert tools_dir in mgr.project_tools_dirs
def test_discovers_skills_from_additional_dir(self, tmp_path: Path) -> None:
extra = tmp_path / "extra_project"
skills_dir = extra / ".vibe" / "skills"
skills_dir.mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
assert skills_dir in mgr.project_skills_dirs
def test_discovers_agents_from_additional_dir(self, tmp_path: Path) -> None:
extra = tmp_path / "extra_project"
agents_dir = extra / ".vibe" / "agents"
agents_dir.mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
assert agents_dir in mgr.project_agents_dirs
def test_discovers_agents_skills_from_additional_dir(self, tmp_path: Path) -> None:
extra = tmp_path / "extra_project"
skills_dir = extra / ".agents" / "skills"
skills_dir.mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
assert skills_dir in mgr.project_skills_dirs
def test_discovers_prompts_from_additional_dir(self, tmp_path: Path) -> None:
extra = tmp_path / "extra_project"
prompts_dir = extra / ".vibe" / "prompts"
prompts_dir.mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
assert prompts_dir in mgr.project_prompts_dirs
def test_no_dirs_when_additional_dir_has_no_vibe(self, tmp_path: Path) -> None:
extra = tmp_path / "bare_project"
extra.mkdir()
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
assert mgr.project_tools_dirs == []
assert mgr.project_skills_dirs == []
assert mgr.project_agents_dirs == []
class TestAdditionalDirsAgentsMd:
def test_load_project_docs_includes_additional_dir_agents_md(
self, tmp_path: Path
) -> None:
extra = tmp_path / "extra_project"
extra.mkdir()
agents_md = extra / AGENTS_MD_FILENAME
agents_md.write_text("Extra project instructions", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
docs = mgr.load_project_docs()
assert any(d == extra and "Extra project" in content for d, content in docs)
def test_load_project_docs_skips_missing_agents_md(self, tmp_path: Path) -> None:
extra = tmp_path / "no_agents_md"
extra.mkdir()
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
docs = mgr.load_project_docs()
assert not any(d == extra for d, _ in docs)
def test_load_project_docs_skips_empty_agents_md(self, tmp_path: Path) -> None:
extra = tmp_path / "empty_agents_md"
extra.mkdir()
(extra / AGENTS_MD_FILENAME).write_text(" \n", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
docs = mgr.load_project_docs()
assert not any(d == extra for d, _ in docs)
def test_find_subdirectory_agents_md_in_additional_dir(
self, tmp_path: Path
) -> None:
extra = tmp_path / "extra_project"
sub = extra / "sub"
sub.mkdir(parents=True)
agents_md = sub / AGENTS_MD_FILENAME
agents_md.write_text("Sub instructions", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
file_in_sub = sub / "main.py"
docs = mgr.find_subdirectory_agents_md(file_in_sub)
assert any("Sub instructions" in content for _, content in docs)
def test_find_subdirectory_agents_md_resolves_additional_dir(
self, tmp_path: Path
) -> None:
extra = tmp_path / "extra_project"
sub = extra / "sub"
sub.mkdir(parents=True)
link = tmp_path / "extra_link"
link.symlink_to(extra, target_is_directory=True)
agents_md = sub / AGENTS_MD_FILENAME
agents_md.write_text("Sub instructions", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(link,))
docs = mgr.find_subdirectory_agents_md(sub / "main.py")
assert any("Sub instructions" in content for _, content in docs)
def test_find_subdirectory_agents_md_outside_all_dirs(self, tmp_path: Path) -> None:
extra = tmp_path / "extra"
extra.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
docs = mgr.find_subdirectory_agents_md(outside / "file.py")
assert docs == []
class TestFilePermissionsAdditionalDirs:
def test_is_within_workdir_for_additional_dir(self, tmp_path: Path) -> None:
extra = tmp_path / "extra"
extra.mkdir()
file_in_extra = extra / "some_file.py"
reset_harness_files_manager()
init_harness_files_manager("user", "project", additional_dirs=[extra])
assert is_path_within_workdir(str(file_in_extra))
def test_is_within_workdir_resolves_additional_dir(self, tmp_path: Path) -> None:
extra = tmp_path / "extra"
extra.mkdir()
link = tmp_path / "extra_link"
link.symlink_to(extra, target_is_directory=True)
file_in_extra = extra / "some_file.py"
reset_harness_files_manager()
init_harness_files_manager("user", "project", additional_dirs=[link])
assert is_path_within_workdir(str(file_in_extra))
def test_is_within_workdir_for_cwd(self, tmp_working_directory: Path) -> None:
file_in_cwd = tmp_working_directory / "file.py"
assert is_path_within_workdir(str(file_in_cwd))
def test_is_not_within_workdir_for_outside_path(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
outside = tmp_path / "outside"
outside.mkdir()
cwd = tmp_path / "cwd"
cwd.mkdir()
monkeypatch.chdir(cwd)
reset_harness_files_manager()
init_harness_files_manager("user", "project")
assert not is_path_within_workdir(str(outside / "file.py"))
def test_is_path_within_workdir_when_manager_uninitialized(
self, tmp_working_directory: Path
) -> None:
reset_harness_files_manager()
# Without a manager, only cwd counts.
assert is_path_within_workdir(str(tmp_working_directory / "file.py"))
class TestAdditionalDirsAreImplicitlyTrusted:
def test_load_project_docs_does_not_require_trust_for_additional_dir(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Untrusted cwd: primary workdir contributes nothing.
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: False)
extra = tmp_path / "extra_project"
extra.mkdir()
(extra / AGENTS_MD_FILENAME).write_text(
"Trusted via --add-dir", encoding="utf-8"
)
mgr = HarnessFilesManager(
sources=("user", "project"), _additional_dirs=(extra,)
)
docs = mgr.load_project_docs()
assert any("Trusted via --add-dir" in content for _, content in docs)
def test_project_tools_dirs_skip_trust_check_for_additional_dir(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: False)
extra = tmp_path / "extra_project"
tools_dir = extra / ".vibe" / "tools"
tools_dir.mkdir(parents=True)
mgr = HarnessFilesManager(
sources=("user", "project"), _additional_dirs=(extra,)
)
assert tools_dir in mgr.project_tools_dirs
class TestLoadProjectDocsDedupe:
def test_dedupes_when_cwd_under_additional_dir(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
extra = (tmp_path / "extra_project").resolve()
extra.mkdir()
sub = extra / "sub"
sub.mkdir()
(extra / AGENTS_MD_FILENAME).write_text("Root instructions", encoding="utf-8")
(sub / AGENTS_MD_FILENAME).write_text("Sub instructions", encoding="utf-8")
monkeypatch.chdir(sub)
trusted_folders_manager.trust_for_session(extra)
mgr = HarnessFilesManager(
sources=("user", "project"), _additional_dirs=(extra,)
)
docs = mgr.load_project_docs()
emitted_dirs = [d for d, _ in docs]
assert emitted_dirs.count(extra) == 1
assert sub.resolve() in emitted_dirs
class TestHookFilesAdditionalDirs:
def test_hook_files_includes_additional_dir_hooks_toml(
self, tmp_path: Path
) -> None:
extra = tmp_path / "extra"
extra.mkdir()
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(extra,))
assert extra / ".vibe" / "hooks.toml" in mgr.hook_files
def test_hook_files_deduplicates_workdir_and_additional_dir(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workdir = tmp_path / "project"
workdir.mkdir()
monkeypatch.chdir(workdir)
trusted_folders_manager.trust_for_session(workdir)
mgr = HarnessFilesManager(
sources=("user", "project"), _additional_dirs=(workdir.resolve(),)
)
hook_file = workdir / ".vibe" / "hooks.toml"
assert mgr.hook_files.count(hook_file.resolve()) == 1
class TestProjectPromptsDirsAdditionalDirs:
def test_project_prompts_dirs_deduplicates_workdir_and_additional_dir(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workdir = tmp_path / "project"
(workdir / ".vibe" / "prompts").mkdir(parents=True)
monkeypatch.chdir(workdir)
trusted_folders_manager.trust_for_session(workdir)
mgr = HarnessFilesManager(
sources=("user", "project"), _additional_dirs=(workdir.resolve(),)
)
assert len(mgr.project_prompts_dirs) == 1
class TestProjectRootsNestedDedup:
def test_nested_add_dirs_collapse(self, tmp_path: Path) -> None:
outer = (tmp_path / "outer").resolve()
inner = outer / "inner"
inner.mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(outer, inner))
assert mgr.project_roots == [outer]
def test_add_dir_containing_cwd_keeps_both(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
outer = (tmp_path / "outer").resolve()
inner = outer / "inner"
inner.mkdir(parents=True)
(outer / AGENTS_MD_FILENAME).write_text("Outer instructions", encoding="utf-8")
(inner / AGENTS_MD_FILENAME).write_text("Inner instructions", encoding="utf-8")
monkeypatch.chdir(inner)
trusted_folders_manager.trust_for_session(inner)
trusted_folders_manager.trust_for_session(outer)
mgr = HarnessFilesManager(
sources=("user", "project"), _additional_dirs=(outer,)
)
# cwd survives so its walk-up semantics still work; the add-dir keeps
# its own root-level discovery.
assert mgr.project_roots == [inner, outer]
docs = mgr.load_project_docs()
assert any("Outer" in c for _, c in docs)
assert any("Inner" in c for _, c in docs)
def test_add_dir_nested_under_trusted_workdir(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
outer = (tmp_path / "outer").resolve()
inner = outer / "inner"
inner.mkdir(parents=True)
monkeypatch.chdir(outer)
trusted_folders_manager.trust_for_session(outer)
mgr = HarnessFilesManager(
sources=("user", "project"), _additional_dirs=(inner,)
)
assert mgr.project_roots == [outer]

View file

@ -14,7 +14,7 @@ class TestTrustedWorkdir:
) -> None:
monkeypatch.chdir(tmp_path)
mgr = HarnessFilesManager(sources=("user",))
assert mgr.trusted_workdir is None
assert mgr._trusted_workdir is None
def test_returns_none_when_not_trusted(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@ -22,7 +22,7 @@ class TestTrustedWorkdir:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: False)
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.trusted_workdir is None
assert mgr._trusted_workdir is None
def test_returns_cwd_when_project_in_sources_and_trusted(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@ -30,7 +30,7 @@ class TestTrustedWorkdir:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.trusted_workdir == tmp_path
assert mgr._trusted_workdir == tmp_path
class TestProjectToolsDirs:

View file

@ -5,6 +5,7 @@ from pathlib import Path
from vibe.core.paths._local_config_walk import (
_MAX_DIRS,
WALK_MAX_DEPTH,
ConfigWalkResult,
walk_local_config_dirs,
)
@ -166,3 +167,29 @@ class TestWalkConfigDirs:
assert resolved / ".vibe" in result.config_dirs
assert resolved / ".agents" in result.config_dirs
assert resolved / "sub" / ".vibe" in result.config_dirs
class TestConfigWalkResultOr:
def test_or_concatenates_each_field(self) -> None:
a = ConfigWalkResult(
config_dirs=(Path("/a/.vibe"),),
tools=(Path("/a/.vibe/tools"),),
skills=(Path("/a/.vibe/skills"),),
agents=(Path("/a/.vibe/agents"),),
)
b = ConfigWalkResult(
config_dirs=(Path("/b/.vibe"),),
tools=(Path("/b/.vibe/tools"),),
skills=(Path("/b/.vibe/skills"),),
agents=(Path("/b/.vibe/agents"),),
)
merged = a | b
assert merged.config_dirs == (Path("/a/.vibe"), Path("/b/.vibe"))
assert merged.tools == (Path("/a/.vibe/tools"), Path("/b/.vibe/tools"))
assert merged.skills == (Path("/a/.vibe/skills"), Path("/b/.vibe/skills"))
assert merged.agents == (Path("/a/.vibe/agents"), Path("/b/.vibe/agents"))
def test_or_with_empty_is_identity(self) -> None:
a = ConfigWalkResult(tools=(Path("/a/.vibe/tools"),))
assert (a | ConfigWalkResult()) == a
assert (ConfigWalkResult() | a) == a

View file

@ -37,7 +37,7 @@ def _make_manager(
async def save_messages() -> None:
save_calls.append(True)
def reset_session() -> None:
async def reset_session() -> None:
reset_calls.append(True)
mgr = RewindManager(

View file

@ -102,6 +102,79 @@ class TestReadSafe:
read_safe(tmp_path / "nope.txt")
class TestReadSafeNewlines:
def test_lf(self, tmp_path: Path) -> None:
f = tmp_path / "lf.txt"
f.write_bytes(b"a\nb\nc\n")
got = read_safe(f)
assert got.text == "a\nb\nc\n"
assert got.newline == "\n"
def test_crlf(self, tmp_path: Path) -> None:
f = tmp_path / "crlf.txt"
f.write_bytes(b"a\r\nb\r\nc\r\n")
got = read_safe(f)
assert got.text == "a\nb\nc\n"
assert got.newline == "\r\n"
def test_cr(self, tmp_path: Path) -> None:
f = tmp_path / "cr.txt"
f.write_bytes(b"a\rb\rc\r")
got = read_safe(f)
assert got.text == "a\nb\nc\n"
assert got.newline == "\r"
def test_mixed_picks_most_frequent(self, tmp_path: Path) -> None:
f = tmp_path / "mixed.txt"
f.write_bytes(b"a\r\nb\r\nc\rd\n")
got = read_safe(f)
assert got.text == "a\nb\nc\nd\n"
assert got.newline == "\r\n"
@pytest.mark.parametrize(("linesep", "expected"), [("\n", "\n"), ("\r\n", "\r\n")])
def test_no_newline_defaults_to_os_linesep(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
linesep: str,
expected: str,
) -> None:
monkeypatch.setattr(io_utils.os, "linesep", linesep)
f = tmp_path / "single.txt"
f.write_bytes(b"hello")
got = read_safe(f)
assert got.text == "hello"
assert got.newline == expected
@pytest.mark.parametrize(("linesep", "expected"), [("\n", "\n"), ("\r\n", "\r\n")])
def test_empty_defaults_to_os_linesep(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
linesep: str,
expected: str,
) -> None:
monkeypatch.setattr(io_utils.os, "linesep", linesep)
f = tmp_path / "empty.txt"
f.write_bytes(b"")
got = read_safe(f)
assert got.text == ""
assert got.newline == expected
def test_decode_safe_reports_newline(self) -> None:
got = decode_safe(b"a\r\nb\r\n")
assert got.text == "a\nb\n"
assert got.newline == "\r\n"
@pytest.mark.asyncio
async def test_async_reports_newline(self, tmp_path: Path) -> None:
f = tmp_path / "crlf.txt"
f.write_bytes(b"a\r\nb\r\n")
got = await read_safe_async(f)
assert got.text == "a\nb\n"
assert got.newline == "\r\n"
class TestReadSafeResultEncoding:
def test_reports_utf8_for_plain_utf8_file(self, tmp_path: Path) -> None:
f = tmp_path / "x.txt"

View file

@ -29,6 +29,7 @@ def write_e2e_config(vibe_home: Path, api_base: str) -> None:
'active_model = "mock-model"',
"enable_update_checks = false",
"enable_auto_update = false",
"disable_welcome_banner_animation = true",
"",
"[[providers]]",
'name = "mock-provider"',

View file

@ -1,19 +1,49 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from pathlib import Path
from types import SimpleNamespace
from typing import cast
import pytest
from textual.pilot import Pilot
from textual.widgets import Input
from vibe.core.config import ProviderConfig
from tests.browser_sign_in.stubs import build_browser_sign_in_service_factory
from tests.conftest import build_test_vibe_config
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.config._settings import (
DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL,
DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL,
)
from vibe.core.config.harness_files import (
init_harness_files_manager,
reset_harness_files_manager,
)
from vibe.core.paths import GLOBAL_ENV_FILE
from vibe.core.telemetry.build_metadata import build_entrypoint_metadata
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.types import Backend
from vibe.setup.auth import (
BrowserSignInError,
BrowserSignInErrorCode,
BrowserSignInService,
BrowserSignInStatus,
)
import vibe.setup.onboarding as onboarding_module
from vibe.setup.onboarding import OnboardingApp
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen, persist_api_key
from vibe.setup.onboarding.screens.auth_method import AuthMethodScreen
from vibe.setup.onboarding.screens.browser_sign_in import (
ERROR_HINT,
PENDING_HINT,
UNEXPECTED_ERROR_MESSAGE,
BrowserSignInScreen,
)
CONSOLE_URL = "https://console.mistral.ai"
API_URL = "https://api.mistral.ai"
async def _wait_for(
@ -26,40 +56,653 @@ async def _wait_for(
while not condition():
await pilot.pause(interval)
if (elapsed := elapsed + interval) >= timeout:
msg = "Timed out waiting for condition."
raise AssertionError("Timed out waiting for condition.")
def _build_onboarding_config(
*,
provider_name: str = "mistral",
model_provider: str | None = None,
backend: Backend = Backend.MISTRAL,
api_key_env_var: str = "MISTRAL_API_KEY",
browser_auth_base_url: str | None = None,
browser_auth_api_base_url: str | None = None,
enable_experimental_browser_sign_in: bool = False,
) -> VibeConfig:
provider = ProviderConfig(
name=provider_name,
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,
)
model = ModelConfig(
name="mistral-vibe-cli-latest",
provider=model_provider or provider_name,
alias="devstral-2",
)
return build_test_vibe_config(
providers=[provider],
models=[model],
enable_experimental_browser_sign_in=enable_experimental_browser_sign_in,
)
def _build_browser_onboarding_app(
*, browser_sign_in_service_factory: Callable[[], BrowserSignInService] | None = None
) -> OnboardingApp:
return OnboardingApp(
config=_build_onboarding_config(
browser_auth_base_url=CONSOLE_URL,
browser_auth_api_base_url=API_URL,
enable_experimental_browser_sign_in=True,
),
browser_sign_in_service_factory=browser_sign_in_service_factory,
)
def _patch_failing_browser_sign_in_service(
monkeypatch: pytest.MonkeyPatch, captured_base_urls: list[tuple[str, str]]
) -> None:
class FakeGateway:
def __init__(self, browser_base_url: str, api_base_url: str) -> None:
captured_base_urls.append((browser_base_url, api_base_url))
class FakeService:
def __init__(self, gateway: FakeGateway) -> None:
self._gateway = gateway
async def authenticate(self, *args, **kwargs) -> str:
raise BrowserSignInError(
"Browser sign-in polling failed.",
code=BrowserSignInErrorCode.POLL_FAILED,
)
async def aclose(self) -> None:
return None
monkeypatch.setattr(onboarding_module, "HttpBrowserSignInGateway", FakeGateway)
monkeypatch.setattr(onboarding_module, "BrowserSignInService", FakeService)
def _saved_env_contents() -> str:
return GLOBAL_ENV_FILE.path.read_text(encoding="utf-8")
def _build_unexpected_browser_sign_in_service_factory(
outcomes: list[str],
*,
api_key: str = "sk-browser-onboarding-test-key",
close_blocker: asyncio.Event | None = None,
close_started: asyncio.Event | None = None,
close_finished: asyncio.Event | None = None,
close_cancelled: asyncio.Event | None = None,
) -> Callable[[], BrowserSignInService]:
remaining_outcomes = list(outcomes)
class UnexpectedBrowserSignInService:
def __init__(self, outcome: str) -> None:
self._outcome = outcome
async def authenticate(
self, status_callback: Callable[[BrowserSignInStatus], None] | None = None
) -> str:
if self._outcome == "completed":
if status_callback is not None:
status_callback(BrowserSignInStatus.COMPLETED)
return api_key
if self._outcome == "runtime_error":
raise RuntimeError("boom")
msg = f"Unsupported browser sign-in outcome: {self._outcome}"
raise AssertionError(msg)
async def aclose(self) -> None:
try:
if close_started is not None:
close_started.set()
if close_blocker is not None:
await close_blocker.wait()
except asyncio.CancelledError:
if close_cancelled is not None:
close_cancelled.set()
raise
finally:
if close_finished is not None:
close_finished.set()
async def pass_welcome_screen(pilot: Pilot) -> None:
return None
def build_service() -> BrowserSignInService:
if not remaining_outcomes:
msg = (
"Unexpected browser sign-in service factory requires scripted outcomes."
)
raise AssertionError(msg)
return cast(
BrowserSignInService,
UnexpectedBrowserSignInService(remaining_outcomes.pop(0)),
)
return build_service
async def _pass_welcome_screen(pilot: Pilot) -> None:
welcome_screen = pilot.app.get_screen("welcome")
await _wait_for(
lambda: not welcome_screen.query_one("#enter-hint").has_class("hidden"), pilot
)
await pilot.press("enter")
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
async def _show_auth_method(pilot: Pilot) -> None:
await _pass_welcome_screen(pilot)
await _wait_for(lambda: isinstance(pilot.app.screen, AuthMethodScreen), pilot)
async def _show_browser_sign_in(pilot: Pilot) -> None:
await _show_auth_method(pilot)
await pilot.press("enter")
await _wait_for(lambda: isinstance(pilot.app.screen, BrowserSignInScreen), pilot)
@pytest.mark.asyncio
async def test_ui_gets_through_the_onboarding_successfully() -> None:
app = OnboardingApp()
async def test_ui_keeps_manual_flow_when_browser_sign_in_is_unsupported() -> None:
app = OnboardingApp(
config=_build_onboarding_config(
browser_auth_base_url="",
browser_auth_api_base_url="",
enable_experimental_browser_sign_in=True,
)
)
api_key_value = "sk-onboarding-test-key"
async with app.run_test() as pilot:
await pass_welcome_screen(pilot)
api_screen = app.screen
input_widget = api_screen.query_one("#key", Input)
await _pass_welcome_screen(pilot)
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
input_widget = app.screen.query_one("#key", Input)
await pilot.press(*api_key_value)
assert input_widget.value == api_key_value
await pilot.press("enter")
await _wait_for(lambda: app.return_value is not None, pilot, timeout=2.0)
assert app.return_value == "completed"
assert api_key_value in _saved_env_contents()
assert GLOBAL_ENV_FILE.path.is_file()
env_contents = GLOBAL_ENV_FILE.path.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_ui_hides_browser_sign_in_when_experimental_flag_is_disabled() -> None:
_, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory(
outcomes=["completed"]
)
app = OnboardingApp(
config=_build_onboarding_config(
browser_auth_base_url=CONSOLE_URL, browser_auth_api_base_url=API_URL
),
browser_sign_in_service_factory=browser_sign_in_service_factory,
)
assert app.supports_browser_sign_in is False
async with app.run_test() as pilot:
await _pass_welcome_screen(pilot)
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
@pytest.mark.asyncio
async def test_ui_supports_browser_sign_in_when_experimental_flag_is_enabled() -> None:
_, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory(
outcomes=["completed"]
)
app = OnboardingApp(
config=_build_onboarding_config(
browser_auth_base_url=CONSOLE_URL,
browser_auth_api_base_url=API_URL,
enable_experimental_browser_sign_in=True,
),
browser_sign_in_service_factory=browser_sign_in_service_factory,
)
assert app.supports_browser_sign_in is True
async with app.run_test() as pilot:
await _show_auth_method(pilot)
@pytest.mark.asyncio
async def test_ui_offers_browser_sign_in_for_renamed_mistral_provider() -> None:
app = OnboardingApp(
config=_build_onboarding_config(
provider_name="customer-mistral",
backend=Backend.MISTRAL,
browser_auth_base_url=CONSOLE_URL,
browser_auth_api_base_url=API_URL,
enable_experimental_browser_sign_in=True,
)
)
assert app.supports_browser_sign_in is True
async with app.run_test() as pilot:
await _show_auth_method(pilot)
@pytest.mark.asyncio
async def test_ui_allows_manual_path_when_browser_sign_in_is_supported() -> None:
app = _build_browser_onboarding_app()
api_key_value = "sk-manual-onboarding-test-key"
async with app.run_test() as pilot:
await _show_auth_method(pilot)
await pilot.press("down", "enter")
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
input_widget = app.screen.query_one("#key", Input)
await pilot.press(*api_key_value)
await pilot.press("enter")
await _wait_for(lambda: app.return_value is not None, pilot, timeout=2.0)
assert input_widget.value == api_key_value
assert app.return_value == "completed"
assert api_key_value in _saved_env_contents()
@pytest.mark.asyncio
async def test_ui_completes_browser_sign_in_and_retries_after_failure() -> None:
gateway, browser_sign_in_service_factory, created_services = (
build_browser_sign_in_service_factory(outcomes=["expired", "completed"])
)
app = _build_browser_onboarding_app(
browser_sign_in_service_factory=browser_sign_in_service_factory
)
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(
lambda: (
"expired"
in str(app.screen.query_one("#browser-sign-in-status").render())
),
pilot,
)
await pilot.press("r")
await _wait_for(lambda: app.return_value is not None, pilot, timeout=2.0)
assert gateway.process_number == 2
assert len(created_services) == 2
assert created_services[0] is not created_services[1]
assert app.return_value == "completed"
assert "sk-browser-onboarding-test-key" in _saved_env_contents()
@pytest.mark.asyncio
async def test_ui_browser_sign_in_falls_back_to_mistral_env_var_when_missing() -> None:
_, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory(
outcomes=["completed"]
)
app = OnboardingApp(
config=_build_onboarding_config(
provider_name="custom-mistral",
api_key_env_var="",
browser_auth_base_url=CONSOLE_URL,
browser_auth_api_base_url=API_URL,
enable_experimental_browser_sign_in=True,
),
browser_sign_in_service_factory=browser_sign_in_service_factory,
)
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(lambda: app.return_value is not None, pilot, timeout=2.0)
assert app.return_value == "completed"
env_contents = _saved_env_contents()
assert "MISTRAL_API_KEY" in env_contents
assert "sk-browser-onboarding-test-key" in env_contents
@pytest.mark.asyncio
async def test_ui_shows_human_message_when_polling_fails() -> None:
_, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory(
outcomes=["poll_failed"]
)
app = _build_browser_onboarding_app(
browser_sign_in_service_factory=browser_sign_in_service_factory
)
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(
lambda: (
"We couldn't complete sign-in. Please try again."
in str(app.screen.query_one("#browser-sign-in-status").render())
),
pilot,
)
@pytest.mark.asyncio
async def test_ui_shows_retryable_error_when_browser_sign_in_fails_unexpectedly() -> (
None
):
app = _build_browser_onboarding_app(
browser_sign_in_service_factory=_build_unexpected_browser_sign_in_service_factory([
"runtime_error"
])
)
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(
lambda: (
UNEXPECTED_ERROR_MESSAGE
in str(app.screen.query_one("#browser-sign-in-status").render())
),
pilot,
)
assert isinstance(app.screen, BrowserSignInScreen)
status_widget = app.screen.query_one("#browser-sign-in-status")
assert status_widget.has_class("error")
assert not status_widget.has_class("pending")
assert ERROR_HINT in str(app.screen.query_one("#browser-sign-in-hint").render())
assert app.return_value is None
@pytest.mark.asyncio
async def test_ui_retries_after_unexpected_browser_sign_in_failure() -> None:
app = _build_browser_onboarding_app(
browser_sign_in_service_factory=_build_unexpected_browser_sign_in_service_factory([
"runtime_error",
"completed",
])
)
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(
lambda: (
UNEXPECTED_ERROR_MESSAGE
in str(app.screen.query_one("#browser-sign-in-status").render())
),
pilot,
)
await pilot.press("r")
await _wait_for(lambda: app.return_value is not None, pilot, timeout=2.0)
assert app.return_value == "completed"
assert "sk-browser-onboarding-test-key" in _saved_env_contents()
@pytest.mark.asyncio
async def test_ui_waits_for_browser_sign_in_cleanup_before_retrying() -> None:
close_started = asyncio.Event()
close_blocker = asyncio.Event()
app = _build_browser_onboarding_app(
browser_sign_in_service_factory=_build_unexpected_browser_sign_in_service_factory(
["runtime_error", "completed"],
close_blocker=close_blocker,
close_started=close_started,
)
)
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(close_started.is_set, pilot)
status_widget = app.screen.query_one("#browser-sign-in-status")
hint_widget = app.screen.query_one("#browser-sign-in-hint")
await _wait_for(
lambda: "Getting things ready..." in str(status_widget.render()), pilot
)
await _wait_for(lambda: PENDING_HINT in str(hint_widget.render()), pilot)
await pilot.press("r")
await _wait_for(
lambda: "Getting things ready..." in str(status_widget.render()), pilot
)
await _wait_for(lambda: PENDING_HINT in str(hint_widget.render()), pilot)
assert app.return_value is None
close_blocker.set()
await _wait_for(
lambda: (
UNEXPECTED_ERROR_MESSAGE
in str(app.screen.query_one("#browser-sign-in-status").render())
),
pilot,
)
await pilot.press("r")
await _wait_for(lambda: app.return_value is not None, pilot, timeout=2.0)
assert app.return_value == "completed"
assert "sk-browser-onboarding-test-key" in _saved_env_contents()
@pytest.mark.asyncio
async def test_ui_switches_to_manual_path_without_cancelling_browser_sign_in_cleanup() -> (
None
):
close_started = asyncio.Event()
close_blocker = asyncio.Event()
close_finished = asyncio.Event()
close_cancelled = asyncio.Event()
app = _build_browser_onboarding_app(
browser_sign_in_service_factory=_build_unexpected_browser_sign_in_service_factory(
["runtime_error"],
close_blocker=close_blocker,
close_started=close_started,
close_finished=close_finished,
close_cancelled=close_cancelled,
)
)
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(close_started.is_set, pilot)
await pilot.press("m")
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
close_blocker.set()
await _wait_for(close_finished.is_set, pilot)
assert close_cancelled.is_set() is False
@pytest.mark.asyncio
async def test_ui_switches_to_manual_path_while_browser_sign_in_is_running() -> None:
blocker = asyncio.Event()
async def wait_forever(_: float) -> None:
await blocker.wait()
gateway, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory(
outcomes=["completed"], sleep=wait_forever
)
app = _build_browser_onboarding_app(
browser_sign_in_service_factory=browser_sign_in_service_factory
)
api_key_value = "sk-manual-after-browser-cancel"
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(
lambda: (
"Waiting for you to finish signing in..."
in str(app.screen.query_one("#browser-sign-in-status").render())
),
pilot,
)
status_widget = app.screen.query_one("#browser-sign-in-status")
assert status_widget.has_class("pending")
assert not status_widget.has_class("error")
step_widgets = list(app.screen.query(".browser-sign-in-step"))
assert len(step_widgets) == 3
assert step_widgets[0].has_class("done")
assert "Open your browser" in str(step_widgets[0].render())
assert step_widgets[1].has_class("active")
assert "Sign in and return here" in str(step_widgets[1].render())
assert step_widgets[2].has_class("idle")
assert "Finish setup" in str(step_widgets[2].render())
await pilot.press("m")
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
await pilot.press(*api_key_value)
await pilot.press("enter")
await _wait_for(lambda: app.return_value is not None, pilot, timeout=2.0)
assert app.return_value == "completed"
assert gateway.closed is True
assert gateway.exchange_requests == []
env_contents = _saved_env_contents()
assert api_key_value in env_contents
assert "sk-browser-onboarding-test-key" not in env_contents
@pytest.mark.asyncio
async def test_ui_uses_default_mistral_browser_auth_urls_when_experiment_is_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured_base_urls: list[tuple[str, str]] = []
_patch_failing_browser_sign_in_service(monkeypatch, captured_base_urls)
app = OnboardingApp(
config=build_test_vibe_config(enable_experimental_browser_sign_in=True)
)
assert app.supports_browser_sign_in is True
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(lambda: bool(captured_base_urls), pilot)
assert captured_base_urls == [
(
DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL,
DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL,
)
]
@pytest.mark.asyncio
async def test_ui_enables_browser_sign_in_from_env_var(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("VIBE_ENABLE_EXPERIMENTAL_BROWSER_SIGN_IN", "true")
captured_base_urls: list[tuple[str, str]] = []
_patch_failing_browser_sign_in_service(monkeypatch, captured_base_urls)
app = OnboardingApp()
assert app.supports_browser_sign_in is True
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(lambda: bool(captured_base_urls), pilot)
assert captured_base_urls == [
(
DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL,
DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL,
)
]
@pytest.mark.asyncio
async def test_ui_keeps_browser_sign_in_disabled_from_false_env_var(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("VIBE_ENABLE_EXPERIMENTAL_BROWSER_SIGN_IN", "false")
app = OnboardingApp()
assert app.supports_browser_sign_in is False
async with app.run_test() as pilot:
await _pass_welcome_screen(pilot)
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
@pytest.mark.asyncio
async def test_ui_preserves_custom_browser_auth_urls_when_api_key_is_missing(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
config_file.write_text(
"\n".join([
'active_model = "devstral-2"',
"enable_experimental_browser_sign_in = true",
"[[providers]]",
'name = "mistral"',
'api_base = "https://api.mistral.ai/v1"',
'api_key_env_var = "MISTRAL_API_KEY"',
'browser_auth_base_url = "http://127.0.0.1:8787"',
'browser_auth_api_base_url = "http://127.0.0.1:8787"',
'backend = "mistral"',
"",
"[[models]]",
'name = "mistral-vibe-cli-latest"',
'provider = "mistral"',
'alias = "devstral-2"',
"",
]),
encoding="utf-8",
)
reset_harness_files_manager()
init_harness_files_manager("user")
captured_base_urls: list[tuple[str, str]] = []
_patch_failing_browser_sign_in_service(monkeypatch, captured_base_urls)
app = OnboardingApp()
assert app.supports_browser_sign_in is True
async with app.run_test() as pilot:
await _show_browser_sign_in(pilot)
await _wait_for(lambda: bool(captured_base_urls), pilot)
assert captured_base_urls == [("http://127.0.0.1:8787", "http://127.0.0.1:8787")]
@pytest.mark.asyncio
async def test_ui_falls_back_to_default_onboarding_context_with_invalid_active_model(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
config_file.write_text(
"\n".join([
'active_model = "does-not-exist"',
"enable_experimental_browser_sign_in = true",
"",
"[[providers]]",
'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://api.mistral.ai"',
'backend = "mistral"',
"",
"[[models]]",
'name = "mistral-vibe-cli-latest"',
'provider = "mistral"',
'alias = "devstral-2"',
"",
]),
encoding="utf-8",
)
reset_harness_files_manager()
init_harness_files_manager("user")
app = OnboardingApp()
assert app.supports_browser_sign_in is True
async with app.run_test() as pilot:
await _show_auth_method(pilot)
def test_api_key_screen_falls_back_to_mistral_for_provider_without_env_key() -> None:

View file

@ -87,13 +87,6 @@ class TestListRemoteResumeSessions:
start_time=datetime(2026, 1, 1),
end_time=None,
)
retrying = WorkflowExecutionWithoutResultResponse(
workflow_name="vibe",
execution_id="exec-retrying",
status=WorkflowExecutionStatus.RETRYING_AFTER_ERROR,
start_time=datetime(2026, 1, 1),
end_time=None,
)
continued = WorkflowExecutionWithoutResultResponse(
workflow_name="vibe",
execution_id="exec-continued",
@ -102,9 +95,7 @@ class TestListRemoteResumeSessions:
end_time=None,
)
mock_response = WorkflowExecutionListResponse(
executions=[running, retrying, continued]
)
mock_response = WorkflowExecutionListResponse(executions=[running, continued])
config = MagicMock()
config.vibe_code_enabled = True
@ -121,10 +112,9 @@ class TestListRemoteResumeSessions:
result = await list_remote_resume_sessions(config)
assert len(result) == 3
assert len(result) == 2
session_ids = {s.session_id for s in result}
assert "exec-running" in session_ids
assert "exec-retrying" in session_ids
assert "exec-continued" in session_ids
assert all(s.source == "remote" for s in result)
@ -133,7 +123,6 @@ class TestListRemoteResumeSessions:
page_size=50,
status=[
WorkflowExecutionStatus.RUNNING,
WorkflowExecutionStatus.RETRYING_AFTER_ERROR,
WorkflowExecutionStatus.CONTINUED_AS_NEW,
],
)
@ -207,7 +196,7 @@ class TestListRemoteResumeSessions:
previous = WorkflowExecutionWithoutResultResponse(
workflow_name="vibe",
execution_id="exec-1",
status=WorkflowExecutionStatus.RETRYING_AFTER_ERROR,
status=WorkflowExecutionStatus.FAILED,
start_time=datetime(2026, 1, 1),
end_time=datetime(2026, 1, 10),
)

View file

@ -346,7 +346,10 @@ class TestSessionLoaderFindLatestSession:
assert result == valid_session
def test_find_latest_session_skips_unreadable_messages_file(
self, session_config: SessionLoggingConfig, create_test_session
self,
session_config: SessionLoggingConfig,
create_test_session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_dir = Path(session_config.save_dir)
@ -355,14 +358,28 @@ class TestSessionLoaderFindLatestSession:
unreadable_session = create_test_session(session_dir, "unreadab-session")
unreadable_messages = unreadable_session / "messages.jsonl"
unreadable_messages.chmod(0)
# chmod doesn't restrict root, so simulate an unreadable file by
# patching Path.read_bytes (used under the hood by read_safe). This
# keeps the test working in CI environments running as root.
original_read_bytes = Path.read_bytes
def fake_read_bytes(self: Path) -> bytes:
if self == unreadable_messages:
raise PermissionError(f"Permission denied: {self}")
return original_read_bytes(self)
monkeypatch.setattr(Path, "read_bytes", fake_read_bytes)
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result == valid_session
def test_find_latest_session_skips_unreadable_metadata_file(
self, session_config: SessionLoggingConfig, create_test_session
self,
session_config: SessionLoggingConfig,
create_test_session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_dir = Path(session_config.save_dir)
@ -371,7 +388,15 @@ class TestSessionLoaderFindLatestSession:
unreadable_session = create_test_session(session_dir, "unreadab-session")
unreadable_metadata = unreadable_session / "meta.json"
unreadable_metadata.chmod(0)
original_read_bytes = Path.read_bytes
def fake_read_bytes(self: Path) -> bytes:
if self == unreadable_metadata:
raise PermissionError(f"Permission denied: {self}")
return original_read_bytes(self)
monkeypatch.setattr(Path, "read_bytes", fake_read_bytes)
result = SessionLoader.find_latest_session(session_config)
assert result is not None
@ -436,6 +461,41 @@ class TestSessionLoaderFindSessionById:
assert result is not None
assert result == session_2
def test_find_session_by_id_filters_by_working_directory(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
session_dir = Path(session_config.save_dir)
session_a = create_test_session(
session_dir,
"abcd1234-session",
working_directory=Path("/home/user/project-a"),
)
time.sleep(0.01)
session_b = create_test_session(
session_dir,
"abcd1234-session",
working_directory=Path("/home/user/project-b"),
)
assert (
SessionLoader.find_session_by_id(
"abcd1234",
session_config,
working_directory=Path("/home/user/project-a"),
)
== session_a
)
assert (
SessionLoader.find_session_by_id(
"abcd1234",
session_config,
working_directory=Path("/home/user/project-c"),
)
is None
)
assert SessionLoader.find_session_by_id("abcd1234", session_config) == session_b
def test_find_session_by_id_no_match(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:

View file

@ -11,6 +11,7 @@ import pytest
from tests.conftest import build_test_vibe_config
from vibe.core.agents.models import AgentProfile, AgentSafety
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.experiments.models import EvalResponse
from vibe.core.loop import ScheduledLoop
from vibe.core.session.session_logger import SessionLogger
from vibe.core.tools.manager import ToolManager
@ -114,16 +115,12 @@ class TestSessionLoggerMetadata:
self, mock_getuser, mock_subprocess, session_config: SessionLoggingConfig
) -> None:
"""Test that session metadata is correctly initialized."""
# Mock git commands
git_commit_mock = MagicMock()
git_commit_mock.returncode = 0
git_commit_mock.stdout = "abc123\n"
# Mock combined git command
git_mock = MagicMock()
git_mock.returncode = 0
git_mock.stdout = "abc123\nmain\n"
git_branch_mock = MagicMock()
git_branch_mock.returncode = 0
git_branch_mock.stdout = "main\n"
mock_subprocess.side_effect = [git_commit_mock, git_branch_mock]
mock_subprocess.return_value = git_mock
mock_getuser.return_value = "testuser"
session_id = "test-session-123"
@ -149,7 +146,7 @@ class TestSessionLoggerMetadata:
self, mock_getuser, mock_subprocess, session_config: SessionLoggingConfig
) -> None:
"""Test that session metadata handles git command errors gracefully."""
# Mock git commands to fail
# Mock combined git command to fail
mock_subprocess.side_effect = FileNotFoundError("git not found")
mock_getuser.return_value = "testuser"
@ -1002,3 +999,153 @@ class TestPersistLoops:
metadata = json.load(f)
assert len(metadata["loops"]) == 1
assert metadata["loops"][0]["id"] == "aabbccdd"
class TestPersistExperiments:
@pytest.fixture
def sample_response(self) -> EvalResponse:
return EvalResponse.model_validate({
"features": {
"vibe_code_cli_test_ab": {
"defaultValue": "cli",
"rules": [
{
"force": "cli_v2",
"tracks": [
{
"experiment": {"key": "vibe_code_cli_test_ab"},
"result": {
"key": "1",
"variationId": 1,
"inExperiment": True,
},
}
],
}
],
}
}
})
@pytest.mark.asyncio
async def test_writes_field_into_existing_metadata(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
sample_response: EvalResponse,
) -> None:
logger = SessionLogger(session_config, "exp-session")
await logger.save_interaction(
messages=[
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
],
stats=AgentStats(steps=1),
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
await logger.persist_experiments(sample_response)
assert logger.session_dir is not None
with open(logger.session_dir / "meta.json") as f:
metadata = json.load(f)
assert "experiments" in metadata
assert (
metadata["experiments"]["features"]["vibe_code_cli_test_ab"]["defaultValue"]
== "cli"
)
@pytest.mark.asyncio
async def test_persists_none_as_null(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
logger = SessionLogger(session_config, "exp-none")
await logger.save_interaction(
messages=[
LLMMessage(role=Role.system, content="x"),
LLMMessage(role=Role.user, content="y"),
],
stats=AgentStats(steps=1),
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
await logger.persist_experiments(None)
assert logger.session_dir is not None
with open(logger.session_dir / "meta.json") as f:
metadata = json.load(f)
assert metadata.get("experiments") is None
@pytest.mark.asyncio
async def test_does_not_create_metadata_file_when_missing(
self, session_config: SessionLoggingConfig, sample_response: EvalResponse
) -> None:
# Sessions without any message must not be persisted at all —
# persist_experiments updates only in-memory state when meta.json is
# absent, and lets the eventual save_interaction write it.
logger = SessionLogger(session_config, "fresh-session")
assert logger.session_dir is not None
assert not (logger.session_dir / "meta.json").exists()
await logger.persist_experiments(sample_response)
assert not (logger.session_dir / "meta.json").exists()
assert logger.session_metadata is not None
assert logger.session_metadata.experiments == sample_response
@pytest.mark.asyncio
async def test_noop_when_logging_disabled(
self,
disabled_session_config: SessionLoggingConfig,
sample_response: EvalResponse,
) -> None:
logger = SessionLogger(disabled_session_config, "ignored")
await logger.persist_experiments(sample_response)
@pytest.mark.asyncio
async def test_first_save_interaction_includes_in_memory_experiments(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
sample_response: EvalResponse,
) -> None:
# Real flow: persist_experiments at session start (no meta.json yet,
# in-memory only). The first save_interaction must succeed AND
# include the experiments snapshot in the eventual meta.json.
logger = SessionLogger(session_config, "first-save-after-experiments")
await logger.persist_experiments(sample_response)
assert logger.session_dir is not None
assert not (logger.session_dir / "meta.json").exists()
await logger.save_interaction(
messages=[
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi"),
],
stats=AgentStats(steps=1),
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
with open(logger.session_dir / "meta.json") as f:
metadata = json.load(f)
assert metadata["total_messages"] == 2
assert (
metadata["experiments"]["features"]["vibe_code_cli_test_ab"]["defaultValue"]
== "cli"
)

View file

@ -187,7 +187,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="305" clip-path="url(#terminal-line-25)">Hello&#160;there,&#160;who&#160;are&#160;you?</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="500.2" clip-path="url(#terminal-line-27)">I&#x27;m&#160;the&#160;Vibe&#160;agent&#160;and&#160;I&#x27;m&#160;ready&#160;to&#160;help.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="500.2" clip-path="url(#terminal-line-27)">I&#x27;m&#160;the&#160;Vibe&#160;agent&#160;and&#160;I&#x27;m&#160;ready&#160;to&#160;help.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r5" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

@ -163,7 +163,7 @@
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#4b4e55" x="12.2" y="611.5" width="158.6" height="24.65" shape-rendering="crispEdges"/>
<rect fill="#4b4e55" x="36.6" y="611.5" width="146.4" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
@ -185,14 +185,14 @@
</text><text class="terminal-r1" x="0" y="434.8" textLength="134.2" clip-path="url(#terminal-line-17)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="434.8" textLength="61" clip-path="url(#terminal-line-17)">Type&#160;</text><text class="terminal-r3" x="231.8" y="434.8" textLength="61" clip-path="url(#terminal-line-17)">/help</text><text class="terminal-r1" x="292.8" y="434.8" textLength="256.2" clip-path="url(#terminal-line-17)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="451.4" clip-path="url(#terminal-line-20)">Here&#x27;s&#160;a&#160;very&#160;long&#160;print&#160;instruction:</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="24.4" y="508" textLength="451.4" clip-path="url(#terminal-line-20)">Here&#x27;s&#160;a&#160;very&#160;long&#160;print&#160;instruction:</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="0" y="556.8" textLength="36.6" clip-path="url(#terminal-line-22)">():</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r4" x="0" y="581.2" textLength="329.4" clip-path="url(#terminal-line-23)">ery&#160;long&#160;line&#160;(Lorem&#160;Ipsum)</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r5" x="0" y="605.6" textLength="1390.8" clip-path="url(#terminal-line-24)">m&#160;ipsum&#160;dolor&#160;sit&#160;amet,&#160;consectetur&#160;adipiscing&#160;elit.&#160;Sed&#160;do&#160;eiusmod&#160;tempor&#160;incididunt&#160;ut&#160;labore&#160;et&#160;dolore&#160;magna&#160;al</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r7" x="12.2" y="630" textLength="158.6" clip-path="url(#terminal-line-25)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r8" x="170.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="24.4" y="556.8" textLength="36.6" clip-path="url(#terminal-line-22)">():</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r4" x="24.4" y="581.2" textLength="329.4" clip-path="url(#terminal-line-23)">ery&#160;long&#160;line&#160;(Lorem&#160;Ipsum)</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r5" x="24.4" y="605.6" textLength="1366.4" clip-path="url(#terminal-line-24)">m&#160;ipsum&#160;dolor&#160;sit&#160;amet,&#160;consectetur&#160;adipiscing&#160;elit.&#160;Sed&#160;do&#160;eiusmod&#160;tempor&#160;incididunt&#160;ut&#160;labore&#160;et&#160;dolore&#160;magna&#160;</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r7" x="36.6" y="630" textLength="146.4" clip-path="url(#terminal-line-25)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r8" x="183" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="48.8" clip-path="url(#terminal-line-27)">The&#160;</text><text class="terminal-r9" x="48.8" y="678.8" textLength="61" clip-path="url(#terminal-line-27)">print</text><text class="terminal-r1" x="109.8" y="678.8" textLength="1085.8" clip-path="url(#terminal-line-27)">&#160;statement&#160;includes&#160;a&#160;very&#160;long&#160;line&#160;of&#160;Lorem&#160;Ipsum&#160;text&#160;to&#160;demonstrate&#160;a&#160;lengthy&#160;output.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="48.8" clip-path="url(#terminal-line-27)">The&#160;</text><text class="terminal-r9" x="73.2" y="678.8" textLength="61" clip-path="url(#terminal-line-27)">print</text><text class="terminal-r1" x="134.2" y="678.8" textLength="1085.8" clip-path="url(#terminal-line-27)">&#160;statement&#160;includes&#160;a&#160;very&#160;long&#160;line&#160;of&#160;Lorem&#160;Ipsum&#160;text&#160;to&#160;demonstrate&#160;a&#160;lengthy&#160;output.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r10" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -189,7 +189,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r5" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r6" x="24.4" y="630" textLength="85.4" clip-path="url(#terminal-line-25)">Thought</text><text class="terminal-r6" x="122" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="207.4" clip-path="url(#terminal-line-27)">The&#160;answer&#160;is&#160;42.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="207.4" clip-path="url(#terminal-line-27)">The&#160;answer&#160;is&#160;42.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -188,7 +188,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">Hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="329.4" clip-path="url(#terminal-line-27)">Sure,&#160;I&#160;can&#160;help&#160;with&#160;that.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="329.4" clip-path="url(#terminal-line-27)">Sure,&#160;I&#160;can&#160;help&#160;with&#160;that.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="841.8" y="727.6" textLength="329.4" clip-path="url(#terminal-line-29)">How&#160;is&#160;Vibe&#160;doing&#160;so&#160;far?&#160;&#160;</text><text class="terminal-r5" x="1171.2" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">1</text><text class="terminal-r1" x="1183.4" y="727.6" textLength="97.6" clip-path="url(#terminal-line-29)">:&#160;good&#160;&#160;</text><text class="terminal-r5" x="1281" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">2</text><text class="terminal-r1" x="1293.2" y="727.6" textLength="97.6" clip-path="url(#terminal-line-29)">:&#160;fine&#160;&#160;</text><text class="terminal-r5" x="1390.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">3</text><text class="terminal-r1" x="1403" y="727.6" textLength="61" clip-path="url(#terminal-line-29)">:&#160;bad</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r6" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -19,183 +19,183 @@
font-weight: 700;
}
.terminal-9108451134-matrix {
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-9108451134-title {
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-9108451134-r1 { fill: #c5c8c6 }
.terminal-9108451134-r2 { fill: #ff8205;font-weight: bold }
.terminal-9108451134-r3 { fill: #68a0b3 }
.terminal-9108451134-r4 { fill: #ff8205 }
.terminal-9108451134-r5 { fill: #9a9b99 }
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #ff8205 }
.terminal-r5 { fill: #9a9b99 }
</style>
<defs>
<clipPath id="terminal-9108451134-clip-terminal">
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1463.0" height="877.4" />
</clipPath>
<clipPath id="terminal-9108451134-line-0">
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-1">
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-2">
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-3">
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-4">
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-5">
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-6">
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-7">
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-8">
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-9">
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-10">
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-11">
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-12">
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-13">
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-14">
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-15">
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-16">
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-17">
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-18">
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-19">
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-20">
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-21">
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-22">
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-23">
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-24">
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-25">
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-26">
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-27">
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-28">
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-29">
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-30">
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-31">
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-32">
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-33">
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-9108451134-line-34">
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-9108451134-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">SnapshotTestAppNoMcpServers</text>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">SnapshotTestAppNoMcpServers</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-9108451134-clip-terminal)">
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-9108451134-matrix">
<text class="terminal-9108451134-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-9108451134-line-0)">
</text><text class="terminal-9108451134-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-1)">
</text><text class="terminal-9108451134-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-2)">
</text><text class="terminal-9108451134-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-3)">
</text><text class="terminal-9108451134-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-9108451134-line-4)">
</text><text class="terminal-9108451134-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-9108451134-line-5)">
</text><text class="terminal-9108451134-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-6)">
</text><text class="terminal-9108451134-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-7)">
</text><text class="terminal-9108451134-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-8)">
</text><text class="terminal-9108451134-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-9108451134-line-9)">
</text><text class="terminal-9108451134-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-9108451134-line-10)">
</text><text class="terminal-9108451134-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-11)">
</text><text class="terminal-9108451134-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-12)">
</text><text class="terminal-9108451134-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-13)">
</text><text class="terminal-9108451134-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-9108451134-line-14)">
</text><text class="terminal-9108451134-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-9108451134-line-15)">
</text><text class="terminal-9108451134-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-16)">
</text><text class="terminal-9108451134-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-17)">
</text><text class="terminal-9108451134-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-18)">
</text><text class="terminal-9108451134-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-9108451134-line-19)">
</text><text class="terminal-9108451134-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-9108451134-line-20)">
</text><text class="terminal-9108451134-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-9108451134-line-21)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-9108451134-r2" x="170.8" y="532.4" textLength="146.4" clip-path="url(#terminal-9108451134-line-21)">Mistral&#160;Vibe</text><text class="terminal-9108451134-r1" x="317.2" y="532.4" textLength="122" clip-path="url(#terminal-9108451134-line-21)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-9108451134-r3" x="439.2" y="532.4" textLength="244" clip-path="url(#terminal-9108451134-line-21)">devstral-latest[off]</text><text class="terminal-9108451134-r1" x="683.2" y="532.4" textLength="256.2" clip-path="url(#terminal-9108451134-line-21)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-9108451134-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-21)">
</text><text class="terminal-9108451134-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-9108451134-line-22)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-9108451134-r1" x="170.8" y="556.8" textLength="414.8" clip-path="url(#terminal-9108451134-line-22)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-9108451134-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-22)">
</text><text class="terminal-9108451134-r1" x="0" y="581.2" textLength="134.2" clip-path="url(#terminal-9108451134-line-23)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-9108451134-r1" x="170.8" y="581.2" textLength="61" clip-path="url(#terminal-9108451134-line-23)">Type&#160;</text><text class="terminal-9108451134-r3" x="231.8" y="581.2" textLength="61" clip-path="url(#terminal-9108451134-line-23)">/help</text><text class="terminal-9108451134-r1" x="292.8" y="581.2" textLength="256.2" clip-path="url(#terminal-9108451134-line-23)">&#160;for&#160;more&#160;information</text><text class="terminal-9108451134-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-23)">
</text><text class="terminal-9108451134-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-9108451134-line-24)">
</text><text class="terminal-9108451134-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-9108451134-line-25)">
</text><text class="terminal-9108451134-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-26)"></text><text class="terminal-9108451134-r2" x="24.4" y="654.4" textLength="48.8" clip-path="url(#terminal-9108451134-line-26)">/mcp</text><text class="terminal-9108451134-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-26)">
</text><text class="terminal-9108451134-r5" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-27)"></text><text class="terminal-9108451134-r1" x="48.8" y="678.8" textLength="317.2" clip-path="url(#terminal-9108451134-line-27)">No&#160;MCP&#160;servers&#160;configured.</text><text class="terminal-9108451134-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-27)">
</text><text class="terminal-9108451134-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-28)">
</text><text class="terminal-9108451134-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-9108451134-line-29)">
</text><text class="terminal-9108451134-r5" x="0" y="752" textLength="1464" clip-path="url(#terminal-9108451134-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-9108451134-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-9108451134-line-30)">
</text><text class="terminal-9108451134-r5" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-31)"></text><text class="terminal-9108451134-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-31)">&gt;</text><text class="terminal-9108451134-r5" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-31)"></text><text class="terminal-9108451134-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-9108451134-line-31)">
</text><text class="terminal-9108451134-r5" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-32)"></text><text class="terminal-9108451134-r5" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-32)"></text><text class="terminal-9108451134-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-9108451134-line-32)">
</text><text class="terminal-9108451134-r5" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-33)"></text><text class="terminal-9108451134-r5" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-33)"></text><text class="terminal-9108451134-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-9108451134-line-33)">
</text><text class="terminal-9108451134-r5" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-9108451134-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-9108451134-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-9108451134-line-34)">
</text><text class="terminal-9108451134-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-9108451134-line-35)">/test/workdir</text><text class="terminal-9108451134-r5" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-9108451134-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
<g class="terminal-matrix">
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="532.4" textLength="146.4" clip-path="url(#terminal-line-21)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="532.4" textLength="122" clip-path="url(#terminal-line-21)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="532.4" textLength="244" clip-path="url(#terminal-line-21)">devstral-latest[off]</text><text class="terminal-r1" x="683.2" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="556.8" textLength="414.8" clip-path="url(#terminal-line-22)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="0" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">Type&#160;</text><text class="terminal-r3" x="231.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">/help</text><text class="terminal-r1" x="292.8" y="581.2" textLength="256.2" clip-path="url(#terminal-line-23)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r2" x="24.4" y="654.4" textLength="48.8" clip-path="url(#terminal-line-26)">/mcp</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r5" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="48.8" y="678.8" textLength="488" clip-path="url(#terminal-line-27)">No&#160;MCP&#160;servers&#160;or&#160;connectors&#160;configured.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r5" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r5" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">&gt;</text><text class="terminal-r5" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r5" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r5" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r5" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r5" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r5" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

@ -187,7 +187,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">Hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello!&#160;I&#160;can&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello!&#160;I&#160;can&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r5" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

@ -189,7 +189,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">Hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello!&#160;I&#160;can&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello!&#160;I&#160;can&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r5" x="0" y="727.6" textLength="36.6" clip-path="url(#terminal-line-29)">▂▅▇</text><text class="terminal-r1" x="36.6" y="727.6" textLength="122" clip-path="url(#terminal-line-29)">&#160;speaking&#160;</text><text class="terminal-r6" x="158.6" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">Esc/Ctrl+C&#160;to&#160;stop</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -189,7 +189,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">Hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello!&#160;I&#160;can&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello!&#160;I&#160;can&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r5" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="12.2" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">&#160;summarizing&#160;</text><text class="terminal-r6" x="170.8" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">Esc/Ctrl+C&#160;to&#160;stop</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -0,0 +1,202 @@
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #9a9b99 }
.terminal-r5 { fill: #608ab1;font-weight: bold }
.terminal-r6 { fill: #608ab1;text-decoration: underline; }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1463.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">PlanFileMessageTestApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="0" y="215.2" textLength="134.2" clip-path="url(#terminal-line-8)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="215.2" textLength="146.4" clip-path="url(#terminal-line-8)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="215.2" textLength="122" clip-path="url(#terminal-line-8)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="215.2" textLength="244" clip-path="url(#terminal-line-8)">devstral-latest[off]</text><text class="terminal-r1" x="683.2" y="215.2" textLength="256.2" clip-path="url(#terminal-line-8)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="0" y="239.6" textLength="134.2" clip-path="url(#terminal-line-9)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="239.6" textLength="414.8" clip-path="url(#terminal-line-9)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="0" y="264" textLength="134.2" clip-path="url(#terminal-line-10)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="264" textLength="61" clip-path="url(#terminal-line-10)">Type&#160;</text><text class="terminal-r3" x="231.8" y="264" textLength="61" clip-path="url(#terminal-line-10)">/help</text><text class="terminal-r1" x="292.8" y="264" textLength="256.2" clip-path="url(#terminal-line-10)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r4" x="0" y="337.2" textLength="1464" clip-path="url(#terminal-line-13)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r4" x="0" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r5" x="610" y="361.6" textLength="231.8" clip-path="url(#terminal-line-14)">Implementation&#160;Plan</text><text class="terminal-r4" x="1451.8" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r4" x="0" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r4" x="1451.8" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r4" x="0" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r4" x="1451.8" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r4" x="0" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r6" x="24.4" y="434.8" textLength="317.2" clip-path="url(#terminal-line-17)">1.&#160;Add&#160;user&#160;authentication</text><text class="terminal-r4" x="1451.8" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r4" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r4" x="1451.8" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r4" x="0" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r1" x="24.4" y="483.6" textLength="268.4" clip-path="url(#terminal-line-19)">&#160;JWT&#160;token&#160;validation</text><text class="terminal-r4" x="1451.8" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r4" x="0" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r1" x="24.4" y="508" textLength="244" clip-path="url(#terminal-line-20)">&#160;Session&#160;management</text><text class="terminal-r4" x="1451.8" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r4" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r4" x="1451.8" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r4" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r4" x="1451.8" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r6" x="24.4" y="581.2" textLength="268.4" clip-path="url(#terminal-line-23)">2.&#160;Database&#160;migrations</text><text class="terminal-r4" x="1451.8" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r4" x="1451.8" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="24.4" y="630" textLength="244" clip-path="url(#terminal-line-25)">&#160;Create&#160;users&#160;table</text><text class="terminal-r4" x="1451.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="24.4" y="654.4" textLength="158.6" clip-path="url(#terminal-line-26)">&#160;Add&#160;indexes</text><text class="terminal-r4" x="1451.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r4" x="0" y="678.8" textLength="1464" clip-path="url(#terminal-line-27)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r4" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r4" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">&gt;</text><text class="terminal-r4" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r4" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r4" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r4" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r4" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r4" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -189,7 +189,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r5" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r6" x="24.4" y="630" textLength="85.4" clip-path="url(#terminal-line-25)">Thought</text><text class="terminal-r6" x="122" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="707.6" clip-path="url(#terminal-line-27)">Here&#160;is&#160;my&#160;carefully&#160;considered&#160;answer.&#160;I&#160;hope&#160;this&#160;helps!</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="707.6" clip-path="url(#terminal-line-27)">Here&#160;is&#160;my&#160;carefully&#160;considered&#160;answer.&#160;I&#160;hope&#160;this&#160;helps!</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -185,11 +185,11 @@
</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r5" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r6" x="24.4" y="532.4" textLength="85.4" clip-path="url(#terminal-line-21)">Thought</text><text class="terminal-r6" x="122" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="0" y="581.2" textLength="439.2" clip-path="url(#terminal-line-23)">Here&#x27;s&#160;the&#160;first&#160;part&#160;of&#160;the&#160;answer.</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="24.4" y="581.2" textLength="439.2" clip-path="url(#terminal-line-23)">Here&#x27;s&#160;the&#160;first&#160;part&#160;of&#160;the&#160;answer.</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r5" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r6" x="24.4" y="630" textLength="85.4" clip-path="url(#terminal-line-25)">Thought</text><text class="terminal-r6" x="122" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="317.2" clip-path="url(#terminal-line-27)">And&#160;here&#x27;s&#160;the&#160;conclusion!</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="317.2" clip-path="url(#terminal-line-27)">And&#160;here&#x27;s&#160;the&#160;conclusion!</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -189,7 +189,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r5" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r6" x="24.4" y="630" textLength="85.4" clip-path="url(#terminal-line-25)">Thought</text><text class="terminal-r6" x="122" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="768.6" clip-path="url(#terminal-line-27)">The&#160;answer&#160;to&#160;your&#160;question&#160;is&#160;42.&#160;This&#160;is&#160;the&#160;ultimate&#160;answer.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="768.6" clip-path="url(#terminal-line-27)">The&#160;answer&#160;to&#160;your&#160;question&#160;is&#160;42.&#160;This&#160;is&#160;the&#160;ultimate&#160;answer.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -189,7 +189,7 @@
</text><text class="terminal-r5" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r6" x="24.4" y="605.6" textLength="85.4" clip-path="url(#terminal-line-24)">Thought</text><text class="terminal-r6" x="122" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r6" x="24.4" y="630" textLength="1390.8" clip-path="url(#terminal-line-25)">Let&#160;me&#160;think&#160;about&#160;this&#160;step&#160;by&#160;step...&#160;First,&#160;I&#160;need&#160;to&#160;understand&#160;the&#160;question.&#160;Then&#160;I&#160;can&#160;formulate&#160;a&#160;response.</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="768.6" clip-path="url(#terminal-line-27)">The&#160;answer&#160;to&#160;your&#160;question&#160;is&#160;42.&#160;This&#160;is&#160;the&#160;ultimate&#160;answer.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="768.6" clip-path="url(#terminal-line-27)">The&#160;answer&#160;to&#160;your&#160;question&#160;is&#160;42.&#160;This&#160;is&#160;the&#160;ultimate&#160;answer.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -183,7 +183,7 @@
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r4" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r2" x="24.4" y="459.2" textLength="158.6" clip-path="url(#terminal-line-18)">first&#160;message</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="24.4" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r4" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r2" x="24.4" y="556.8" textLength="170.8" clip-path="url(#terminal-line-22)">second&#160;message</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

@ -183,7 +183,7 @@
</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r4" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r2" x="24.4" y="532.4" textLength="158.6" clip-path="url(#terminal-line-21)">first&#160;message</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="0" y="581.2" textLength="317.2" clip-path="url(#terminal-line-23)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="24.4" y="581.2" textLength="317.2" clip-path="url(#terminal-line-23)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="170.8" clip-path="url(#terminal-line-25)">second&#160;message</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -182,7 +182,7 @@
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r4" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r2" x="24.4" y="459.2" textLength="158.6" clip-path="url(#terminal-line-18)">first&#160;message</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="24.4" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r4" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r2" x="24.4" y="556.8" textLength="170.8" clip-path="url(#terminal-line-22)">second&#160;message</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -182,7 +182,7 @@
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r4" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r2" x="24.4" y="459.2" textLength="158.6" clip-path="url(#terminal-line-18)">first&#160;message</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="24.4" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r4" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r5" x="24.4" y="556.8" textLength="170.8" clip-path="url(#terminal-line-22)">second&#160;message</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -182,7 +182,7 @@
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r4" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r2" x="24.4" y="459.2" textLength="158.6" clip-path="url(#terminal-line-18)">first&#160;message</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="24.4" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r4" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r2" x="24.4" y="556.8" textLength="170.8" clip-path="url(#terminal-line-22)">second&#160;message</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -186,7 +186,7 @@
</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r2" x="24.4" y="581.2" textLength="231.8" clip-path="url(#terminal-line-23)">Hello,&#160;how&#160;are&#160;you?</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="0" y="630" textLength="695.4" clip-path="url(#terminal-line-25)">I&#x27;m&#160;doing&#160;well,&#160;thank&#160;you!&#160;Let&#160;me&#160;read&#160;that&#160;file&#160;for&#160;you.</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="24.4" y="630" textLength="695.4" clip-path="url(#terminal-line-25)">I&#x27;m&#160;doing&#160;well,&#160;thank&#160;you!&#160;Let&#160;me&#160;read&#160;that&#160;file&#160;for&#160;you.</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r5" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="24.4" y="678.8" textLength="109.8" clip-path="url(#terminal-line-27)">read_file</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -183,11 +183,11 @@
</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r4" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r2" x="24.4" y="532.4" textLength="280.6" clip-path="url(#terminal-line-21)">Hello,&#160;can&#160;you&#160;help&#160;me?</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="0" y="581.2" textLength="280.6" clip-path="url(#terminal-line-23)">Sure!&#160;What&#160;do&#160;you&#160;need?</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="24.4" y="581.2" textLength="280.6" clip-path="url(#terminal-line-23)">Sure!&#160;What&#160;do&#160;you&#160;need?</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="329.4" clip-path="url(#terminal-line-25)">Please&#160;read&#160;my&#160;config&#160;file.</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="488" clip-path="url(#terminal-line-27)">Here&#160;is&#160;the&#160;content&#160;of&#160;your&#160;config&#160;file.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="488" clip-path="url(#terminal-line-27)">Here&#160;is&#160;the&#160;content&#160;of&#160;your&#160;config&#160;file.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r5" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -173,23 +173,23 @@
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r4" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r2" x="24.4" y="239.6" textLength="146.4" clip-path="url(#terminal-line-9)">show&#160;diagram</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r5" x="0" y="288.4" textLength="97.6" clip-path="url(#terminal-line-11)">&#160;&#160;Client</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r5" x="0" y="312.8" textLength="61" clip-path="url(#terminal-line-12)">&#160;&#160;&#160;&#160;|</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r5" x="0" y="337.2" textLength="280.6" clip-path="url(#terminal-line-13)">&#160;&#160;&#160;&#160;|&#160;&#160;POST&#160;/api/orders</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r5" x="0" y="361.6" textLength="61" clip-path="url(#terminal-line-14)">&#160;&#160;&#160;&#160;v</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r3" x="0" y="386" textLength="134.2" clip-path="url(#terminal-line-15)">+---------+</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r5" x="0" y="410.4" textLength="134.2" clip-path="url(#terminal-line-16)">|&#160;Gateway&#160;|</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r3" x="0" y="434.8" textLength="134.2" clip-path="url(#terminal-line-17)">+---------+</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r5" x="0" y="459.2" textLength="61" clip-path="url(#terminal-line-18)">&#160;&#160;&#160;&#160;|</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r5" x="0" y="483.6" textLength="61" clip-path="url(#terminal-line-19)">&#160;&#160;&#160;&#160;v</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r3" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">+---------+</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r5" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">|&#160;Service&#160;|</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r3" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">+---------+</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r5" x="0" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">&#160;&#160;&#160;&#160;|</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r5" x="0" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">&#160;&#160;&#160;&#160;v</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r3" x="0" y="630" textLength="134.2" clip-path="url(#terminal-line-25)">+----+----+</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r5" x="0" y="654.4" textLength="134.2" clip-path="url(#terminal-line-26)">|&#160;&#160;&#160;DB&#160;&#160;&#160;&#160;|</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r3" x="0" y="678.8" textLength="134.2" clip-path="url(#terminal-line-27)">+---------+</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r5" x="24.4" y="288.4" textLength="97.6" clip-path="url(#terminal-line-11)">&#160;&#160;Client</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r5" x="24.4" y="312.8" textLength="61" clip-path="url(#terminal-line-12)">&#160;&#160;&#160;&#160;|</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r5" x="24.4" y="337.2" textLength="280.6" clip-path="url(#terminal-line-13)">&#160;&#160;&#160;&#160;|&#160;&#160;POST&#160;/api/orders</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r5" x="24.4" y="361.6" textLength="61" clip-path="url(#terminal-line-14)">&#160;&#160;&#160;&#160;v</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r3" x="24.4" y="386" textLength="134.2" clip-path="url(#terminal-line-15)">+---------+</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r5" x="24.4" y="410.4" textLength="134.2" clip-path="url(#terminal-line-16)">|&#160;Gateway&#160;|</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r3" x="24.4" y="434.8" textLength="134.2" clip-path="url(#terminal-line-17)">+---------+</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r5" x="24.4" y="459.2" textLength="61" clip-path="url(#terminal-line-18)">&#160;&#160;&#160;&#160;|</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r5" x="24.4" y="483.6" textLength="61" clip-path="url(#terminal-line-19)">&#160;&#160;&#160;&#160;v</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r3" x="24.4" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">+---------+</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r5" x="24.4" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">|&#160;Service&#160;|</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r3" x="24.4" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">+---------+</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r5" x="24.4" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">&#160;&#160;&#160;&#160;|</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r5" x="24.4" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">&#160;&#160;&#160;&#160;v</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r3" x="24.4" y="630" textLength="134.2" clip-path="url(#terminal-line-25)">+----+----+</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r5" x="24.4" y="654.4" textLength="134.2" clip-path="url(#terminal-line-26)">|&#160;&#160;&#160;DB&#160;&#160;&#160;&#160;|</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r3" x="24.4" y="678.8" textLength="134.2" clip-path="url(#terminal-line-27)">+---------+</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -187,7 +187,7 @@
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">I&#x27;m&#160;ready&#160;to&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="24.4" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">I&#x27;m&#160;ready&#160;to&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r5" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

@ -12,7 +12,6 @@ from tests.stubs.fake_mcp_registry import (
FakeMCPRegistryWithBrokenServer,
)
from vibe.core.config import MCPHttp, MCPStdio
from vibe.core.tools.connectors import CONNECTORS_ENV_VAR
from vibe.core.tools.mcp.tools import RemoteTool
_MCP_PATCH = "vibe.core.agent_loop.MCPRegistry"
@ -239,7 +238,6 @@ class SnapshotTestAppConnectorsMixedState(BaseSnapshotTestApp):
# ---------------------------------------------------------------------------
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_mcp_with_connectors_overview(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
@ -258,7 +256,6 @@ def test_snapshot_mcp_with_connectors_overview(snap_compare: SnapCompare) -> Non
# ---------------------------------------------------------------------------
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_connector_auth_opens_on_disconnected(
snap_compare: SnapCompare,
) -> None:
@ -280,7 +277,6 @@ def test_snapshot_connector_auth_opens_on_disconnected(
)
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_connector_auth_show_url(snap_compare: SnapCompare) -> None:
"""Selecting 'Manually show the URL' reveals the auth URL."""
@ -303,7 +299,6 @@ def test_snapshot_connector_auth_show_url(snap_compare: SnapCompare) -> None:
)
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_connector_auth_back_to_mcp(snap_compare: SnapCompare) -> None:
"""Pressing backspace in the auth app returns to the /mcp menu."""
@ -323,7 +318,6 @@ def test_snapshot_connector_auth_back_to_mcp(snap_compare: SnapCompare) -> None:
)
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_mcp_help_bar_shows_authenticate(snap_compare: SnapCompare) -> None:
"""Help bar shows 'Enter Authenticate' when a disconnected connector is highlighted."""
@ -340,7 +334,6 @@ def test_snapshot_mcp_help_bar_shows_authenticate(snap_compare: SnapCompare) ->
)
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_mcp_connectors_only(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
@ -353,7 +346,6 @@ def test_snapshot_mcp_connectors_only(snap_compare: SnapCompare) -> None:
)
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_mcp_connectors_sorted_by_status(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
@ -366,7 +358,6 @@ def test_snapshot_mcp_connectors_sorted_by_status(snap_compare: SnapCompare) ->
)
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_mcp_drill_into_connector(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
@ -387,7 +378,6 @@ def test_snapshot_mcp_drill_into_connector(snap_compare: SnapCompare) -> None:
)
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_mcp_connector_back_to_overview(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:

View file

@ -0,0 +1,43 @@
from __future__ import annotations
from pathlib import Path
from typing import cast
from textual.pilot import Pilot
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
from tests.snapshots.snap_compare import SnapCompare
from vibe.cli.textual_ui.widgets.messages import PlanFileMessage
PLAN_CONTENT = """\
# Implementation Plan
## 1. Add user authentication
- JWT token validation
- Session management
## 2. Database migrations
- Create users table
- Add indexes
"""
class PlanFileMessageTestApp(BaseSnapshotTestApp):
pass
def test_snapshot_plan_file_message(snap_compare: SnapCompare, tmp_path: Path) -> None:
plan_file = tmp_path / "plan.md"
plan_file.write_text(PLAN_CONTENT)
async def run_before(pilot: Pilot) -> None:
app = cast(PlanFileMessageTestApp, pilot.app)
plan_widget = PlanFileMessage(file_path=plan_file)
await app._mount_and_scroll(plan_widget)
await pilot.pause(0.3)
assert snap_compare(
"test_ui_snapshot_plan_file_message.py:PlanFileMessageTestApp",
terminal_size=(120, 36),
run_before=run_before,
)

View file

@ -146,7 +146,7 @@ async def test_passes_parent_session_id_to_backend_after_reset(vibe_config: Vibe
[_ async for _ in agent.act("Hello")]
first_session_id = agent.session_id
agent._reset_session()
await agent._reset_session()
[_ async for _ in agent.act("Hello again")]
assert len(backend.requests_metadata) >= 2

View file

@ -0,0 +1,33 @@
from __future__ import annotations
import pytest
from tests.conftest import build_test_agent_loop, build_test_vibe_config
@pytest.mark.asyncio
async def test_refresh_system_prompt_preserves_scratchpad_section() -> None:
# Regression: refresh_system_prompt must pass scratchpad_dir, otherwise
# it silently drops the scratchpad instructions from the system prompt.
# This fires on every session start/resume via initialize_experiments
# and hydrate_experiments_from_session, so the LLM would lose awareness
# of the scratchpad on the very first turn for any user with telemetry
# enabled and a Mistral API key.
config = build_test_vibe_config(
include_project_context=False,
include_prompt_detail=True,
include_model_info=False,
include_commit_signature=False,
)
agent = build_test_agent_loop(config=config)
initial_prompt = agent.messages[0].content or ""
assert "Scratchpad Directory" in initial_prompt
assert agent.scratchpad_dir is not None
assert str(agent.scratchpad_dir) in initial_prompt
await agent.refresh_system_prompt()
refreshed_prompt = agent.messages[0].content or ""
assert "Scratchpad Directory" in refreshed_prompt
assert str(agent.scratchpad_dir) in refreshed_prompt

View file

@ -864,3 +864,34 @@ async def test_parallel_conversation_history_has_all_tool_messages() -> None:
"call_h3",
}
assert agent_loop.stats.tool_calls_succeeded == 4
@pytest.mark.asyncio
async def test_pending_injected_message_continues_loop_after_tool_result() -> None:
tool_call = make_todo_tool_call("call_inject")
backend = FakeBackend([
[mock_llm_chunk(content="Let me check.", tool_calls=[tool_call])],
[mock_llm_chunk(content="Acting on the injected guidance.")],
])
agent_loop = make_agent_loop(auto_approve=True, backend=backend)
events: list[BaseEvent] = []
async for event in agent_loop.act("Go"):
events.append(event)
if isinstance(event, ToolResultEvent):
agent_loop._pending_injected_messages.append(
LLMMessage(role=Role.user, content="updated context", injected=True)
)
assistant_events = [e for e in events if isinstance(e, AssistantEvent)]
assert len(assistant_events) == 2
injected_msgs = [
m for m in agent_loop.messages if m.role == Role.user and m.injected
]
assert any("updated context" in (m.content or "") for m in injected_msgs)
last_assistant = next(
m for m in reversed(agent_loop.messages) if m.role == Role.assistant
)
assert "Acting on the injected guidance" in (last_assistant.content or "")

View file

@ -259,7 +259,7 @@ class TestAgentApplyToConfig:
mock_manager = _MockManager(sources=("user",))
monkeypatch.setattr(
"vibe.core.config._settings.get_harness_files_manager", lambda: mock_manager
"vibe.core.prompts.get_harness_files_manager", lambda: mock_manager
)
base = VibeConfig(include_project_context=False, include_prompt_detail=False)
@ -298,7 +298,7 @@ class TestAgentApplyToConfig:
mock_manager = _MockManager(sources=("user",))
monkeypatch.setattr(
"vibe.core.config._settings.get_harness_files_manager", lambda: mock_manager
"vibe.core.prompts.get_harness_files_manager", lambda: mock_manager
)
config = VibeConfig(
@ -700,7 +700,7 @@ class TestAgentLoopInitialization:
mock_manager = _MockManager(sources=("user",))
monkeypatch.setattr(
"vibe.core.config._settings.get_harness_files_manager", lambda: mock_manager
"vibe.core.prompts.get_harness_files_manager", lambda: mock_manager
)
custom_agent = AgentProfile(

View file

@ -4,6 +4,7 @@ from pathlib import Path
import tomllib
from tests.conftest import build_test_agent_loop, build_test_vibe_config
from vibe.core.tools.base import ToolPermission
from vibe.core.tools.permissions import PermissionScope, RequiredPermission
@ -27,7 +28,10 @@ class TestApproveAlwaysPermanentNoGranularPermissions:
agent.approve_always("bash", None, save_permanently=False)
assert agent.config.tools["bash"]["permission"] == "always"
assert (
agent.tool_manager.get_tool_config("bash").permission
== ToolPermission.ALWAYS
)
persisted = _read_persisted_config(config_dir)
assert "bash" not in persisted.get("tools", {})
@ -58,8 +62,8 @@ class TestApproveAlwaysPermanentWithGranularPermissions:
agent.approve_always("bash", perms, save_permanently=True)
assert len(agent._session_rules) == 1
rule = agent._session_rules[0]
assert len(agent._permission_store._rules) == 1
rule = agent._permission_store._rules[0]
assert rule.tool_name == "bash"
assert rule.scope == PermissionScope.COMMAND_PATTERN
assert rule.session_pattern == "npm install *"
@ -70,7 +74,7 @@ class TestApproveAlwaysPermanentWithGranularPermissions:
agent.approve_always("bash", perms, save_permanently=False)
assert len(agent._session_rules) == 1
assert len(agent._permission_store._rules) == 1
persisted = _read_persisted_config(config_dir)
assert "bash" not in persisted.get("tools", {})

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import threading
from unittest.mock import AsyncMock, MagicMock, patch
@ -12,6 +13,7 @@ from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_connector_registry import FakeConnectorRegistry
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
from vibe.core import agent_loop as agent_loop_module
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import MCPStdio
from vibe.core.tools.manager import ToolManager
@ -256,3 +258,252 @@ class TestDeferredInitPublicMethods:
assert loop.is_initialized
assert loop.messages[-1].content == "context"
# ---------------------------------------------------------------------------
# start_initialize_experiments / wait_until_ready experiment gating
# ---------------------------------------------------------------------------
class TestStartInitializeExperiments:
@pytest.mark.asyncio
async def test_does_not_block_caller(self) -> None:
loop = build_test_agent_loop()
gate = asyncio.Event()
async def slow_init() -> None:
await gate.wait()
with patch.object(loop, "initialize_experiments", side_effect=slow_init):
loop.start_initialize_experiments()
task = loop._experiments_task
assert task is not None
assert not task.done()
gate.set()
await task
@pytest.mark.asyncio
async def test_is_idempotent(self) -> None:
loop = build_test_agent_loop()
init_mock = AsyncMock()
with patch.object(loop, "initialize_experiments", new=init_mock):
loop.start_initialize_experiments()
first_task = loop._experiments_task
loop.start_initialize_experiments()
second_task = loop._experiments_task
assert first_task is second_task
assert first_task is not None
await first_task
assert init_mock.await_count == 1
@pytest.mark.asyncio
async def test_sets_pending_telemetry_flags(self) -> None:
loop = build_test_agent_loop()
with patch.object(loop, "initialize_experiments", new=AsyncMock()):
loop.start_initialize_experiments()
assert loop._pending_new_session_telemetry is True
assert loop._ready_telemetry_pending is True
task = loop._experiments_task
assert task is not None
await task
@pytest.mark.asyncio
async def test_refreshes_system_prompt_when_experiments_update(self) -> None:
loop = build_test_agent_loop()
refresh_mock = AsyncMock()
with (
patch.object(
agent_loop_module,
"session_initialize_experiments",
new=AsyncMock(return_value=True),
),
patch.object(loop, "refresh_system_prompt", new=refresh_mock),
):
await loop.initialize_experiments()
refresh_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_does_not_refresh_system_prompt_when_experiments_unchanged(
self,
) -> None:
loop = build_test_agent_loop()
refresh_mock = AsyncMock()
with (
patch.object(
agent_loop_module,
"session_initialize_experiments",
new=AsyncMock(return_value=False),
),
patch.object(loop, "refresh_system_prompt", new=refresh_mock),
):
await loop.initialize_experiments()
refresh_mock.assert_not_awaited()
class TestWaitUntilReadyJoinsExperiments:
@pytest.mark.asyncio
async def test_joins_in_flight_task(self) -> None:
loop = build_test_agent_loop()
gate = asyncio.Event()
completed = False
async def slow_init() -> None:
nonlocal completed
await gate.wait()
completed = True
with patch.object(loop, "initialize_experiments", side_effect=slow_init):
loop.start_initialize_experiments()
async def release() -> None:
await asyncio.sleep(0.01)
gate.set()
asyncio.create_task(release())
await loop.wait_until_ready()
assert completed is True
task = loop._experiments_task
assert task is not None
assert task.done()
@pytest.mark.asyncio
async def test_emits_new_session_telemetry_once(self) -> None:
loop = build_test_agent_loop()
emit_new_session = MagicMock()
with (
patch.object(loop, "initialize_experiments", new=AsyncMock()),
patch.object(loop, "emit_new_session_telemetry", new=emit_new_session),
):
loop.start_initialize_experiments()
await loop.wait_until_ready()
await loop.wait_until_ready()
emit_new_session.assert_called_once()
assert loop._pending_new_session_telemetry is False
@pytest.mark.asyncio
async def test_emits_ready_telemetry_when_only_experiments_deferred(self) -> None:
loop = build_test_agent_loop()
emit_ready = MagicMock()
with (
patch.object(loop, "initialize_experiments", new=AsyncMock()),
patch.object(loop, "emit_ready_telemetry", new=emit_ready),
):
loop.start_initialize_experiments()
await loop.wait_until_ready()
await loop.wait_until_ready()
emit_ready.assert_called_once()
((duration,), _) = emit_ready.call_args
assert isinstance(duration, int)
assert duration >= 0
assert loop._ready_telemetry_pending is False
@pytest.mark.asyncio
async def test_does_not_emit_new_session_when_only_hydrating(self) -> None:
loop = build_test_agent_loop()
emit_new_session = MagicMock()
with (
patch.object(loop, "hydrate_experiments_from_session", new=AsyncMock()),
patch.object(loop, "emit_new_session_telemetry", new=emit_new_session),
):
await loop.hydrate_experiments_from_session()
await loop.wait_until_ready()
emit_new_session.assert_not_called()
assert loop._pending_new_session_telemetry is False
@pytest.mark.asyncio
async def test_no_op_when_nothing_deferred(self) -> None:
loop = build_test_agent_loop()
emit_ready = MagicMock()
emit_new_session = MagicMock()
with (
patch.object(loop, "emit_ready_telemetry", new=emit_ready),
patch.object(loop, "emit_new_session_telemetry", new=emit_new_session),
):
await loop.wait_until_ready()
emit_ready.assert_not_called()
emit_new_session.assert_not_called()
class TestACloseCancelsExperimentsTask:
@pytest.mark.asyncio
async def test_cancels_in_flight_task(self) -> None:
loop = build_test_agent_loop()
gate = asyncio.Event()
async def never_completing() -> None:
await gate.wait()
with patch.object(loop, "initialize_experiments", side_effect=never_completing):
loop.start_initialize_experiments()
task = loop._experiments_task
assert task is not None
assert not task.done()
await loop.aclose()
assert task.done()
assert task.cancelled()
@pytest.mark.asyncio
async def test_does_not_cancel_completed_task(self) -> None:
loop = build_test_agent_loop()
with patch.object(loop, "initialize_experiments", new=AsyncMock()):
loop.start_initialize_experiments()
task = loop._experiments_task
assert task is not None
await task
await loop.aclose()
assert task.done()
assert not task.cancelled()
class TestActGatesOnExperiments:
@pytest.mark.asyncio
async def test_act_awaits_experiments_before_llm_call(self) -> None:
loop = build_test_agent_loop(
backend=FakeBackend(mock_llm_chunk(content="hello"))
)
gate = asyncio.Event()
finished_init = False
async def slow_init() -> None:
nonlocal finished_init
await gate.wait()
finished_init = True
with patch.object(loop, "initialize_experiments", side_effect=slow_init):
loop.start_initialize_experiments()
async def release() -> None:
await asyncio.sleep(0.01)
gate.set()
asyncio.create_task(release())
events = [event async for event in loop.act("Hello")]
assert finished_init is True
assert any(getattr(event, "content", None) == "hello" for event in events)

View file

@ -1,47 +0,0 @@
from __future__ import annotations
from vibe.core.llm.message_utils import merge_consecutive_user_messages
from vibe.core.types import LLMMessage, Role
def test_merge_consecutive_user_messages() -> None:
messages = [
LLMMessage(role=Role.system, content="System"),
LLMMessage(role=Role.user, content="User 1"),
LLMMessage(role=Role.user, content="User 2"),
LLMMessage(role=Role.assistant, content="Assistant"),
]
result = merge_consecutive_user_messages(messages)
assert len(result) == 3
assert result[1].content == "User 1\n\nUser 2"
def test_preserves_non_consecutive_user_messages() -> None:
messages = [
LLMMessage(role=Role.user, content="User 1"),
LLMMessage(role=Role.assistant, content="Assistant"),
LLMMessage(role=Role.user, content="User 2"),
]
result = merge_consecutive_user_messages(messages)
assert len(result) == 3
def test_empty_messages() -> None:
assert merge_consecutive_user_messages([]) == []
def test_single_message() -> None:
messages = [LLMMessage(role=Role.user, content="Only one")]
result = merge_consecutive_user_messages(messages)
assert len(result) == 1
def test_three_consecutive_user_messages() -> None:
messages = [
LLMMessage(role=Role.user, content="A"),
LLMMessage(role=Role.user, content="B"),
LLMMessage(role=Role.user, content="C"),
]
result = merge_consecutive_user_messages(messages)
assert len(result) == 1
assert result[0].content == "A\n\nB\n\nC"

View file

@ -0,0 +1,166 @@
from __future__ import annotations
from pydantic import BaseModel
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.tools.base import ToolPermission
from vibe.core.tools.permissions import (
ApprovedRule,
PermissionScope,
PermissionStore,
RequiredPermission,
)
from vibe.core.types import ApprovalResponse, FunctionCall, ToolCall, ToolResultEvent
class TestPermissionStore:
def test_covers_returns_false_when_empty(self):
store = PermissionStore()
rp = RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern="npm install foo",
session_pattern="npm install *",
label="npm install *",
)
assert not store.covers("bash", rp)
def test_covers_returns_true_after_matching_rule_added(self):
store = PermissionStore()
store.add_rule(
ApprovedRule(
tool_name="bash",
scope=PermissionScope.COMMAND_PATTERN,
session_pattern="npm install *",
)
)
rp = RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern="npm install foo",
session_pattern="npm install *",
label="npm install *",
)
assert store.covers("bash", rp)
def test_covers_isolates_by_tool_name(self):
store = PermissionStore()
store.add_rule(
ApprovedRule(
tool_name="bash",
scope=PermissionScope.COMMAND_PATTERN,
session_pattern="npm install *",
)
)
rp = RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern="npm install foo",
session_pattern="npm install *",
label="npm install *",
)
assert not store.covers("read_file", rp)
def test_tool_permission_round_trip(self):
store = PermissionStore()
assert store.get_tool_permission("bash") is None
store.set_tool_permission("bash", ToolPermission.ALWAYS)
assert store.get_tool_permission("bash") == ToolPermission.ALWAYS
class TestAgentLoopSharesStore:
def test_subagent_inherits_parent_session_rules(self):
store = PermissionStore()
parent = build_test_agent_loop(permission_store=store)
subagent = build_test_agent_loop(permission_store=store)
parent.approve_always(
"bash",
[
RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern="npm install foo",
session_pattern="npm install *",
label="npm install *",
)
],
)
rp = RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern="npm install bar",
session_pattern="npm install *",
label="npm install *",
)
assert subagent._permission_store.covers("bash", rp)
def test_subagent_inherits_parent_tool_permission(self):
store = PermissionStore()
parent = build_test_agent_loop(permission_store=store)
subagent = build_test_agent_loop(permission_store=store)
parent.approve_always("bash", None)
assert (
subagent._permission_store.get_tool_permission("bash")
== ToolPermission.ALWAYS
)
@pytest.mark.asyncio
async def test_subagent_applies_parent_tool_permission_before_resolution(self):
store = PermissionStore()
parent = build_test_agent_loop(permission_store=store)
parent.approve_always("bash", None)
approval_requested = False
async def approval_callback(
tool_name: str,
args: BaseModel,
tool_call_id: str,
required_permissions: list[RequiredPermission] | None,
) -> tuple[ApprovalResponse, str | None]:
nonlocal approval_requested
approval_requested = True
return ApprovalResponse.NO, None
tool_call = ToolCall(
id="call_1",
index=0,
function=FunctionCall(name="bash", arguments='{"command":"true"}'),
)
subagent = build_test_agent_loop(
config=build_test_vibe_config(enabled_tools=["bash"]),
backend=FakeBackend([
[mock_llm_chunk(content="Running it.", tool_calls=[tool_call])],
[mock_llm_chunk(content="Done.")],
]),
permission_store=store,
)
subagent.set_approval_callback(approval_callback)
events = [event async for event in subagent.act("run true")]
tool_results = [event for event in events if isinstance(event, ToolResultEvent)]
assert not approval_requested
assert len(tool_results) == 1
assert tool_results[0].skipped is False
assert "permission" not in subagent.config.tools.get("bash", {})
def test_default_store_is_per_loop_when_not_shared(self):
a = build_test_agent_loop()
b = build_test_agent_loop()
assert a._permission_store is not b._permission_store
assert a._permission_store.lock is not b._permission_store.lock
def test_subagent_shares_parent_approval_lock(self):
store = PermissionStore()
parent = build_test_agent_loop(permission_store=store)
subagent = build_test_agent_loop(permission_store=store)
assert parent._permission_store.lock is subagent._permission_store.lock

View file

@ -0,0 +1,82 @@
from __future__ import annotations
import subprocess
import tomllib
from scripts import prepare_release
def test_pin_dependencies_preserves_toml_format_and_refreshes_lock(
monkeypatch, tmp_path
):
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text(
"""[project]
name = "mistral-vibe"
version = "2.9.6"
license = { text = "Apache-2.0" }
authors = [{ name = "Mistral AI" }]
dependencies = [
"httpx[http2]>=0.28.1",
]
[dependency-groups]
build = ["pyinstaller>=6.17.0"]
[tool.pytest.ini_options]
filterwarnings = [
# Keep this comment when pinning dependencies.
"ignore:example",
]
""",
encoding="utf-8",
)
(tmp_path / "uv.lock").write_text("", encoding="utf-8")
monkeypatch.chdir(tmp_path)
def fake_get_pinned_dependencies(group=None):
if group == "build":
return ["pyinstaller==6.17.0", "truststore==0.10.4"]
return ["anyio==4.12.0", 'httpx[http2]==0.28.1 ; python_version >= "3.12"']
uv_commands = []
def fake_subprocess_run(args, **kwargs):
uv_commands.append(args)
return subprocess.CompletedProcess(args=args, returncode=0, stdout="")
git_commands = []
def fake_run_git_command(*args, **kwargs):
git_commands.append(args)
return subprocess.CompletedProcess(args=args, returncode=0, stdout="")
monkeypatch.setattr(
prepare_release, "get_pinned_dependencies", fake_get_pinned_dependencies
)
monkeypatch.setattr(prepare_release.subprocess, "run", fake_subprocess_run)
monkeypatch.setattr(prepare_release, "run_git_command", fake_run_git_command)
prepare_release.pin_dependencies("2.9.6")
updated_pyproject = pyproject_path.read_text(encoding="utf-8")
updated_data = tomllib.loads(updated_pyproject)
assert 'license = { text = "Apache-2.0" }' in updated_pyproject
assert 'authors = [{ name = "Mistral AI" }]' in updated_pyproject
assert "# Keep this comment when pinning dependencies." in updated_pyproject
assert updated_data["project"]["dependencies"] == [
"anyio==4.12.0",
'httpx[http2]==0.28.1 ; python_version >= "3.12"',
]
assert updated_data["dependency-groups"]["build"] == [
"pyinstaller==6.17.0",
"truststore==0.10.4",
]
assert uv_commands == [["uv", "lock"]]
assert ("add", "pyproject.toml", "uv.lock") in git_commands
assert (
"commit",
"--allow-empty",
"-m",
"chore: pin dependencies for v2.9.6",
) in git_commands

View file

@ -61,10 +61,15 @@ async def test_ui_navigation_restores_partially_typed_draft_after_round_trip(
async with vibe_app.run_test() as pilot:
inject_history_file(vibe_app, history_file)
chat_input = vibe_app.query_one(ChatInputContainer)
textarea = chat_input.input_widget
assert textarea is not None
await pilot.press(*"he")
assert chat_input.value == "he"
await pilot.press("up")
assert chat_input.value == "he"
assert textarea.cursor_location == (0, 0)
await pilot.press("up")
assert chat_input.value == "how are you?"
await pilot.press("down")
@ -207,6 +212,7 @@ async def test_ui_intercepts_arrow_up_only_on_first_wrapped_row(
while textarea.get_cursor_up_location() != textarea.cursor_location:
textarea.action_cursor_up()
assert textarea.cursor_location == (0, 0)
await pilot.press("up")
assert chat_input.value == "how are you?"

View file

@ -1,6 +1,5 @@
from __future__ import annotations
import os
from typing import Any, cast
from unittest.mock import AsyncMock, patch
@ -8,11 +7,11 @@ import httpx
import pytest
import respx
from tests.conftest import build_test_vibe_config
from tests.stubs.fake_connector_registry import FakeConnectorRegistry
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
from vibe.core.config import ConnectorConfig, VibeConfig
from vibe.core.tools.base import BaseToolConfig, ToolError
from vibe.core.tools.connectors import CONNECTORS_ENV_VAR
from vibe.core.tools.connectors.connector_registry import (
ConnectorRegistry,
RemoteTool,
@ -153,22 +152,7 @@ class TestFakeConnectorRegistry:
class TestToolManagerConnectorIntegration:
@staticmethod
def _make_config(connectors: list[ConnectorConfig] | None = None) -> VibeConfig:
"""Minimal VibeConfig-like stub for ToolManager."""
return cast(
VibeConfig,
type(
"_Cfg",
(),
{
"mcp_servers": [],
"connectors": connectors or [],
"enabled_tools": [],
"disabled_tools": [],
"tools": {},
"tool_paths": [],
},
)(),
)
return build_test_vibe_config(connectors=connectors or [])
def test_connector_tools_registered(self) -> None:
registry = FakeConnectorRegistry(
@ -198,22 +182,6 @@ class TestToolManagerConnectorIntegration:
assert connector_tools == []
# ---------------------------------------------------------------------------
# ConnectorRegistry env var gating (tested via agent_loop helper logic)
# ---------------------------------------------------------------------------
class TestConnectorRegistryEnvGating:
def test_disabled_without_env_var(self) -> None:
with patch.dict(os.environ, {}, clear=False):
os.environ.pop(CONNECTORS_ENV_VAR, None)
assert os.getenv(CONNECTORS_ENV_VAR) != "1"
def test_enabled_with_env_var(self) -> None:
with patch.dict(os.environ, {CONNECTORS_ENV_VAR: "1"}):
assert os.getenv(CONNECTORS_ENV_VAR) == "1"
# ---------------------------------------------------------------------------
# Error message helpers
# ---------------------------------------------------------------------------
@ -339,7 +307,7 @@ class TestConnectorProxyToolRun:
mock_call.assert_awaited_once()
call_args = mock_call.call_args
assert "/v1/experimental/connectors/conn-123/mcp" in call_args.args[0]
assert "/v1/connectors-gateway/conn-123/mcp" in call_args.args[0]
assert call_args.args[1] == "search"
assert call_args.kwargs["headers"]["Authorization"] == "Bearer test-key"
@ -412,21 +380,7 @@ class TestConnectorProxyToolRun:
class TestConnectorDisableFiltering:
@staticmethod
def _make_config(connectors: list[ConnectorConfig] | None = None) -> VibeConfig:
return cast(
VibeConfig,
type(
"_Cfg",
(),
{
"mcp_servers": [],
"connectors": connectors or [],
"enabled_tools": [],
"disabled_tools": [],
"tools": {},
"tool_paths": [],
},
)(),
)
return build_test_vibe_config(connectors=connectors or [])
def test_disabled_connector_excludes_all_tools(self) -> None:
registry = FakeConnectorRegistry(

View file

@ -232,7 +232,7 @@ class TestAnswerHandling:
class TestPlanFile:
@pytest.mark.asyncio
async def test_content_passed_as_preview(
async def test_keybinding_hint_shown_as_preview(
self, tool: ExitPlanMode, plan_manager: MockAgentManager, tmp_path: Path
) -> None:
plan_file = tmp_path / "plan.md"
@ -247,21 +247,36 @@ class TestPlanFile:
)
await collect_result(tool.run(ExitPlanModeArgs(), ctx))
assert isinstance(cb.received_args, AskUserQuestionArgs)
assert cb.received_args.content_preview == "# My Plan\n\n- Step 1\n- Step 2\n"
assert cb.received_args.footer_note is not None
assert "Ctrl+G" in cb.received_args.footer_note
assert str(plan_file) in cb.received_args.footer_note
@pytest.mark.asyncio
async def test_missing_file_means_none_preview(
async def test_result_does_not_include_plan_content(
self, tool: ExitPlanMode, plan_manager: MockAgentManager, tmp_path: Path
) -> None:
plan_file = tmp_path / "nonexistent.md"
plan_file = tmp_path / "plan.md"
plan_file.write_text("# My Plan\n\n- Step 1\n- Step 2\n")
cb = MockCallback(AskUserQuestionResult(answers=[], cancelled=True))
cb = MockCallback(
AskUserQuestionResult(
answers=[
Answer(
question="q",
answer="Yes, and auto approve edits",
is_other=False,
)
],
cancelled=False,
)
)
ctx = InvokeContext(
tool_call_id="t1",
agent_manager=plan_manager, # type: ignore[arg-type]
user_input_callback=cb,
plan_file_path=plan_file,
)
await collect_result(tool.run(ExitPlanModeArgs(), ctx))
assert isinstance(cb.received_args, AskUserQuestionArgs)
assert cb.received_args.content_preview is None
result = await collect_result(tool.run(ExitPlanModeArgs(), ctx))
assert result.switched is True
assert "# My Plan" not in result.message
assert "source of truth" not in result.message

View file

@ -34,8 +34,8 @@ from vibe.core.tools.permissions import (
PermissionContext,
PermissionScope,
RequiredPermission,
wildcard_match,
)
from vibe.core.tools.utils import wildcard_match
class TestBashGranularPermissions:

View file

@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from pydantic import ValidationError
import pytest
from tests.conftest import build_test_vibe_config
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp, VibeConfig
from vibe.core.tools.mcp import (
@ -763,21 +764,7 @@ class TestMCPDisableFiltering:
def _make_config(
mcp_servers: list[MCPHttp | MCPStdio | MCPStreamableHttp] | None = None,
) -> VibeConfig:
return cast(
VibeConfig,
type(
"_Cfg",
(),
{
"mcp_servers": mcp_servers or [],
"connectors": [],
"enabled_tools": [],
"disabled_tools": [],
"tools": {},
"tool_paths": [],
},
)(),
)
return build_test_vibe_config(mcp_servers=mcp_servers or [])
def test_disabled_server_excludes_all_tools(self):
srv = MCPHttp(

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock, patch
from mistralai.client import Mistral
from mistralai.client.errors import SDKError
@ -13,12 +14,47 @@ from mistralai.client.models import (
)
import pytest
from tests.conftest import build_test_vibe_config
from tests.mock.utils import collect_result
from vibe.core.config import ProviderConfig
from vibe.core.config import ProviderConfig, VibeConfig
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
from vibe.core.tools.builtins.websearch import WebSearch, WebSearchArgs, WebSearchConfig
from vibe.core.tools.manager import ToolManager
from vibe.core.types import Backend
if TYPE_CHECKING:
from vibe.core.agents.manager import AgentManager
class InMemoryAgentManager:
def __init__(self, config: VibeConfig) -> None:
self.config = config
def _ctx_with_config(config: VibeConfig) -> InvokeContext:
return InvokeContext(
tool_call_id="t1",
agent_manager=cast("AgentManager", InMemoryAgentManager(config)),
)
def _mistral_provider(
api_key_env_var: str = "MISTRAL_API_KEY",
api_base: str = "https://on-prem.example.com/v1",
) -> ProviderConfig:
return ProviderConfig(
name="mistral",
api_base=api_base,
api_key_env_var=api_key_env_var,
backend=Backend.MISTRAL,
)
def _llamacpp_provider() -> ProviderConfig:
return ProviderConfig(
name="llamacpp", api_base="http://127.0.0.1:8080/v1", backend=Backend.GENERIC
)
def _make_response(
content: list | None = None, outputs: list | None = None
@ -109,6 +145,68 @@ async def test_run_missing_api_key(monkeypatch):
await collect_result(ws.run(WebSearchArgs(query="test")))
@pytest.mark.asyncio
async def test_run_uses_mistral_provider_api_key_env_var(monkeypatch):
monkeypatch.setenv("MISTRAL_API_KEY", "wrong-key")
monkeypatch.setenv("TEST_API_KEY", "provider-key")
config = WebSearchConfig()
ws = WebSearch(config_getter=lambda: config, state=BaseToolState())
ctx = _ctx_with_config(
build_test_vibe_config(providers=[_mistral_provider("TEST_API_KEY")])
)
response = _make_response(content=[TextChunk(text="The answer")])
with patch("vibe.core.tools.builtins.websearch.Mistral") as mistral_cls:
client = mistral_cls.return_value
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=None)
client.beta.conversations.start_async = AsyncMock(return_value=response)
result = await collect_result(ws.run(WebSearchArgs(query="test query"), ctx))
assert result.answer == "The answer"
assert mistral_cls.call_args.kwargs["api_key"] == "provider-key"
assert mistral_cls.call_args.kwargs["server_url"] == "https://on-prem.example.com"
assert mistral_cls.call_args.kwargs["timeout_ms"] == 120000
@pytest.mark.asyncio
async def test_run_falls_back_to_default_api_key_env_var_when_provider_env_var_empty(
monkeypatch,
):
monkeypatch.setenv("MISTRAL_API_KEY", "fallback-key")
config = WebSearchConfig()
ws = WebSearch(config_getter=lambda: config, state=BaseToolState())
ctx = _ctx_with_config(build_test_vibe_config(providers=[_mistral_provider("")]))
response = _make_response(content=[TextChunk(text="The answer")])
with patch("vibe.core.tools.builtins.websearch.Mistral") as mistral_cls:
client = mistral_cls.return_value
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=None)
client.beta.conversations.start_async = AsyncMock(return_value=response)
result = await collect_result(ws.run(WebSearchArgs(query="test query"), ctx))
assert result.answer == "The answer"
assert mistral_cls.call_args.kwargs["api_key"] == "fallback-key"
@pytest.mark.asyncio
async def test_run_reports_configured_api_key_env_var_when_missing(monkeypatch):
monkeypatch.setenv("MISTRAL_API_KEY", "fallback-key")
monkeypatch.setenv("TEST_API_KEY", "provider-key")
ctx = _ctx_with_config(
build_test_vibe_config(providers=[_mistral_provider("TEST_API_KEY")])
)
monkeypatch.delenv("TEST_API_KEY", raising=False)
config = WebSearchConfig()
ws = WebSearch(config_getter=lambda: config, state=BaseToolState())
with pytest.raises(ToolError, match="TEST_API_KEY"):
await collect_result(ws.run(WebSearchArgs(query="test"), ctx))
@pytest.mark.asyncio
async def test_run_returns_parsed_result(websearch):
response = _make_response(
@ -165,48 +263,23 @@ def test_resolve_server_url_no_agent_manager(websearch):
def test_resolve_server_url_with_mistral_provider(websearch):
config = MagicMock()
config.providers = [
ProviderConfig(
name="mistral",
api_base="https://on-prem.example.com/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
]
agent_manager = MagicMock()
agent_manager.config = config
ctx = InvokeContext(tool_call_id="t1", agent_manager=agent_manager)
ctx = _ctx_with_config(build_test_vibe_config(providers=[_mistral_provider()]))
assert websearch._resolve_server_url(ctx) == "https://on-prem.example.com"
def test_resolve_server_url_with_default_provider(websearch):
config = MagicMock()
config.providers = [
ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
ctx = _ctx_with_config(
build_test_vibe_config(
providers=[_mistral_provider(api_base="https://api.mistral.ai/v1")]
)
]
agent_manager = MagicMock()
agent_manager.config = config
ctx = InvokeContext(tool_call_id="t1", agent_manager=agent_manager)
)
assert websearch._resolve_server_url(ctx) == "https://api.mistral.ai"
def test_resolve_server_url_no_mistral_provider(websearch):
config = MagicMock()
config.providers = [
ProviderConfig(name="llamacpp", api_base="http://127.0.0.1:8080/v1")
]
agent_manager = MagicMock()
agent_manager.config = config
ctx = InvokeContext(tool_call_id="t1", agent_manager=agent_manager)
ctx = _ctx_with_config(
build_test_vibe_config(active_model="local", providers=[_llamacpp_provider()])
)
assert websearch._resolve_server_url(ctx) is None
@ -220,5 +293,86 @@ def test_is_available_without_key(monkeypatch):
assert WebSearch.is_available() is False
def test_is_available_uses_mistral_provider_api_key_env_var(monkeypatch):
monkeypatch.setenv("MISTRAL_API_KEY", "fallback-key")
monkeypatch.setenv("TEST_API_KEY", "provider-key")
config = build_test_vibe_config(providers=[_mistral_provider("TEST_API_KEY")])
monkeypatch.delenv("TEST_API_KEY", raising=False)
assert WebSearch.is_available(config) is False
monkeypatch.setenv("TEST_API_KEY", "provider-key")
assert WebSearch.is_available(config) is True
def test_is_available_uses_non_active_mistral_provider(monkeypatch):
monkeypatch.setenv("TEST_API_KEY", "provider-key")
config = build_test_vibe_config(
active_model="local",
providers=[_llamacpp_provider(), _mistral_provider("TEST_API_KEY")],
)
monkeypatch.delenv("TEST_API_KEY", raising=False)
assert WebSearch.is_available(config) is False
monkeypatch.setenv("TEST_API_KEY", "provider-key")
assert WebSearch.is_available(config) is True
def test_is_available_falls_back_to_default_api_key_env_var_without_mistral_provider(
monkeypatch,
):
monkeypatch.setenv("MISTRAL_API_KEY", "fallback-key")
config = build_test_vibe_config(
active_model="local", providers=[_llamacpp_provider()]
)
assert WebSearch.is_available(config) is True
monkeypatch.delenv("MISTRAL_API_KEY")
assert WebSearch.is_available(config) is False
def test_is_available_falls_back_to_default_api_key_env_var_when_provider_env_var_empty(
monkeypatch,
):
monkeypatch.setenv("MISTRAL_API_KEY", "fallback-key")
config = build_test_vibe_config(providers=[_mistral_provider("")])
assert WebSearch.is_available(config) is True
monkeypatch.delenv("MISTRAL_API_KEY")
assert WebSearch.is_available(config) is False
def test_tool_manager_websearch_availability_uses_provider_api_key_env_var(monkeypatch):
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
monkeypatch.setenv("TEST_API_KEY", "provider-key")
config = build_test_vibe_config(providers=[_mistral_provider("TEST_API_KEY")])
manager = ToolManager(lambda: config)
assert "web_search" in manager.available_tools
monkeypatch.delenv("TEST_API_KEY")
assert "web_search" not in manager.available_tools
def test_tool_manager_websearch_availability_falls_back_without_mistral_provider(
monkeypatch,
):
monkeypatch.setenv("MISTRAL_API_KEY", "fallback-key")
config = build_test_vibe_config(
active_model="local", providers=[_llamacpp_provider()]
)
manager = ToolManager(lambda: config)
assert "web_search" in manager.available_tools
monkeypatch.delenv("MISTRAL_API_KEY")
assert "web_search" not in manager.available_tools
def test_get_status_text():
assert WebSearch.get_status_text() == "Searching the web"

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from vibe.core.tools.utils import wildcard_match
from vibe.core.tools.permissions import wildcard_match
class TestWildcardMatch: