Co-authored-by: Antoine <33425718+anth2o@users.noreply.github.com>
Co-authored-by: Bastien <bastien.baret@gmail.com>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Robin Gullo <robin.gullo@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-04-28 17:44:07 +02:00 committed by GitHub
parent a83c81ecf5
commit 632ea8c032
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
253 changed files with 13965 additions and 2525 deletions

View file

@ -67,6 +67,7 @@ def create_test_session():
messages: list[dict] | None = None,
title: str | None = None,
end_time: str | None = None,
parent_session_id: str | None = None,
) -> Path:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
session_folder = session_dir / f"session_{timestamp}_{session_id[:8]}"
@ -84,9 +85,14 @@ def create_test_session():
"session_id": session_id,
"start_time": "2024-01-01T12:00:00Z",
"end_time": end_time or "2024-01-01T12:05:00Z",
"git_commit": None,
"git_branch": None,
"username": "test-user",
"environment": {"working_directory": cwd},
"title": title,
}
if parent_session_id is not None:
metadata["parent_session_id"] = parent_session_id
metadata_file = session_folder / "meta.json"
with metadata_file.open("w", encoding="utf-8") as f:

View file

@ -513,12 +513,11 @@ class TestSessionUpdates:
),
),
)
text_responses = await read_multiple_responses(process, max_count=10)
text_responses = await read_multiple_responses(
process, max_count=15, timeout_per_response=2.0
)
assert len(text_responses) > 0
responses = [
UpdateJsonRpcNotification.model_validate(json.loads(r))
for r in text_responses
]
responses = parse_conversation(text_responses)
tool_call = next(
(

171
tests/acp/test_acp_hooks.py Normal file
View file

@ -0,0 +1,171 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import tomli_w
from tests.conftest import get_base_config
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_client import FakeClient
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agent_loop import AgentLoop
from vibe.core.hooks.models import HookConfigResult, HookType
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role, SessionMetadata
@pytest.fixture
def backend() -> FakeBackend:
return FakeBackend(
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Hi"),
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
)
)
def _write_config(config_dir: Path, *, enable_hooks: bool) -> None:
config = get_base_config()
config["enable_experimental_hooks"] = enable_hooks
with (config_dir / "config.toml").open("wb") as f:
tomli_w.dump(config, f)
def _create_acp_agent() -> VibeAcpAgentLoop:
agent = VibeAcpAgentLoop()
client = FakeClient()
agent.on_connect(client)
client.on_connect(agent)
return agent
def _spy_agent_loop(backend: FakeBackend) -> tuple[MagicMock, type[AgentLoop]]:
spy = MagicMock()
class Patched(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
spy(*args, **kwargs)
super().__init__(*args, **kwargs, backend=backend)
return spy, Patched
def _hook_config_from_spy(spy: MagicMock) -> HookConfigResult | None:
hook_config_result = spy.call_args.kwargs["hook_config_result"]
if hook_config_result is None:
return None
assert isinstance(hook_config_result, HookConfigResult)
return hook_config_result
async def _new_session(backend: FakeBackend) -> MagicMock:
spy, patched_loop = _spy_agent_loop(backend)
acp = _create_acp_agent()
with patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=patched_loop):
await acp.new_session(cwd=str(Path.cwd()), mcp_servers=[])
spy.assert_called_once()
return spy
async def _load_session(
backend: FakeBackend,
tmp_path: Path,
*,
session_id: str = "session-id",
loaded_messages: list[LLMMessage] | None = None,
) -> MagicMock:
spy, patched_loop = _spy_agent_loop(backend)
acp = _create_acp_agent()
session_dir = tmp_path / "sessions" / session_id
session_dir.mkdir(parents=True)
disk_metadata = SessionMetadata(
session_id=session_id,
start_time="2024-01-01T12:00:00Z",
end_time="2024-01-01T12:05:00Z",
git_commit=None,
git_branch=None,
environment={"working_directory": str(Path.cwd())},
username="test-user",
)
(session_dir / "meta.json").write_text(disk_metadata.model_dump_json())
loader_result = (loaded_messages or [], {"session_id": session_id})
with (
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=patched_loop),
patch(
"vibe.acp.acp_agent_loop.SessionLoader.find_session_by_id",
return_value=session_dir,
),
patch(
"vibe.acp.acp_agent_loop.SessionLoader.load_session",
return_value=loader_result,
),
):
await acp.load_session(
cwd=str(Path.cwd()), session_id=session_id, mcp_servers=[]
)
spy.assert_called_once()
return spy
@pytest.mark.asyncio
class TestAcpHooksLoading:
async def test_new_session_hooks_enabled_loads_valid_hook(
self, backend: FakeBackend, config_dir: Path
) -> None:
_write_config(config_dir, enable_hooks=True)
(config_dir / "hooks.toml").write_text(
'[[hooks]]\nname = "lint"\ntype = "post_agent_turn"\ncommand = "eslint ."\n'
)
spy = await _new_session(backend)
result = _hook_config_from_spy(spy)
assert result is not None
assert len(result.hooks) == 1
assert result.hooks[0].name == "lint"
assert result.hooks[0].type == HookType.POST_AGENT_TURN
assert result.hooks[0].command == "eslint ."
assert result.hooks[0].timeout == 30.0
assert result.issues == []
async def test_new_session_hooks_enabled_invalid_toml(
self, backend: FakeBackend, config_dir: Path
) -> None:
_write_config(config_dir, enable_hooks=True)
(config_dir / "hooks.toml").write_text("{{broken toml")
spy = await _new_session(backend)
result = _hook_config_from_spy(spy)
assert result is not None
assert result.hooks == []
assert len(result.issues) == 1
assert "Failed to parse" in result.issues[0].message
async def test_load_session_hooks_enabled_loads_valid_hook(
self, backend: FakeBackend, config_dir: Path, tmp_path: Path
) -> None:
_write_config(config_dir, enable_hooks=True)
(config_dir / "hooks.toml").write_text(
'[[hooks]]\nname = "lint"\ntype = "post_agent_turn"\ncommand = "eslint ."\n'
)
spy = await _load_session(
backend,
tmp_path,
loaded_messages=[
LLMMessage(role=Role.system, content="system"),
LLMMessage(role=Role.user, content="hello"),
],
)
result = _hook_config_from_spy(spy)
assert result is not None
assert len(result.hooks) == 1
assert result.hooks[0].name == "lint"
assert result.hooks[0].type == HookType.POST_AGENT_TURN
assert result.hooks[0].command == "eslint ."
assert result.hooks[0].timeout == 30.0
assert result.issues == []

View file

@ -0,0 +1,101 @@
from __future__ import annotations
import asyncio
from typing import Any, cast
from unittest.mock import AsyncMock
from acp import RequestError
from acp.schema import TextContentBlock
import pytest
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
class TestCloseSession:
@pytest.mark.asyncio
async def test_close_session_removes_session_and_closes_resources(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(cwd=".", mcp_servers=[])
session = acp_agent_loop.sessions[session_response.session_id]
backend_close = AsyncMock()
telemetry_close = AsyncMock()
cast(Any, session.agent_loop.backend).close = backend_close
session.agent_loop.telemetry_client.aclose = telemetry_close
response = await acp_agent_loop.close_session(session_response.session_id)
assert response is not None
assert session_response.session_id not in acp_agent_loop.sessions
backend_close.assert_awaited_once()
telemetry_close.assert_awaited_once()
@pytest.mark.asyncio
async def test_close_session_cancels_active_prompt(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(cwd=".", mcp_servers=[])
session = acp_agent_loop.sessions[session_response.session_id]
async def wait_forever() -> None:
await asyncio.Event().wait()
task = session.set_prompt_task(wait_forever())
session.agent_loop.telemetry_client.aclose = AsyncMock()
await acp_agent_loop.close_session(session_response.session_id)
assert task.cancelled()
@pytest.mark.asyncio
async def test_close_session_cancels_background_tasks(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(cwd=".", mcp_servers=[])
session = acp_agent_loop.sessions[session_response.session_id]
background_event = asyncio.Event()
async def background_work() -> None:
await background_event.wait()
bg_task = session.spawn(background_work())
assert bg_task is not None
session.agent_loop.telemetry_client.aclose = AsyncMock()
await acp_agent_loop.close_session(session_response.session_id)
assert bg_task.cancelled()
@pytest.mark.asyncio
async def test_close_session_rejects_new_background_tasks(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(cwd=".", mcp_servers=[])
session = acp_agent_loop.sessions[session_response.session_id]
session.agent_loop.telemetry_client.aclose = AsyncMock()
await acp_agent_loop.close_session(session_response.session_id)
async def noop() -> None:
pass
assert session.spawn(noop()) is None
@pytest.mark.asyncio
async def test_closed_session_rejects_new_prompts(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(cwd=".", mcp_servers=[])
session = acp_agent_loop.sessions[session_response.session_id]
session.agent_loop.telemetry_client.aclose = AsyncMock()
await acp_agent_loop.close_session(session_response.session_id)
with pytest.raises(RequestError, match="Session not found"):
await acp_agent_loop.prompt(
prompt=[TextContentBlock(type="text", text="Hello")],
session_id=session_response.session_id,
)

View file

@ -259,6 +259,72 @@ class TestAvailableCommandsWithSkills:
assert "hidden-skill" not in cmd_names
class TestSlashCommandTelemetry:
@pytest.mark.asyncio
async def test_builtin_command_fires_telemetry(
self, acp_agent_loop: VibeAcpAgentLoop, telemetry_events: list[dict]
) -> None:
session_id = await _new_session_and_clear(acp_agent_loop)
telemetry_events.clear()
await _prompt(acp_agent_loop, session_id, "/help")
slash_events = [
e for e in telemetry_events if e["event_name"] == "vibe.slash_command_used"
]
assert len(slash_events) == 1
assert slash_events[0]["properties"]["command"] == "help"
assert slash_events[0]["properties"]["command_type"] == "builtin"
@pytest.mark.asyncio
async def test_skill_command_fires_telemetry(
self,
acp_agent_loop_with_skills: VibeAcpAgentLoop,
skills_dir: Path,
telemetry_events: list[dict],
) -> None:
create_skill(skills_dir, "my-skill", "Does something")
session_id = await _new_session_and_clear(acp_agent_loop_with_skills)
telemetry_events.clear()
await _prompt(acp_agent_loop_with_skills, session_id, "/my-skill")
slash_events = [
e for e in telemetry_events if e["event_name"] == "vibe.slash_command_used"
]
assert len(slash_events) == 1
assert slash_events[0]["properties"]["command"] == "my-skill"
assert slash_events[0]["properties"]["command_type"] == "skill"
@pytest.mark.asyncio
async def test_unknown_slash_command_does_not_fire_telemetry(
self, acp_agent_loop: VibeAcpAgentLoop, telemetry_events: list[dict]
) -> None:
session_id = await _new_session_and_clear(acp_agent_loop)
telemetry_events.clear()
await _prompt(acp_agent_loop, session_id, "/nonexistent")
slash_events = [
e for e in telemetry_events if e["event_name"] == "vibe.slash_command_used"
]
assert slash_events == []
@pytest.mark.asyncio
async def test_regular_message_does_not_fire_telemetry(
self, acp_agent_loop: VibeAcpAgentLoop, telemetry_events: list[dict]
) -> None:
session_id = await _new_session_and_clear(acp_agent_loop)
telemetry_events.clear()
await _prompt(acp_agent_loop, session_id, "Hello world")
slash_events = [
e for e in telemetry_events if e["event_name"] == "vibe.slash_command_used"
]
assert slash_events == []
class TestCommandCaseInsensitivity:
@pytest.mark.asyncio
async def test_uppercase_command(self, acp_agent_loop: VibeAcpAgentLoop) -> None:

View file

@ -0,0 +1,192 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from acp.schema import TextContentBlock
import pytest
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.acp.exceptions import InvalidRequestError
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.session.session_id import extract_suffix
from vibe.core.types import FunctionCall, LLMMessage, Role, ToolCall
class TestACPForkSession:
@pytest.mark.asyncio
async def test_fork_session_clones_history_and_mode(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
source_session = acp_agent_loop.sessions[session_response.session_id]
await acp_agent_loop.set_session_mode(
session_id=source_session.id, mode_id=BuiltinAgentName.PLAN
)
await acp_agent_loop.prompt(
prompt=[TextContentBlock(type="text", text="Say hi")],
session_id=source_session.id,
)
response = await acp_agent_loop.fork_session(
cwd=str(Path.cwd()), session_id=source_session.id, mcp_servers=[]
)
assert response.session_id != source_session.id
assert response.modes is not None
assert response.modes.current_mode_id == BuiltinAgentName.PLAN
assert response.models is not None
assert (
response.models.current_model_id
== source_session.agent_loop.config.active_model
)
assert response.config_options is not None
assert len(response.config_options) == 3
forked_session = acp_agent_loop.sessions[response.session_id]
assert forked_session.agent_loop.agent_profile.name == BuiltinAgentName.PLAN
source_messages = [
message.model_dump(mode="json", exclude_none=True)
for message in source_session.agent_loop.messages
if message.role != Role.system
]
forked_messages = [
message.model_dump(mode="json", exclude_none=True)
for message in forked_session.agent_loop.messages
if message.role != Role.system
]
assert forked_messages == source_messages
@pytest.mark.asyncio
async def test_fork_session_from_user_message_keeps_full_turn(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
source_session = acp_agent_loop.sessions[session_response.session_id]
source_session.agent_loop.messages.extend([
LLMMessage(role=Role.user, content="First", message_id="user-1"),
LLMMessage(
role=Role.assistant, content="First answer", message_id="assistant-1"
),
LLMMessage(
role=Role.assistant,
content="",
message_id="assistant-2",
tool_calls=[
ToolCall(
id="call-1",
index=0,
function=FunctionCall(
name="read_file", arguments='{"path":"a.txt"}'
),
)
],
),
LLMMessage(role=Role.tool, content="contents", tool_call_id="call-1"),
LLMMessage(role=Role.user, content="Second", message_id="user-2"),
LLMMessage(
role=Role.assistant, content="Second answer", message_id="assistant-3"
),
])
response = await acp_agent_loop.fork_session(
cwd=str(Path.cwd()),
session_id=source_session.id,
mcp_servers=[],
messageId="user-1",
)
forked_session = acp_agent_loop.sessions[response.session_id]
assert [
(message.role, message.content, message.message_id, message.tool_call_id)
for message in forked_session.agent_loop.messages
if message.role != Role.system
] == [
(Role.user, "First", "user-1", None),
(Role.assistant, "First answer", "assistant-1", None),
(Role.assistant, "", "assistant-2", None),
(Role.tool, "contents", None, "call-1"),
]
@pytest.mark.asyncio
async def test_fork_session_rejects_non_user_message_id(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
source_session = acp_agent_loop.sessions[session_response.session_id]
source_session.agent_loop.messages.extend([
LLMMessage(role=Role.user, content="First", message_id="user-1"),
LLMMessage(
role=Role.assistant, content="First answer", message_id="assistant-1"
),
])
with pytest.raises(
InvalidRequestError,
match="Fork from message_id is only supported for user messages",
):
await acp_agent_loop.fork_session(
cwd=str(Path.cwd()),
session_id=source_session.id,
mcp_servers=[],
messageId="assistant-1",
)
@pytest.mark.asyncio
async def test_fork_session_sets_parent_session_id(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
source_session = acp_agent_loop.sessions[session_response.session_id]
response = await acp_agent_loop.fork_session(
cwd=str(Path.cwd()), session_id=source_session.id, mcp_servers=[]
)
forked_session = acp_agent_loop.sessions[response.session_id]
assert forked_session.agent_loop.parent_session_id == source_session.id
@pytest.mark.asyncio
async def test_fork_session_rejects_running_session(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
source_session = acp_agent_loop.sessions[session_response.session_id]
source_session.set_prompt_task(asyncio.sleep(10))
with pytest.raises(
InvalidRequestError,
match="Cannot fork a session while the agent loop is running",
):
await acp_agent_loop.fork_session(
cwd=str(Path.cwd()), session_id=source_session.id, mcp_servers=[]
)
await source_session.cancel_prompt()
@pytest.mark.asyncio
async def test_fork_session_preserves_session_id_suffix(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
source_session = acp_agent_loop.sessions[session_response.session_id]
response = await acp_agent_loop.fork_session(
cwd=str(Path.cwd()), session_id=source_session.id, mcp_servers=[]
)
assert extract_suffix(response.session_id) == extract_suffix(source_session.id)

View file

@ -7,6 +7,8 @@ from acp.schema import (
Implementation,
PromptCapabilities,
SessionCapabilities,
SessionCloseCapabilities,
SessionForkCapabilities,
SessionListCapabilities,
)
import pytest
@ -25,10 +27,14 @@ class TestACPInitialize:
prompt_capabilities=PromptCapabilities(
audio=False, embedded_context=True, image=False
),
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
session_capabilities=SessionCapabilities(
close=SessionCloseCapabilities(),
list=SessionListCapabilities(),
fork=SessionForkCapabilities(),
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.8.1"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.0"
)
assert response.auth_methods == []
@ -49,10 +55,14 @@ class TestACPInitialize:
prompt_capabilities=PromptCapabilities(
audio=False, embedded_context=True, image=False
),
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
session_capabilities=SessionCapabilities(
close=SessionCloseCapabilities(),
list=SessionListCapabilities(),
fork=SessionForkCapabilities(),
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.8.1"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.0"
)
assert response.auth_methods is not None

View file

@ -99,7 +99,7 @@ class TestLoadSession:
}
assert response.config_options is not None
assert len(response.config_options) == 2
assert len(response.config_options) == 3
assert response.config_options[0].id == "mode"
assert response.config_options[0].category == "mode"
assert response.config_options[0].current_value == BuiltinAgentName.DEFAULT
@ -118,6 +118,9 @@ class TestLoadSession:
assert len(response.config_options[1].options) == 2
model_option_values = {opt.value for opt in response.config_options[1].options}
assert model_option_values == {"devstral-latest", "devstral-small"}
assert response.config_options[2].id == "thinking"
assert response.config_options[2].category == "thinking"
assert response.config_options[2].current_value == "off"
@pytest.mark.asyncio
async def test_load_session_registers_session_with_original_id(
@ -136,6 +139,7 @@ class TestLoadSession:
assert session_id in acp_agent.sessions
assert acp_agent.sessions[session_id].id == session_id
assert acp_agent.sessions[session_id].agent_loop.session_id == session_id
@pytest.mark.asyncio
async def test_load_session_injects_messages_into_agent_loop(
@ -375,6 +379,36 @@ class TestLoadSession:
assert agent_updates[0].update.content.text == "First response"
assert agent_updates[1].update.content.text == "Second response"
@pytest.mark.asyncio
async def test_load_session_restores_agent_loop_session_identity(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
create_test_session,
) -> None:
acp_agent, _client = acp_agent_with_session_config
session_id = "restore-id-12345678"
parent_session_id = "parent-id-87654321"
cwd = str(Path.cwd())
session_dir = create_test_session(
temp_session_dir, session_id, cwd, parent_session_id=parent_session_id
)
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
agent_loop = acp_agent.sessions[session_id].agent_loop
assert agent_loop.session_id == session_id
assert agent_loop.parent_session_id == parent_session_id
assert agent_loop.session_logger.session_id == session_id
assert agent_loop.session_logger.session_dir == session_dir
assert agent_loop.session_logger.session_metadata is not None
assert (
agent_loop.session_logger.session_metadata.parent_session_id
== parent_session_id
)
@pytest.mark.asyncio
async def test_replay_user_message_has_message_id(
self,

View file

@ -99,7 +99,7 @@ class TestACPNewSession:
# Check config_options
assert session_response.config_options is not None
assert len(session_response.config_options) == 2
assert len(session_response.config_options) == 3
# Mode config option
mode_config = session_response.config_options[0]
@ -125,6 +125,13 @@ class TestACPNewSession:
model_option_values = {opt.value for opt in model_config.options}
assert model_option_values == {"devstral-latest", "devstral-small"}
# Thinking config option
thinking_config = session_response.config_options[2]
assert thinking_config.id == "thinking"
assert thinking_config.category == "thinking"
assert thinking_config.current_value == "off"
assert len(thinking_config.options) == 5
@pytest.mark.skip(reason="TODO: Fix this test")
@pytest.mark.asyncio
async def test_new_session_preserves_model_after_set_model(

155
tests/acp/test_session.py Normal file
View file

@ -0,0 +1,155 @@
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock
import pytest
from vibe.acp.session import AcpSessionLoop
def _make_session() -> AcpSessionLoop:
return AcpSessionLoop(
id="test-session", agent_loop=MagicMock(), command_registry=MagicMock()
)
class TestSpawn:
@pytest.mark.asyncio
async def test_spawn_creates_task(self) -> None:
session = _make_session()
ran = asyncio.Event()
async def work() -> None:
ran.set()
task = session.spawn(work())
assert task is not None
await task
assert ran.is_set()
@pytest.mark.asyncio
async def test_spawn_returns_none_after_close(self) -> None:
session = _make_session()
await session.close()
async def noop() -> None:
pass
assert session.spawn(noop()) is None
@pytest.mark.asyncio
async def test_spawn_tracks_multiple_tasks(self) -> None:
session = _make_session()
gate = asyncio.Event()
async def wait_for_gate() -> None:
await gate.wait()
t1 = session.spawn(wait_for_gate())
t2 = session.spawn(wait_for_gate())
assert t1 is not None
assert t2 is not None
assert not t1.done()
assert not t2.done()
gate.set()
await asyncio.gather(t1, t2)
class TestPromptTask:
@pytest.mark.asyncio
async def test_set_prompt_task_tracks_task(self) -> None:
session = _make_session()
async def work() -> None:
pass
task = session.set_prompt_task(work())
assert session.prompt_task is task
await task
assert session.prompt_task is None
@pytest.mark.asyncio
async def test_cancel_prompt_cancels_active_task(self) -> None:
session = _make_session()
async def hang() -> None:
await asyncio.Event().wait()
task = session.set_prompt_task(hang())
await session.cancel_prompt()
assert task.cancelled()
assert session.prompt_task is None
@pytest.mark.asyncio
async def test_cancel_prompt_is_noop_without_task(self) -> None:
session = _make_session()
await session.cancel_prompt()
@pytest.mark.asyncio
async def test_cancel_prompt_does_not_cancel_background_tasks(self) -> None:
session = _make_session()
gate = asyncio.Event()
async def bg() -> None:
await gate.wait()
bg_task = session.spawn(bg())
assert bg_task is not None
async def hang() -> None:
await asyncio.Event().wait()
session.set_prompt_task(hang())
await session.cancel_prompt()
assert not bg_task.cancelled()
gate.set()
await bg_task
class TestClose:
@pytest.mark.asyncio
async def test_close_cancels_all_tasks(self) -> None:
session = _make_session()
async def hang() -> None:
await asyncio.Event().wait()
bg = session.spawn(hang())
prompt = session.set_prompt_task(hang())
assert bg is not None
await session.close()
assert bg.cancelled()
assert prompt.cancelled()
assert session.prompt_task is None
@pytest.mark.asyncio
async def test_close_is_idempotent(self) -> None:
session = _make_session()
await session.close()
await session.close()
@pytest.mark.asyncio
async def test_close_waits_for_task_cleanup(self) -> None:
session = _make_session()
cleanup_ran = asyncio.Event()
async def task_with_cleanup() -> None:
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cleanup_ran.set()
raise
session.spawn(task_with_cleanup())
await asyncio.sleep(0) # let the task start
await session.close()
assert cleanup_ran.is_set()

View file

@ -75,11 +75,11 @@ class TestACPSetConfigOptionMode:
assert response is not None
assert response.config_options is not None
assert len(response.config_options) == 2
assert len(response.config_options) == 3
assert (
acp_session.agent_loop.agent_profile.name == BuiltinAgentName.AUTO_APPROVE
)
assert acp_session.agent_loop.auto_approve is True
assert acp_session.agent_loop.bypass_tool_permissions is True
# Verify config_options reflect the new state
mode_config = response.config_options[0]
@ -126,10 +126,10 @@ class TestACPSetConfigOptionMode:
assert response is not None
assert response.config_options is not None
assert len(response.config_options) == 2
assert len(response.config_options) == 3
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.CHAT
assert (
acp_session.agent_loop.auto_approve is True
acp_session.agent_loop.bypass_tool_permissions is True
) # Chat mode auto-approves read-only tools
mode_config = response.config_options[0]
@ -200,7 +200,7 @@ class TestACPSetConfigOptionModel:
assert response is not None
assert response.config_options is not None
assert len(response.config_options) == 2
assert len(response.config_options) == 3
assert acp_session.agent_loop.config.active_model == "devstral-small"
# Verify config_options reflect the new state
@ -315,3 +315,87 @@ class TestACPSetConfigOptionInvalidConfigId:
)
assert response is None
class TestACPSetConfigOptionThinking:
@pytest.mark.asyncio
async def test_set_config_option_thinking_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.get_active_model().thinking == "off"
response = await acp_agent_loop.set_config_option(
session_id=session_id, config_id="thinking", value="high"
)
assert response is not None
assert response.config_options is not None
assert len(response.config_options) == 3
assert acp_session.agent_loop.config.get_active_model().thinking == "high"
thinking_config = response.config_options[2]
assert thinking_config.id == "thinking"
assert thinking_config.current_value == "high"
@pytest.mark.asyncio
async def test_set_config_option_thinking_all_levels(
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
for level in ["low", "medium", "high", "max", "off"]:
response = await acp_agent_loop.set_config_option(
session_id=session_id, config_id="thinking", value=level
)
assert response is not None
assert acp_session.agent_loop.config.get_active_model().thinking == level
@pytest.mark.asyncio
async def test_set_config_option_thinking_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
response = await acp_agent_loop.set_config_option(
session_id=session_id, config_id="thinking", value="ultra"
)
assert response is None
assert acp_session.agent_loop.config.get_active_model().thinking == "off"
@pytest.mark.asyncio
async def test_set_config_option_thinking_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
response = await acp_agent_loop.set_config_option(
session_id=session_id, config_id="thinking", value=""
)
assert response is None

View file

@ -28,7 +28,7 @@ class TestACPSetMode:
assert response is not None
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
assert acp_session.agent_loop.auto_approve is False
assert acp_session.agent_loop.bypass_tool_permissions is False
@pytest.mark.asyncio
async def test_set_mode_to_auto_approve(
@ -44,7 +44,7 @@ class TestACPSetMode:
assert acp_session is not None
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
assert acp_session.agent_loop.auto_approve is False
assert acp_session.agent_loop.bypass_tool_permissions is False
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id=BuiltinAgentName.AUTO_APPROVE
@ -54,7 +54,7 @@ class TestACPSetMode:
assert (
acp_session.agent_loop.agent_profile.name == BuiltinAgentName.AUTO_APPROVE
)
assert acp_session.agent_loop.auto_approve is True
assert acp_session.agent_loop.bypass_tool_permissions is True
@pytest.mark.asyncio
async def test_set_mode_to_plan(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
@ -76,7 +76,7 @@ class TestACPSetMode:
assert response is not None
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.PLAN
assert (
acp_session.agent_loop.auto_approve is False
acp_session.agent_loop.bypass_tool_permissions is False
) # Plan mode uses per-tool allowlists, not global auto-approve
@pytest.mark.asyncio
@ -103,7 +103,7 @@ class TestACPSetMode:
acp_session.agent_loop.agent_profile.name == BuiltinAgentName.ACCEPT_EDITS
)
assert (
acp_session.agent_loop.auto_approve is False
acp_session.agent_loop.bypass_tool_permissions is False
) # Accept Edits mode doesn't auto-approve all
@pytest.mark.asyncio
@ -126,7 +126,7 @@ class TestACPSetMode:
assert response is not None
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.CHAT
assert (
acp_session.agent_loop.auto_approve is True
acp_session.agent_loop.bypass_tool_permissions is True
) # Chat mode auto-approves read-only tools
@pytest.mark.asyncio
@ -143,7 +143,7 @@ class TestACPSetMode:
assert acp_session is not None
initial_agent = acp_session.agent_loop.agent_profile.name
initial_auto_approve = acp_session.agent_loop.auto_approve
initial_bypass = acp_session.agent_loop.bypass_tool_permissions
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id="invalid-mode"
@ -151,7 +151,7 @@ class TestACPSetMode:
assert response is None
assert acp_session.agent_loop.agent_profile.name == initial_agent
assert acp_session.agent_loop.auto_approve == initial_auto_approve
assert acp_session.agent_loop.bypass_tool_permissions == initial_bypass
@pytest.mark.asyncio
async def test_set_mode_to_same_mode(
@ -174,7 +174,7 @@ class TestACPSetMode:
assert response is not None
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
assert acp_session.agent_loop.auto_approve is False
assert acp_session.agent_loop.bypass_tool_permissions is False
@pytest.mark.asyncio
async def test_set_mode_with_empty_string(
@ -190,7 +190,7 @@ class TestACPSetMode:
assert acp_session is not None
initial_agent = acp_session.agent_loop.agent_profile.name
initial_auto_approve = acp_session.agent_loop.auto_approve
initial_bypass = acp_session.agent_loop.bypass_tool_permissions
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id=""
@ -198,4 +198,4 @@ class TestACPSetMode:
assert response is None
assert acp_session.agent_loop.agent_profile.name == initial_agent
assert acp_session.agent_loop.auto_approve == initial_auto_approve
assert acp_session.agent_loop.bypass_tool_permissions == initial_bypass

View file

@ -0,0 +1,53 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from tests.acp.conftest import _create_acp_agent
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.acp.exceptions import InvalidRequestError
from vibe.core.agent_loop import AgentLoop
@pytest.fixture
def acp_agent_loop(backend) -> VibeAcpAgentLoop:
class PatchedAgentLoop(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **{**kwargs, "backend": backend})
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start()
return _create_acp_agent()
class TestTelemetryNotification:
@pytest.mark.asyncio
async def test_ignores_unknown_event_gracefully(
self,
acp_agent_loop: VibeAcpAgentLoop,
telemetry_events: list[dict[str, object]],
) -> None:
session = await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
telemetry_events.clear()
await acp_agent_loop.ext_notification(
"telemetry/send",
{
"event": "vibe.unsupported_event",
"session_id": session.session_id,
"properties": {"context_type": "file"},
},
)
assert telemetry_events == []
@pytest.mark.asyncio
async def test_raises_on_invalid_params(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
with pytest.raises(InvalidRequestError):
await acp_agent_loop.ext_notification(
"telemetry/send",
{"event": "vibe.some_event"}, # missing session_id
)

View file

@ -212,6 +212,9 @@ class TestLoadSessionUsageUpdate:
"session_id": session_id,
"start_time": "2024-01-01T12:00:00Z",
"end_time": "2024-01-01T12:05:00Z",
"git_commit": None,
"git_branch": None,
"username": "test-user",
"environment": {"working_directory": cwd},
}
with (session_folder / "meta.json").open("w") as f: