v2.3.0 (#429)
Co-authored-by: Carlo <carloantonio.patti@mistral.ai> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com> Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai> Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-authored-by: Thomas Kenbeek <thomas.kenbeek@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
|
|
@ -28,7 +28,7 @@ class TestACPInitialize:
|
|||
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.2.1"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.3.0"
|
||||
)
|
||||
|
||||
assert response.auth_methods == []
|
||||
|
|
@ -52,7 +52,7 @@ class TestACPInitialize:
|
|||
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.2.1"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.3.0"
|
||||
)
|
||||
|
||||
assert response.auth_methods is not None
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ def acp_agent_with_session_config(
|
|||
models=[
|
||||
ModelConfig(
|
||||
name="devstral-latest", provider="mistral", alias="devstral-latest"
|
||||
)
|
||||
),
|
||||
ModelConfig(
|
||||
name="devstral-small", provider="mistral", alias="devstral-small"
|
||||
),
|
||||
],
|
||||
session_logging=session_config,
|
||||
)
|
||||
|
|
@ -58,7 +61,7 @@ def acp_agent_with_session_config(
|
|||
|
||||
class TestLoadSession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_returns_response_with_models_and_modes(
|
||||
async def test_load_session_response_structure(
|
||||
self,
|
||||
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||
temp_session_dir: Path,
|
||||
|
|
@ -76,9 +79,49 @@ class TestLoadSession:
|
|||
|
||||
assert response is not None
|
||||
assert response.models is not None
|
||||
assert len(response.models.available_models) == 2
|
||||
|
||||
assert response.models.current_model_id == "devstral-latest"
|
||||
assert response.models.available_models[0].model_id == "devstral-latest"
|
||||
assert response.models.available_models[0].name == "devstral-latest"
|
||||
assert response.models.available_models[1].model_id == "devstral-small"
|
||||
assert response.models.available_models[1].name == "devstral-small"
|
||||
|
||||
assert response.modes is not None
|
||||
assert response.modes.current_mode_id == BuiltinAgentName.DEFAULT
|
||||
modes_ids = {m.id for m in response.modes.available_modes}
|
||||
assert modes_ids == {
|
||||
BuiltinAgentName.DEFAULT,
|
||||
BuiltinAgentName.CHAT,
|
||||
BuiltinAgentName.AUTO_APPROVE,
|
||||
BuiltinAgentName.PLAN,
|
||||
BuiltinAgentName.ACCEPT_EDITS,
|
||||
}
|
||||
|
||||
assert response.config_options is not None
|
||||
assert len(response.config_options) == 2
|
||||
assert response.config_options[0].root.id == "mode"
|
||||
assert response.config_options[0].root.category == "mode"
|
||||
assert response.config_options[0].root.current_value == BuiltinAgentName.DEFAULT
|
||||
assert len(response.config_options[0].root.options) == 5
|
||||
mode_option_values = {
|
||||
opt.value for opt in response.config_options[0].root.options
|
||||
}
|
||||
assert mode_option_values == {
|
||||
BuiltinAgentName.DEFAULT,
|
||||
BuiltinAgentName.CHAT,
|
||||
BuiltinAgentName.AUTO_APPROVE,
|
||||
BuiltinAgentName.PLAN,
|
||||
BuiltinAgentName.ACCEPT_EDITS,
|
||||
}
|
||||
assert response.config_options[1].root.id == "model"
|
||||
assert response.config_options[1].root.category == "model"
|
||||
assert response.config_options[1].root.current_value == "devstral-latest"
|
||||
assert len(response.config_options[1].root.options) == 2
|
||||
model_option_values = {
|
||||
opt.value for opt in response.config_options[1].root.options
|
||||
}
|
||||
assert model_option_values == {"devstral-latest", "devstral-small"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_registers_session_with_original_id(
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class TestACPNewSession:
|
|||
)
|
||||
|
||||
new_session_events = [
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe/new_session"
|
||||
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"
|
||||
|
|
@ -84,18 +84,47 @@ class TestACPNewSession:
|
|||
assert session_response.modes is not None
|
||||
assert session_response.modes.current_mode_id is not None
|
||||
assert session_response.modes.available_modes is not None
|
||||
assert len(session_response.modes.available_modes) == 4
|
||||
assert len(session_response.modes.available_modes) == 5
|
||||
|
||||
assert session_response.modes.current_mode_id == BuiltinAgentName.DEFAULT
|
||||
# Check that all primary agents are available (order may vary)
|
||||
mode_ids = {m.id for m in session_response.modes.available_modes}
|
||||
assert mode_ids == {
|
||||
BuiltinAgentName.DEFAULT,
|
||||
BuiltinAgentName.CHAT,
|
||||
BuiltinAgentName.AUTO_APPROVE,
|
||||
BuiltinAgentName.PLAN,
|
||||
BuiltinAgentName.ACCEPT_EDITS,
|
||||
}
|
||||
|
||||
# Check config_options
|
||||
assert session_response.config_options is not None
|
||||
assert len(session_response.config_options) == 2
|
||||
|
||||
# Mode config option
|
||||
mode_config = session_response.config_options[0]
|
||||
assert mode_config.root.id == "mode"
|
||||
assert mode_config.root.category == "mode"
|
||||
assert mode_config.root.current_value == BuiltinAgentName.DEFAULT
|
||||
assert len(mode_config.root.options) == 5
|
||||
mode_option_values = {opt.value for opt in mode_config.root.options}
|
||||
assert mode_option_values == {
|
||||
BuiltinAgentName.DEFAULT,
|
||||
BuiltinAgentName.CHAT,
|
||||
BuiltinAgentName.AUTO_APPROVE,
|
||||
BuiltinAgentName.PLAN,
|
||||
BuiltinAgentName.ACCEPT_EDITS,
|
||||
}
|
||||
|
||||
# Model config option
|
||||
model_config = session_response.config_options[1]
|
||||
assert model_config.root.id == "model"
|
||||
assert model_config.root.category == "model"
|
||||
assert model_config.root.current_value == "devstral-latest"
|
||||
assert len(model_config.root.options) == 2
|
||||
model_option_values = {opt.value for opt in model_config.root.options}
|
||||
assert model_option_values == {"devstral-latest", "devstral-small"}
|
||||
|
||||
@pytest.mark.skip(reason="TODO: Fix this test")
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_preserves_model_after_set_model(
|
||||
|
|
|
|||
287
tests/acp/test_set_config_option.py
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.acp.conftest import _create_acp_agent
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import ModelConfig, VibeConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent_loop(backend) -> VibeAcpAgentLoop:
|
||||
config = build_test_vibe_config(
|
||||
active_model="devstral-latest",
|
||||
models=[
|
||||
ModelConfig(
|
||||
name="devstral-latest",
|
||||
provider="mistral",
|
||||
alias="devstral-latest",
|
||||
input_price=0.4,
|
||||
output_price=2.0,
|
||||
),
|
||||
ModelConfig(
|
||||
name="devstral-small",
|
||||
provider="mistral",
|
||||
alias="devstral-small",
|
||||
input_price=0.1,
|
||||
output_price=0.3,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
VibeConfig.dump_config(config.model_dump())
|
||||
|
||||
class PatchedAgentLoop(AgentLoop):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **{**kwargs, "backend": backend})
|
||||
self._base_config = config
|
||||
self.agent_manager.invalidate_config()
|
||||
try:
|
||||
active_model = config.get_active_model()
|
||||
self.stats.input_price_per_million = active_model.input_price
|
||||
self.stats.output_price_per_million = active_model.output_price
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start()
|
||||
|
||||
return _create_acp_agent()
|
||||
|
||||
|
||||
class TestACPSetConfigOptionMode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_mode_to_auto_approve(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
|
||||
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="mode", value=BuiltinAgentName.AUTO_APPROVE
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.config_options is not None
|
||||
assert len(response.config_options) == 2
|
||||
assert (
|
||||
acp_session.agent_loop.agent_profile.name == BuiltinAgentName.AUTO_APPROVE
|
||||
)
|
||||
assert acp_session.agent_loop.auto_approve is True
|
||||
|
||||
# Verify config_options reflect the new state
|
||||
mode_config = response.config_options[0]
|
||||
assert mode_config.root.id == "mode"
|
||||
assert mode_config.root.current_value == BuiltinAgentName.AUTO_APPROVE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_mode_to_plan(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="mode", value=BuiltinAgentName.PLAN
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.PLAN
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_mode_invalid_returns_none(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
initial_mode = acp_session.agent_loop.agent_profile.name
|
||||
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="mode", value="invalid-mode"
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.agent_loop.agent_profile.name == initial_mode
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_mode_empty_string_returns_none(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
initial_mode = acp_session.agent_loop.agent_profile.name
|
||||
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="mode", value=""
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.agent_loop.agent_profile.name == initial_mode
|
||||
|
||||
|
||||
class TestACPSetConfigOptionModel:
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_model_success(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
assert acp_session.agent_loop.config.active_model == "devstral-latest"
|
||||
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="model", value="devstral-small"
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.config_options is not None
|
||||
assert len(response.config_options) == 2
|
||||
assert acp_session.agent_loop.config.active_model == "devstral-small"
|
||||
|
||||
# Verify config_options reflect the new state
|
||||
model_config = response.config_options[1]
|
||||
assert model_config.root.id == "model"
|
||||
assert model_config.root.current_value == "devstral-small"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_model_invalid_returns_none(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
initial_model = acp_session.agent_loop.config.active_model
|
||||
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="model", value="non-existent-model"
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.agent_loop.config.active_model == initial_model
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_model_empty_string_returns_none(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
initial_model = acp_session.agent_loop.config.active_model
|
||||
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="model", value=""
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.agent_loop.config.active_model == initial_model
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_model_saves_to_config(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
|
||||
with patch("vibe.acp.acp_agent_loop.VibeConfig.save_updates") as mock_save:
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="model", value="devstral-small"
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
mock_save.assert_called_once_with({"active_model": "devstral-small"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_model_does_not_save_on_invalid(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
|
||||
with patch("vibe.acp.acp_agent_loop.VibeConfig.save_updates") as mock_save:
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="model", value="non-existent-model"
|
||||
)
|
||||
|
||||
assert response is None
|
||||
mock_save.assert_not_called()
|
||||
|
||||
|
||||
class TestACPSetConfigOptionInvalidConfigId:
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_invalid_config_id_returns_none(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="invalid_config", value="some_value"
|
||||
)
|
||||
|
||||
assert response is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_empty_config_id_returns_none(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="", value="some_value"
|
||||
)
|
||||
|
||||
assert response is None
|
||||
|
|
@ -106,6 +106,29 @@ class TestACPSetMode:
|
|||
acp_session.agent_loop.auto_approve is False
|
||||
) # Accept Edits mode doesn't auto-approve all
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_to_chat(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
|
||||
|
||||
response = await acp_agent_loop.set_session_mode(
|
||||
session_id=session_id, mode_id=BuiltinAgentName.CHAT
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.CHAT
|
||||
assert (
|
||||
acp_session.agent_loop.auto_approve is True
|
||||
) # Chat mode auto-approves read-only tools
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_invalid_mode_returns_none(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
|
|
|
|||
44
tests/acp/test_tool_call_session_update.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from acp.schema import ToolCallStart
|
||||
|
||||
from vibe.acp.tools.session_update import tool_call_session_update
|
||||
from vibe.core.tools.builtins.read_file import ReadFile, ReadFileArgs
|
||||
from vibe.core.types import ToolCallEvent
|
||||
|
||||
|
||||
class TestToolCallSessionUpdate:
|
||||
def _create_event(self) -> ToolCallEvent:
|
||||
return ToolCallEvent(
|
||||
tool_name="read_file",
|
||||
tool_call_id="test_call_123",
|
||||
args=ReadFileArgs(path="/tmp/test.txt"),
|
||||
tool_class=ReadFile,
|
||||
)
|
||||
|
||||
def test_returns_tool_call_start(self) -> None:
|
||||
event = self._create_event()
|
||||
|
||||
update = tool_call_session_update(event)
|
||||
|
||||
assert update is not None
|
||||
assert isinstance(update, ToolCallStart)
|
||||
assert update.session_update == "tool_call"
|
||||
assert update.tool_call_id == "test_call_123"
|
||||
|
||||
def test_returns_tool_call_start_for_streaming_event(self) -> None:
|
||||
event = ToolCallEvent(
|
||||
tool_name="read_file",
|
||||
tool_call_id="test_call_123",
|
||||
tool_class=ReadFile,
|
||||
args=None,
|
||||
)
|
||||
|
||||
update = tool_call_session_update(event)
|
||||
|
||||
assert update is not None
|
||||
assert isinstance(update, ToolCallStart)
|
||||
assert update.session_update == "tool_call"
|
||||
assert update.tool_call_id == "test_call_123"
|
||||
assert update.kind == "read"
|
||||
assert update.raw_input is None
|
||||
|
|
@ -89,11 +89,15 @@ def test_updates_index_on_file_rename(
|
|||
old_file.rename(new_file)
|
||||
|
||||
assert _wait_for(
|
||||
lambda: all(
|
||||
entry.rel != old_file.name for entry in file_indexer.get_index(Path("."))
|
||||
)
|
||||
and any(
|
||||
entry.rel == new_file.name for entry in file_indexer.get_index(Path("."))
|
||||
lambda: (
|
||||
all(
|
||||
entry.rel != old_file.name
|
||||
for entry in file_indexer.get_index(Path("."))
|
||||
)
|
||||
and any(
|
||||
entry.rel == new_file.name
|
||||
for entry in file_indexer.get_index(Path("."))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ async def test_pressing_enter_submits_selected_command_and_hides_popup(
|
|||
slash_used = [
|
||||
e
|
||||
for e in telemetry_events
|
||||
if e.get("event_name") == "vibe/slash_command_used"
|
||||
if e.get("event_name") == "vibe.slash_command_used"
|
||||
]
|
||||
assert any(
|
||||
e.get("properties", {}).get("command") == "help"
|
||||
|
|
|
|||
151
tests/cli/test_bell_notifications.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.cli.textual_ui.notifications import (
|
||||
NotificationContext,
|
||||
TextualNotificationAdapter,
|
||||
)
|
||||
|
||||
|
||||
def _make_fake_app(*, is_headless: bool = False) -> MagicMock:
|
||||
app = MagicMock()
|
||||
type(app).is_headless = PropertyMock(return_value=is_headless)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_app() -> MagicMock:
|
||||
return _make_fake_app()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter_enabled(fake_app: MagicMock) -> TextualNotificationAdapter:
|
||||
return TextualNotificationAdapter(
|
||||
fake_app, get_enabled=lambda: True, default_title="Vibe"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter_disabled(fake_app: MagicMock) -> TextualNotificationAdapter:
|
||||
return TextualNotificationAdapter(
|
||||
fake_app, get_enabled=lambda: False, default_title="Vibe"
|
||||
)
|
||||
|
||||
|
||||
class TestTextualNotificationAdapter:
|
||||
def test_no_notification_when_disabled(
|
||||
self, adapter_disabled: TextualNotificationAdapter, fake_app: MagicMock
|
||||
) -> None:
|
||||
adapter_disabled.on_blur()
|
||||
|
||||
adapter_disabled.notify(NotificationContext.ACTION_REQUIRED)
|
||||
|
||||
fake_app.bell.assert_not_called()
|
||||
|
||||
def test_no_notification_when_focused(
|
||||
self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
|
||||
) -> None:
|
||||
adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
|
||||
|
||||
fake_app.bell.assert_not_called()
|
||||
|
||||
def test_notification_sent_when_unfocused_and_enabled(
|
||||
self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
|
||||
) -> None:
|
||||
adapter_enabled.on_blur()
|
||||
|
||||
adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
|
||||
|
||||
fake_app.bell.assert_called_once()
|
||||
fake_app._driver.write.assert_called_once_with(
|
||||
"\x1b]0;Vibe - Action Required\x07"
|
||||
)
|
||||
|
||||
def test_throttle_prevents_rapid_notifications(
|
||||
self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
|
||||
) -> None:
|
||||
adapter_enabled.on_blur()
|
||||
|
||||
adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
|
||||
assert fake_app.bell.call_count == 1
|
||||
|
||||
adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
|
||||
assert fake_app.bell.call_count == 1
|
||||
|
||||
def test_contextual_title_for_action_required(
|
||||
self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
|
||||
) -> None:
|
||||
adapter_enabled.on_blur()
|
||||
|
||||
adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
|
||||
|
||||
fake_app._driver.write.assert_called_once_with(
|
||||
"\x1b]0;Vibe - Action Required\x07"
|
||||
)
|
||||
|
||||
def test_contextual_title_for_complete(
|
||||
self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
|
||||
) -> None:
|
||||
adapter_enabled.on_blur()
|
||||
|
||||
adapter_enabled.notify(NotificationContext.COMPLETE)
|
||||
|
||||
fake_app._driver.write.assert_called_once_with(
|
||||
"\x1b]0;Vibe - Task Complete\x07"
|
||||
)
|
||||
|
||||
def test_restore_sets_default_title(
|
||||
self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
|
||||
) -> None:
|
||||
adapter_enabled.restore()
|
||||
|
||||
fake_app._driver.write.assert_called_once_with("\x1b]0;Vibe\x07")
|
||||
|
||||
def test_on_focus_restores_title(
|
||||
self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
|
||||
) -> None:
|
||||
adapter_enabled.on_blur()
|
||||
adapter_enabled.on_focus()
|
||||
|
||||
fake_app._driver.write.assert_called_once_with("\x1b]0;Vibe\x07")
|
||||
|
||||
def test_on_focus_prevents_notifications(
|
||||
self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
|
||||
) -> None:
|
||||
adapter_enabled.on_blur()
|
||||
adapter_enabled.on_focus()
|
||||
fake_app.reset_mock()
|
||||
|
||||
adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
|
||||
|
||||
fake_app.bell.assert_not_called()
|
||||
|
||||
def test_no_title_write_when_headless(self) -> None:
|
||||
app = _make_fake_app(is_headless=True)
|
||||
adapter = TextualNotificationAdapter(
|
||||
app, get_enabled=lambda: True, default_title="Vibe"
|
||||
)
|
||||
adapter.on_blur()
|
||||
|
||||
adapter.notify(NotificationContext.ACTION_REQUIRED)
|
||||
|
||||
app.bell.assert_called_once()
|
||||
app._driver.write.assert_not_called()
|
||||
|
||||
def test_enabled_callback_reads_live_value(self, fake_app: MagicMock) -> None:
|
||||
enabled = True
|
||||
adapter = TextualNotificationAdapter(
|
||||
fake_app, get_enabled=lambda: enabled, default_title="Vibe"
|
||||
)
|
||||
adapter.on_blur()
|
||||
|
||||
adapter.notify(NotificationContext.ACTION_REQUIRED)
|
||||
assert fake_app.bell.call_count == 1
|
||||
|
||||
enabled = False
|
||||
adapter._last_notification_time = 0.0
|
||||
adapter.notify(NotificationContext.ACTION_REQUIRED)
|
||||
assert fake_app.bell.call_count == 1
|
||||
|
|
@ -52,3 +52,11 @@ class TestCommandRegistry:
|
|||
assert registry.get_command_name("/exit") is None
|
||||
assert registry.find_command("/exit") is None
|
||||
assert registry.get_command_name("/help") == "help"
|
||||
|
||||
def test_resume_command_registration(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
assert registry.get_command_name("/resume") == "resume"
|
||||
assert registry.get_command_name("/continue") == "resume"
|
||||
cmd = registry.find_command("/resume")
|
||||
assert cmd is not None
|
||||
assert cmd.handler == "_show_session_picker"
|
||||
|
|
|
|||
109
tests/cli/test_switching_mode.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_app
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
||||
from vibe.cli.textual_ui.widgets.chat_input.body import ChatInputBody, _PromptSpinner
|
||||
from vibe.cli.textual_ui.widgets.messages import UserMessage
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_ignored_while_switching_mode() -> None:
|
||||
"""Enter press during mode switch must not clear input or send a message."""
|
||||
app = build_test_vibe_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
body = app.query_one(ChatInputBody)
|
||||
body.switching_mode = True
|
||||
await pilot.pause(0.1)
|
||||
|
||||
# Type some text and press enter
|
||||
app.query_one(ChatInputContainer).value = "hello world"
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
# Text must remain in the input
|
||||
assert app.query_one(ChatInputContainer).value == "hello world"
|
||||
# No user message should have been posted
|
||||
assert len(app.query(UserMessage)) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_works_after_switching_mode_ends() -> None:
|
||||
"""After switching_mode is set back to False, Enter should work normally."""
|
||||
app = build_test_vibe_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
body = app.query_one(ChatInputBody)
|
||||
|
||||
# Enable then disable switching mode
|
||||
body.switching_mode = True
|
||||
await pilot.pause(0.1)
|
||||
body.switching_mode = False
|
||||
await pilot.pause(0.1)
|
||||
|
||||
# Now submit should work
|
||||
app.query_one(ChatInputContainer).value = "hello"
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert app.query_one(ChatInputContainer).value == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spinner_shown_while_switching_mode() -> None:
|
||||
"""Prompt widget is hidden and spinner is mounted when switching_mode is True."""
|
||||
app = build_test_vibe_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
body = app.query_one(ChatInputBody)
|
||||
prompt = body.prompt_widget
|
||||
assert prompt is not None
|
||||
assert prompt.display is True
|
||||
assert len(body.query(_PromptSpinner)) == 0
|
||||
|
||||
body.switching_mode = True
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert prompt.display is False
|
||||
assert len(body.query(_PromptSpinner)) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spinner_removed_after_switching_mode_ends() -> None:
|
||||
"""Prompt is restored and spinner removed when switching_mode becomes False."""
|
||||
app = build_test_vibe_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
body = app.query_one(ChatInputBody)
|
||||
body.switching_mode = True
|
||||
await pilot.pause(0.1)
|
||||
body.switching_mode = False
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert body.prompt_widget is not None
|
||||
assert body.prompt_widget.display is True
|
||||
assert len(body.query(_PromptSpinner)) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rapid_switching_mode_no_duplicate_spinners() -> None:
|
||||
"""Rapidly toggling switching_mode must never produce duplicate spinners."""
|
||||
app = build_test_vibe_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
body = app.query_one(ChatInputBody)
|
||||
|
||||
# Rapidly toggle several times
|
||||
for _ in range(5):
|
||||
body.switching_mode = True
|
||||
body.switching_mode = True # double set
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert len(body.query(_PromptSpinner)) == 1
|
||||
|
|
@ -144,8 +144,9 @@ async def test_ui_session_incremental_loader_load_more_batches_until_done(
|
|||
app.post_message(HistoryLoadMoreRequested())
|
||||
await _wait_until(
|
||||
pilot.pause,
|
||||
lambda current_count=current_count: len(app.query(UserMessage))
|
||||
> current_count,
|
||||
lambda current_count=current_count: (
|
||||
len(app.query(UserMessage)) > current_count
|
||||
),
|
||||
)
|
||||
|
||||
await _wait_until(
|
||||
|
|
|
|||
0
tests/cli/textual_ui/__init__.py
Normal file
131
tests/cli/textual_ui/test_session_picker.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.cli.textual_ui.widgets.session_picker import (
|
||||
SessionPickerApp,
|
||||
_format_relative_time,
|
||||
)
|
||||
from vibe.core.session.session_loader import SessionInfo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_sessions() -> list[SessionInfo]:
|
||||
return [
|
||||
SessionInfo(
|
||||
session_id="session-a",
|
||||
cwd="/test",
|
||||
title="Session A",
|
||||
end_time=(datetime.now(UTC) - timedelta(minutes=5)).isoformat(),
|
||||
),
|
||||
SessionInfo(
|
||||
session_id="session-b",
|
||||
cwd="/test",
|
||||
title="Session B",
|
||||
end_time=(datetime.now(UTC) - timedelta(hours=1)).isoformat(),
|
||||
),
|
||||
SessionInfo(
|
||||
session_id="session-c",
|
||||
cwd="/test",
|
||||
title="Session C",
|
||||
end_time=(datetime.now(UTC) - timedelta(days=1)).isoformat(),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_latest_messages() -> dict[str, str]:
|
||||
return {
|
||||
"session-a": "Help me fix this bug",
|
||||
"session-b": "Refactor the authentication module",
|
||||
"session-c": "Add unit tests for the API",
|
||||
}
|
||||
|
||||
|
||||
class TestFormatRelativeTime:
|
||||
def test_just_now(self) -> None:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
assert _format_relative_time(now) == "just now"
|
||||
|
||||
def test_minutes_ago(self) -> None:
|
||||
time_5m_ago = (datetime.now(UTC) - timedelta(minutes=5)).isoformat()
|
||||
assert _format_relative_time(time_5m_ago) == "5m ago"
|
||||
|
||||
def test_hours_ago(self) -> None:
|
||||
time_2h_ago = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
|
||||
assert _format_relative_time(time_2h_ago) == "2h ago"
|
||||
|
||||
def test_days_ago(self) -> None:
|
||||
time_3d_ago = (datetime.now(UTC) - timedelta(days=3)).isoformat()
|
||||
assert _format_relative_time(time_3d_ago) == "3d ago"
|
||||
|
||||
def test_weeks_ago(self) -> None:
|
||||
time_2w_ago = (datetime.now(UTC) - timedelta(weeks=2)).isoformat()
|
||||
assert _format_relative_time(time_2w_ago) == "2w ago"
|
||||
|
||||
def test_none_returns_unknown(self) -> None:
|
||||
assert _format_relative_time(None) == "unknown"
|
||||
|
||||
def test_invalid_format_returns_unknown(self) -> None:
|
||||
assert _format_relative_time("not-a-date") == "unknown"
|
||||
|
||||
def test_handles_z_suffix(self) -> None:
|
||||
time_str = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
assert _format_relative_time(time_str) == "just now"
|
||||
|
||||
def test_boundary_59_seconds(self) -> None:
|
||||
time_59s_ago = (datetime.now(UTC) - timedelta(seconds=59)).isoformat()
|
||||
assert _format_relative_time(time_59s_ago) == "just now"
|
||||
|
||||
def test_boundary_60_seconds(self) -> None:
|
||||
time_60s_ago = (datetime.now(UTC) - timedelta(seconds=60)).isoformat()
|
||||
assert _format_relative_time(time_60s_ago) == "1m ago"
|
||||
|
||||
|
||||
class TestSessionPickerAppInit:
|
||||
def test_init_sets_properties(
|
||||
self, sample_sessions: list[SessionInfo], sample_latest_messages: dict[str, str]
|
||||
) -> None:
|
||||
picker = SessionPickerApp(
|
||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||
)
|
||||
assert picker._sessions == sample_sessions
|
||||
assert picker._latest_messages == sample_latest_messages
|
||||
|
||||
def test_id_is_sessionpicker_app(self) -> None:
|
||||
picker = SessionPickerApp(sessions=[], latest_messages={})
|
||||
assert picker.id == "sessionpicker-app"
|
||||
|
||||
def test_can_focus_children_is_true(self) -> None:
|
||||
assert SessionPickerApp.can_focus_children is True
|
||||
|
||||
|
||||
class TestSessionPickerMessages:
|
||||
def test_session_selected_stores_session_id(self) -> None:
|
||||
msg = SessionPickerApp.SessionSelected("test-session-id")
|
||||
assert msg.session_id == "test-session-id"
|
||||
|
||||
def test_session_selected_with_full_uuid(self) -> None:
|
||||
session_id = "abc12345-6789-0123-4567-89abcdef0123"
|
||||
msg = SessionPickerApp.SessionSelected(session_id)
|
||||
assert msg.session_id == session_id
|
||||
|
||||
def test_cancelled_can_be_instantiated(self) -> None:
|
||||
msg = SessionPickerApp.Cancelled()
|
||||
assert isinstance(msg, SessionPickerApp.Cancelled)
|
||||
|
||||
|
||||
class TestSessionPickerAppBindings:
|
||||
def _get_binding_keys(self) -> list[str]:
|
||||
keys = []
|
||||
for binding in SessionPickerApp.BINDINGS:
|
||||
if isinstance(binding, tuple) and len(binding) >= 1:
|
||||
keys.append(binding[0])
|
||||
else:
|
||||
keys.append(binding.key)
|
||||
return keys
|
||||
|
||||
def test_has_escape_binding(self) -> None:
|
||||
assert "escape" in self._get_binding_keys()
|
||||
|
|
@ -3,29 +3,33 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from vibe.core.paths.config_paths import resolve_local_skills_dirs
|
||||
from vibe.core.paths.config_paths import (
|
||||
discover_local_agents_dirs,
|
||||
discover_local_skills_dirs,
|
||||
discover_local_tools_dirs,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveLocalSkillsDirs:
|
||||
class TestDiscoverLocalSkillsDirs:
|
||||
def test_returns_empty_list_when_dir_not_trusted(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = False
|
||||
assert resolve_local_skills_dirs(tmp_path) == []
|
||||
assert discover_local_skills_dirs(tmp_path) == []
|
||||
|
||||
def test_returns_empty_list_when_trusted_but_no_skills_dirs(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
assert resolve_local_skills_dirs(tmp_path) == []
|
||||
assert discover_local_skills_dirs(tmp_path) == []
|
||||
|
||||
def test_returns_vibe_skills_only_when_only_it_exists(self, tmp_path: Path) -> None:
|
||||
vibe_skills = tmp_path / ".vibe" / "skills"
|
||||
vibe_skills.mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = resolve_local_skills_dirs(tmp_path)
|
||||
result = discover_local_skills_dirs(tmp_path)
|
||||
assert result == [vibe_skills]
|
||||
|
||||
def test_returns_agents_skills_only_when_only_it_exists(
|
||||
|
|
@ -35,7 +39,7 @@ class TestResolveLocalSkillsDirs:
|
|||
agents_skills.mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = resolve_local_skills_dirs(tmp_path)
|
||||
result = discover_local_skills_dirs(tmp_path)
|
||||
assert result == [agents_skills]
|
||||
|
||||
def test_returns_both_in_order_when_both_exist(self, tmp_path: Path) -> None:
|
||||
|
|
@ -45,7 +49,7 @@ class TestResolveLocalSkillsDirs:
|
|||
agents_skills.mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = resolve_local_skills_dirs(tmp_path)
|
||||
result = discover_local_skills_dirs(tmp_path)
|
||||
assert result == [vibe_skills, agents_skills]
|
||||
|
||||
def test_ignores_vibe_skills_when_file_not_dir(self, tmp_path: Path) -> None:
|
||||
|
|
@ -53,5 +57,128 @@ class TestResolveLocalSkillsDirs:
|
|||
(tmp_path / ".vibe" / "skills").write_text("", encoding="utf-8")
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = resolve_local_skills_dirs(tmp_path)
|
||||
result = discover_local_skills_dirs(tmp_path)
|
||||
assert result == []
|
||||
|
||||
def test_finds_skills_dirs_recursively_in_trusted_folder(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
(tmp_path / "sub" / ".agents" / "skills").mkdir(parents=True)
|
||||
(tmp_path / "sub" / "deep" / ".vibe" / "skills").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_skills_dirs(tmp_path)
|
||||
assert result == [
|
||||
tmp_path / ".vibe" / "skills",
|
||||
tmp_path / "sub" / ".agents" / "skills",
|
||||
tmp_path / "sub" / "deep" / ".vibe" / "skills",
|
||||
]
|
||||
|
||||
def test_does_not_descend_into_ignored_dirs(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_skills_dirs(tmp_path)
|
||||
assert result == [tmp_path / ".vibe" / "skills"]
|
||||
|
||||
|
||||
class TestDiscoverLocalToolsDirs:
|
||||
def test_returns_empty_list_when_dir_not_trusted(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = False
|
||||
assert discover_local_tools_dirs(tmp_path) == []
|
||||
|
||||
def test_returns_empty_list_when_trusted_but_no_tools_dir(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
assert discover_local_tools_dirs(tmp_path) == []
|
||||
|
||||
def test_returns_tools_dir_when_exists(self, tmp_path: Path) -> None:
|
||||
vibe_tools = tmp_path / ".vibe" / "tools"
|
||||
vibe_tools.mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_tools_dirs(tmp_path)
|
||||
assert result == [vibe_tools]
|
||||
|
||||
def test_ignores_tools_when_file_not_dir(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe").mkdir()
|
||||
(tmp_path / ".vibe" / "tools").write_text("", encoding="utf-8")
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_tools_dirs(tmp_path)
|
||||
assert result == []
|
||||
|
||||
def test_finds_tools_dirs_recursively(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
(tmp_path / "sub" / ".vibe" / "tools").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_tools_dirs(tmp_path)
|
||||
assert result == [
|
||||
tmp_path / ".vibe" / "tools",
|
||||
tmp_path / "sub" / ".vibe" / "tools",
|
||||
]
|
||||
|
||||
def test_does_not_descend_into_ignored_dirs(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
(tmp_path / ".git" / ".vibe" / "tools").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_tools_dirs(tmp_path)
|
||||
assert result == [tmp_path / ".vibe" / "tools"]
|
||||
|
||||
|
||||
class TestDiscoverLocalAgentsDirs:
|
||||
def test_returns_empty_list_when_dir_not_trusted(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "agents").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = False
|
||||
assert discover_local_agents_dirs(tmp_path) == []
|
||||
|
||||
def test_returns_empty_list_when_trusted_but_no_agents_dir(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
assert discover_local_agents_dirs(tmp_path) == []
|
||||
|
||||
def test_returns_agents_dir_when_exists(self, tmp_path: Path) -> None:
|
||||
vibe_agents = tmp_path / ".vibe" / "agents"
|
||||
vibe_agents.mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_agents_dirs(tmp_path)
|
||||
assert result == [vibe_agents]
|
||||
|
||||
def test_ignores_agents_when_file_not_dir(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe").mkdir()
|
||||
(tmp_path / ".vibe" / "agents").write_text("", encoding="utf-8")
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_agents_dirs(tmp_path)
|
||||
assert result == []
|
||||
|
||||
def test_finds_agents_dirs_recursively(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "agents").mkdir(parents=True)
|
||||
(tmp_path / "sub" / "deep" / ".vibe" / "agents").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_agents_dirs(tmp_path)
|
||||
assert result == [
|
||||
tmp_path / ".vibe" / "agents",
|
||||
tmp_path / "sub" / "deep" / ".vibe" / "agents",
|
||||
]
|
||||
|
||||
def test_does_not_descend_into_ignored_dirs(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "agents").mkdir(parents=True)
|
||||
(tmp_path / "__pycache__" / ".vibe" / "agents").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_agents_dirs(tmp_path)
|
||||
assert result == [tmp_path / ".vibe" / "agents"]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
|
|||
|
||||
import pytest
|
||||
|
||||
from vibe.core.utils import StructuredLogFormatter, apply_logging_config
|
||||
from vibe.core.logger import StructuredLogFormatter, apply_logging_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -19,8 +19,8 @@ def mock_log_dir(tmp_path: Path):
|
|||
mock_file = MagicMock()
|
||||
mock_file.path = tmp_path / "vibe.log"
|
||||
with (
|
||||
patch("vibe.core.utils.LOG_DIR", mock_dir),
|
||||
patch("vibe.core.utils.LOG_FILE", mock_file),
|
||||
patch("vibe.core.logger.LOG_DIR", mock_dir),
|
||||
patch("vibe.core.logger.LOG_FILE", mock_file),
|
||||
):
|
||||
yield tmp_path
|
||||
|
||||
|
|
@ -222,8 +222,8 @@ class TestApplyLoggingConfig:
|
|||
mock_file = MagicMock()
|
||||
mock_file.path = log_dir / "vibe.log"
|
||||
with (
|
||||
patch("vibe.core.utils.LOG_DIR", mock_dir),
|
||||
patch("vibe.core.utils.LOG_FILE", mock_file),
|
||||
patch("vibe.core.logger.LOG_DIR", mock_dir),
|
||||
patch("vibe.core.logger.LOG_FILE", mock_file),
|
||||
):
|
||||
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
||||
test_logger = logging.getLogger("test_mkdir")
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class TestTelemetryClient:
|
|||
def test_send_telemetry_event_does_nothing_when_api_key_is_none(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
env_key = config.get_provider_for_model(
|
||||
config.get_active_model()
|
||||
).api_key_env_var
|
||||
|
|
@ -57,7 +57,7 @@ class TestTelemetryClient:
|
|||
client._client = MagicMock()
|
||||
client._client.post = AsyncMock()
|
||||
|
||||
client.send_telemetry_event("vibe/test", {})
|
||||
client.send_telemetry_event("vibe.test", {})
|
||||
_run_telemetry_tasks()
|
||||
|
||||
client._client.post.assert_not_called()
|
||||
|
|
@ -65,7 +65,10 @@ class TestTelemetryClient:
|
|||
def test_send_telemetry_event_does_nothing_when_disabled(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=True)
|
||||
monkeypatch.setattr(
|
||||
TelemetryClient, "send_telemetry_event", _original_send_telemetry_event
|
||||
)
|
||||
config = build_test_vibe_config(enable_telemetry=False)
|
||||
env_key = config.get_provider_for_model(
|
||||
config.get_active_model()
|
||||
).api_key_env_var
|
||||
|
|
@ -74,7 +77,7 @@ class TestTelemetryClient:
|
|||
client._client = MagicMock()
|
||||
client._client.post = AsyncMock()
|
||||
|
||||
client.send_telemetry_event("vibe/test", {})
|
||||
client.send_telemetry_event("vibe.test", {})
|
||||
_run_telemetry_tasks()
|
||||
|
||||
client._client.post.assert_not_called()
|
||||
|
|
@ -86,7 +89,7 @@ class TestTelemetryClient:
|
|||
monkeypatch.setattr(
|
||||
TelemetryClient, "send_telemetry_event", _original_send_telemetry_event
|
||||
)
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
env_key = config.get_provider_for_model(
|
||||
config.get_active_model()
|
||||
).api_key_env_var
|
||||
|
|
@ -97,12 +100,12 @@ class TestTelemetryClient:
|
|||
client._client.post = mock_post
|
||||
client._client.aclose = AsyncMock()
|
||||
|
||||
client.send_telemetry_event("vibe/test_event", {"key": "value"})
|
||||
client.send_telemetry_event("vibe.test_event", {"key": "value"})
|
||||
await client.aclose()
|
||||
|
||||
mock_post.assert_called_once_with(
|
||||
DATALAKE_EVENTS_URL,
|
||||
json={"event": "vibe/test_event", "properties": {"key": "value"}},
|
||||
json={"event": "vibe.test_event", "properties": {"key": "value"}},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer sk-test",
|
||||
|
|
@ -113,7 +116,7 @@ class TestTelemetryClient:
|
|||
def test_send_tool_call_finished_payload_shape(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
tool_call = _make_resolved_tool_call("todo", {})
|
||||
decision = ToolDecision(
|
||||
|
|
@ -129,7 +132,7 @@ class TestTelemetryClient:
|
|||
|
||||
assert len(telemetry_events) == 1
|
||||
event_name = telemetry_events[0]["event_name"]
|
||||
assert event_name == "vibe/tool_call_finished"
|
||||
assert event_name == "vibe.tool_call_finished"
|
||||
properties = telemetry_events[0]["properties"]
|
||||
assert properties["tool_name"] == "todo"
|
||||
assert properties["status"] == "success"
|
||||
|
|
@ -142,7 +145,7 @@ class TestTelemetryClient:
|
|||
def test_send_tool_call_finished_nb_files_created_write_file_new(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
tool_call = _make_resolved_tool_call("write_file", {"overwrite": False})
|
||||
|
||||
|
|
@ -160,7 +163,7 @@ class TestTelemetryClient:
|
|||
def test_send_tool_call_finished_nb_files_modified_write_file_overwrite(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
tool_call = _make_resolved_tool_call("write_file", {"overwrite": True})
|
||||
|
||||
|
|
@ -178,7 +181,7 @@ class TestTelemetryClient:
|
|||
def test_send_tool_call_finished_decision_none(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
tool_call = _make_resolved_tool_call("todo", {})
|
||||
|
||||
|
|
@ -195,49 +198,49 @@ class TestTelemetryClient:
|
|||
def test_send_user_copied_text_payload(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_user_copied_text("hello world")
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
assert telemetry_events[0]["event_name"] == "vibe/user_copied_text"
|
||||
assert telemetry_events[0]["event_name"] == "vibe.user_copied_text"
|
||||
assert telemetry_events[0]["properties"]["text_length"] == 11
|
||||
|
||||
def test_send_user_cancelled_action_payload(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_user_cancelled_action("interrupt_agent")
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
assert telemetry_events[0]["event_name"] == "vibe/user_cancelled_action"
|
||||
assert telemetry_events[0]["event_name"] == "vibe.user_cancelled_action"
|
||||
assert telemetry_events[0]["properties"]["action"] == "interrupt_agent"
|
||||
|
||||
def test_send_auto_compact_triggered_payload(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_auto_compact_triggered()
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
assert telemetry_events[0]["event_name"] == "vibe/auto_compact_triggered"
|
||||
assert telemetry_events[0]["event_name"] == "vibe.auto_compact_triggered"
|
||||
|
||||
def test_send_slash_command_used_payload(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_slash_command_used("help", "builtin")
|
||||
client.send_slash_command_used("my_skill", "skill")
|
||||
|
||||
assert len(telemetry_events) == 2
|
||||
assert telemetry_events[0]["event_name"] == "vibe/slash_command_used"
|
||||
assert telemetry_events[0]["event_name"] == "vibe.slash_command_used"
|
||||
assert telemetry_events[0]["properties"]["command"] == "help"
|
||||
assert telemetry_events[0]["properties"]["command_type"] == "builtin"
|
||||
assert telemetry_events[1]["properties"]["command"] == "my_skill"
|
||||
|
|
@ -246,7 +249,7 @@ class TestTelemetryClient:
|
|||
def test_send_new_session_payload(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_new_session(
|
||||
|
|
@ -255,15 +258,17 @@ class TestTelemetryClient:
|
|||
nb_mcp_servers=1,
|
||||
nb_models=3,
|
||||
entrypoint="cli",
|
||||
terminal_emulator="vscode",
|
||||
)
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
event_name = telemetry_events[0]["event_name"]
|
||||
assert event_name == "vibe/new_session"
|
||||
assert event_name == "vibe.new_session"
|
||||
properties = telemetry_events[0]["properties"]
|
||||
assert properties["has_agents_md"] is True
|
||||
assert properties["nb_skills"] == 2
|
||||
assert properties["nb_mcp_servers"] == 1
|
||||
assert properties["nb_models"] == 3
|
||||
assert properties["entrypoint"] == "cli"
|
||||
assert properties["terminal_emulator"] == "vscode"
|
||||
assert "version" in properties
|
||||
|
|
|
|||
|
|
@ -237,11 +237,11 @@ class TestHasAgentsMdFile:
|
|||
|
||||
class TestHasTrustableContent:
|
||||
def test_returns_true_when_vibe_dir_exists(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe").mkdir()
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_trustable_content(tmp_path) is True
|
||||
|
||||
def test_returns_true_when_agents_dir_exists(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".agents").mkdir()
|
||||
(tmp_path / ".agents" / "skills").mkdir(parents=True)
|
||||
assert has_trustable_content(tmp_path) is True
|
||||
|
||||
def test_returns_true_when_agents_md_filename_exists(self, tmp_path: Path) -> None:
|
||||
|
|
@ -253,3 +253,17 @@ class TestHasTrustableContent:
|
|||
def test_returns_false_when_no_trustable_content(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "other.txt").write_text("", encoding="utf-8")
|
||||
assert has_trustable_content(tmp_path) is False
|
||||
|
||||
def test_returns_true_when_vibe_config_in_subfolder(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "sub" / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_trustable_content(tmp_path) is True
|
||||
|
||||
def test_returns_true_when_agents_skills_in_subfolder(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "deep" / "nested" / ".agents" / "skills").mkdir(parents=True)
|
||||
assert has_trustable_content(tmp_path) is True
|
||||
|
||||
def test_returns_false_when_config_only_inside_ignored_dir(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_trustable_content(tmp_path) is False
|
||||
|
|
|
|||
86
tests/e2e/common.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractContextManager
|
||||
import io
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
import pexpect
|
||||
|
||||
|
||||
class SpawnedVibeProcessFixture(Protocol):
|
||||
def __call__(
|
||||
self, workdir: Path
|
||||
) -> AbstractContextManager[tuple[pexpect.spawn, io.StringIO]]: ...
|
||||
|
||||
|
||||
def ansi_tolerant_pattern(text: str) -> re.Pattern[str]:
|
||||
ansi = r"(?:\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07|\r|\n)*"
|
||||
return re.compile(ansi.join(re.escape(char) for char in text))
|
||||
|
||||
|
||||
def write_e2e_config(vibe_home: Path, api_base: str) -> None:
|
||||
vibe_home.mkdir(parents=True, exist_ok=True)
|
||||
(vibe_home / "config.toml").write_text(
|
||||
"\n".join([
|
||||
'active_model = "mock-model"',
|
||||
"enable_update_checks = false",
|
||||
"enable_auto_update = false",
|
||||
"",
|
||||
"[[providers]]",
|
||||
'name = "mock-provider"',
|
||||
f'api_base = "{api_base}"',
|
||||
'api_key_env_var = "MISTRAL_API_KEY"',
|
||||
'backend = "generic"',
|
||||
"",
|
||||
"[[models]]",
|
||||
'name = "mock-model"',
|
||||
'provider = "mock-provider"',
|
||||
'alias = "mock-model"',
|
||||
]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def strip_ansi(text: str) -> str:
|
||||
return re.sub(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07", "", text)
|
||||
|
||||
|
||||
def wait_for_request_count(
|
||||
request_count_getter: Callable[[], int], expected_count: int, timeout: float
|
||||
) -> None:
|
||||
start = time.monotonic()
|
||||
while time.monotonic() - start < timeout:
|
||||
if request_count_getter() >= expected_count:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
raise AssertionError(f"Timed out waiting for {expected_count} backend request(s).")
|
||||
|
||||
|
||||
def wait_for_main_screen(child: pexpect.spawn, timeout: float = 20.0) -> None:
|
||||
child.expect(ansi_tolerant_pattern("Mistral Vibe v"), timeout=timeout)
|
||||
|
||||
|
||||
def wait_for_rendered_text(
|
||||
child: pexpect.spawn, captured: io.StringIO, needle: str, timeout: float
|
||||
) -> None:
|
||||
start = time.monotonic()
|
||||
while time.monotonic() - start < timeout:
|
||||
if needle in strip_ansi(captured.getvalue()):
|
||||
return
|
||||
try:
|
||||
child.expect(r"\S", timeout=0.1)
|
||||
except pexpect.TIMEOUT:
|
||||
pass
|
||||
except pexpect.EOF as exc:
|
||||
rendered_tail = strip_ansi(captured.getvalue())[-1200:]
|
||||
raise AssertionError(
|
||||
f"Child exited while waiting for rendered text: {needle!r}\n\nRendered tail:\n{rendered_tail}"
|
||||
) from exc
|
||||
rendered_tail = strip_ansi(captured.getvalue())[-1200:]
|
||||
raise AssertionError(
|
||||
f"Timed out waiting for rendered text: {needle!r}\n\nRendered tail:\n{rendered_tail}"
|
||||
)
|
||||
82
tests/e2e/conftest.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pexpect
|
||||
import pytest
|
||||
|
||||
from tests import TESTS_ROOT
|
||||
from tests.e2e.common import write_e2e_config
|
||||
from tests.e2e.mock_server import ChunkFactory, StreamingMockServer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def streaming_mock_server(
|
||||
request: pytest.FixtureRequest,
|
||||
) -> Iterator[StreamingMockServer]:
|
||||
chunk_factory = cast(ChunkFactory | None, getattr(request, "param", None))
|
||||
server = StreamingMockServer(chunk_factory=chunk_factory)
|
||||
server.start()
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_e2e_env(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
streaming_mock_server: StreamingMockServer,
|
||||
) -> None:
|
||||
vibe_home = tmp_path / "vibe-home"
|
||||
write_e2e_config(vibe_home, streaming_mock_server.api_base)
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "fake-key")
|
||||
monkeypatch.setenv("VIBE_HOME", str(vibe_home))
|
||||
monkeypatch.setenv("TERM", "xterm-256color")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def e2e_workdir(tmp_path: Path) -> Path:
|
||||
workdir = tmp_path / "workdir"
|
||||
workdir.mkdir()
|
||||
return workdir
|
||||
|
||||
|
||||
type SpawnedVibeContext = Iterator[tuple[pexpect.spawn, io.StringIO]]
|
||||
type SpawnedVibeContextManager = AbstractContextManager[
|
||||
tuple[pexpect.spawn, io.StringIO]
|
||||
]
|
||||
type SpawnedVibeFactory = Callable[[Path], SpawnedVibeContextManager]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spawned_vibe_process() -> SpawnedVibeFactory:
|
||||
@contextmanager
|
||||
def spawn(workdir: Path) -> SpawnedVibeContext:
|
||||
captured = io.StringIO()
|
||||
child = pexpect.spawn(
|
||||
"uv",
|
||||
["run", "vibe", "--workdir", str(workdir)],
|
||||
cwd=str(TESTS_ROOT.parent),
|
||||
env=os.environ,
|
||||
encoding="utf-8",
|
||||
timeout=30,
|
||||
dimensions=(36, 120),
|
||||
)
|
||||
child.logfile_read = captured
|
||||
|
||||
try:
|
||||
yield child, captured
|
||||
finally:
|
||||
if child.isalive():
|
||||
child.terminate(force=True)
|
||||
if not child.closed:
|
||||
child.close()
|
||||
|
||||
return spawn
|
||||
149
tests/e2e/mock_server.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import TypedDict, cast
|
||||
|
||||
|
||||
class StreamOptionsPayload(TypedDict, total=False):
|
||||
include_usage: bool
|
||||
stream_tool_calls: bool
|
||||
|
||||
|
||||
class ChatMessagePayload(TypedDict, total=False):
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class ChatCompletionsRequestPayload(TypedDict, total=False):
|
||||
model: str
|
||||
messages: list[ChatMessagePayload]
|
||||
stream: bool
|
||||
stream_options: StreamOptionsPayload
|
||||
|
||||
|
||||
type StreamChunk = dict[str, object]
|
||||
type ChunkFactory = Callable[[int, ChatCompletionsRequestPayload], list[StreamChunk]]
|
||||
|
||||
|
||||
class StreamingMockServer:
|
||||
@staticmethod
|
||||
def build_chunk(
|
||||
*,
|
||||
created: int,
|
||||
delta: dict[str, object],
|
||||
finish_reason: str | None,
|
||||
usage: dict[str, int] | None = None,
|
||||
) -> StreamChunk:
|
||||
chunk: dict[str, object] = {
|
||||
"id": "mock-id",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": "mock-model",
|
||||
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
|
||||
}
|
||||
if usage is not None:
|
||||
chunk["usage"] = usage
|
||||
return chunk
|
||||
|
||||
@staticmethod
|
||||
def build_tool_call_delta(
|
||||
*, call_id: str, tool_name: str, arguments: str, index: int = 0
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"index": index,
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {"name": tool_name, "arguments": arguments},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _stream_chunks() -> list[StreamChunk]:
|
||||
return [
|
||||
StreamingMockServer.build_chunk(
|
||||
created=123,
|
||||
delta={"role": "assistant", "content": "Hello"},
|
||||
finish_reason=None,
|
||||
),
|
||||
StreamingMockServer.build_chunk(
|
||||
created=124, delta={"content": " from mock server"}, finish_reason=None
|
||||
),
|
||||
StreamingMockServer.build_chunk(
|
||||
created=125,
|
||||
delta={},
|
||||
finish_reason="stop",
|
||||
usage={"prompt_tokens": 3, "completion_tokens": 4},
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(self, *, chunk_factory: ChunkFactory | None = None) -> None:
|
||||
self.requests: list[ChatCompletionsRequestPayload] = []
|
||||
self._lock = threading.Lock()
|
||||
self._chunk_factory = chunk_factory
|
||||
self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._build_handler())
|
||||
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
||||
|
||||
def _build_handler(self) -> type[BaseHTTPRequestHandler]:
|
||||
parent = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
return
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if self.path != "/v1/chat/completions":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(length)
|
||||
payload = cast(
|
||||
ChatCompletionsRequestPayload, json.loads(body.decode("utf-8"))
|
||||
)
|
||||
|
||||
with parent._lock:
|
||||
parent.requests.append(payload)
|
||||
request_index = len(parent.requests) - 1
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
|
||||
chunks = (
|
||||
parent._chunk_factory(request_index, payload)
|
||||
if parent._chunk_factory is not None
|
||||
else parent._stream_chunks()
|
||||
)
|
||||
|
||||
for chunk in chunks:
|
||||
data = json.dumps(chunk, ensure_ascii=False)
|
||||
self.wfile.write(f"data: {data}\n\n".encode())
|
||||
self.wfile.flush()
|
||||
time.sleep(0.03)
|
||||
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
self.wfile.flush()
|
||||
|
||||
return Handler
|
||||
|
||||
@property
|
||||
def api_base(self) -> str:
|
||||
return f"http://127.0.0.1:{self._server.server_port}/v1"
|
||||
|
||||
def start(self) -> None:
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
self._thread.join(timeout=1)
|
||||
31
tests/e2e/test_cli_tui_onboarding.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pexpect
|
||||
import pytest
|
||||
|
||||
from tests.e2e.common import SpawnedVibeProcessFixture, ansi_tolerant_pattern
|
||||
|
||||
|
||||
@pytest.mark.timeout(15)
|
||||
def test_spawn_cli_shows_onboarding_when_api_key_missing(
|
||||
tmp_path: Path,
|
||||
e2e_workdir: Path,
|
||||
spawned_vibe_process: SpawnedVibeProcessFixture,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
vibe_home = tmp_path / "vibe-home-onboarding"
|
||||
vibe_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setenv("VIBE_HOME", str(vibe_home))
|
||||
monkeypatch.setenv("TERM", "xterm-256color")
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
|
||||
with spawned_vibe_process(e2e_workdir) as (child, captured):
|
||||
child.expect(ansi_tolerant_pattern("Welcome to Mistral Vibe"), timeout=15)
|
||||
child.sendcontrol("c")
|
||||
child.expect(pexpect.EOF, timeout=10)
|
||||
|
||||
output = captured.getvalue()
|
||||
assert "Setup cancelled" in output
|
||||
51
tests/e2e/test_cli_tui_streaming.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pexpect
|
||||
import pytest
|
||||
|
||||
from tests.e2e.common import (
|
||||
SpawnedVibeProcessFixture,
|
||||
ansi_tolerant_pattern,
|
||||
wait_for_main_screen,
|
||||
wait_for_request_count,
|
||||
)
|
||||
from tests.e2e.mock_server import StreamingMockServer
|
||||
|
||||
|
||||
@pytest.mark.timeout(15)
|
||||
def test_spawn_cli_to_send_and_receive_message(
|
||||
streaming_mock_server: StreamingMockServer,
|
||||
setup_e2e_env: None,
|
||||
e2e_workdir: Path,
|
||||
spawned_vibe_process: SpawnedVibeProcessFixture,
|
||||
) -> None:
|
||||
with spawned_vibe_process(e2e_workdir) as (child, captured):
|
||||
wait_for_main_screen(child, timeout=15)
|
||||
child.send("Greet")
|
||||
child.send("\r")
|
||||
|
||||
wait_for_request_count(
|
||||
lambda: len(streaming_mock_server.requests), expected_count=1, timeout=10
|
||||
)
|
||||
child.expect(ansi_tolerant_pattern("Hello from mock server"), timeout=10)
|
||||
|
||||
child.sendcontrol("c")
|
||||
child.expect(pexpect.EOF, timeout=10)
|
||||
|
||||
output = captured.getvalue()
|
||||
assert "Welcome to Mistral Vibe" not in output
|
||||
|
||||
request_payload = streaming_mock_server.requests[-1]
|
||||
assert request_payload.get("stream") is True
|
||||
assert request_payload.get("model") == "mock-model"
|
||||
stream_options = request_payload.get("stream_options")
|
||||
assert stream_options is not None
|
||||
assert stream_options.get("include_usage") is True
|
||||
messages = request_payload.get("messages")
|
||||
assert messages is not None
|
||||
assert any(
|
||||
message.get("role") == "user" and message.get("content") == "Greet"
|
||||
for message in messages
|
||||
)
|
||||
87
tests/e2e/test_cli_tui_tool_approval.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pexpect
|
||||
import pytest
|
||||
|
||||
from tests.e2e.common import (
|
||||
SpawnedVibeProcessFixture,
|
||||
wait_for_main_screen,
|
||||
wait_for_rendered_text,
|
||||
wait_for_request_count,
|
||||
)
|
||||
from tests.e2e.mock_server import ChatCompletionsRequestPayload, StreamingMockServer
|
||||
|
||||
PREDICTABLE_OUTPUT = "__E2E_BASH_OK__"
|
||||
TOOL_ARGUMENTS = f'{{"command":"printf \\"{PREDICTABLE_OUTPUT}\\\\n\\""}}'
|
||||
|
||||
|
||||
def _tool_call_factory(
|
||||
request_index: int, _payload: ChatCompletionsRequestPayload
|
||||
) -> list[dict[str, object]]:
|
||||
if request_index == 0:
|
||||
return [
|
||||
StreamingMockServer.build_chunk(
|
||||
created=1,
|
||||
delta=StreamingMockServer.build_tool_call_delta(
|
||||
call_id="call_bash_1", tool_name="bash", arguments=TOOL_ARGUMENTS
|
||||
),
|
||||
finish_reason=None,
|
||||
),
|
||||
StreamingMockServer.build_chunk(
|
||||
created=2,
|
||||
delta={},
|
||||
finish_reason="tool_calls",
|
||||
usage={"prompt_tokens": 3, "completion_tokens": 4},
|
||||
),
|
||||
]
|
||||
|
||||
return [
|
||||
StreamingMockServer.build_chunk(
|
||||
created=3,
|
||||
delta={
|
||||
"role": "assistant",
|
||||
"content": f"The string {PREDICTABLE_OUTPUT} has been printed successfully.",
|
||||
},
|
||||
finish_reason=None,
|
||||
),
|
||||
StreamingMockServer.build_chunk(
|
||||
created=4, delta={"content": PREDICTABLE_OUTPUT}, finish_reason=None
|
||||
),
|
||||
StreamingMockServer.build_chunk(
|
||||
created=5,
|
||||
delta={},
|
||||
finish_reason="stop",
|
||||
usage={"prompt_tokens": 3, "completion_tokens": 4},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.timeout(25)
|
||||
@pytest.mark.parametrize(
|
||||
"streaming_mock_server",
|
||||
[pytest.param(_tool_call_factory, id="tool-call-stream")],
|
||||
indirect=True,
|
||||
)
|
||||
def test_spawn_cli_asks_bash_permission_and_shows_tool_output_after_approval(
|
||||
streaming_mock_server: StreamingMockServer,
|
||||
setup_e2e_env: None,
|
||||
e2e_workdir: Path,
|
||||
spawned_vibe_process: SpawnedVibeProcessFixture,
|
||||
) -> None:
|
||||
with spawned_vibe_process(e2e_workdir) as (child, captured):
|
||||
wait_for_main_screen(child, timeout=15)
|
||||
child.send("Run a shell command")
|
||||
child.send("\r")
|
||||
|
||||
wait_for_request_count(
|
||||
lambda: len(streaming_mock_server.requests), expected_count=1, timeout=10
|
||||
)
|
||||
wait_for_rendered_text(child, captured, needle="bash command", timeout=10)
|
||||
child.send("y")
|
||||
child.send("\r")
|
||||
wait_for_rendered_text(child, captured, needle=PREDICTABLE_OUTPUT, timeout=10)
|
||||
|
||||
child.sendcontrol("c")
|
||||
child.expect(pexpect.EOF, timeout=10)
|
||||
|
|
@ -822,3 +822,133 @@ class TestSessionLoaderListSessions:
|
|||
assert len(result) == 1
|
||||
assert result[0]["session_id"] == "notitle0"
|
||||
assert result[0]["title"] is None
|
||||
|
||||
|
||||
class TestSessionLoaderGetFirstUserMessage:
|
||||
"""Tests for SessionLoader.get_first_user_message method."""
|
||||
|
||||
def test_returns_first_user_message(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test that get_first_user_message returns the first user message."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content="First user message"),
|
||||
LLMMessage(role=Role.assistant, content="First response"),
|
||||
LLMMessage(role=Role.user, content="Second user message"),
|
||||
LLMMessage(role=Role.assistant, content="Second response"),
|
||||
]
|
||||
create_test_session(session_dir, "test-sess", messages=messages)
|
||||
|
||||
result = SessionLoader.get_first_user_message("test-sess", session_config)
|
||||
|
||||
assert result == "First user message"
|
||||
|
||||
def test_returns_fallback_for_missing_session(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that get_first_user_message returns fallback when session not found."""
|
||||
result = SessionLoader.get_first_user_message("nonexistent", session_config)
|
||||
|
||||
assert result == "(session not found)"
|
||||
|
||||
def test_returns_no_user_messages_fallback(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test fallback when session has no user messages."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.assistant, content="Assistant only"),
|
||||
]
|
||||
create_test_session(session_dir, "no-user0", messages=messages)
|
||||
|
||||
result = SessionLoader.get_first_user_message("no-user0", session_config)
|
||||
|
||||
assert result == "(no user messages)"
|
||||
|
||||
def test_replaces_newlines_with_spaces(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test that newlines in messages are replaced with spaces."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content="Line one\nLine two\nLine three"),
|
||||
]
|
||||
create_test_session(session_dir, "newline0", messages=messages)
|
||||
|
||||
result = SessionLoader.get_first_user_message("newline0", session_config)
|
||||
|
||||
assert "\n" not in result
|
||||
assert "Line one Line two Line three" == result
|
||||
|
||||
def test_handles_empty_user_message(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test handling of empty user message content."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content=""),
|
||||
]
|
||||
create_test_session(session_dir, "empty-ms", messages=messages)
|
||||
|
||||
result = SessionLoader.get_first_user_message("empty-ms", session_config)
|
||||
|
||||
assert result == "(no user messages)"
|
||||
|
||||
def test_handles_whitespace_only_message(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test handling of whitespace-only user message."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content=" \n\t "),
|
||||
]
|
||||
create_test_session(session_dir, "whitespc", messages=messages)
|
||||
|
||||
result = SessionLoader.get_first_user_message("whitespc", session_config)
|
||||
|
||||
assert result == "(empty message)"
|
||||
|
||||
def test_handles_invalid_session_as_not_found(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that invalid sessions (bad JSON) are treated as not found.
|
||||
|
||||
Note: Sessions with invalid JSON are filtered out by _is_valid_session
|
||||
during find_session_by_id, so they return 'not found' rather than
|
||||
'corrupted'. This is the expected behavior.
|
||||
"""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
# Create a session with invalid JSON - will fail validation
|
||||
session_folder = session_dir / "test_20230101_120000_corrupt0"
|
||||
session_folder.mkdir()
|
||||
(session_folder / "messages.jsonl").write_text("{invalid json}")
|
||||
(session_folder / "meta.json").write_text('{"session_id": "corrupt0"}')
|
||||
|
||||
result = SessionLoader.get_first_user_message("corrupt0", session_config)
|
||||
|
||||
# Invalid sessions are filtered by _is_valid_session, so not found
|
||||
assert result == "(session not found)"
|
||||
|
||||
def test_skips_non_user_messages(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test that only user messages are considered, not assistant/system."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content="User question"),
|
||||
LLMMessage(role=Role.assistant, content="Assistant response"),
|
||||
]
|
||||
create_test_session(session_dir, "skip-non", messages=messages)
|
||||
|
||||
result = SessionLoader.get_first_user_message("skip-non", session_config)
|
||||
|
||||
# Should return "User question", not "Assistant response"
|
||||
assert result == "User question"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -502,6 +502,61 @@ class TestSessionLoggerSaveInteraction:
|
|||
with open(messages_file) as f:
|
||||
assert len(f.readlines()) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_interaction_throttles_tmp_cleanup(
|
||||
self,
|
||||
session_config: SessionLoggingConfig,
|
||||
mock_vibe_config: VibeConfig,
|
||||
mock_tool_manager: ToolManager,
|
||||
mock_agent_profile: AgentProfile,
|
||||
) -> None:
|
||||
logger = SessionLogger(session_config, "test-session-123")
|
||||
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content="Hello"),
|
||||
LLMMessage(role=Role.assistant, content="Hi there!"),
|
||||
]
|
||||
|
||||
cleanup_spy = MagicMock()
|
||||
with (
|
||||
patch.object(
|
||||
SessionLogger, "persist_messages", new_callable=AsyncMock
|
||||
) as persist_messages_mock,
|
||||
patch.object(
|
||||
SessionLogger, "persist_metadata", new_callable=AsyncMock
|
||||
) as persist_metadata_mock,
|
||||
patch.object(logger, "cleanup_tmp_files", cleanup_spy),
|
||||
patch(
|
||||
"vibe.core.session.session_logger.utc_now",
|
||||
# a bit brittle, but required for the call-count choregraphy...
|
||||
side_effect=[
|
||||
datetime(2026, 1, 1, 10, 0, 0, tzinfo=UTC),
|
||||
datetime(2026, 1, 1, 10, 0, 0, tzinfo=UTC),
|
||||
datetime(2026, 1, 1, 10, 0, 1, tzinfo=UTC),
|
||||
datetime(2026, 1, 1, 10, 0, 1, tzinfo=UTC),
|
||||
],
|
||||
),
|
||||
):
|
||||
await logger.save_interaction(
|
||||
messages=messages,
|
||||
stats=AgentStats(steps=1),
|
||||
base_config=mock_vibe_config,
|
||||
tool_manager=mock_tool_manager,
|
||||
agent_profile=mock_agent_profile,
|
||||
)
|
||||
await logger.save_interaction(
|
||||
messages=messages,
|
||||
stats=AgentStats(steps=2),
|
||||
base_config=mock_vibe_config,
|
||||
tool_manager=mock_tool_manager,
|
||||
agent_profile=mock_agent_profile,
|
||||
)
|
||||
|
||||
assert persist_messages_mock.await_count == 2
|
||||
assert persist_metadata_mock.await_count == 2
|
||||
assert cleanup_spy.call_count == 1
|
||||
|
||||
|
||||
class TestSessionLoggerResetSession:
|
||||
def test_reset_session(self, session_config: SessionLoggingConfig) -> None:
|
||||
|
|
@ -701,3 +756,27 @@ class TestSessionLoggerCleanupTmpFiles:
|
|||
|
||||
assert old_tmp_file.exists()
|
||||
assert not another_old_tmp_file.exists()
|
||||
|
||||
def test_maybe_cleanup_tmp_files_throttles_calls(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
cleanup_spy = MagicMock()
|
||||
with (
|
||||
patch.object(logger, "cleanup_tmp_files", cleanup_spy),
|
||||
patch(
|
||||
"vibe.core.session.session_logger.utc_now",
|
||||
side_effect=[
|
||||
datetime(2026, 1, 1, 10, 0, 0, tzinfo=UTC),
|
||||
datetime(2026, 1, 1, 10, 0, 1, tzinfo=UTC),
|
||||
datetime(2026, 1, 1, 10, 0, 6, tzinfo=UTC),
|
||||
],
|
||||
),
|
||||
):
|
||||
logger.maybe_cleanup_tmp_files()
|
||||
logger.maybe_cleanup_tmp_files()
|
||||
logger.maybe_cleanup_tmp_files()
|
||||
|
||||
assert cleanup_spy.call_count == 2
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
<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: #ff8205 }
|
||||
.terminal-r5 { fill: #98a84b }
|
||||
.terminal-r6 { fill: #9a9b99;font-style: italic; }
|
||||
.terminal-r7 { fill: #9a9b99 }
|
||||
</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">SnapshotTestAppEmptyAssistantThenReasoning</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="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="0" y="459.2" textLength="134.2" clip-path="url(#terminal-line-18)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="459.2" textLength="146.4" clip-path="url(#terminal-line-18)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="459.2" textLength="122" clip-path="url(#terminal-line-18)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="459.2" textLength="183" clip-path="url(#terminal-line-18)">devstral-latest</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="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="483.6" textLength="414.8" clip-path="url(#terminal-line-19)">1 model · 0 MCP servers · 0 skills</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="134.2" clip-path="url(#terminal-line-20)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="508" textLength="61" clip-path="url(#terminal-line-20)">Type </text><text class="terminal-r3" x="231.8" y="508" textLength="61" clip-path="url(#terminal-line-20)">/help</text><text class="terminal-r1" x="292.8" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> for more information</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="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)">What is the 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="207.4" clip-path="url(#terminal-line-27)">The answer is 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
|
||||
</text><text class="terminal-r7" 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)">></text><text class="terminal-r7" 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-r7" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r7" 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-r7" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r7" 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-r7" 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-r7" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r7" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -0,0 +1,114 @@
|
|||
<svg class="rich-terminal" viewBox="0 0 994 416.0" 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: #d9d9d9 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="365.0" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="414" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">ParallelToolCallsApp</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)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="50.3" width="268.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="292.8" y="50.3" width="683.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="268.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="292.8" y="74.7" width="683.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="99.1" width="268.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="292.8" y="99.1" width="683.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r2" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">■</text><text class="terminal-r2" x="24.4" y="68.8" textLength="268.4" clip-path="url(#terminal-line-2)">Reading /src/file_0.py</text><text class="terminal-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r2" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">■</text><text class="terminal-r2" x="24.4" y="93.2" textLength="268.4" clip-path="url(#terminal-line-3)">Reading /src/file_1.py</text><text class="terminal-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r2" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">■</text><text class="terminal-r2" x="24.4" y="117.6" textLength="268.4" clip-path="url(#terminal-line-4)">Reading /src/file_2.py</text><text class="terminal-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r1" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -33,13 +33,12 @@
|
|||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #4b4e55 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #292929 }
|
||||
.terminal-r5 { fill: #9a9b99 }
|
||||
.terminal-r6 { fill: #608ab1;font-weight: bold }
|
||||
.terminal-r4 { fill: #9a9b99 }
|
||||
.terminal-r5 { fill: #608ab1;font-weight: bold }
|
||||
.terminal-r6 { fill: #949798 }
|
||||
.terminal-r7 { fill: #868887 }
|
||||
.terminal-r8 { fill: #949798 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
|
|
@ -161,44 +160,44 @@
|
|||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
<rect fill="#4b4e55" x="1207.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1207.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1207.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="48.8" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/>
|
||||
<rect fill="#4b4e55" x="48.8" y="538.3" width="12.2" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="20" textLength="414.8" clip-path="url(#terminal-line-0)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r2" x="1207.8" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">▂</text><text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">Type </text><text class="terminal-r3" x="231.8" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">/help</text><text class="terminal-r1" x="292.8" y="44.4" textLength="256.2" clip-path="url(#terminal-line-1)"> for more information</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r5" x="24.4" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">⎣</text><text class="terminal-r1" x="48.8" y="93.2" textLength="256.2" clip-path="url(#terminal-line-3)">Proxy setup opened...</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r5" x="0" y="166.4" textLength="1220" clip-path="url(#terminal-line-6)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r5" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r6" x="24.4" y="190.8" textLength="231.8" clip-path="url(#terminal-line-7)">Proxy Configuration</text><text class="terminal-r5" x="1207.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r5" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r5" x="1207.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r5" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r6" x="24.4" y="239.6" textLength="122" clip-path="url(#terminal-line-9)">HTTP_PROXY</text><text class="terminal-r7" x="170.8" y="239.6" textLength="329.4" clip-path="url(#terminal-line-9)">Proxy URL for HTTP requests</text><text class="terminal-r5" x="1207.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r5" x="0" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">│</text><text class="terminal-r5" x="1207.8" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">│</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r5" x="0" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">│</text><text class="terminal-r5" x="24.4" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">▎</text><text class="terminal-r8" x="48.8" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">N</text><text class="terminal-r7" x="61" y="288.4" textLength="73.2" clip-path="url(#terminal-line-11)">OT SET</text><text class="terminal-r5" x="1207.8" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">│</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r5" x="0" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">│</text><text class="terminal-r5" x="1207.8" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">│</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r5" x="0" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">│</text><text class="terminal-r6" x="24.4" y="337.2" textLength="134.2" clip-path="url(#terminal-line-13)">HTTPS_PROXY</text><text class="terminal-r7" x="183" y="337.2" textLength="341.6" clip-path="url(#terminal-line-13)">Proxy URL for HTTPS requests</text><text class="terminal-r5" x="1207.8" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">│</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r5" x="0" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">│</text><text class="terminal-r5" x="1207.8" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">│</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r5" x="0" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">│</text><text class="terminal-r5" x="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">▎</text><text class="terminal-r7" x="48.8" y="386" textLength="85.4" clip-path="url(#terminal-line-15)">NOT SET</text><text class="terminal-r5" x="1207.8" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">│</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r5" x="0" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">│</text><text class="terminal-r5" x="1207.8" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">│</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r5" 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="109.8" clip-path="url(#terminal-line-17)">ALL_PROXY</text><text class="terminal-r7" x="158.6" y="434.8" textLength="451.4" clip-path="url(#terminal-line-17)">Proxy URL for all requests (fallback)</text><text class="terminal-r5" x="1207.8" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">│</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r5" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">│</text><text class="terminal-r5" x="1207.8" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">│</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r5" x="0" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">│</text><text class="terminal-r5" x="24.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">▎</text><text class="terminal-r7" x="48.8" y="483.6" textLength="85.4" clip-path="url(#terminal-line-19)">NOT SET</text><text class="terminal-r5" x="1207.8" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">│</text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r5" x="0" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">│</text><text class="terminal-r5" x="1207.8" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">│</text><text class="terminal-r1" x="1220" 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="97.6" clip-path="url(#terminal-line-21)">NO_PROXY</text><text class="terminal-r7" x="146.4" y="532.4" textLength="549" clip-path="url(#terminal-line-21)">Comma-separated list of hosts to bypass proxy</text><text class="terminal-r5" x="1207.8" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">│</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r5" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">│</text><text class="terminal-r5" x="1207.8" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">│</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r5" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">│</text><text class="terminal-r5" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">▎</text><text class="terminal-r7" x="48.8" y="581.2" textLength="85.4" clip-path="url(#terminal-line-23)">NOT SET</text><text class="terminal-r5" x="1207.8" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">│</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r5" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">│</text><text class="terminal-r5" x="1207.8" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">│</text><text class="terminal-r1" x="1220" 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="158.6" clip-path="url(#terminal-line-25)">SSL_CERT_FILE</text><text class="terminal-r7" x="207.4" y="630" textLength="427" clip-path="url(#terminal-line-25)">Path to custom SSL certificate file</text><text class="terminal-r5" x="1207.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">│</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r5" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">│</text><text class="terminal-r5" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">│</text><text class="terminal-r1" x="1220" 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-r5" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">▎</text><text class="terminal-r7" x="48.8" y="678.8" textLength="85.4" clip-path="url(#terminal-line-27)">NOT SET</text><text class="terminal-r5" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r5" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r5" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r1" x="1220" 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-r6" x="24.4" y="727.6" textLength="146.4" clip-path="url(#terminal-line-29)">SSL_CERT_DIR</text><text class="terminal-r7" x="195.2" y="727.6" textLength="549" clip-path="url(#terminal-line-29)">Path to directory containing SSL certificates</text><text class="terminal-r5" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r5" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r5" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r1" x="1220" 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-r5" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">▎</text><text class="terminal-r7" x="48.8" y="776.4" textLength="85.4" clip-path="url(#terminal-line-31)">NOT SET</text><text class="terminal-r5" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1220" 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="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1220" 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="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓ navigate  Enter save & exit  ESC cancel</text><text class="terminal-r5" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r5" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" 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="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="0" y="312.8" textLength="134.2" clip-path="url(#terminal-line-12)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="312.8" textLength="146.4" clip-path="url(#terminal-line-12)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="312.8" textLength="122" clip-path="url(#terminal-line-12)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="312.8" textLength="183" clip-path="url(#terminal-line-12)">devstral-latest</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r1" x="0" y="337.2" textLength="134.2" clip-path="url(#terminal-line-13)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="337.2" textLength="414.8" clip-path="url(#terminal-line-13)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r1" x="0" y="361.6" textLength="134.2" clip-path="url(#terminal-line-14)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="361.6" textLength="61" clip-path="url(#terminal-line-14)">Type </text><text class="terminal-r3" x="231.8" y="361.6" textLength="61" clip-path="url(#terminal-line-14)">/help</text><text class="terminal-r1" x="292.8" y="361.6" textLength="256.2" clip-path="url(#terminal-line-14)"> for more information</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r4" x="24.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">⎣</text><text class="terminal-r1" x="48.8" y="410.4" textLength="256.2" clip-path="url(#terminal-line-16)">Proxy setup opened...</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r4" x="0" y="483.6" textLength="1220" clip-path="url(#terminal-line-19)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" 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-r5" x="24.4" y="508" textLength="231.8" clip-path="url(#terminal-line-20)">Proxy Configuration</text><text class="terminal-r4" x="1207.8" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">│</text><text class="terminal-r1" x="1220" 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-r5" x="24.4" y="532.4" textLength="122" clip-path="url(#terminal-line-21)">HTTP_PROXY</text><text class="terminal-r4" x="1207.8" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">│</text><text class="terminal-r1" x="1220" 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="24.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">▎</text><text class="terminal-r6" x="48.8" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">P</text><text class="terminal-r7" x="61" y="556.8" textLength="317.2" clip-path="url(#terminal-line-22)">roxy URL for HTTP requests</text><text class="terminal-r4" x="1207.8" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">│</text><text class="terminal-r1" x="1220" 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-r5" x="24.4" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">HTTPS_PROXY</text><text class="terminal-r4" x="1207.8" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">│</text><text class="terminal-r1" x="1220" 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="24.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">▎</text><text class="terminal-r7" x="48.8" y="605.6" textLength="341.6" clip-path="url(#terminal-line-24)">Proxy URL for HTTPS requests</text><text class="terminal-r4" x="1207.8" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">│</text><text class="terminal-r1" x="1220" 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-r5" x="24.4" y="630" textLength="109.8" clip-path="url(#terminal-line-25)">ALL_PROXY</text><text class="terminal-r4" x="1207.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">│</text><text class="terminal-r1" x="1220" 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-r4" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">▎</text><text class="terminal-r7" x="48.8" y="654.4" textLength="451.4" clip-path="url(#terminal-line-26)">Proxy URL for all requests (fallback)</text><text class="terminal-r4" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">│</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r4" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r5" x="24.4" y="678.8" textLength="97.6" clip-path="url(#terminal-line-27)">NO_PROXY</text><text class="terminal-r4" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r4" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r4" x="24.4" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">▎</text><text class="terminal-r7" x="48.8" y="703.2" textLength="549" clip-path="url(#terminal-line-28)">Comma-separated list of hosts to bypass proxy</text><text class="terminal-r4" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r4" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r5" x="24.4" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">SSL_CERT_FILE</text><text class="terminal-r4" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r4" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r4" x="24.4" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">▎</text><text class="terminal-r7" x="48.8" y="752" textLength="427" clip-path="url(#terminal-line-30)">Path to custom SSL certificate file</text><text class="terminal-r4" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r1" x="1220" 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-r5" x="24.4" y="776.4" textLength="146.4" clip-path="url(#terminal-line-31)">SSL_CERT_DIR</text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1220" 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="24.4" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">▎</text><text class="terminal-r7" x="48.8" y="800.8" textLength="549" clip-path="url(#terminal-line-32)">Path to directory containing SSL certificates</text><text class="terminal-r4" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1220" 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="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓ navigate  Enter save & exit  ESC cancel</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" 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="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 18 KiB |
|
|
@ -33,12 +33,11 @@
|
|||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #4b4e55 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #292929 }
|
||||
.terminal-r5 { fill: #9a9b99 }
|
||||
.terminal-r6 { fill: #608ab1;font-weight: bold }
|
||||
.terminal-r7 { fill: #868887 }
|
||||
.terminal-r4 { fill: #9a9b99 }
|
||||
.terminal-r5 { fill: #608ab1;font-weight: bold }
|
||||
.terminal-r6 { fill: #868887 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
|
|
@ -160,44 +159,44 @@
|
|||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
<rect fill="#4b4e55" x="1207.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1207.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1207.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="48.8" y="269.9" width="256.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="305" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/>
|
||||
<rect fill="#608ab1" x="48.8" y="538.3" width="256.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="305" y="538.3" width="12.2" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="20" textLength="414.8" clip-path="url(#terminal-line-0)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r2" x="1207.8" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">▂</text><text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">Type </text><text class="terminal-r3" x="231.8" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">/help</text><text class="terminal-r1" x="292.8" y="44.4" textLength="256.2" clip-path="url(#terminal-line-1)"> for more information</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r5" x="24.4" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">⎣</text><text class="terminal-r1" x="48.8" y="93.2" textLength="256.2" clip-path="url(#terminal-line-3)">Proxy setup opened...</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r5" x="0" y="166.4" textLength="1220" clip-path="url(#terminal-line-6)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r5" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r6" x="24.4" y="190.8" textLength="231.8" clip-path="url(#terminal-line-7)">Proxy Configuration</text><text class="terminal-r5" x="1207.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">│</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r5" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r5" x="1207.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">│</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r5" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r6" x="24.4" y="239.6" textLength="122" clip-path="url(#terminal-line-9)">HTTP_PROXY</text><text class="terminal-r7" x="170.8" y="239.6" textLength="329.4" clip-path="url(#terminal-line-9)">Proxy URL for HTTP requests</text><text class="terminal-r5" x="1207.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r5" x="0" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">│</text><text class="terminal-r5" x="1207.8" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">│</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r5" x="0" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">│</text><text class="terminal-r5" x="24.4" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">▎</text><text class="terminal-r1" x="48.8" y="288.4" textLength="256.2" clip-path="url(#terminal-line-11)">http://old-proxy:8080</text><text class="terminal-r5" x="1207.8" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">│</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r5" x="0" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">│</text><text class="terminal-r5" x="1207.8" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">│</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r5" x="0" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">│</text><text class="terminal-r6" x="24.4" y="337.2" textLength="134.2" clip-path="url(#terminal-line-13)">HTTPS_PROXY</text><text class="terminal-r7" x="183" y="337.2" textLength="341.6" clip-path="url(#terminal-line-13)">Proxy URL for HTTPS requests</text><text class="terminal-r5" x="1207.8" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">│</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r5" x="0" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">│</text><text class="terminal-r5" x="1207.8" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">│</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r5" x="0" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">│</text><text class="terminal-r5" x="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">▎</text><text class="terminal-r1" x="48.8" y="386" textLength="1146.8" clip-path="url(#terminal-line-15)">https://old-proxy:8443                                                                        </text><text class="terminal-r5" x="1207.8" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">│</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r5" x="0" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">│</text><text class="terminal-r5" x="1207.8" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">│</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r5" 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="109.8" clip-path="url(#terminal-line-17)">ALL_PROXY</text><text class="terminal-r7" x="158.6" y="434.8" textLength="451.4" clip-path="url(#terminal-line-17)">Proxy URL for all requests (fallback)</text><text class="terminal-r5" x="1207.8" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">│</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r5" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">│</text><text class="terminal-r5" x="1207.8" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">│</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r5" x="0" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">│</text><text class="terminal-r5" x="24.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">▎</text><text class="terminal-r7" x="48.8" y="483.6" textLength="85.4" clip-path="url(#terminal-line-19)">NOT SET</text><text class="terminal-r5" x="1207.8" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">│</text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r5" x="0" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">│</text><text class="terminal-r5" x="1207.8" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">│</text><text class="terminal-r1" x="1220" 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="97.6" clip-path="url(#terminal-line-21)">NO_PROXY</text><text class="terminal-r7" x="146.4" y="532.4" textLength="549" clip-path="url(#terminal-line-21)">Comma-separated list of hosts to bypass proxy</text><text class="terminal-r5" x="1207.8" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">│</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r5" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">│</text><text class="terminal-r5" x="1207.8" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">│</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r5" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">│</text><text class="terminal-r5" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">▎</text><text class="terminal-r7" x="48.8" y="581.2" textLength="85.4" clip-path="url(#terminal-line-23)">NOT SET</text><text class="terminal-r5" x="1207.8" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">│</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r5" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">│</text><text class="terminal-r5" x="1207.8" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">│</text><text class="terminal-r1" x="1220" 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="158.6" clip-path="url(#terminal-line-25)">SSL_CERT_FILE</text><text class="terminal-r7" x="207.4" y="630" textLength="427" clip-path="url(#terminal-line-25)">Path to custom SSL certificate file</text><text class="terminal-r5" x="1207.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">│</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r5" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">│</text><text class="terminal-r5" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">│</text><text class="terminal-r1" x="1220" 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-r5" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">▎</text><text class="terminal-r7" x="48.8" y="678.8" textLength="85.4" clip-path="url(#terminal-line-27)">NOT SET</text><text class="terminal-r5" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r5" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r5" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r1" x="1220" 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-r6" x="24.4" y="727.6" textLength="146.4" clip-path="url(#terminal-line-29)">SSL_CERT_DIR</text><text class="terminal-r7" x="195.2" y="727.6" textLength="549" clip-path="url(#terminal-line-29)">Path to directory containing SSL certificates</text><text class="terminal-r5" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r5" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r5" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r1" x="1220" 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-r5" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">▎</text><text class="terminal-r7" x="48.8" y="776.4" textLength="85.4" clip-path="url(#terminal-line-31)">NOT SET</text><text class="terminal-r5" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1220" 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="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1220" 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="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓ navigate  Enter save & exit  ESC cancel</text><text class="terminal-r5" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r5" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" 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="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="0" y="312.8" textLength="134.2" clip-path="url(#terminal-line-12)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="312.8" textLength="146.4" clip-path="url(#terminal-line-12)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="312.8" textLength="122" clip-path="url(#terminal-line-12)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="312.8" textLength="183" clip-path="url(#terminal-line-12)">devstral-latest</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r1" x="0" y="337.2" textLength="134.2" clip-path="url(#terminal-line-13)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="337.2" textLength="414.8" clip-path="url(#terminal-line-13)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r1" x="0" y="361.6" textLength="134.2" clip-path="url(#terminal-line-14)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="361.6" textLength="61" clip-path="url(#terminal-line-14)">Type </text><text class="terminal-r3" x="231.8" y="361.6" textLength="61" clip-path="url(#terminal-line-14)">/help</text><text class="terminal-r1" x="292.8" y="361.6" textLength="256.2" clip-path="url(#terminal-line-14)"> for more information</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r4" x="24.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">⎣</text><text class="terminal-r1" x="48.8" y="410.4" textLength="256.2" clip-path="url(#terminal-line-16)">Proxy setup opened...</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r4" x="0" y="483.6" textLength="1220" clip-path="url(#terminal-line-19)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" 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-r5" x="24.4" y="508" textLength="231.8" clip-path="url(#terminal-line-20)">Proxy Configuration</text><text class="terminal-r4" x="1207.8" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">│</text><text class="terminal-r1" x="1220" 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-r5" x="24.4" y="532.4" textLength="122" clip-path="url(#terminal-line-21)">HTTP_PROXY</text><text class="terminal-r4" x="1207.8" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">│</text><text class="terminal-r1" x="1220" 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="24.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">▎</text><text class="terminal-r1" x="48.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)">http://old-proxy:8080</text><text class="terminal-r4" x="1207.8" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">│</text><text class="terminal-r1" x="1220" 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-r5" x="24.4" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">HTTPS_PROXY</text><text class="terminal-r4" x="1207.8" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">│</text><text class="terminal-r1" x="1220" 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="24.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">▎</text><text class="terminal-r1" x="48.8" y="605.6" textLength="1146.8" clip-path="url(#terminal-line-24)">https://old-proxy:8443                                                                        </text><text class="terminal-r4" x="1207.8" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">│</text><text class="terminal-r1" x="1220" 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-r5" x="24.4" y="630" textLength="109.8" clip-path="url(#terminal-line-25)">ALL_PROXY</text><text class="terminal-r4" x="1207.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">│</text><text class="terminal-r1" x="1220" 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-r4" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">▎</text><text class="terminal-r6" x="48.8" y="654.4" textLength="451.4" clip-path="url(#terminal-line-26)">Proxy URL for all requests (fallback)</text><text class="terminal-r4" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">│</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r4" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r5" x="24.4" y="678.8" textLength="97.6" clip-path="url(#terminal-line-27)">NO_PROXY</text><text class="terminal-r4" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r4" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r4" x="24.4" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">▎</text><text class="terminal-r6" x="48.8" y="703.2" textLength="549" clip-path="url(#terminal-line-28)">Comma-separated list of hosts to bypass proxy</text><text class="terminal-r4" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r4" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r5" x="24.4" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">SSL_CERT_FILE</text><text class="terminal-r4" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r4" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r4" x="24.4" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">▎</text><text class="terminal-r6" x="48.8" y="752" textLength="427" clip-path="url(#terminal-line-30)">Path to custom SSL certificate file</text><text class="terminal-r4" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r1" x="1220" 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-r5" x="24.4" y="776.4" textLength="146.4" clip-path="url(#terminal-line-31)">SSL_CERT_DIR</text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1220" 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="24.4" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">▎</text><text class="terminal-r6" x="48.8" y="800.8" textLength="549" clip-path="url(#terminal-line-32)">Path to directory containing SSL certificates</text><text class="terminal-r4" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1220" 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="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓ navigate  Enter save & exit  ESC cancel</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" 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="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 18 KiB |
|
|
@ -0,0 +1,94 @@
|
|||
<svg class="rich-terminal" viewBox="0 0 994 294.0" 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: #d9d9d9 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="243.0" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="292" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">ToolCallStreamingUpdateTest</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)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="109.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="134.2" y="25.9" width="841.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r2" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">■</text><text class="terminal-r2" x="24.4" y="44.4" textLength="109.8" clip-path="url(#terminal-line-1)">read_file</text><text class="terminal-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
|
|
@ -0,0 +1,94 @@
|
|||
<svg class="rich-terminal" viewBox="0 0 994 294.0" 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: #d9d9d9 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="243.0" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="292" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">ToolCallStreamingUpdateTest</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)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="25.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r2" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">■</text><text class="terminal-r2" x="24.4" y="44.4" textLength="292.8" clip-path="url(#terminal-line-1)">Reading /test/example.py</text><text class="terminal-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
|
|
@ -0,0 +1,58 @@
|
|||
"""Snapshot tests for empty assistant message removed when reasoning starts (e.g. Opus)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.conftest import build_test_agent_loop
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
|
||||
|
||||
class SnapshotTestAppEmptyAssistantThenReasoning(BaseSnapshotTestApp):
|
||||
"""Backend stream: first chunk is assistant content only (empty), then reasoning.
|
||||
|
||||
Ensures the empty assistant bubble is removed when the first reasoning chunk
|
||||
arrives, so the UI does not show a blank assistant message above the thinking block.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
config = default_config()
|
||||
fake_backend = FakeBackend(
|
||||
chunks=[
|
||||
mock_llm_chunk(content="\n\n"),
|
||||
mock_llm_chunk(
|
||||
content="", reasoning_content="Let me think about this..."
|
||||
),
|
||||
mock_llm_chunk(
|
||||
content="", reasoning_content=" Considering the options."
|
||||
),
|
||||
mock_llm_chunk(content="The answer is 42."),
|
||||
]
|
||||
)
|
||||
super().__init__(config=config)
|
||||
self.agent_loop = build_test_agent_loop(
|
||||
config=config,
|
||||
agent_name=self._current_agent_name,
|
||||
enable_streaming=True,
|
||||
backend=fake_backend,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_empty_assistant_removed_when_reasoning_starts(
|
||||
snap_compare: SnapCompare,
|
||||
) -> None:
|
||||
"""Empty assistant message is removed when reasoning starts; no blank bubble above thinking."""
|
||||
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.press(*"What is the answer?")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.5)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_empty_assistant_before_reasoning.py:SnapshotTestAppEmptyAssistantThenReasoning",
|
||||
terminal_size=(120, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
|
@ -24,6 +24,7 @@ def test_snapshot_cycle_to_plan_mode(snap_compare: SnapCompare) -> None:
|
|||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("shift+tab") # default -> plan
|
||||
await pilot.app.workers.wait_for_complete()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
|
|
@ -40,6 +41,7 @@ def test_snapshot_cycle_to_accept_edits_mode(snap_compare: SnapCompare) -> None:
|
|||
await pilot.pause(0.1)
|
||||
await pilot.press("shift+tab") # default -> plan
|
||||
await pilot.press("shift+tab") # plan -> accept edits
|
||||
await pilot.app.workers.wait_for_complete()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
|
|
@ -57,6 +59,7 @@ def test_snapshot_cycle_to_auto_approve_mode(snap_compare: SnapCompare) -> None:
|
|||
await pilot.press("shift+tab") # default -> plan
|
||||
await pilot.press("shift+tab") # plan -> accept edits
|
||||
await pilot.press("shift+tab") # accept edits -> auto approve
|
||||
await pilot.app.workers.wait_for_complete()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
|
|
@ -75,6 +78,7 @@ def test_snapshot_cycle_wraps_to_default(snap_compare: SnapCompare) -> None:
|
|||
await pilot.press("shift+tab") # plan -> accept edits
|
||||
await pilot.press("shift+tab") # accept edits -> auto approve
|
||||
await pilot.press("shift+tab") # auto approve -> default (wrap)
|
||||
await pilot.app.workers.wait_for_complete()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
|
|
|
|||
114
tests/snapshots/test_ui_snapshot_parallel_tool_calls.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.pilot import Pilot
|
||||
from textual.widget import Widget
|
||||
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage
|
||||
from vibe.core.tools.builtins.read_file import ReadFile, ReadFileArgs, ReadFileResult
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class ParallelToolCallsApp(App):
|
||||
CSS_PATH = "../../vibe/cli/textual_ui/app.tcss"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._scroll: VerticalScroll | None = None
|
||||
self._handler: EventHandler | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
self._scroll = VerticalScroll(id="messages")
|
||||
yield self._scroll
|
||||
|
||||
def on_mount(self) -> None:
|
||||
async def mount_callback(
|
||||
widget: Widget, *, after: Widget | None = None
|
||||
) -> None:
|
||||
if self._scroll is None:
|
||||
return
|
||||
if after is not None and after.parent is self._scroll:
|
||||
await self._scroll.mount(widget, after=after)
|
||||
else:
|
||||
await self._scroll.mount(widget)
|
||||
|
||||
self._handler = EventHandler(
|
||||
mount_callback=mount_callback, get_tools_collapsed=lambda: False
|
||||
)
|
||||
|
||||
async def emit_all_tool_calls(self) -> None:
|
||||
if self._handler is None:
|
||||
return
|
||||
for i in range(3):
|
||||
await self._handler.handle_event(
|
||||
ToolCallEvent(
|
||||
tool_call_id=f"tc_{i}",
|
||||
tool_call_index=i,
|
||||
tool_name="read_file",
|
||||
tool_class=ReadFile,
|
||||
args=ReadFileArgs(path=f"/src/file_{i}.py"),
|
||||
)
|
||||
)
|
||||
|
||||
def freeze_spinners(self) -> None:
|
||||
for widget in self.query(ToolCallMessage):
|
||||
widget._is_spinning = False
|
||||
if widget._spinner_timer:
|
||||
widget._spinner_timer.stop()
|
||||
widget._spinner_timer = None
|
||||
widget._spinner.reset()
|
||||
if widget._indicator_widget:
|
||||
widget._indicator_widget.update(widget._spinner.current_frame())
|
||||
|
||||
async def resolve_all_results(self) -> None:
|
||||
if self._handler is None:
|
||||
return
|
||||
for i in range(3):
|
||||
await self._handler.handle_event(
|
||||
ToolResultEvent(
|
||||
tool_name="read_file",
|
||||
tool_class=ReadFile,
|
||||
result=ReadFileResult(
|
||||
path=f"/src/file_{i}.py",
|
||||
content=f"# content of file_{i}.py",
|
||||
lines_read=1,
|
||||
was_truncated=False,
|
||||
),
|
||||
tool_call_id=f"tc_{i}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_parallel_tool_calls_pending(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
app = cast(ParallelToolCallsApp, pilot.app)
|
||||
await app.emit_all_tool_calls()
|
||||
await pilot.pause(0.3)
|
||||
app.freeze_spinners()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_parallel_tool_calls.py:ParallelToolCallsApp",
|
||||
terminal_size=(80, 15),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_parallel_tool_calls_resolved(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
app = cast(ParallelToolCallsApp, pilot.app)
|
||||
await app.emit_all_tool_calls()
|
||||
await pilot.pause(0.3)
|
||||
await app.resolve_all_results()
|
||||
await pilot.pause(0.3)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_parallel_tool_calls.py:ParallelToolCallsApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
75
tests/snapshots/test_ui_snapshot_streaming_tool_call.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
from unittest.mock import patch
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage
|
||||
from vibe.core.tools.builtins.read_file import ReadFile, ReadFileArgs
|
||||
from vibe.core.types import ToolCallEvent
|
||||
|
||||
|
||||
class ToolCallStreamingUpdateTest(App):
|
||||
CSS_PATH = "../../vibe/cli/textual_ui/app.tcss"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._widget: ToolCallMessage | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
partial_event = ToolCallEvent(
|
||||
tool_call_id="tc_streaming",
|
||||
tool_call_index=0,
|
||||
tool_name="read_file",
|
||||
tool_class=ReadFile,
|
||||
args=None,
|
||||
)
|
||||
self._widget = ToolCallMessage(partial_event)
|
||||
|
||||
with VerticalScroll():
|
||||
yield self._widget
|
||||
|
||||
def update_with_full_event(self) -> None:
|
||||
if self._widget is None:
|
||||
return
|
||||
|
||||
full_event = ToolCallEvent(
|
||||
tool_call_id="tc_streaming",
|
||||
tool_call_index=0,
|
||||
tool_name="read_file",
|
||||
tool_class=ReadFile,
|
||||
args=ReadFileArgs(path="/test/example.py"),
|
||||
)
|
||||
self._widget.update_event(full_event)
|
||||
|
||||
|
||||
def test_snapshot_tool_call_partial(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
with patch.object(StatusMessage, "start_spinner_timer"):
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_streaming_tool_call.py:ToolCallStreamingUpdateTest",
|
||||
terminal_size=(80, 10),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_tool_call_updated(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
app = cast(ToolCallStreamingUpdateTest, pilot.app)
|
||||
app.update_with_full_event()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
with patch.object(StatusMessage, "start_spinner_timer"):
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_streaming_tool_call.py:ToolCallStreamingUpdateTest",
|
||||
terminal_size=(80, 10),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable, Iterable
|
||||
from collections.abc import AsyncGenerator, Callable, Iterable, Sequence
|
||||
from typing import cast
|
||||
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
|
|
@ -21,7 +21,7 @@ class FakeBackend:
|
|||
| Iterable[Iterable[LLMChunk]]
|
||||
| None = None,
|
||||
*,
|
||||
token_counter: Callable[[list[LLMMessage]], int] | None = None,
|
||||
token_counter: Callable[[Sequence[LLMMessage]], int] | None = None,
|
||||
exception_to_raise: Exception | None = None,
|
||||
) -> None:
|
||||
"""Fake backend that will output the given chunks in the order they are given.
|
||||
|
|
@ -35,6 +35,7 @@ class FakeBackend:
|
|||
"""
|
||||
self._requests_messages: list[list[LLMMessage]] = []
|
||||
self._requests_extra_headers: list[dict[str, str] | None] = []
|
||||
self._requests_metadata: list[dict[str, str] | None] = []
|
||||
self._count_tokens_calls: list[list[LLMMessage]] = []
|
||||
self._token_counter = token_counter or self._default_token_counter
|
||||
self._exception_to_raise = exception_to_raise
|
||||
|
|
@ -65,8 +66,12 @@ class FakeBackend:
|
|||
def requests_extra_headers(self) -> list[dict[str, str] | None]:
|
||||
return self._requests_extra_headers
|
||||
|
||||
@property
|
||||
def requests_metadata(self) -> list[dict[str, str] | None]:
|
||||
return self._requests_metadata
|
||||
|
||||
@staticmethod
|
||||
def _default_token_counter(messages: list[LLMMessage]) -> int:
|
||||
def _default_token_counter(messages: Sequence[LLMMessage]) -> int:
|
||||
return 1
|
||||
|
||||
async def __aenter__(self):
|
||||
|
|
@ -85,12 +90,14 @@ class FakeBackend:
|
|||
tool_choice,
|
||||
extra_headers,
|
||||
max_tokens,
|
||||
metadata=None,
|
||||
) -> LLMChunk:
|
||||
if self._exception_to_raise:
|
||||
raise self._exception_to_raise
|
||||
|
||||
self._requests_messages.append(messages)
|
||||
self._requests_extra_headers.append(extra_headers)
|
||||
self._requests_metadata.append(metadata)
|
||||
|
||||
if self._streams:
|
||||
stream = self._streams.pop(0)
|
||||
|
|
@ -111,12 +118,14 @@ class FakeBackend:
|
|||
tool_choice,
|
||||
extra_headers,
|
||||
max_tokens,
|
||||
metadata=None,
|
||||
) -> AsyncGenerator[LLMChunk]:
|
||||
if self._exception_to_raise:
|
||||
raise self._exception_to_raise
|
||||
|
||||
self._requests_messages.append(messages)
|
||||
self._requests_extra_headers.append(extra_headers)
|
||||
self._requests_metadata.append(metadata)
|
||||
|
||||
if self._streams:
|
||||
stream = list(self._streams.pop(0))
|
||||
|
|
@ -134,6 +143,7 @@ class FakeBackend:
|
|||
tools,
|
||||
tool_choice=None,
|
||||
extra_headers,
|
||||
metadata=None,
|
||||
) -> int:
|
||||
self._count_tokens_calls.append(list(messages))
|
||||
return self._token_counter(messages)
|
||||
|
|
|
|||
|
|
@ -16,22 +16,13 @@ from vibe.core.types import (
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_triggers_and_batches_observer(
|
||||
telemetry_events: list[dict],
|
||||
) -> None:
|
||||
observed: list[tuple[Role, str | None]] = []
|
||||
|
||||
def observer(msg: LLMMessage) -> None:
|
||||
observed.append((msg.role, msg.content))
|
||||
|
||||
async def test_auto_compact_emits_correct_events(telemetry_events: list[dict]) -> None:
|
||||
backend = FakeBackend([
|
||||
[mock_llm_chunk(content="<summary>")],
|
||||
[mock_llm_chunk(content="<final>")],
|
||||
])
|
||||
cfg = build_test_vibe_config(auto_compact_threshold=1)
|
||||
agent = build_test_agent_loop(
|
||||
config=cfg, message_observer=observer, backend=backend
|
||||
)
|
||||
agent = build_test_agent_loop(config=cfg, backend=backend)
|
||||
agent.stats.context_tokens = 2
|
||||
|
||||
events = [ev async for ev in agent.act("Hello")]
|
||||
|
|
@ -50,14 +41,83 @@ async def test_auto_compact_triggers_and_batches_observer(
|
|||
assert end.new_context_tokens >= 1
|
||||
assert final.content == "<final>"
|
||||
|
||||
roles = [r for r, _ in observed]
|
||||
assert roles == [Role.system, Role.user, Role.assistant]
|
||||
assert observed[1][1] is not None and "<summary>" in observed[1][1]
|
||||
assert observed[2][1] == "<final>"
|
||||
|
||||
auto_compact = [
|
||||
e
|
||||
for e in telemetry_events
|
||||
if e.get("event_name") == "vibe/auto_compact_triggered"
|
||||
if e.get("event_name") == "vibe.auto_compact_triggered"
|
||||
]
|
||||
assert len(auto_compact) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_observer_sees_user_msg_not_summary() -> None:
|
||||
"""Observer sees the original user message and final response.
|
||||
|
||||
Compact internals (summary request, LLM summary) are invisible
|
||||
to the observer because they happen inside silent() / reset().
|
||||
"""
|
||||
observed: list[tuple[Role, str | None]] = []
|
||||
|
||||
def observer(msg: LLMMessage) -> None:
|
||||
observed.append((msg.role, msg.content))
|
||||
|
||||
backend = FakeBackend([
|
||||
[mock_llm_chunk(content="<summary>")],
|
||||
[mock_llm_chunk(content="<final>")],
|
||||
])
|
||||
cfg = build_test_vibe_config(auto_compact_threshold=1)
|
||||
agent = build_test_agent_loop(
|
||||
config=cfg, message_observer=observer, backend=backend
|
||||
)
|
||||
agent.stats.context_tokens = 2
|
||||
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
roles = [r for r, _ in observed]
|
||||
assert roles == [Role.system, Role.user, Role.assistant]
|
||||
assert observed[1][1] == "Hello"
|
||||
assert observed[2][1] == "<final>"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_observer_does_not_see_summary_request() -> None:
|
||||
"""The compact summary request and LLM response must not leak to observer."""
|
||||
observed: list[tuple[Role, str | None]] = []
|
||||
|
||||
def observer(msg: LLMMessage) -> None:
|
||||
observed.append((msg.role, msg.content))
|
||||
|
||||
backend = FakeBackend([
|
||||
[mock_llm_chunk(content="<summary>")],
|
||||
[mock_llm_chunk(content="<final>")],
|
||||
])
|
||||
cfg = build_test_vibe_config(auto_compact_threshold=1)
|
||||
agent = build_test_agent_loop(
|
||||
config=cfg, message_observer=observer, backend=backend
|
||||
)
|
||||
agent.stats.context_tokens = 2
|
||||
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
contents = [c for _, c in observed]
|
||||
assert "<summary>" not in contents
|
||||
assert all("compact" not in (c or "").lower() for c in contents)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compact_replaces_messages_with_summary() -> None:
|
||||
"""After compact, messages list contains only system + summary."""
|
||||
backend = FakeBackend([
|
||||
[mock_llm_chunk(content="<summary>")],
|
||||
[mock_llm_chunk(content="<final>")],
|
||||
])
|
||||
cfg = build_test_vibe_config(auto_compact_threshold=1)
|
||||
agent = build_test_agent_loop(config=cfg, backend=backend)
|
||||
agent.stats.context_tokens = 2
|
||||
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
# After compact + final response: system, summary, final
|
||||
assert agent.messages[0].role == Role.system
|
||||
assert agent.messages[-1].role == Role.assistant
|
||||
assert agent.messages[-1].content == "<final>"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,54 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from mcp.types import (
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
SamplingMessage,
|
||||
TextContent,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_agent_loop
|
||||
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.config import VibeConfig
|
||||
from vibe.core.config import Backend, ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.types import EntrypointMetadata
|
||||
|
||||
|
||||
def _two_model_vibe_config(active_model: str) -> VibeConfig:
|
||||
"""VibeConfig with two models so we can switch active_model."""
|
||||
models = [
|
||||
ModelConfig(
|
||||
name="mistral-vibe-cli-latest", provider="mistral", alias="devstral-latest"
|
||||
),
|
||||
ModelConfig(
|
||||
name="devstral-small-latest", provider="mistral", alias="devstral-small"
|
||||
),
|
||||
]
|
||||
providers = [
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
]
|
||||
return build_test_vibe_config(
|
||||
active_model=active_model, models=models, providers=providers
|
||||
)
|
||||
|
||||
|
||||
def _make_sampling_params() -> CreateMessageRequestParams:
|
||||
return CreateMessageRequestParams(
|
||||
messages=[
|
||||
SamplingMessage(role="user", content=TextContent(type="text", text="Hi"))
|
||||
],
|
||||
systemPrompt=None,
|
||||
temperature=None,
|
||||
maxTokens=100,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -66,3 +109,79 @@ async def test_updates_tokens_stats_based_on_backend_response_streaming(
|
|||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
assert agent.stats.context_tokens == 275
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_entrypoint_metadata_to_backend(vibe_config: VibeConfig):
|
||||
metadata = EntrypointMetadata(
|
||||
agent_entrypoint="acp",
|
||||
agent_version="2.0.0",
|
||||
client_name="vibe_ide",
|
||||
client_version="0.5.0",
|
||||
)
|
||||
backend = FakeBackend([mock_llm_chunk(content="Response")])
|
||||
agent = build_test_agent_loop(
|
||||
config=vibe_config,
|
||||
backend=backend,
|
||||
enable_streaming=True,
|
||||
entrypoint_metadata=metadata,
|
||||
)
|
||||
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
assert len(backend.requests_metadata) > 0
|
||||
assert backend.requests_metadata[0] == {
|
||||
"agent_entrypoint": "acp",
|
||||
"agent_version": "2.0.0",
|
||||
"client_name": "vibe_ide",
|
||||
"client_version": "0.5.0",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_sampling_handler_uses_updated_backend_when_agent_backend_changes():
|
||||
"""AgentLoop's MCP sampling handler uses current backend when backend is reassigned."""
|
||||
backend1 = FakeBackend([mock_llm_chunk(content="from-backend-1")])
|
||||
backend2 = FakeBackend([mock_llm_chunk(content="from-backend-2")])
|
||||
config = _two_model_vibe_config("devstral-latest")
|
||||
agent = build_test_agent_loop(config=config, backend=backend1)
|
||||
handler = agent._sampling_handler
|
||||
params = _make_sampling_params()
|
||||
context = MagicMock()
|
||||
|
||||
result1 = await handler(context, params)
|
||||
assert isinstance(result1, CreateMessageResult)
|
||||
assert result1.content.type == "text"
|
||||
assert result1.content.text == "from-backend-1"
|
||||
assert len(backend1.requests_messages) == 1
|
||||
assert len(backend2.requests_messages) == 0
|
||||
|
||||
agent.backend = backend2
|
||||
result2 = await handler(context, params)
|
||||
assert isinstance(result2, CreateMessageResult)
|
||||
assert result2.content.type == "text"
|
||||
assert result2.content.text == "from-backend-2"
|
||||
assert len(backend1.requests_messages) == 1
|
||||
assert len(backend2.requests_messages) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_sampling_handler_uses_updated_config_when_agent_config_changes():
|
||||
chunk = mock_llm_chunk(content="ok")
|
||||
backend = FakeBackend([chunk])
|
||||
config1 = _two_model_vibe_config("devstral-latest")
|
||||
config2 = _two_model_vibe_config("devstral-small")
|
||||
agent = build_test_agent_loop(config=config1, backend=backend)
|
||||
handler = agent._sampling_handler
|
||||
params = _make_sampling_params()
|
||||
context = MagicMock()
|
||||
|
||||
result1 = await handler(context, params)
|
||||
assert isinstance(result1, CreateMessageResult)
|
||||
assert result1.model == "mistral-vibe-cli-latest"
|
||||
|
||||
agent._base_config = config2
|
||||
agent.agent_manager.invalidate_config()
|
||||
result2 = await handler(context, params)
|
||||
assert isinstance(result2, CreateMessageResult)
|
||||
assert result2.model == "devstral-small-latest"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
|
@ -180,6 +181,27 @@ async def test_act_streams_chunks_in_order() -> None:
|
|||
assert agent.messages[-1].content == "Hello from Vibe! More and end"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_act_streaming_does_not_cleanup_tmp_files_directly() -> None:
|
||||
backend = FakeBackend([
|
||||
mock_llm_chunk(content="Hello"),
|
||||
mock_llm_chunk(content=" from"),
|
||||
mock_llm_chunk(content=" Vibe"),
|
||||
])
|
||||
agent = build_test_agent_loop(
|
||||
config=make_config(), backend=backend, enable_streaming=True
|
||||
)
|
||||
agent.session_logger.save_interaction = AsyncMock(return_value=None)
|
||||
cleanup_spy = Mock()
|
||||
agent.session_logger.maybe_cleanup_tmp_files = cleanup_spy
|
||||
|
||||
events = [event async for event in agent.act("Stream, please.")]
|
||||
|
||||
assistant_events = [event for event in events if isinstance(event, AssistantEvent)]
|
||||
assert len(assistant_events) == 3
|
||||
assert cleanup_spy.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None:
|
||||
todo_tool_call = ToolCall(
|
||||
|
|
@ -210,6 +232,7 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None
|
|||
UserMessageEvent,
|
||||
AssistantEvent,
|
||||
ToolCallEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
AssistantEvent,
|
||||
]
|
||||
|
|
@ -217,12 +240,17 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None
|
|||
assert isinstance(events[1], AssistantEvent)
|
||||
assert events[1].content == "Checking your todos."
|
||||
assert isinstance(events[2], ToolCallEvent)
|
||||
assert events[2].args is None # streaming event
|
||||
assert events[2].tool_call_id == "tc_stream"
|
||||
assert events[2].tool_name == "todo"
|
||||
assert isinstance(events[3], ToolResultEvent)
|
||||
assert events[3].error is None
|
||||
assert events[3].skipped is False
|
||||
assert isinstance(events[4], AssistantEvent)
|
||||
assert events[4].content == "Done reviewing todos."
|
||||
assert isinstance(events[3], ToolCallEvent)
|
||||
assert events[3].args is not None
|
||||
assert events[3].tool_name == "todo"
|
||||
assert isinstance(events[4], ToolResultEvent)
|
||||
assert events[4].error is None
|
||||
assert events[4].skipped is False
|
||||
assert isinstance(events[5], AssistantEvent)
|
||||
assert events[5].content == "Done reviewing todos."
|
||||
assert agent.messages[-1].content == "Done reviewing todos."
|
||||
|
||||
|
||||
|
|
@ -250,21 +278,27 @@ async def test_act_handles_tool_call_chunk_with_content() -> None:
|
|||
|
||||
events = [event async for event in agent.act("Check todos with content.")]
|
||||
|
||||
assert [type(event) for event in events] == [
|
||||
UserMessageEvent,
|
||||
AssistantEvent,
|
||||
AssistantEvent,
|
||||
AssistantEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
]
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
assert isinstance(events[1], AssistantEvent)
|
||||
assert isinstance(events[2], AssistantEvent)
|
||||
assert isinstance(events[3], AssistantEvent)
|
||||
assert events[1].content == "Preparing "
|
||||
assert events[2].content == "todo request"
|
||||
assert events[3].content == " complete"
|
||||
event_types = [type(e) for e in events]
|
||||
assert Counter(event_types) == Counter({
|
||||
UserMessageEvent: 1,
|
||||
AssistantEvent: 3,
|
||||
ToolCallEvent: 2,
|
||||
ToolResultEvent: 1,
|
||||
})
|
||||
|
||||
tool_call_events = [e for e in events if isinstance(e, ToolCallEvent)]
|
||||
assert len(tool_call_events) == 2
|
||||
assert any(
|
||||
tc.args is None and tc.tool_call_id == "tc_content" for tc in tool_call_events
|
||||
)
|
||||
assert any(tc.args is not None for tc in tool_call_events)
|
||||
|
||||
assistant_events = [e for e in events if isinstance(e, AssistantEvent)]
|
||||
assistant_contents = {e.content for e in assistant_events}
|
||||
assert "Preparing " in assistant_contents
|
||||
assert "todo request" in assistant_contents
|
||||
assert " complete" in assistant_contents
|
||||
|
||||
assert any(
|
||||
m.role == Role.assistant and m.content == "Preparing todo request complete"
|
||||
for m in agent.messages
|
||||
|
|
@ -304,17 +338,21 @@ async def test_act_merges_streamed_tool_call_arguments() -> None:
|
|||
UserMessageEvent,
|
||||
AssistantEvent,
|
||||
ToolCallEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
]
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
call_event = events[2]
|
||||
assert isinstance(events[2], ToolCallEvent)
|
||||
assert events[2].args is None # streaming event
|
||||
assert events[2].tool_call_id == "tc_merge"
|
||||
call_event = events[3]
|
||||
assert isinstance(call_event, ToolCallEvent)
|
||||
assert call_event.tool_call_id == "tc_merge"
|
||||
call_args = cast(TodoArgs, call_event.args)
|
||||
assert call_args.action == "read"
|
||||
assert isinstance(events[3], ToolResultEvent)
|
||||
assert events[3].error is None
|
||||
assert events[3].skipped is False
|
||||
assert isinstance(events[4], ToolResultEvent)
|
||||
assert events[4].error is None
|
||||
assert events[4].skipped is False
|
||||
assistant_with_calls = next(
|
||||
m for m in agent.messages if m.role == Role.assistant and m.tool_calls
|
||||
)
|
||||
|
|
@ -371,6 +409,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
|
|||
assert [type(event) for event in events] == [
|
||||
UserMessageEvent,
|
||||
AssistantEvent,
|
||||
ToolCallEvent,
|
||||
AssistantEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
|
|
|
|||
|
|
@ -531,7 +531,7 @@ class TestAutoCompactIntegration:
|
|||
|
||||
roles = [r for r, _ in observed]
|
||||
assert roles == [Role.system, Role.user, Role.assistant]
|
||||
assert observed[1][1] is not None and "<summary>" in observed[1][1]
|
||||
assert observed[1][1] == "Hello"
|
||||
|
||||
|
||||
class TestClearHistoryFullReset:
|
||||
|
|
@ -623,6 +623,37 @@ class TestClearHistoryFullReset:
|
|||
assert agent.session_id == agent.session_logger.session_id
|
||||
|
||||
|
||||
class TestClearHistoryObserverBugfix:
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_history_observer_sees_new_messages(
|
||||
self, observer_capture
|
||||
) -> None:
|
||||
"""Bug fix: clear_history previously left a stale index, so new messages
|
||||
appended after clearing were never observed.
|
||||
"""
|
||||
observed, observer = observer_capture
|
||||
backend = FakeBackend([
|
||||
[mock_llm_chunk(content="First")],
|
||||
[mock_llm_chunk(content="Second")],
|
||||
])
|
||||
agent = build_test_agent_loop(
|
||||
config=make_config(), message_observer=observer, backend=backend
|
||||
)
|
||||
|
||||
async for _ in agent.act("Hello"):
|
||||
pass
|
||||
|
||||
await agent.clear_history()
|
||||
observed.clear()
|
||||
|
||||
async for _ in agent.act("After clear"):
|
||||
pass
|
||||
|
||||
roles = [msg.role for msg in observed]
|
||||
assert Role.user in roles
|
||||
assert Role.assistant in roles
|
||||
|
||||
|
||||
class TestStatsEdgeCases:
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_cost_approximation_on_model_change(
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ async def test_single_tool_call_executes_under_auto_approve(
|
|||
assert "total_count" in (tool_msgs[-1].content or "")
|
||||
|
||||
tool_finished = [
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
|
||||
]
|
||||
assert len(tool_finished) == 1
|
||||
assert tool_finished[0]["properties"]["tool_name"] == "todo"
|
||||
|
|
@ -158,7 +158,7 @@ async def test_tool_call_requires_approval_if_not_auto_approved(
|
|||
assert agent_loop.stats.tool_calls_succeeded == 0
|
||||
|
||||
tool_finished = [
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
|
||||
]
|
||||
assert len(tool_finished) == 1
|
||||
assert tool_finished[0]["properties"]["approval_type"] == "ask"
|
||||
|
|
@ -198,7 +198,7 @@ async def test_tool_call_approved_by_callback(telemetry_events: list[dict]) -> N
|
|||
assert agent_loop.stats.tool_calls_succeeded == 1
|
||||
|
||||
tool_finished = [
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
|
||||
]
|
||||
assert len(tool_finished) == 1
|
||||
assert tool_finished[0]["properties"]["approval_type"] == "ask"
|
||||
|
|
@ -243,7 +243,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
|
|||
assert agent_loop.stats.tool_calls_succeeded == 0
|
||||
|
||||
tool_finished = [
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
|
||||
]
|
||||
assert len(tool_finished) == 1
|
||||
assert tool_finished[0]["properties"]["approval_type"] == "ask"
|
||||
|
|
@ -287,7 +287,7 @@ async def test_tool_call_skipped_when_permission_is_never(
|
|||
assert agent_loop.stats.tool_calls_succeeded == 0
|
||||
|
||||
tool_finished = [
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
|
||||
]
|
||||
assert len(tool_finished) == 1
|
||||
assert tool_finished[0]["properties"]["approval_type"] == "never"
|
||||
|
|
@ -492,14 +492,14 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
|
|||
assistant_msg = LLMMessage(
|
||||
role=Role.assistant, content="Calling tools...", tool_calls=tool_calls_messages
|
||||
)
|
||||
agent_loop.messages = [
|
||||
agent_loop.messages.reset([
|
||||
agent_loop.messages[0],
|
||||
assistant_msg,
|
||||
# only one tool responded: the second is missing
|
||||
LLMMessage(
|
||||
role=Role.tool, tool_call_id="tc1", name="todo", content="Retrieved 0 todos"
|
||||
),
|
||||
]
|
||||
])
|
||||
|
||||
await act_and_collect_events(agent_loop, "Proceed")
|
||||
|
||||
|
|
@ -524,7 +524,7 @@ async def test_ensure_assistant_after_tool_appends_understood() -> None:
|
|||
tool_msg = LLMMessage(
|
||||
role=Role.tool, tool_call_id="tc_z", name="todo", content="Done"
|
||||
)
|
||||
agent_loop.messages = [agent_loop.messages[0], tool_msg]
|
||||
agent_loop.messages.reset([agent_loop.messages[0], tool_msg])
|
||||
|
||||
await act_and_collect_events(agent_loop, "Next")
|
||||
|
||||
|
|
|
|||
|
|
@ -126,8 +126,9 @@ class TestAgentSafety:
|
|||
|
||||
|
||||
class TestAgentProfile:
|
||||
def test_all_builtin_agents_exist(self) -> None:
|
||||
assert set(BUILTIN_AGENTS.keys()) == set(BuiltinAgentName)
|
||||
def test_all_builtin_agents_have_valid_names(self) -> None:
|
||||
acp_only = {BuiltinAgentName.CHAT}
|
||||
assert set(BUILTIN_AGENTS.keys()) == set(BuiltinAgentName) - acp_only
|
||||
|
||||
def test_display_name_property(self) -> None:
|
||||
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].display_name == "Default"
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ def test_run_programmatic_preload_streaming_is_batched(
|
|||
]
|
||||
|
||||
new_session = [
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe/new_session"
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe.new_session"
|
||||
]
|
||||
assert len(new_session) == 1
|
||||
assert new_session[0]["properties"]["entrypoint"] == "programmatic"
|
||||
|
|
|
|||
|
|
@ -3,44 +3,67 @@ from __future__ import annotations
|
|||
import pytest
|
||||
|
||||
from tests.conftest import build_test_agent_loop, build_test_vibe_config
|
||||
from vibe.core.agents.models import BUILTIN_AGENTS, AgentProfile, BuiltinAgentName
|
||||
from vibe.core.agents.models import BUILTIN_AGENTS, CHAT, AgentProfile, BuiltinAgentName
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.middleware import (
|
||||
CHAT_AGENT_EXIT,
|
||||
CHAT_AGENT_REMINDER,
|
||||
PLAN_AGENT_EXIT,
|
||||
PLAN_AGENT_REMINDER,
|
||||
ConversationContext,
|
||||
MiddlewareAction,
|
||||
MiddlewarePipeline,
|
||||
PlanAgentMiddleware,
|
||||
ReadOnlyAgentMiddleware,
|
||||
ResetReason,
|
||||
)
|
||||
from vibe.core.types import AgentStats
|
||||
from vibe.core.types import AgentStats, MessageList
|
||||
|
||||
REMINDER = "test reminder"
|
||||
EXIT_MSG = "test exit"
|
||||
TARGET_AGENT = BuiltinAgentName.PLAN
|
||||
|
||||
|
||||
def _build_middleware(
|
||||
profile_getter,
|
||||
agent_name: str = TARGET_AGENT,
|
||||
reminder: str = REMINDER,
|
||||
exit_message: str = EXIT_MSG,
|
||||
) -> ReadOnlyAgentMiddleware:
|
||||
return ReadOnlyAgentMiddleware(profile_getter, agent_name, reminder, exit_message)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(vibe_config: VibeConfig) -> ConversationContext:
|
||||
return ConversationContext(messages=[], stats=AgentStats(), config=vibe_config)
|
||||
return ConversationContext(
|
||||
messages=MessageList(), stats=AgentStats(), config=vibe_config
|
||||
)
|
||||
|
||||
|
||||
class TestPlanAgentMiddleware:
|
||||
class TestReadOnlyAgentMiddleware:
|
||||
@pytest.mark.asyncio
|
||||
async def test_injects_reminder_when_plan_agent_active(
|
||||
async def test_injects_reminder_when_target_agent_active(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_REMINDER
|
||||
assert result.message == REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_inject_when_default_agent(
|
||||
self, ctx: ConversationContext
|
||||
@pytest.mark.parametrize(
|
||||
"agent_name",
|
||||
[
|
||||
BuiltinAgentName.DEFAULT,
|
||||
BuiltinAgentName.AUTO_APPROVE,
|
||||
BuiltinAgentName.ACCEPT_EDITS,
|
||||
],
|
||||
)
|
||||
async def test_does_not_inject_when_non_target_agent(
|
||||
self, ctx: ConversationContext, agent_name: str
|
||||
) -> None:
|
||||
middleware = PlanAgentMiddleware(
|
||||
lambda: BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
)
|
||||
middleware = _build_middleware(lambda: BUILTIN_AGENTS[agent_name])
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
|
||||
|
|
@ -48,89 +71,56 @@ class TestPlanAgentMiddleware:
|
|||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_inject_when_auto_approve_agent(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
middleware = PlanAgentMiddleware(
|
||||
lambda: BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
|
||||
)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_inject_when_accept_edits_agent(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
middleware = PlanAgentMiddleware(
|
||||
lambda: BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS]
|
||||
)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injects_reminder_only_once_while_in_plan_mode(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
async def test_injects_reminder_only_once(self, ctx: ConversationContext) -> None:
|
||||
middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
|
||||
result1 = await middleware.before_turn(ctx)
|
||||
assert result1.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result1.message == PLAN_AGENT_REMINDER
|
||||
assert result1.message == REMINDER
|
||||
|
||||
result2 = await middleware.before_turn(ctx)
|
||||
assert result2.action == MiddlewareAction.CONTINUE
|
||||
assert result2.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injects_exit_message_when_leaving_plan_mode(
|
||||
async def test_injects_exit_message_when_leaving(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
# Enter plan mode
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
# Leave plan mode
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_EXIT
|
||||
assert result.message == EXIT_MSG
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reinjects_reminder_when_reentering_plan_mode(
|
||||
async def test_reinjects_reminder_on_reentry(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
# Enter plan mode - should inject reminder
|
||||
result1 = await middleware.before_turn(ctx)
|
||||
assert result1.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result1.message == PLAN_AGENT_REMINDER
|
||||
assert result1.message == REMINDER
|
||||
|
||||
# Leave plan mode - should inject exit message
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
result2 = await middleware.before_turn(ctx)
|
||||
assert result2.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result2.message == PLAN_AGENT_EXIT
|
||||
assert result2.message == EXIT_MSG
|
||||
|
||||
# Re-enter plan mode - should inject reminder again
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
result3 = await middleware.before_turn(ctx)
|
||||
assert result3.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result3.message == PLAN_AGENT_REMINDER
|
||||
assert result3.message == REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_reminder(self, ctx: ConversationContext) -> None:
|
||||
custom_reminder = "Custom plan agent reminder"
|
||||
middleware = PlanAgentMiddleware(
|
||||
custom_reminder = "Custom reminder"
|
||||
middleware = _build_middleware(
|
||||
lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN], reminder=custom_reminder
|
||||
)
|
||||
|
||||
|
|
@ -138,246 +128,216 @@ class TestPlanAgentMiddleware:
|
|||
|
||||
assert result.message == custom_reminder
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_clears_state(self, ctx: ConversationContext) -> None:
|
||||
middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
await middleware.before_turn(ctx) # Enter and inject
|
||||
|
||||
middleware.reset()
|
||||
|
||||
# Should inject again after reset
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exit_message_fires_only_once(self, ctx: ConversationContext) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
# Enter plan mode
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
# Leave plan mode - first call should inject exit
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_EXIT
|
||||
|
||||
# Subsequent calls in default mode should be CONTINUE
|
||||
result2 = await middleware.before_turn(ctx)
|
||||
assert result2.action == MiddlewareAction.CONTINUE
|
||||
assert result2.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_turns_in_plan_mode_after_entry(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
|
||||
# First turn: inject reminder
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
|
||||
# Several more turns in plan mode: all should be CONTINUE
|
||||
for _ in range(5):
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_turns_in_default_mode_after_exit(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx) # enter plan
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
await middleware.before_turn(ctx) # exit plan (fires exit message)
|
||||
|
||||
# Several more turns in default mode: all should be CONTINUE
|
||||
for _ in range(5):
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rapid_toggling_plan_default_multiple_cycles(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
for _ in range(3):
|
||||
# Enter plan
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_REMINDER
|
||||
|
||||
# Leave plan
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_EXIT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exit_to_non_default_agent(self, ctx: ConversationContext) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx) # enter plan
|
||||
|
||||
# Switch to auto_approve (not default)
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_EXIT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exit_to_accept_edits_agent(self, ctx: ConversationContext) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx) # enter plan
|
||||
|
||||
# Switch to accept_edits
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_EXIT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switching_between_non_plan_agents(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_plan_to_plan_entry(self, ctx: ConversationContext) -> None:
|
||||
"""Starting in a non-plan agent then entering plan should inject reminder."""
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
# Now switch to plan
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_while_in_default_after_exiting_plan(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx) # enter plan
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
await middleware.before_turn(ctx) # exit plan
|
||||
|
||||
middleware.reset()
|
||||
|
||||
# Still in default mode - should CONTINUE (no phantom exit message)
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_while_in_default_then_reenter_plan(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx) # enter plan
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
await middleware.before_turn(ctx) # exit plan
|
||||
|
||||
middleware.reset()
|
||||
|
||||
# Re-enter plan after reset
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_with_compact_reason(self, ctx: ConversationContext) -> None:
|
||||
middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
await middleware.before_turn(ctx) # enter and inject
|
||||
|
||||
middleware.reset(ResetReason.COMPACT)
|
||||
|
||||
# Should reinject reminder after compact reset
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_exit_message(self, ctx: ConversationContext) -> None:
|
||||
custom_exit = "Custom exit message"
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(
|
||||
middleware = _build_middleware(
|
||||
lambda: current_profile, exit_message=custom_exit
|
||||
)
|
||||
|
||||
await middleware.before_turn(ctx) # enter plan
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.message == custom_exit
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_entry_then_immediate_exit_same_not_possible(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
"""Even if profile changes between two calls, each call sees one transition."""
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
async def test_reset_clears_state(self, ctx: ConversationContext) -> None:
|
||||
middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
middleware.reset()
|
||||
|
||||
# First call: entry
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_REMINDER
|
||||
|
||||
# Second call (still plan): no injection
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
@pytest.mark.asyncio
|
||||
async def test_exit_message_fires_only_once(self, ctx: ConversationContext) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
# Third call (switched to default): exit
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_EXIT
|
||||
assert result.message == EXIT_MSG
|
||||
|
||||
result2 = await middleware.before_turn(ctx)
|
||||
assert result2.action == MiddlewareAction.CONTINUE
|
||||
assert result2.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_turns_after_entry(self, ctx: ConversationContext) -> None:
|
||||
middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
|
||||
for _ in range(5):
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_turns_after_exit(self, ctx: ConversationContext) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
for _ in range(5):
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rapid_toggling_multiple_cycles(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
for _ in range(3):
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == REMINDER
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == EXIT_MSG
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exit_to_non_default_agent(self, ctx: ConversationContext) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == EXIT_MSG
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switching_between_non_target_agents(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_target_to_target_entry(self, ctx: ConversationContext) -> None:
|
||||
"""Starting in a non-target agent then entering target should inject reminder."""
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_while_inactive_after_exit(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx)
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
middleware.reset()
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_while_inactive_then_reenter(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx)
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
middleware.reset()
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_with_compact_reason(self, ctx: ConversationContext) -> None:
|
||||
middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
middleware.reset(ResetReason.COMPACT)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entry_then_continuation_then_exit_then_continuation(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
"""Each call sees one transition at a time."""
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == REMINDER
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == EXIT_MSG
|
||||
|
||||
# Fourth call (still default): no injection
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
|
||||
class TestMiddlewarePipelineWithPlanAgent:
|
||||
class TestMiddlewarePipelineWithReadOnlyAgent:
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_includes_plan_agent_injection(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
async def test_pipeline_includes_injection(self, ctx: ConversationContext) -> None:
|
||||
pipeline = MiddlewarePipeline()
|
||||
pipeline.add(PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN]))
|
||||
pipeline.add(
|
||||
ReadOnlyAgentMiddleware(
|
||||
lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN],
|
||||
BuiltinAgentName.PLAN,
|
||||
PLAN_AGENT_REMINDER,
|
||||
PLAN_AGENT_EXIT,
|
||||
)
|
||||
)
|
||||
|
||||
result = await pipeline.run_before_turn(ctx)
|
||||
|
||||
|
|
@ -385,20 +345,73 @@ class TestMiddlewarePipelineWithPlanAgent:
|
|||
assert PLAN_AGENT_REMINDER in (result.message or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_skips_injection_when_not_plan_agent(
|
||||
async def test_pipeline_skips_injection_when_not_target_agent(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
pipeline = MiddlewarePipeline()
|
||||
pipeline.add(
|
||||
PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.DEFAULT])
|
||||
ReadOnlyAgentMiddleware(
|
||||
lambda: BUILTIN_AGENTS[BuiltinAgentName.DEFAULT],
|
||||
BuiltinAgentName.PLAN,
|
||||
PLAN_AGENT_REMINDER,
|
||||
PLAN_AGENT_EXIT,
|
||||
)
|
||||
)
|
||||
|
||||
result = await pipeline.run_before_turn(ctx)
|
||||
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_plan_to_chat_transition_delivers_both_messages(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
pipeline = MiddlewarePipeline()
|
||||
pipeline.add(
|
||||
ReadOnlyAgentMiddleware(
|
||||
lambda: current_profile,
|
||||
BuiltinAgentName.PLAN,
|
||||
PLAN_AGENT_REMINDER,
|
||||
PLAN_AGENT_EXIT,
|
||||
)
|
||||
)
|
||||
pipeline.add(
|
||||
ReadOnlyAgentMiddleware(
|
||||
lambda: current_profile,
|
||||
BuiltinAgentName.CHAT,
|
||||
CHAT_AGENT_REMINDER,
|
||||
CHAT_AGENT_EXIT,
|
||||
)
|
||||
)
|
||||
|
||||
class TestPlanAgentMiddlewareIntegration:
|
||||
result = await pipeline.run_before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert PLAN_AGENT_REMINDER in (result.message or "")
|
||||
|
||||
current_profile = CHAT
|
||||
result = await pipeline.run_before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert PLAN_AGENT_EXIT in (result.message or "")
|
||||
assert CHAT_AGENT_REMINDER in (result.message or "")
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
result = await pipeline.run_before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert CHAT_AGENT_EXIT in (result.message or "")
|
||||
assert PLAN_AGENT_REMINDER in (result.message or "")
|
||||
|
||||
|
||||
def _find_plan_middleware(agent) -> ReadOnlyAgentMiddleware:
|
||||
return next(
|
||||
mw
|
||||
for mw in agent.middleware_pipeline.middlewares
|
||||
if isinstance(mw, ReadOnlyAgentMiddleware)
|
||||
and mw._agent_name == BuiltinAgentName.PLAN
|
||||
)
|
||||
|
||||
|
||||
class TestReadOnlyAgentMiddlewareIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_agent_preserves_middleware_state_for_exit_message(
|
||||
self,
|
||||
|
|
@ -414,11 +427,7 @@ class TestPlanAgentMiddlewareIntegration:
|
|||
)
|
||||
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
|
||||
|
||||
plan_middleware = next(
|
||||
mw
|
||||
for mw in agent.middleware_pipeline.middlewares
|
||||
if isinstance(mw, PlanAgentMiddleware)
|
||||
)
|
||||
plan_middleware = _find_plan_middleware(agent)
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
|
|
@ -429,11 +438,7 @@ class TestPlanAgentMiddlewareIntegration:
|
|||
|
||||
await agent.switch_agent(BuiltinAgentName.DEFAULT)
|
||||
|
||||
plan_middleware_after = next(
|
||||
mw
|
||||
for mw in agent.middleware_pipeline.middlewares
|
||||
if isinstance(mw, PlanAgentMiddleware)
|
||||
)
|
||||
plan_middleware_after = _find_plan_middleware(agent)
|
||||
assert plan_middleware is plan_middleware_after
|
||||
|
||||
ctx = ConversationContext(
|
||||
|
|
@ -456,11 +461,7 @@ class TestPlanAgentMiddlewareIntegration:
|
|||
)
|
||||
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
|
||||
|
||||
plan_middleware = next(
|
||||
mw
|
||||
for mw in agent.middleware_pipeline.middlewares
|
||||
if isinstance(mw, PlanAgentMiddleware)
|
||||
)
|
||||
plan_middleware = _find_plan_middleware(agent)
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
|
|
@ -497,11 +498,7 @@ class TestPlanAgentMiddlewareIntegration:
|
|||
)
|
||||
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
|
||||
|
||||
plan_middleware = next(
|
||||
mw
|
||||
for mw in agent.middleware_pipeline.middlewares
|
||||
if isinstance(mw, PlanAgentMiddleware)
|
||||
)
|
||||
plan_middleware = _find_plan_middleware(agent)
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
|
|
@ -532,11 +529,7 @@ class TestPlanAgentMiddlewareIntegration:
|
|||
config=config, agent_name=BuiltinAgentName.DEFAULT
|
||||
)
|
||||
|
||||
plan_middleware = next(
|
||||
mw
|
||||
for mw in agent.middleware_pipeline.middlewares
|
||||
if isinstance(mw, PlanAgentMiddleware)
|
||||
)
|
||||
plan_middleware = _find_plan_middleware(agent)
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
|
|
@ -566,11 +559,7 @@ class TestPlanAgentMiddlewareIntegration:
|
|||
)
|
||||
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
|
||||
|
||||
plan_middleware = next(
|
||||
mw
|
||||
for mw in agent.middleware_pipeline.middlewares
|
||||
if isinstance(mw, PlanAgentMiddleware)
|
||||
)
|
||||
plan_middleware = _find_plan_middleware(agent)
|
||||
|
||||
def _ctx():
|
||||
return ConversationContext(
|
||||
|
|
|
|||
|
|
@ -76,14 +76,14 @@ async def test_decodes_non_utf8_bytes(bash):
|
|||
assert result.stderr == ""
|
||||
|
||||
|
||||
def test_check_allowlist_denylist():
|
||||
def test_resolve_permission():
|
||||
config = BashToolConfig(allowlist=["echo", "pwd"], denylist=["rm"])
|
||||
bash_tool = Bash(config=config, state=BaseToolState())
|
||||
|
||||
allowlisted = bash_tool.check_allowlist_denylist(BashArgs(command="echo hi"))
|
||||
denylisted = bash_tool.check_allowlist_denylist(BashArgs(command="rm -rf /tmp"))
|
||||
mixed = bash_tool.check_allowlist_denylist(BashArgs(command="pwd && whoami"))
|
||||
empty = bash_tool.check_allowlist_denylist(BashArgs(command=""))
|
||||
allowlisted = bash_tool.resolve_permission(BashArgs(command="echo hi"))
|
||||
denylisted = bash_tool.resolve_permission(BashArgs(command="rm -rf /tmp"))
|
||||
mixed = bash_tool.resolve_permission(BashArgs(command="pwd && whoami"))
|
||||
empty = bash_tool.resolve_permission(BashArgs(command=""))
|
||||
|
||||
assert allowlisted is ToolPermission.ALWAYS
|
||||
assert denylisted is ToolPermission.NEVER
|
||||
|
|
|
|||
|
|
@ -5,21 +5,15 @@ import shutil
|
|||
import pytest
|
||||
|
||||
from tests.mock.utils import collect_result
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.builtins.grep import (
|
||||
Grep,
|
||||
GrepArgs,
|
||||
GrepBackend,
|
||||
GrepState,
|
||||
GrepToolConfig,
|
||||
)
|
||||
from vibe.core.tools.base import BaseToolState, ToolError
|
||||
from vibe.core.tools.builtins.grep import Grep, GrepArgs, GrepBackend, GrepToolConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def grep(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
config = GrepToolConfig()
|
||||
return Grep(config=config, state=GrepState())
|
||||
return Grep(config=config, state=BaseToolState())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -34,7 +28,7 @@ def grep_gnu_only(tmp_path, monkeypatch):
|
|||
|
||||
monkeypatch.setattr("shutil.which", mock_which)
|
||||
config = GrepToolConfig()
|
||||
return Grep(config=config, state=GrepState())
|
||||
return Grep(config=config, state=BaseToolState())
|
||||
|
||||
|
||||
def test_detects_ripgrep_when_available(grep):
|
||||
|
|
@ -143,7 +137,7 @@ async def test_truncates_to_max_matches(grep, tmp_path):
|
|||
async def test_truncates_to_max_output_bytes(grep, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
config = GrepToolConfig(max_output_bytes=100)
|
||||
grep_tool = Grep(config=config, state=GrepState())
|
||||
grep_tool = Grep(config=config, state=BaseToolState())
|
||||
(tmp_path / "test.py").write_text("\n".join("x" * 100 for _ in range(10)))
|
||||
|
||||
result = await collect_result(grep_tool.run(GrepArgs(pattern="x")))
|
||||
|
|
@ -191,22 +185,11 @@ async def test_ignores_comments_in_vibeignore(grep, tmp_path):
|
|||
assert result.match_count >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tracks_search_history(grep, tmp_path):
|
||||
(tmp_path / "test.py").write_text("content\n")
|
||||
|
||||
await collect_result(grep.run(GrepArgs(pattern="first")))
|
||||
await collect_result(grep.run(GrepArgs(pattern="second")))
|
||||
await collect_result(grep.run(GrepArgs(pattern="third")))
|
||||
|
||||
assert grep.state.search_history == ["first", "second", "third"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_effective_workdir(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
config = GrepToolConfig()
|
||||
grep_tool = Grep(config=config, state=GrepState())
|
||||
grep_tool = Grep(config=config, state=BaseToolState())
|
||||
(tmp_path / "test.py").write_text("match\n")
|
||||
|
||||
result = await collect_result(grep_tool.run(GrepArgs(pattern="match", path=".")))
|
||||
|
|
|
|||
|
|
@ -227,6 +227,70 @@ class FileTool(BaseTool[FileToolArgs, FileToolResult, BaseToolConfig, BaseToolSt
|
|||
assert "file_tool" in tools
|
||||
|
||||
|
||||
class TestToolRuntimeAvailability:
|
||||
"""Tests for is_available() filtering in ToolManager."""
|
||||
|
||||
def test_unavailable_tool_excluded_from_available_tools(
|
||||
self, tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Tools where is_available() returns False should be excluded."""
|
||||
import sys
|
||||
|
||||
tool_dir = tmp_path / "tools"
|
||||
tool_dir.mkdir()
|
||||
(tool_dir / "conditional_tool.py").write_text("""
|
||||
import os
|
||||
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ConditionalToolArgs(BaseModel):
|
||||
pass
|
||||
|
||||
class ConditionalToolResult(BaseModel):
|
||||
pass
|
||||
|
||||
class ConditionalTool(BaseTool[ConditionalToolArgs, ConditionalToolResult, BaseToolConfig, BaseToolState]):
|
||||
description = "Tool that requires TEST_VAR"
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
return bool(os.getenv("TEST_VAR"))
|
||||
|
||||
async def run(self, args, ctx=None):
|
||||
yield ConditionalToolResult()
|
||||
""")
|
||||
|
||||
to_remove = [k for k in sys.modules if "conditional_tool" in k]
|
||||
for k in to_remove:
|
||||
del sys.modules[k]
|
||||
|
||||
monkeypatch.delenv("TEST_VAR", raising=False)
|
||||
vibe_config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
tool_paths=[tool_dir],
|
||||
)
|
||||
manager = ToolManager(lambda: vibe_config)
|
||||
assert "conditional_tool" not in manager.available_tools
|
||||
|
||||
to_remove = [k for k in sys.modules if "conditional_tool" in k]
|
||||
for k in to_remove:
|
||||
del sys.modules[k]
|
||||
|
||||
monkeypatch.setenv("TEST_VAR", "1")
|
||||
manager2 = ToolManager(lambda: vibe_config)
|
||||
assert "conditional_tool" in manager2.available_tools
|
||||
|
||||
def test_default_is_available_returns_true(self):
|
||||
"""Tools without is_available() override should be available."""
|
||||
vibe_config = build_test_vibe_config(
|
||||
system_prompt_id="tests", include_project_context=False
|
||||
)
|
||||
manager = ToolManager(lambda: vibe_config)
|
||||
|
||||
assert "bash" in manager.available_tools
|
||||
|
||||
|
||||
class TestToolManagerModuleReuse:
|
||||
"""Tests for module reuse across ToolManager instances.
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import pytest
|
|||
|
||||
from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp
|
||||
from vibe.core.tools.mcp import (
|
||||
MCPRegistry,
|
||||
MCPToolResult,
|
||||
RemoteTool,
|
||||
_mcp_stderr_capture,
|
||||
|
|
@ -372,3 +373,181 @@ class TestMCPConfigModels:
|
|||
|
||||
# Trailing special chars become underscores which are then stripped
|
||||
assert config.name == "my_server"
|
||||
|
||||
|
||||
class TestMCPRegistry:
|
||||
def _make_http_server(
|
||||
self, name: str, url: str = "http://localhost:8080"
|
||||
) -> MCPHttp:
|
||||
return MCPHttp(name=name, transport="http", url=url)
|
||||
|
||||
def _make_stdio_server(self, name: str, command: str = "python -m srv") -> MCPStdio:
|
||||
return MCPStdio(name=name, transport="stdio", command=command)
|
||||
|
||||
def test_server_key_is_stable(self):
|
||||
srv = self._make_http_server("s1")
|
||||
registry = MCPRegistry()
|
||||
|
||||
assert registry._server_key(srv) == registry._server_key(srv)
|
||||
|
||||
def test_different_configs_produce_different_keys(self):
|
||||
registry = MCPRegistry()
|
||||
s1 = self._make_http_server("s1", url="http://a:1")
|
||||
s2 = self._make_http_server("s2", url="http://b:2")
|
||||
|
||||
assert registry._server_key(s1) != registry._server_key(s2)
|
||||
|
||||
def test_get_tools_caches_discovery(self):
|
||||
registry = MCPRegistry()
|
||||
srv = self._make_http_server("cached")
|
||||
remote = RemoteTool(name="tool_a", description="A tool")
|
||||
proxy = create_mcp_http_proxy_tool_class(
|
||||
url="http://localhost:8080", remote=remote, alias="cached"
|
||||
)
|
||||
|
||||
key = registry._server_key(srv)
|
||||
registry._cache[key] = {proxy.get_name(): proxy}
|
||||
|
||||
tools = registry.get_tools([srv])
|
||||
assert "cached_tool_a" in tools
|
||||
assert tools["cached_tool_a"] is proxy
|
||||
|
||||
def test_get_tools_returns_empty_for_no_servers(self):
|
||||
registry = MCPRegistry()
|
||||
|
||||
assert registry.get_tools([]) == {}
|
||||
|
||||
def test_clear_drops_cache(self):
|
||||
registry = MCPRegistry()
|
||||
srv = self._make_http_server("s")
|
||||
proxy = create_mcp_http_proxy_tool_class(
|
||||
url="http://localhost:8080", remote=RemoteTool(name="t"), alias="s"
|
||||
)
|
||||
key = registry._server_key(srv)
|
||||
registry._cache[key] = {proxy.get_name(): proxy}
|
||||
|
||||
registry.clear()
|
||||
|
||||
assert len(registry._cache) == 0
|
||||
|
||||
def test_cache_survives_multiple_get_tools_calls(self):
|
||||
registry = MCPRegistry()
|
||||
srv = self._make_http_server("stable")
|
||||
remote = RemoteTool(name="t1")
|
||||
proxy = create_mcp_http_proxy_tool_class(
|
||||
url="http://localhost:8080", remote=remote, alias="stable"
|
||||
)
|
||||
|
||||
key = registry._server_key(srv)
|
||||
registry._cache[key] = {proxy.get_name(): proxy}
|
||||
|
||||
first = registry.get_tools([srv])
|
||||
second = registry.get_tools([srv])
|
||||
|
||||
assert first == second
|
||||
assert first["stable_t1"] is second["stable_t1"]
|
||||
|
||||
def test_disjoint_server_lists_across_agents(self):
|
||||
registry = MCPRegistry()
|
||||
|
||||
srv_x = self._make_http_server("x", url="http://x:1")
|
||||
srv_y = self._make_http_server("y", url="http://y:2")
|
||||
|
||||
proxy_x = create_mcp_http_proxy_tool_class(
|
||||
url="http://x:1", remote=RemoteTool(name="tx"), alias="x"
|
||||
)
|
||||
proxy_y = create_mcp_http_proxy_tool_class(
|
||||
url="http://y:2", remote=RemoteTool(name="ty"), alias="y"
|
||||
)
|
||||
|
||||
registry._cache[registry._server_key(srv_x)] = {proxy_x.get_name(): proxy_x}
|
||||
registry._cache[registry._server_key(srv_y)] = {proxy_y.get_name(): proxy_y}
|
||||
|
||||
agent_a_tools = registry.get_tools([srv_x])
|
||||
agent_b_tools = registry.get_tools([srv_y])
|
||||
|
||||
assert "x_tx" in agent_a_tools
|
||||
assert "y_ty" not in agent_a_tools
|
||||
assert "y_ty" in agent_b_tools
|
||||
assert "x_tx" not in agent_b_tools
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_http_success(self):
|
||||
registry = MCPRegistry()
|
||||
srv = self._make_http_server("demo", url="http://demo:9090")
|
||||
remote = RemoteTool(name="hello", description="Hi")
|
||||
|
||||
with patch(
|
||||
"vibe.core.tools.mcp.registry.list_tools_http", return_value=[remote]
|
||||
):
|
||||
tools = await registry._discover_http(srv)
|
||||
|
||||
assert tools is not None
|
||||
assert len(tools) == 1
|
||||
name = next(iter(tools))
|
||||
assert name == "demo_hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_http_failure_returns_none(self):
|
||||
registry = MCPRegistry()
|
||||
srv = self._make_http_server("fail", url="http://fail:1")
|
||||
|
||||
with patch(
|
||||
"vibe.core.tools.mcp.registry.list_tools_http",
|
||||
side_effect=ConnectionError("down"),
|
||||
):
|
||||
tools = await registry._discover_http(srv)
|
||||
|
||||
assert tools is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_stdio_success(self):
|
||||
registry = MCPRegistry()
|
||||
srv = self._make_stdio_server("local", command="python -m local_srv")
|
||||
remote = RemoteTool(name="run", description="Run it")
|
||||
|
||||
with patch(
|
||||
"vibe.core.tools.mcp.registry.list_tools_stdio", return_value=[remote]
|
||||
):
|
||||
tools = await registry._discover_stdio(srv)
|
||||
|
||||
assert tools is not None
|
||||
assert len(tools) == 1
|
||||
name = next(iter(tools))
|
||||
assert name == "local_run"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_stdio_failure_returns_none(self):
|
||||
registry = MCPRegistry()
|
||||
srv = self._make_stdio_server("broken")
|
||||
|
||||
with patch(
|
||||
"vibe.core.tools.mcp.registry.list_tools_stdio",
|
||||
side_effect=OSError("no binary"),
|
||||
):
|
||||
tools = await registry._discover_stdio(srv)
|
||||
|
||||
assert tools is None
|
||||
|
||||
def test_get_tools_discovers_only_uncached(self):
|
||||
registry = MCPRegistry()
|
||||
|
||||
cached_srv = self._make_http_server("cached", url="http://c:1")
|
||||
new_srv = self._make_http_server("new", url="http://n:2")
|
||||
|
||||
cached_proxy = create_mcp_http_proxy_tool_class(
|
||||
url="http://c:1", remote=RemoteTool(name="ct"), alias="cached"
|
||||
)
|
||||
registry._cache[registry._server_key(cached_srv)] = {
|
||||
cached_proxy.get_name(): cached_proxy
|
||||
}
|
||||
|
||||
new_remote = RemoteTool(name="nt")
|
||||
with patch(
|
||||
"vibe.core.tools.mcp.registry.list_tools_http", return_value=[new_remote]
|
||||
):
|
||||
tools = registry.get_tools([cached_srv, new_srv])
|
||||
|
||||
assert "cached_ct" in tools
|
||||
assert "new_nt" in tools
|
||||
assert len(registry._cache) == 2
|
||||
|
|
|
|||
171
tests/tools/test_mcp_sampling.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from mcp.types import (
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
ErrorData,
|
||||
SamplingMessage,
|
||||
TextContent,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.core.tools.mcp_sampling import (
|
||||
MCPSamplingHandler,
|
||||
_extract_text_content,
|
||||
_map_sampling_messages,
|
||||
)
|
||||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
|
||||
def _make_config(model_name: str = "test-model") -> MagicMock:
|
||||
config = MagicMock()
|
||||
model = MagicMock()
|
||||
model.name = model_name
|
||||
model.temperature = 0.7
|
||||
config.get_active_model.return_value = model
|
||||
return config
|
||||
|
||||
|
||||
def _make_params(
|
||||
messages: list[SamplingMessage] | None = None,
|
||||
system_prompt: str | None = None,
|
||||
temperature: float | None = None,
|
||||
max_tokens: int = 100,
|
||||
) -> CreateMessageRequestParams:
|
||||
if messages is None:
|
||||
messages = [
|
||||
SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))
|
||||
]
|
||||
return CreateMessageRequestParams(
|
||||
messages=messages,
|
||||
systemPrompt=system_prompt,
|
||||
temperature=temperature,
|
||||
maxTokens=max_tokens,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractTextContent:
|
||||
def test_single_text_block(self) -> None:
|
||||
block = TextContent(type="text", text="hello")
|
||||
assert _extract_text_content(block) == "hello"
|
||||
|
||||
def test_list_of_text_blocks(self) -> None:
|
||||
blocks = [
|
||||
TextContent(type="text", text="a"),
|
||||
TextContent(type="text", text="b"),
|
||||
]
|
||||
assert _extract_text_content(blocks) == "a\nb"
|
||||
|
||||
def test_unsupported_single_block(self) -> None:
|
||||
block = MagicMock(type="image", text=None)
|
||||
assert _extract_text_content(block) == ""
|
||||
|
||||
def test_mixed_blocks_skips_non_text(self) -> None:
|
||||
blocks = [TextContent(type="text", text="keep"), MagicMock(type="image")]
|
||||
assert _extract_text_content(blocks) == "keep"
|
||||
|
||||
|
||||
class TestMapSamplingMessages:
|
||||
def test_maps_user_message(self) -> None:
|
||||
msgs = [
|
||||
SamplingMessage(role="user", content=TextContent(type="text", text="hi"))
|
||||
]
|
||||
result = _map_sampling_messages(msgs)
|
||||
assert len(result) == 1
|
||||
assert result[0].role == Role.user
|
||||
assert result[0].content == "hi"
|
||||
|
||||
def test_maps_assistant_message(self) -> None:
|
||||
msgs = [
|
||||
SamplingMessage(
|
||||
role="assistant", content=TextContent(type="text", text="hello")
|
||||
)
|
||||
]
|
||||
result = _map_sampling_messages(msgs)
|
||||
assert result[0].role == Role.assistant
|
||||
assert result[0].content == "hello"
|
||||
|
||||
def test_maps_multiple_messages(self) -> None:
|
||||
msgs = [
|
||||
SamplingMessage(role="user", content=TextContent(type="text", text="q")),
|
||||
SamplingMessage(
|
||||
role="assistant", content=TextContent(type="text", text="a")
|
||||
),
|
||||
]
|
||||
result = _map_sampling_messages(msgs)
|
||||
assert len(result) == 2
|
||||
assert result[0].role == Role.user
|
||||
assert result[1].role == Role.assistant
|
||||
|
||||
|
||||
class TestMCPSamplingHandler:
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_text_response(self) -> None:
|
||||
chunk = mock_llm_chunk(content="LLM says hi")
|
||||
backend = FakeBackend(chunk)
|
||||
config = _make_config("my-model")
|
||||
handler = MCPSamplingHandler(
|
||||
backend_getter=lambda: backend, config_getter=lambda: config
|
||||
)
|
||||
|
||||
result = await handler(MagicMock(), _make_params())
|
||||
|
||||
assert not isinstance(result, Exception)
|
||||
assert isinstance(result, CreateMessageResult)
|
||||
assert result.role == "assistant"
|
||||
assert result.content.type == "text"
|
||||
assert result.content.text == "LLM says hi"
|
||||
assert result.model == "my-model"
|
||||
assert result.stopReason == "endTurn"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prompt_prepended(self) -> None:
|
||||
chunk = mock_llm_chunk(content="ok")
|
||||
backend = FakeBackend(chunk)
|
||||
config = _make_config()
|
||||
handler = MCPSamplingHandler(
|
||||
backend_getter=lambda: backend, config_getter=lambda: config
|
||||
)
|
||||
|
||||
await handler(MagicMock(), _make_params(system_prompt="Be helpful"))
|
||||
|
||||
sent_messages: list[LLMMessage] = backend.requests_messages[0]
|
||||
assert sent_messages[0].role == Role.system
|
||||
assert sent_messages[0].content == "Be helpful"
|
||||
assert sent_messages[1].role == Role.user
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calls_backend_with_messages(self) -> None:
|
||||
"""Verify the handler forwards messages to the backend."""
|
||||
chunk = mock_llm_chunk(content="ok")
|
||||
backend = FakeBackend(chunk)
|
||||
config = _make_config()
|
||||
handler = MCPSamplingHandler(
|
||||
backend_getter=lambda: backend, config_getter=lambda: config
|
||||
)
|
||||
|
||||
await handler(MagicMock(), _make_params())
|
||||
|
||||
assert len(backend.requests_messages) == 1
|
||||
sent = backend.requests_messages[0]
|
||||
assert len(sent) == 1
|
||||
assert sent[0].role == Role.user
|
||||
assert sent[0].content == "Hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_error_on_backend_failure(self) -> None:
|
||||
backend = FakeBackend(exception_to_raise=RuntimeError("boom"))
|
||||
config = _make_config()
|
||||
handler = MCPSamplingHandler(
|
||||
backend_getter=lambda: backend, config_getter=lambda: config
|
||||
)
|
||||
|
||||
result = await handler(MagicMock(), _make_params())
|
||||
|
||||
assert isinstance(result, ErrorData)
|
||||
assert result.code == -1
|
||||
assert "boom" in result.message
|
||||
|
|
@ -8,7 +8,7 @@ from tests.conftest import build_test_vibe_config
|
|||
from tests.mock.utils import collect_result
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.agents.models import BUILTIN_AGENTS, AgentType
|
||||
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
|
||||
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError, ToolPermission
|
||||
from vibe.core.tools.builtins.task import Task, TaskArgs, TaskResult, TaskToolConfig
|
||||
from vibe.core.types import AssistantEvent, LLMMessage, Role
|
||||
|
||||
|
|
@ -76,6 +76,50 @@ class TestTaskToolValidation:
|
|||
assert agent.agent_type == AgentType.SUBAGENT
|
||||
|
||||
|
||||
class TestTaskToolResolvePermission:
|
||||
def test_explore_allowed_by_default(self, task_tool: Task) -> None:
|
||||
args = TaskArgs(task="do something", agent="explore")
|
||||
result = task_tool.resolve_permission(args)
|
||||
assert result == ToolPermission.ALWAYS
|
||||
|
||||
def test_unknown_agent_returns_none(self, task_tool: Task) -> None:
|
||||
args = TaskArgs(task="do something", agent="custom_agent")
|
||||
result = task_tool.resolve_permission(args)
|
||||
assert result is None
|
||||
|
||||
def test_denylist_takes_precedence(self) -> None:
|
||||
config = TaskToolConfig(allowlist=["explore"], denylist=["explore"])
|
||||
tool = Task(config=config, state=BaseToolState())
|
||||
args = TaskArgs(task="do something", agent="explore")
|
||||
result = tool.resolve_permission(args)
|
||||
assert result == ToolPermission.NEVER
|
||||
|
||||
def test_glob_pattern_in_allowlist(self) -> None:
|
||||
config = TaskToolConfig(allowlist=["exp*"])
|
||||
tool = Task(config=config, state=BaseToolState())
|
||||
args = TaskArgs(task="do something", agent="explore")
|
||||
result = tool.resolve_permission(args)
|
||||
assert result == ToolPermission.ALWAYS
|
||||
|
||||
def test_glob_pattern_in_denylist(self) -> None:
|
||||
config = TaskToolConfig(denylist=["danger*"])
|
||||
tool = Task(config=config, state=BaseToolState())
|
||||
args = TaskArgs(task="do something", agent="dangerous_agent")
|
||||
result = tool.resolve_permission(args)
|
||||
assert result == ToolPermission.NEVER
|
||||
|
||||
def test_empty_lists_returns_none(self) -> None:
|
||||
config = TaskToolConfig(allowlist=[], denylist=[])
|
||||
tool = Task(config=config, state=BaseToolState())
|
||||
args = TaskArgs(task="do something", agent="explore")
|
||||
result = tool.resolve_permission(args)
|
||||
assert result is None
|
||||
|
||||
def test_default_config_has_explore_in_allowlist(self) -> None:
|
||||
config = TaskToolConfig()
|
||||
assert "explore" in config.allowlist
|
||||
|
||||
|
||||
class TestTaskToolExecution:
|
||||
@pytest.fixture
|
||||
def ctx(self) -> InvokeContext:
|
||||
|
|
|
|||
253
tests/tools/test_webfetch.py
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from tests.mock.utils import collect_result
|
||||
from vibe.core.tools.base import BaseToolState, ToolError
|
||||
from vibe.core.tools.builtins.webfetch import WebFetch, WebFetchArgs, WebFetchConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def webfetch():
|
||||
config = WebFetchConfig()
|
||||
return WebFetch(config=config, state=BaseToolState())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def webfetch_small():
|
||||
config = WebFetchConfig(max_content_bytes=100)
|
||||
return WebFetch(config=config, state=BaseToolState())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_bare_domain_gets_https(webfetch):
|
||||
respx.get("https://example.com").mock(
|
||||
return_value=httpx.Response(
|
||||
200, text="ok", headers={"Content-Type": "text/plain"}
|
||||
)
|
||||
)
|
||||
result = await collect_result(webfetch.run(WebFetchArgs(url="example.com")))
|
||||
assert result.url == "https://example.com"
|
||||
assert result.content == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_http_url_stays_http(webfetch):
|
||||
respx.get("http://example.com").mock(
|
||||
return_value=httpx.Response(
|
||||
200, text="ok", headers={"Content-Type": "text/plain"}
|
||||
)
|
||||
)
|
||||
result = await collect_result(webfetch.run(WebFetchArgs(url="http://example.com")))
|
||||
assert result.url == "http://example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_https_url_stays_https(webfetch):
|
||||
respx.get("https://example.com").mock(
|
||||
return_value=httpx.Response(
|
||||
200, text="ok", headers={"Content-Type": "text/plain"}
|
||||
)
|
||||
)
|
||||
result = await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
|
||||
assert result.url == "https://example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_protocol_relative_url_normalized(webfetch):
|
||||
respx.get("https://example.com").mock(
|
||||
return_value=httpx.Response(
|
||||
200, text="ok", headers={"Content-Type": "text/plain"}
|
||||
)
|
||||
)
|
||||
result = await collect_result(webfetch.run(WebFetchArgs(url="//example.com")))
|
||||
assert result.url == "https://example.com"
|
||||
assert result.content == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ftp_scheme_rejected(webfetch):
|
||||
with pytest.raises(ToolError, match="Invalid URL scheme: ftp"):
|
||||
await collect_result(webfetch.run(WebFetchArgs(url="ftp://example.com")))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_url_rejected(webfetch):
|
||||
with pytest.raises(ToolError, match="URL cannot be empty"):
|
||||
await collect_result(webfetch.run(WebFetchArgs(url=" ")))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_html_converted_to_markdown(webfetch):
|
||||
html = "<html><body><h1>Title</h1><p>Hello world</p></body></html>"
|
||||
respx.get("https://example.com").mock(
|
||||
return_value=httpx.Response(
|
||||
200, text=html, headers={"Content-Type": "text/html; charset=utf-8"}
|
||||
)
|
||||
)
|
||||
result = await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
|
||||
assert "# Title" in result.content
|
||||
assert "Hello world" in result.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_plain_text_unchanged(webfetch):
|
||||
respx.get("https://example.com/file.txt").mock(
|
||||
return_value=httpx.Response(
|
||||
200, text="just text", headers={"Content-Type": "text/plain"}
|
||||
)
|
||||
)
|
||||
result = await collect_result(
|
||||
webfetch.run(WebFetchArgs(url="https://example.com/file.txt"))
|
||||
)
|
||||
assert result.content == "just text"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_scripts_stripped_from_markdown(webfetch):
|
||||
html = "<html><body><script>alert('xss')</script><style>.x{}</style><p>Clean</p></body></html>"
|
||||
respx.get("https://example.com").mock(
|
||||
return_value=httpx.Response(
|
||||
200, text=html, headers={"Content-Type": "text/html"}
|
||||
)
|
||||
)
|
||||
result = await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
|
||||
assert "alert" not in result.content
|
||||
assert ".x{}" not in result.content
|
||||
assert "Clean" in result.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_cloudflare_retry_on_challenge(webfetch):
|
||||
route = respx.get("https://example.com")
|
||||
route.side_effect = [
|
||||
httpx.Response(403, headers={"cf-mitigated": "challenge"}),
|
||||
httpx.Response(200, text="success", headers={"Content-Type": "text/plain"}),
|
||||
]
|
||||
result = await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
|
||||
assert result.content == "success"
|
||||
assert route.call_count == 2
|
||||
|
||||
second_request = route.calls[1].request
|
||||
assert second_request.headers["User-Agent"] == "vibe-cli"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_regular_403_not_retried(webfetch):
|
||||
route = respx.get("https://example.com").mock(
|
||||
return_value=httpx.Response(403, headers={"Content-Type": "text/plain"})
|
||||
)
|
||||
with pytest.raises(ToolError, match="HTTP error 403"):
|
||||
await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
|
||||
assert route.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_truncates_to_max_bytes_with_disclaimer(webfetch_small):
|
||||
body = "a" * 200
|
||||
respx.get("https://example.com").mock(
|
||||
return_value=httpx.Response(
|
||||
200, text=body, headers={"Content-Type": "text/plain"}
|
||||
)
|
||||
)
|
||||
result = await collect_result(
|
||||
webfetch_small.run(WebFetchArgs(url="https://example.com"))
|
||||
)
|
||||
assert result.content.startswith("a" * 100)
|
||||
assert "[Content truncated due to size limit]" in result.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_truncates_html_with_disclaimer(webfetch_small):
|
||||
html = (
|
||||
"<html><body><h2>first title</h2>"
|
||||
+ "x" * 200
|
||||
+ "<h2>second title</h2></body></html>"
|
||||
)
|
||||
respx.get("https://example.com").mock(
|
||||
return_value=httpx.Response(
|
||||
200, text=html, headers={"Content-Type": "text/html"}
|
||||
)
|
||||
)
|
||||
result = await collect_result(
|
||||
webfetch_small.run(WebFetchArgs(url="https://example.com"))
|
||||
)
|
||||
|
||||
assert "## first title" in result.content
|
||||
assert "## second title" not in result.content
|
||||
assert "[Content truncated due to size limit]" in result.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_http_404_raises_tool_error(webfetch):
|
||||
respx.get("https://example.com").mock(return_value=httpx.Response(404))
|
||||
with pytest.raises(ToolError, match="HTTP error 404"):
|
||||
await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_http_500_raises_tool_error(webfetch):
|
||||
respx.get("https://example.com").mock(return_value=httpx.Response(500))
|
||||
with pytest.raises(ToolError, match="HTTP error 500"):
|
||||
await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_timeout_raises_tool_error(webfetch):
|
||||
respx.get("https://example.com").mock(side_effect=httpx.ReadTimeout("timed out"))
|
||||
with pytest.raises(ToolError, match="Request timed out"):
|
||||
await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_network_error_raises_tool_error(webfetch):
|
||||
respx.get("https://example.com").mock(
|
||||
side_effect=httpx.ConnectError("connection refused")
|
||||
)
|
||||
with pytest.raises(ToolError, match="Failed to fetch URL"):
|
||||
await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_timeout_rejected(webfetch):
|
||||
with pytest.raises(ToolError, match="Timeout must be a positive number"):
|
||||
await collect_result(
|
||||
webfetch.run(WebFetchArgs(url="https://example.com", timeout=-1))
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_timeout_rejected(webfetch):
|
||||
with pytest.raises(ToolError, match="Timeout must be a positive number"):
|
||||
await collect_result(
|
||||
webfetch.run(WebFetchArgs(url="https://example.com", timeout=0))
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_over_max_timeout_rejected(webfetch):
|
||||
with pytest.raises(ToolError, match="Timeout cannot exceed"):
|
||||
await collect_result(
|
||||
webfetch.run(WebFetchArgs(url="https://example.com", timeout=999))
|
||||
)
|
||||
|
||||
|
||||
def test_get_status_text():
|
||||
assert WebFetch.get_status_text() == "Fetching URL"
|
||||
165
tests/tools/test_websearch.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import mistralai
|
||||
import pytest
|
||||
|
||||
from tests.mock.utils import collect_result
|
||||
from vibe.core.tools.base import BaseToolState, ToolError
|
||||
from vibe.core.tools.builtins.websearch import WebSearch, WebSearchArgs, WebSearchConfig
|
||||
|
||||
|
||||
def _make_response(
|
||||
content: list | None = None, outputs: list | None = None
|
||||
) -> mistralai.ConversationResponse:
|
||||
if outputs is None:
|
||||
outputs = [mistralai.MessageOutputEntry(content=content or [])]
|
||||
return mistralai.ConversationResponse(
|
||||
conversation_id="test",
|
||||
outputs=outputs,
|
||||
usage=mistralai.ConversationUsageInfo(
|
||||
prompt_tokens=10, completion_tokens=20, total_tokens=30
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def websearch(monkeypatch):
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
config = WebSearchConfig()
|
||||
return WebSearch(config=config, state=BaseToolState())
|
||||
|
||||
|
||||
def test_parse_text_chunks(websearch):
|
||||
response = _make_response(
|
||||
content=[mistralai.TextChunk(text="Hello "), mistralai.TextChunk(text="world")]
|
||||
)
|
||||
result = websearch._parse_response(response)
|
||||
assert result.answer == "Hello world"
|
||||
assert result.sources == []
|
||||
|
||||
|
||||
def test_parse_sources_deduped(websearch):
|
||||
response = _make_response(
|
||||
content=[
|
||||
mistralai.TextChunk(text="Answer"),
|
||||
mistralai.ToolReferenceChunk(
|
||||
tool="web_search", title="Site A", url="https://a.com"
|
||||
),
|
||||
mistralai.ToolReferenceChunk(
|
||||
tool="web_search", title="Site A duplicate", url="https://a.com"
|
||||
),
|
||||
mistralai.ToolReferenceChunk(
|
||||
tool="web_search", title="Site B", url="https://b.com"
|
||||
),
|
||||
]
|
||||
)
|
||||
result = websearch._parse_response(response)
|
||||
assert result.answer == "Answer"
|
||||
assert len(result.sources) == 2
|
||||
assert result.sources[0].url == "https://a.com"
|
||||
assert result.sources[0].title == "Site A"
|
||||
assert result.sources[1].url == "https://b.com"
|
||||
|
||||
|
||||
def test_parse_skips_source_without_url(websearch):
|
||||
response = _make_response(
|
||||
content=[
|
||||
mistralai.TextChunk(text="Answer"),
|
||||
mistralai.ToolReferenceChunk(tool="web_search", title="No URL"),
|
||||
]
|
||||
)
|
||||
result = websearch._parse_response(response)
|
||||
assert result.sources == []
|
||||
|
||||
|
||||
def test_parse_empty_text_raises(websearch):
|
||||
response = _make_response(content=[])
|
||||
with pytest.raises(ToolError, match="No text in agent response"):
|
||||
websearch._parse_response(response)
|
||||
|
||||
|
||||
def test_parse_whitespace_only_raises(websearch):
|
||||
response = _make_response(content=[mistralai.TextChunk(text=" ")])
|
||||
with pytest.raises(ToolError, match="No text in agent response"):
|
||||
websearch._parse_response(response)
|
||||
|
||||
|
||||
def test_parse_skips_non_message_entries(websearch):
|
||||
response = _make_response(
|
||||
outputs=[
|
||||
mistralai.MessageOutputEntry(content=[mistralai.TextChunk(text="Answer")])
|
||||
]
|
||||
)
|
||||
result = websearch._parse_response(response)
|
||||
assert result.answer == "Answer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_missing_api_key(monkeypatch):
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
config = WebSearchConfig()
|
||||
ws = WebSearch(config=config, state=BaseToolState())
|
||||
with pytest.raises(ToolError, match="MISTRAL_API_KEY"):
|
||||
await collect_result(ws.run(WebSearchArgs(query="test")))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_returns_parsed_result(websearch):
|
||||
response = _make_response(
|
||||
content=[
|
||||
mistralai.TextChunk(text="The answer"),
|
||||
mistralai.ToolReferenceChunk(
|
||||
tool="web_search", title="Source", url="https://example.com"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
mock_start = AsyncMock(return_value=response)
|
||||
with patch.object(mistralai.Mistral, "beta", create=True) as mock_beta:
|
||||
mock_beta.conversations.start_async = mock_start
|
||||
with patch.object(mistralai.Mistral, "__aenter__", return_value=None):
|
||||
with patch.object(mistralai.Mistral, "__aexit__", return_value=None):
|
||||
result = await collect_result(
|
||||
websearch.run(WebSearchArgs(query="test query"))
|
||||
)
|
||||
|
||||
assert result.answer == "The answer"
|
||||
assert len(result.sources) == 1
|
||||
assert result.sources[0].url == "https://example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_sdk_error_wrapped(websearch):
|
||||
from unittest.mock import Mock
|
||||
|
||||
import httpx
|
||||
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
mock_response.status_code = 500
|
||||
mock_response.text = "error"
|
||||
mock_response.headers = httpx.Headers({"content-type": "application/json"})
|
||||
|
||||
with patch.object(mistralai.Mistral, "beta", create=True) as mock_beta:
|
||||
mock_beta.conversations.start_async = AsyncMock(
|
||||
side_effect=mistralai.SDKError("API failed", mock_response)
|
||||
)
|
||||
with patch.object(mistralai.Mistral, "__aenter__", return_value=None):
|
||||
with patch.object(mistralai.Mistral, "__aexit__", return_value=None):
|
||||
with pytest.raises(ToolError, match="Mistral API error"):
|
||||
await collect_result(websearch.run(WebSearchArgs(query="test")))
|
||||
|
||||
|
||||
def test_is_available_with_key(monkeypatch):
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "key")
|
||||
assert WebSearch.is_available() is True
|
||||
|
||||
|
||||
def test_is_available_without_key(monkeypatch):
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
assert WebSearch.is_available() is False
|
||||
|
||||
|
||||
def test_get_status_text():
|
||||
assert WebSearch.get_status_text() == "Searching the web"
|
||||