From 54b9a1745730f395cdfdd8685b43266d1bb72311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Drouin?= Date: Tue, 31 Mar 2026 16:28:55 +0200 Subject: [PATCH] v2.7.1 (#551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Clément Sirieix Co-authored-by: Jean-Malo Delignon <56539593+jean-malo@users.noreply.github.com> Co-authored-by: Paul Cacheux Co-authored-by: Quentin Co-authored-by: angelapopopo Co-authored-by: Mistral Vibe --- .vscode/launch.json | 2 +- CHANGELOG.md | 21 +++ distribution/zed/extension.toml | 14 +- pyproject.toml | 8 +- tests/acp/test_acp.py | 8 - tests/acp/test_agent_thought.py | 15 +- tests/acp/test_initialize.py | 4 +- tests/acp/test_load_session.py | 120 +++++++++++-- tests/acp/test_message_id.py | 156 +++++++++++++++++ tests/acp/test_new_session.py | 20 +-- tests/acp/test_proxy_setup_acp.py | 92 ++++++++++ tests/acp/test_set_config_option.py | 12 +- tests/acp/test_usage_update.py | 254 ++++++++++++++++++++++++++++ tests/cli/test_clipboard.py | 23 +++ tests/core/test_backend_error.py | 74 ++++++++ tests/core/test_retry.py | 31 ++++ tests/stubs/fake_client.py | 4 +- tests/test_agent_tool_call.py | 20 --- uv.lock | 18 +- vibe/__init__.py | 2 +- vibe/acp/acp_agent_loop.py | 148 +++++++++++----- vibe/acp/utils.py | 43 +++-- vibe/cli/clipboard.py | 8 +- vibe/cli/textual_ui/app.py | 1 + vibe/core/agent_loop.py | 33 ++-- vibe/core/llm/backend/generic.py | 6 +- vibe/core/llm/exceptions.py | 10 +- vibe/core/prompts/cli.md | 39 ++--- vibe/core/types.py | 12 +- vibe/core/utils/retry.py | 2 +- 30 files changed, 1000 insertions(+), 200 deletions(-) create mode 100644 tests/acp/test_message_id.py create mode 100644 tests/acp/test_usage_update.py create mode 100644 tests/core/test_backend_error.py create mode 100644 tests/core/test_retry.py diff --git a/.vscode/launch.json b/.vscode/launch.json index 25b5555..0651cb4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,5 @@ { - "version": "2.7.0", + "version": "2.7.1", "configurations": [ { "name": "ACP Server", diff --git a/CHANGELOG.md b/CHANGELOG.md index a3078c5..079edca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.7.1] - 2026-03-31 + +### Added + +- ACP message-id support for reliable message boundary identification +- Reasoning effort parameter for supported models + +### Changed + +- Updated MistralAI SDK +- Updated ACP SDK dependency +- Refined system prompt wording and structure +- Reduced scroll sensitivity to 1 line per tick for smoother scrolling + +### Fixed + +- Non-standard HTTP 529 status codes now handled gracefully in error formatting and retried +- Text selection errors when copying from unmounting components +- Excluded "injected" field from user messages in generic backend + + ## [2.7.0] - 2026-03-24 ### Added diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 38ce340..7fc87fc 100644 --- a/distribution/zed/extension.toml +++ b/distribution/zed/extension.toml @@ -1,7 +1,7 @@ id = "mistral-vibe" name = "Mistral Vibe" description = "Mistral's open-source coding assistant" -version = "2.7.0" +version = "2.7.1" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,25 +11,25 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.0/vibe-acp-darwin-aarch64-2.7.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.1/vibe-acp-darwin-aarch64-2.7.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.0/vibe-acp-darwin-x86_64-2.7.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.1/vibe-acp-darwin-x86_64-2.7.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.0/vibe-acp-linux-aarch64-2.7.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.1/vibe-acp-linux-aarch64-2.7.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.0/vibe-acp-linux-x86_64-2.7.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.1/vibe-acp-linux-x86_64-2.7.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.0/vibe-acp-windows-aarch64-2.7.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.1/vibe-acp-windows-aarch64-2.7.1.zip" cmd = "./vibe-acp.exe" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.0/vibe-acp-windows-x86_64-2.7.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.1/vibe-acp-windows-x86_64-2.7.1.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index c991486..63f473b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.7.0" +version = "2.7.1" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" @@ -27,7 +27,7 @@ classifiers = [ "Topic :: Utilities", ] dependencies = [ - "agent-client-protocol==0.8.1", + "agent-client-protocol==0.9.0", "anyio>=4.12.0", "cachetools>=5.5.0", "cryptography>=44.0.0,<=46.0.3", @@ -86,6 +86,9 @@ vibe-acp = "vibe.acp.entrypoint:main" [tool.uv] +exclude-newer = "7 days" +exclude-newer-package = { agent-client-protocol = false, mistralai = false } + package = true required-version = ">=0.8.0" @@ -113,6 +116,7 @@ build = ["pyinstaller>=6.17.0", "truststore>=0.10.4"] pythonVersion = "3.12" reportMissingTypeStubs = false reportPrivateImportUsage = false + include = ["vibe/**/*.py", "tests/**/*.py"] exclude = ["pyinstaller/"] venvPath = "." diff --git a/tests/acp/test_acp.py b/tests/acp/test_acp.py index 619bff8..257e8c4 100644 --- a/tests/acp/test_acp.py +++ b/tests/acp/test_acp.py @@ -463,14 +463,6 @@ class TestSessionUpdates: == "available_commands_update" ) - user_response_text = await read_response(process) - assert user_response_text is not None - user_response = UpdateJsonRpcNotification.model_validate( - json.loads(user_response_text) - ) - assert user_response.params is not None - assert user_response.params.update.session_update == "user_message_chunk" - text_response = await read_response(process) assert text_response is not None response = UpdateJsonRpcNotification.model_validate( diff --git a/tests/acp/test_agent_thought.py b/tests/acp/test_agent_thought.py index 41aac43..17b9f12 100644 --- a/tests/acp/test_agent_thought.py +++ b/tests/acp/test_agent_thought.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path from unittest.mock import patch -from acp.schema import AgentThoughtChunk, TextContentBlock +from acp.schema import AgentMessageChunk, AgentThoughtChunk, TextContentBlock import pytest from tests.acp.conftest import _create_acp_agent @@ -146,9 +146,16 @@ class TestACPAgentThought: for update in fake_client._session_updates if isinstance(update.update, AgentThoughtChunk) ] + agent_updates = [ + update + for update in fake_client._session_updates + if isinstance(update.update, AgentMessageChunk) + ] assert len(thought_updates) == 1 thought_chunk = thought_updates[0].update - assert thought_chunk.field_meta is not None - assert "messageId" in thought_chunk.field_meta - assert thought_chunk.field_meta["messageId"] is not None + assert thought_chunk.message_id is not None + + assert len(agent_updates) == 1 + agent_chunk = agent_updates[0].update + assert thought_chunk.message_id != agent_chunk.message_id diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index d5f09d9..a1d0146 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -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.7.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.1" ) 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.7.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.1" ) assert response.auth_methods is not None diff --git a/tests/acp/test_load_session.py b/tests/acp/test_load_session.py index 302b65c..8fca6ad 100644 --- a/tests/acp/test_load_session.py +++ b/tests/acp/test_load_session.py @@ -100,13 +100,11 @@ class TestLoadSession: 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 response.config_options[0].id == "mode" + assert response.config_options[0].category == "mode" + assert response.config_options[0].current_value == BuiltinAgentName.DEFAULT + assert len(response.config_options[0].options) == 5 + mode_option_values = {opt.value for opt in response.config_options[0].options} assert mode_option_values == { BuiltinAgentName.DEFAULT, BuiltinAgentName.CHAT, @@ -114,13 +112,11 @@ class TestLoadSession: 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 response.config_options[1].id == "model" + assert response.config_options[1].category == "model" + assert response.config_options[1].current_value == "devstral-latest" + 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"} @pytest.mark.asyncio @@ -342,3 +338,99 @@ class TestLoadSession: assert user_updates[1].update.content.text == "Second message" assert agent_updates[0].update.content.text == "First response" assert agent_updates[1].update.content.text == "Second response" + + @pytest.mark.asyncio + async def test_replay_user_message_has_message_id( + 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 = "msg-id-usr-1234567" + cwd = str(Path.cwd()) + message_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + messages = [{"role": "user", "content": "Hello", "message_id": message_id}] + create_test_session(temp_session_dir, session_id, cwd, messages=messages) + + await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id) + + user_updates = [ + u for u in client._session_updates if isinstance(u.update, UserMessageChunk) + ] + assert len(user_updates) == 1 + assert user_updates[0].update.message_id == message_id + + @pytest.mark.asyncio + async def test_replay_agent_message_has_message_id( + 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 = "msg-id-ast-1234567" + cwd = str(Path.cwd()) + message_id = "11111111-2222-3333-4444-555555555555" + messages = [ + {"role": "user", "content": "Hi"}, + {"role": "assistant", "content": "Hello!", "message_id": message_id}, + ] + create_test_session(temp_session_dir, session_id, cwd, messages=messages) + + await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id) + + agent_updates = [ + u + for u in client._session_updates + if isinstance(u.update, AgentMessageChunk) + ] + assert len(agent_updates) == 1 + assert agent_updates[0].update.message_id == message_id + + @pytest.mark.asyncio + async def test_replay_reasoning_has_different_message_id_than_agent_message( + 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 = "msg-id-rsn-1234567" + cwd = str(Path.cwd()) + agent_message_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + reasoning_message_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + messages = [ + {"role": "user", "content": "Think about this"}, + { + "role": "assistant", + "content": "Here is my answer", + "message_id": agent_message_id, + "reasoning_content": "Let me think...", + "reasoning_message_id": reasoning_message_id, + }, + ] + create_test_session(temp_session_dir, session_id, cwd, messages=messages) + + await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id) + + agent_updates = [ + u + for u in client._session_updates + if isinstance(u.update, AgentMessageChunk) + ] + thought_updates = [ + u + for u in client._session_updates + if isinstance(u.update, AgentThoughtChunk) + ] + assert len(agent_updates) == 1 + assert len(thought_updates) == 1 + assert agent_updates[0].update.message_id == agent_message_id + assert thought_updates[0].update.message_id == reasoning_message_id + assert ( + agent_updates[0].update.message_id != thought_updates[0].update.message_id + ) diff --git a/tests/acp/test_message_id.py b/tests/acp/test_message_id.py new file mode 100644 index 0000000..18b71f9 --- /dev/null +++ b/tests/acp/test_message_id.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch +from uuid import UUID + +from acp.schema import AgentMessageChunk, TextContentBlock +import pytest + +from tests.acp.conftest import _create_acp_agent +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.types import LLMChunk, LLMMessage, LLMUsage, Role + + +def _is_uuid(value: str) -> bool: + try: + UUID(value) + return True + except ValueError: + return False + + +def _make_response_chunk(content: str = "Hi") -> LLMChunk: + return LLMChunk( + message=LLMMessage(role=Role.assistant, content=content), + usage=LLMUsage(prompt_tokens=1, completion_tokens=1), + ) + + +@pytest.fixture +def two_turn_acp_agent_loop() -> VibeAcpAgentLoop: + backend = FakeBackend([ + [_make_response_chunk("Hi")], + [_make_response_chunk("Hi again")], + ]) + + 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 TestPromptResponseUserMessageId: + @pytest.mark.asyncio + async def test_generates_user_message_id_when_client_provides_none( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + + response = await acp_agent_loop.prompt( + session_id=session_response.session_id, + prompt=[TextContentBlock(type="text", text="hi")], + ) + + assert response.user_message_id is not None + assert _is_uuid(response.user_message_id) + + @pytest.mark.asyncio + async def test_echoes_client_provided_message_id( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + client_message_id = "550e8400-e29b-41d4-a716-446655440000" + + response = await acp_agent_loop.prompt( + session_id=session_response.session_id, + prompt=[TextContentBlock(type="text", text="hi")], + message_id=client_message_id, + ) + + assert response.user_message_id == client_message_id + + @pytest.mark.asyncio + async def test_user_message_ids_are_unique_across_turns( + 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_1 = await acp_agent_loop.prompt( + session_id=session_id, prompt=[TextContentBlock(type="text", text="hi")] + ) + response_2 = await acp_agent_loop.prompt( + session_id=session_id, + prompt=[TextContentBlock(type="text", text="hi again")], + ) + + assert response_1.user_message_id != response_2.user_message_id + + +class TestAgentMessageChunkMessageId: + @pytest.mark.asyncio + async def test_agent_message_chunk_has_message_id( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + + await acp_agent_loop.prompt( + session_id=session_response.session_id, + prompt=[TextContentBlock(type="text", text="hi")], + ) + + fake_client: FakeClient = acp_agent_loop.client # type: ignore + agent_chunks = [ + u + for u in fake_client._session_updates + if isinstance(u.update, AgentMessageChunk) + ] + + assert len(agent_chunks) >= 1 + assert agent_chunks[0].update.message_id is not None + + @pytest.mark.asyncio + async def test_agent_message_ids_are_unique_across_turns( + self, two_turn_acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await two_turn_acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + fake_client: FakeClient = two_turn_acp_agent_loop.client # type: ignore + + await two_turn_acp_agent_loop.prompt( + session_id=session_id, prompt=[TextContentBlock(type="text", text="hi")] + ) + chunks_turn_1 = [ + u + for u in fake_client._session_updates + if isinstance(u.update, AgentMessageChunk) + ] + fake_client._session_updates.clear() + + await two_turn_acp_agent_loop.prompt( + session_id=session_id, + prompt=[TextContentBlock(type="text", text="hi again")], + ) + chunks_turn_2 = [ + u + for u in fake_client._session_updates + if isinstance(u.update, AgentMessageChunk) + ] + + assert chunks_turn_1[0].update.message_id != chunks_turn_2[0].update.message_id diff --git a/tests/acp/test_new_session.py b/tests/acp/test_new_session.py index 52942d8..c57f09e 100644 --- a/tests/acp/test_new_session.py +++ b/tests/acp/test_new_session.py @@ -103,11 +103,11 @@ class TestACPNewSession: # 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_config.id == "mode" + assert mode_config.category == "mode" + assert mode_config.current_value == BuiltinAgentName.DEFAULT + assert len(mode_config.options) == 5 + mode_option_values = {opt.value for opt in mode_config.options} assert mode_option_values == { BuiltinAgentName.DEFAULT, BuiltinAgentName.CHAT, @@ -118,11 +118,11 @@ class TestACPNewSession: # 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_config.id == "model" + assert model_config.category == "model" + assert model_config.current_value == "devstral-latest" + assert len(model_config.options) == 2 + model_option_values = {opt.value for opt in model_config.options} assert model_option_values == {"devstral-latest", "devstral-small"} @pytest.mark.skip(reason="TODO: Fix this test") diff --git a/tests/acp/test_proxy_setup_acp.py b/tests/acp/test_proxy_setup_acp.py index e952a6a..f8c7d52 100644 --- a/tests/acp/test_proxy_setup_acp.py +++ b/tests/acp/test_proxy_setup_acp.py @@ -148,6 +148,98 @@ class TestProxySetupCommand: assert "HTTP_PROXY" in env_content assert "http://localhost:8080" in env_content + +class TestProxySetupMessageId: + @pytest.mark.asyncio + async def test_proxy_setup_response_has_user_message_id( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + env_file = tmp_path / ".env" + + class FakeGlobalEnvFile: + path = env_file + + monkeypatch.setattr( + "vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile() + ) + + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + + response = await acp_agent_loop.prompt( + prompt=[TextContentBlock(type="text", text="/proxy-setup")], + session_id=session_response.session_id, + ) + + assert response.user_message_id is not None + + @pytest.mark.asyncio + async def test_proxy_setup_echoes_client_message_id( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + env_file = tmp_path / ".env" + + class FakeGlobalEnvFile: + path = env_file + + monkeypatch.setattr( + "vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile() + ) + + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + client_message_id = "550e8400-e29b-41d4-a716-446655440000" + + response = await acp_agent_loop.prompt( + prompt=[TextContentBlock(type="text", text="/proxy-setup")], + session_id=session_response.session_id, + message_id=client_message_id, + ) + + assert response.user_message_id == client_message_id + + @pytest.mark.asyncio + async def test_proxy_setup_agent_message_has_message_id( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + env_file = tmp_path / ".env" + + class FakeGlobalEnvFile: + path = env_file + + monkeypatch.setattr( + "vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile() + ) + + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + _get_fake_client(acp_agent_loop)._session_updates.clear() + + await acp_agent_loop.prompt( + prompt=[TextContentBlock(type="text", text="/proxy-setup")], + session_id=session_response.session_id, + ) + + message_updates = [ + u + for u in _get_fake_client(acp_agent_loop)._session_updates + if isinstance(u.update, AgentMessageChunk) + ] + assert len(message_updates) == 1 + assert message_updates[0].update.message_id is not None + @pytest.mark.asyncio async def test_proxy_setup_unsets_value( self, diff --git a/tests/acp/test_set_config_option.py b/tests/acp/test_set_config_option.py index 92bb7b1..5f3866f 100644 --- a/tests/acp/test_set_config_option.py +++ b/tests/acp/test_set_config_option.py @@ -83,8 +83,8 @@ class TestACPSetConfigOptionMode: # 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 + assert mode_config.id == "mode" + assert mode_config.current_value == BuiltinAgentName.AUTO_APPROVE @pytest.mark.asyncio async def test_set_config_option_mode_to_plan( @@ -133,8 +133,8 @@ class TestACPSetConfigOptionMode: ) # Chat mode auto-approves read-only tools mode_config = response.config_options[0] - assert mode_config.root.id == "mode" - assert mode_config.root.current_value == BuiltinAgentName.CHAT + assert mode_config.id == "mode" + assert mode_config.current_value == BuiltinAgentName.CHAT @pytest.mark.asyncio async def test_set_config_option_mode_invalid_returns_none( @@ -205,8 +205,8 @@ class TestACPSetConfigOptionModel: # 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" + assert model_config.id == "model" + assert model_config.current_value == "devstral-small" @pytest.mark.asyncio async def test_set_config_option_model_invalid_returns_none( diff --git a/tests/acp/test_usage_update.py b/tests/acp/test_usage_update.py new file mode 100644 index 0000000..856f5a5 --- /dev/null +++ b/tests/acp/test_usage_update.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from unittest.mock import patch + +from acp.schema import TextContentBlock, UsageUpdate +import pytest + +from tests.acp.conftest import _create_acp_agent +from tests.conftest import build_test_vibe_config +from tests.stubs.fake_backend import FakeBackend +from tests.stubs.fake_client import FakeClient +from vibe.acp.acp_agent_loop import VibeAcpAgentLoop +from vibe.core.agent_loop import AgentLoop +from vibe.core.config import SessionLoggingConfig +from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role + + +def _make_backend(prompt_tokens: int = 100, completion_tokens: int = 50) -> FakeBackend: + return FakeBackend( + LLMChunk( + message=LLMMessage(role=Role.assistant, content="Hi"), + usage=LLMUsage( + prompt_tokens=prompt_tokens, completion_tokens=completion_tokens + ), + ) + ) + + +def _make_acp_agent(backend: FakeBackend) -> VibeAcpAgentLoop: + config = build_test_vibe_config() + + class PatchedAgentLoop(AgentLoop): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **{**kwargs, "backend": backend}) + self._base_config = config + self.agent_manager.invalidate_config() + + patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start() + return _create_acp_agent() + + +def _get_fake_client(agent: VibeAcpAgentLoop) -> FakeClient: + return agent.client # type: ignore[return-value] + + +def _get_usage_updates(client: FakeClient) -> list[UsageUpdate]: + return [ + update.update + for update in client._session_updates + if isinstance(update.update, UsageUpdate) + ] + + +class TestPromptResponseUsage: + @pytest.mark.asyncio + async def test_prompt_returns_usage_in_response(self) -> None: + agent = _make_acp_agent(_make_backend(prompt_tokens=100, completion_tokens=50)) + session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + response = await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello")], + ) + + assert response.usage is not None + assert response.usage.input_tokens == 100 + assert response.usage.output_tokens == 50 + assert response.usage.total_tokens == 150 + + @pytest.mark.asyncio + async def test_prompt_usage_optional_fields_are_none(self) -> None: + agent = _make_acp_agent(_make_backend()) + session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + response = await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello")], + ) + + assert response.usage is not None + assert response.usage.thought_tokens is None + assert response.usage.cached_read_tokens is None + assert response.usage.cached_write_tokens is None + + @pytest.mark.asyncio + async def test_prompt_usage_accumulates_across_turns(self) -> None: + backend = _make_backend(prompt_tokens=100, completion_tokens=50) + agent = _make_acp_agent(backend) + session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + first = await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello")], + ) + + second = await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello again")], + ) + + assert first.usage is not None + assert second.usage is not None + # Second turn should have strictly more cumulative tokens + assert second.usage.input_tokens > first.usage.input_tokens + assert second.usage.output_tokens > first.usage.output_tokens + assert second.usage.total_tokens > first.usage.total_tokens + + +class TestUsageUpdateNotification: + @pytest.mark.asyncio + async def test_prompt_sends_usage_update(self) -> None: + agent = _make_acp_agent(_make_backend()) + session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello")], + ) + await asyncio.sleep(0) + + usage_updates = _get_usage_updates(_get_fake_client(agent)) + assert len(usage_updates) == 1 + assert usage_updates[0].session_update == "usage_update" + + @pytest.mark.asyncio + async def test_usage_update_contains_context_window_info(self) -> None: + agent = _make_acp_agent(_make_backend(prompt_tokens=100, completion_tokens=50)) + session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello")], + ) + await asyncio.sleep(0) + + usage_updates = _get_usage_updates(_get_fake_client(agent)) + assert len(usage_updates) == 1 + assert usage_updates[0].size > 0 + assert usage_updates[0].used > 0 + + @pytest.mark.asyncio + async def test_usage_update_contains_cost_when_pricing_set(self) -> None: + agent = _make_acp_agent( + _make_backend(prompt_tokens=1_000_000, completion_tokens=500_000) + ) + session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + # Set pricing directly on the session stats (config loading uses fixture defaults) + acp_session = agent.sessions[session.session_id] + acp_session.agent_loop.stats.update_pricing(input_price=0.4, output_price=2.0) + + await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello")], + ) + await asyncio.sleep(0) + + usage_updates = _get_usage_updates(_get_fake_client(agent)) + assert len(usage_updates) == 1 + cost = usage_updates[0].cost + assert cost is not None + assert cost.currency == "USD" + assert cost.amount > 0 + + @pytest.mark.asyncio + async def test_usage_update_no_cost_when_zero_pricing(self) -> None: + agent = _make_acp_agent(_make_backend()) + session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello")], + ) + await asyncio.sleep(0) + + usage_updates = _get_usage_updates(_get_fake_client(agent)) + assert len(usage_updates) == 1 + assert usage_updates[0].cost is None + + @pytest.mark.asyncio + async def test_usage_update_sent_per_prompt(self) -> None: + backend = _make_backend() + agent = _make_acp_agent(backend) + session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello")], + ) + await asyncio.sleep(0) + await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello again")], + ) + await asyncio.sleep(0) + + usage_updates = _get_usage_updates(_get_fake_client(agent)) + assert len(usage_updates) == 2 + + +class TestLoadSessionUsageUpdate: + def _make_session_dir(self, tmp_path: Path, session_id: str, cwd: str) -> Path: + session_folder = tmp_path / f"session_20240101_120000_{session_id[:8]}" + session_folder.mkdir() + messages_file = session_folder / "messages.jsonl" + with messages_file.open("w") as f: + f.write(json.dumps({"role": "user", "content": "Hello"}) + "\n") + meta = { + "session_id": session_id, + "start_time": "2024-01-01T12:00:00Z", + "end_time": "2024-01-01T12:05:00Z", + "environment": {"working_directory": cwd}, + } + with (session_folder / "meta.json").open("w") as f: + json.dump(meta, f) + return session_folder + + def _make_agent_with_session_logging( + self, backend: FakeBackend, session_dir: Path + ) -> VibeAcpAgentLoop: + session_config = SessionLoggingConfig( + save_dir=str(session_dir), session_prefix="session", enabled=True + ) + config = build_test_vibe_config(session_logging=session_config) + + class PatchedAgentLoop(AgentLoop): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **{**kwargs, "backend": backend}) + self._base_config = config + self.agent_manager.invalidate_config() + + patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start() + agent = _create_acp_agent() + patch.object(agent, "_load_config", return_value=config).start() + return agent + + @pytest.mark.asyncio + async def test_load_session_sends_usage_update(self, tmp_path: Path) -> None: + backend = _make_backend() + agent = self._make_agent_with_session_logging(backend, tmp_path) + session_id = "test-session-load-usage" + self._make_session_dir(tmp_path, session_id, str(Path.cwd())) + + await agent.load_session(cwd=str(Path.cwd()), session_id=session_id) + await asyncio.sleep(0) + + client = _get_fake_client(agent) + usage_updates = _get_usage_updates(client) + assert len(usage_updates) == 1 + assert usage_updates[0].session_update == "usage_update" + assert usage_updates[0].size > 0 diff --git a/tests/cli/test_clipboard.py b/tests/cli/test_clipboard.py index aca63d2..316caf3 100644 --- a/tests/cli/test_clipboard.py +++ b/tests/cli/test_clipboard.py @@ -38,6 +38,12 @@ class MockWidget: return self._get_selection_result +class MockWidgetNoScreen: + @property + def text_selection(self) -> object: + raise RuntimeError("node has no screen") + + @pytest.fixture def mock_app() -> App: app = MagicMock(spec=App) @@ -73,6 +79,7 @@ def mock_app() -> App: ], "empty text", ), + ([MockWidgetNoScreen()], "widget with no screen (text_selection raises)"), ], ) def test_copy_selection_to_clipboard_no_notification( @@ -87,6 +94,22 @@ def test_copy_selection_to_clipboard_no_notification( mock_app.notify.assert_not_called() +@patch("vibe.cli.clipboard._copy_to_clipboard") +def test_copy_selection_skips_detached_widget_and_collects_valid( + mock_copy_to_clipboard: MagicMock, mock_app: MagicMock +) -> None: + detached = MockWidgetNoScreen() + valid = MockWidget( + text_selection=SimpleNamespace(), get_selection_result=("valid text", None) + ) + mock_app.query.return_value = [detached, valid] + + result = copy_selection_to_clipboard(mock_app) + + assert result == "valid text" + mock_copy_to_clipboard.assert_called_once_with("valid text") + + @patch("vibe.cli.clipboard._copy_to_clipboard") def test_copy_selection_to_clipboard_success( mock_copy_to_clipboard: MagicMock, mock_app: MagicMock diff --git a/tests/core/test_backend_error.py b/tests/core/test_backend_error.py new file mode 100644 index 0000000..2fa0cec --- /dev/null +++ b/tests/core/test_backend_error.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import pytest + +from vibe.core.llm.exceptions import BackendError, PayloadSummary + + +def _make_payload_summary() -> PayloadSummary: + return PayloadSummary( + model="test-model", + message_count=1, + approx_chars=10, + temperature=0.7, + has_tools=False, + tool_choice=None, + ) + + +def _make_error( + *, status: int | None, headers: dict[str, str] | None = None +) -> BackendError: + return BackendError( + provider="test-provider", + endpoint="/v1/chat/completions", + status=status, + reason="some reason", + headers=headers or {}, + body_text="body", + parsed_error=None, + model="test-model", + payload_summary=_make_payload_summary(), + ) + + +class TestBackendErrorFmt: + def test_standard_status_code(self) -> None: + err = _make_error(status=500) + msg = str(err) + assert "500 Internal Server Error" in msg + assert "test-provider" in msg + + def test_non_standard_status_code(self) -> None: + """Status 529 is not in HTTPStatus and previously raised ValueError.""" + err = _make_error(status=529) + msg = str(err) + assert "529" in msg + # Should not contain a phrase since 529 is not standard + assert "LLM backend error [test-provider]" in msg + + def test_no_status(self) -> None: + err = _make_error(status=None) + msg = str(err) + assert "status: N/A" in msg + + def test_unauthorized_short_circuits(self) -> None: + err = _make_error(status=401) + assert str(err) == "Invalid API key. Please check your API key and try again." + + def test_rate_limit_short_circuits(self) -> None: + err = _make_error(status=429) + assert ( + str(err) == "Rate limit exceeded. Please wait a moment before trying again." + ) + + def test_request_id_from_headers(self) -> None: + err = _make_error(status=500, headers={"x-request-id": "req-123"}) + assert "req-123" in str(err) + + @pytest.mark.parametrize("code", [530, 599, 999]) + def test_other_non_standard_codes(self, code: int) -> None: + err = _make_error(status=code) + msg = str(err) + assert str(code) in msg + assert "LLM backend error" in msg diff --git a/tests/core/test_retry.py b/tests/core/test_retry.py new file mode 100644 index 0000000..b859789 --- /dev/null +++ b/tests/core/test_retry.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import httpx +import pytest + +from vibe.core.utils.retry import _is_retryable_http_error + + +def _make_http_status_error(status_code: int) -> httpx.HTTPStatusError: + response = httpx.Response( + status_code=status_code, request=httpx.Request("GET", "https://example.com") + ) + return httpx.HTTPStatusError( + message=f"Error {status_code}", request=response.request, response=response + ) + + +class TestIsRetryableHttpError: + @pytest.mark.parametrize("code", [408, 409, 425, 429, 500, 502, 503, 504, 529]) + def test_retryable_codes(self, code: int) -> None: + assert _is_retryable_http_error(_make_http_status_error(code)) is True + + @pytest.mark.parametrize("code", [400, 401, 403, 404, 422]) + def test_non_retryable_codes(self, code: int) -> None: + assert _is_retryable_http_error(_make_http_status_error(code)) is False + + def test_non_http_error_returns_false(self) -> None: + assert _is_retryable_http_error(ValueError("not http")) is False + + def test_generic_exception_returns_false(self) -> None: + assert _is_retryable_http_error(RuntimeError("boom")) is False diff --git a/tests/stubs/fake_client.py b/tests/stubs/fake_client.py index ef6294b..9660bb7 100644 --- a/tests/stubs/fake_client.py +++ b/tests/stubs/fake_client.py @@ -6,7 +6,7 @@ from acp import ( Agent as AcpAgent, Client, CreateTerminalResponse, - KillTerminalCommandResponse, + KillTerminalResponse, ReadTextFileResponse, ReleaseTerminalResponse, RequestPermissionResponse, @@ -112,7 +112,7 @@ class FakeClient(Client): async def kill_terminal( self, session_id: str, terminal_id: str, **kwargs: Any - ) -> KillTerminalCommandResponse | None: + ) -> KillTerminalResponse | None: raise NotImplementedError() async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/test_agent_tool_call.py b/tests/test_agent_tool_call.py index 8e8af18..aa7d7d1 100644 --- a/tests/test_agent_tool_call.py +++ b/tests/test_agent_tool_call.py @@ -516,26 +516,6 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None: ) -@pytest.mark.asyncio -async def test_ensure_assistant_after_tool_appends_understood() -> None: - agent_loop = build_test_agent_loop( - config=make_config(), - agent_name=BuiltinAgentName.AUTO_APPROVE, - backend=FakeBackend(mock_llm_chunk(content="ok")), - ) - tool_msg = LLMMessage( - role=Role.tool, tool_call_id="tc_z", name="todo", content="Done" - ) - agent_loop.messages.reset([agent_loop.messages[0], tool_msg]) - - await act_and_collect_events(agent_loop, "Next") - - # find the seeded tool message and ensure the next message is "Understood." - idx = next(i for i, m in enumerate(agent_loop.messages) if m.role == Role.tool) - assert agent_loop.messages[idx + 1].role == Role.assistant - assert agent_loop.messages[idx + 1].content == "Understood." - - @pytest.mark.asyncio async def test_parallel_tool_calls_produce_correct_events( telemetry_events: list[dict], diff --git a/uv.lock b/uv.lock index 493ea9b..c24dcf4 100644 --- a/uv.lock +++ b/uv.lock @@ -2,16 +2,24 @@ version = 1 revision = 3 requires-python = ">=3.12" +[options] +exclude-newer = "2026-03-24T11:14:04.796843Z" +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +mistralai = false +agent-client-protocol = false + [[package]] name = "agent-client-protocol" -version = "0.8.1" +version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1b/7b/7cdac86db388809d9e3bc58cac88cc7dfa49b7615b98fab304a828cd7f8a/agent_client_protocol-0.8.1.tar.gz", hash = "sha256:1bbf15663bf51f64942597f638e32a6284c5da918055d9672d3510e965143dbd", size = 68866, upload-time = "2026-02-13T15:34:54.567Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/13/3b893421369767e7043cc115d6ef0df417c298b84563be3a12df0416158d/agent_client_protocol-0.9.0.tar.gz", hash = "sha256:f744c48ab9af0f0b4452e5ab5498d61bcab97c26dbe7d6feec5fd36de49be30b", size = 71853, upload-time = "2026-03-26T01:21:00.379Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/f3/219eeca0ad4a20843d4b9eaac5532f87018b9d25730a62a16f54f6c52d1a/agent_client_protocol-0.8.1-py3-none-any.whl", hash = "sha256:9421a11fd435b4831660272d169c3812d553bb7247049c138c3ca127e4b8af8e", size = 54529, upload-time = "2026-02-13T15:34:53.344Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ed/c284543c08aa443a4ef2c8bd120be51da8433dd174c01749b5d87c333f22/agent_client_protocol-0.9.0-py3-none-any.whl", hash = "sha256:06911500b51d8cb69112544e2be01fc5e7db39ef88fecbc3848c5c6f194798ee", size = 56850, upload-time = "2026-03-26T01:20:59.252Z" }, ] [[package]] @@ -779,7 +787,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.7.0" +version = "2.7.1" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, @@ -841,7 +849,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "agent-client-protocol", specifier = "==0.8.1" }, + { name = "agent-client-protocol", specifier = "==0.9.0" }, { name = "anyio", specifier = ">=4.12.0" }, { name = "cachetools", specifier = ">=5.5.0" }, { name = "cryptography", specifier = ">=44.0.0,<=46.0.3" }, diff --git a/vibe/__init__.py b/vibe/__init__.py index 4eee166..9152801 100644 --- a/vibe/__init__.py +++ b/vibe/__init__.py @@ -3,4 +3,4 @@ from __future__ import annotations from pathlib import Path VIBE_ROOT = Path(__file__).parent -__version__ = "2.7.0" +__version__ = "2.7.1" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index 3aa156c..1e1afdf 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -6,6 +6,7 @@ import os from pathlib import Path import sys from typing import Any, cast, override +from uuid import uuid4 from acp import ( PROTOCOL_VERSION, @@ -26,11 +27,14 @@ from acp.schema import ( AgentThoughtChunk, AllowedOutcome, AuthenticateResponse, - AuthMethod, + AuthMethodAgent, AvailableCommand, AvailableCommandInput, ClientCapabilities, + CloseSessionResponse, ContentToolCallContent, + Cost, + EnvVarAuthMethod, ForkSessionResponse, HttpMcpServer, Implementation, @@ -43,12 +47,14 @@ from acp.schema import ( SessionListCapabilities, SetSessionConfigOptionResponse, SseMcpServer, + TerminalAuthMethod, TextContentBlock, TextResourceContents, ToolCallProgress, ToolCallUpdate, UnstructuredCommandInput, - UserMessageChunk, + Usage, + UsageUpdate, ) from pydantic import BaseModel, ConfigDict @@ -117,7 +123,6 @@ from vibe.core.types import ( ToolCallEvent, ToolResultEvent, ToolStreamEvent, - UserMessageEvent, ) from vibe.core.utils import ( CancellationReason, @@ -126,6 +131,12 @@ from vibe.core.utils import ( ) +def _resolved_user_message_id(client_message_id: str | None) -> str: + if client_message_id is not None: + return client_message_id + return str(uuid4()) + + class AcpSessionLoop(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) id: str @@ -174,9 +185,10 @@ class VibeAcpAgentLoop(AcpAgent): and self.client_capabilities.field_meta.get("terminal-auth") is True ) - auth_methods = ( + auth_methods: list[EnvVarAuthMethod | TerminalAuthMethod | AuthMethodAgent] = ( [ - AuthMethod( + TerminalAuthMethod( + type="terminal", id="vibe-setup", name="Register your API Key", description="Register your API Key inside Mistral Vibe", @@ -380,6 +392,39 @@ class VibeAcpAgentLoop(AcpAgent): raise SessionNotFoundError(session_id) return self.sessions[session_id] + def _build_usage(self, session: AcpSessionLoop) -> Usage: + stats = session.agent_loop.stats + return Usage( + input_tokens=stats.session_prompt_tokens, + output_tokens=stats.session_completion_tokens, + total_tokens=stats.session_total_llm_tokens, + ) + + def _build_usage_update(self, session: AcpSessionLoop) -> UsageUpdate: + stats = session.agent_loop.stats + active_model = session.agent_loop.config.get_active_model() + cost = ( + Cost(amount=stats.session_cost, currency="USD") + if stats.input_price_per_million > 0 or stats.output_price_per_million > 0 + else None + ) + return UsageUpdate( + session_update="usage_update", + used=stats.context_tokens, + size=active_model.auto_compact_threshold, + cost=cost, + ) + + def _send_usage_update(self, session: AcpSessionLoop) -> None: + async def _send() -> None: + try: + update = self._build_usage_update(session) + await self.client.session_update(session_id=session.id, update=update) + except Exception: + pass + + asyncio.create_task(_send()) + async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None: if not msg.tool_calls: return @@ -442,7 +487,7 @@ class VibeAcpAgentLoop(AcpAgent): await self.client.session_update(session_id=session_id, update=update) async def _handle_proxy_setup_command( - self, session_id: str, text_prompt: str + self, session_id: str, text_prompt: str, message_id: str ) -> PromptResponse: args = text_prompt.strip()[len("/proxy-setup") :].strip() @@ -465,11 +510,14 @@ class VibeAcpAgentLoop(AcpAgent): update=AgentMessageChunk( session_update="agent_message_chunk", content=TextContentBlock(type="text", text=message), + message_id=str(uuid4()), ), ) - return PromptResponse(stop_reason="end_turn") + return PromptResponse(stop_reason="end_turn", user_message_id=message_id) - async def _handle_leanstall_command(self, session_id: str) -> PromptResponse: + async def _handle_leanstall_command( + self, session_id: str, message_id: str + ) -> PromptResponse: session = self._get_session(session_id) current = list(session.agent_loop.base_config.installed_agents) if "lean" in current: @@ -492,11 +540,14 @@ class VibeAcpAgentLoop(AcpAgent): update=AgentMessageChunk( session_update="agent_message_chunk", content=TextContentBlock(type="text", text=message), + message_id=str(uuid4()), ), ) - return PromptResponse(stop_reason="end_turn") + return PromptResponse(stop_reason="end_turn", user_message_id=message_id) - async def _handle_unleanstall_command(self, session_id: str) -> PromptResponse: + async def _handle_unleanstall_command( + self, session_id: str, message_id: str + ) -> PromptResponse: session = self._get_session(session_id) current = list(session.agent_loop.base_config.installed_agents) if "lean" not in current: @@ -519,9 +570,10 @@ class VibeAcpAgentLoop(AcpAgent): update=AgentMessageChunk( session_update="agent_message_chunk", content=TextContentBlock(type="text", text=message), + message_id=str(uuid4()), ), ) - return PromptResponse(stop_reason="end_turn") + return PromptResponse(stop_reason="end_turn", user_message_id=message_id) @override async def load_session( @@ -564,6 +616,7 @@ class VibeAcpAgentLoop(AcpAgent): session = await self._create_acp_session(session_id, agent_loop) await self._replay_conversation_history(session_id, non_system_messages) + self._send_usage_update(session) modes_state, modes_config = make_mode_response( list(agent_loop.agent_manager.available_agents.values()), @@ -635,14 +688,14 @@ class VibeAcpAgentLoop(AcpAgent): @override async def set_config_option( - self, config_id: str, session_id: str, value: str, **kwargs: Any + self, config_id: str, session_id: str, value: str | bool, **kwargs: Any ) -> SetSessionConfigOptionResponse | None: session = self._get_session(session_id) match config_id: - case "mode": + case "mode" if isinstance(value, str): success = await self._apply_mode_change(session, value) - case "model": + case "model" if isinstance(value, str): success = await self._apply_model_change(session, value) case _: success = False @@ -690,7 +743,11 @@ class VibeAcpAgentLoop(AcpAgent): @override async def prompt( - self, prompt: list[ContentBlock], session_id: str, **kwargs: Any + self, + prompt: list[ContentBlock], + session_id: str, + message_id: str | None = None, + **kwargs: Any, ) -> PromptResponse: session = self._get_session(session_id) @@ -700,21 +757,24 @@ class VibeAcpAgentLoop(AcpAgent): ) text_prompt = self._build_text_prompt(prompt) + resolved_message_id = _resolved_user_message_id(message_id) if text_prompt.strip().lower().startswith("/proxy-setup"): - return await self._handle_proxy_setup_command(session_id, text_prompt) + return await self._handle_proxy_setup_command( + session_id, text_prompt, resolved_message_id + ) if text_prompt.strip().lower().startswith("/unleanstall"): - return await self._handle_unleanstall_command(session_id) + return await self._handle_unleanstall_command( + session_id, resolved_message_id + ) if text_prompt.strip().lower().startswith("/leanstall"): - return await self._handle_leanstall_command(session_id) - - temp_user_message_id: str | None = kwargs.get("messageId") + return await self._handle_leanstall_command(session_id, resolved_message_id) async def agent_loop_task() -> None: async for update in self._run_agent_loop( - session, text_prompt, temp_user_message_id + session, text_prompt, resolved_message_id ): await self.client.session_update(session_id=session.id, update=update) @@ -723,7 +783,12 @@ class VibeAcpAgentLoop(AcpAgent): await session.task except asyncio.CancelledError: - return PromptResponse(stop_reason="cancelled") + self._send_usage_update(session) + return PromptResponse( + stop_reason="cancelled", + usage=self._build_usage(session), + user_message_id=resolved_message_id, + ) except CoreRateLimitError as e: raise RateLimitError.from_core(e) from e @@ -737,7 +802,12 @@ class VibeAcpAgentLoop(AcpAgent): finally: session.task = None - return PromptResponse(stop_reason="end_turn") + self._send_usage_update(session) + return PromptResponse( + stop_reason="end_turn", + usage=self._build_usage(session), + user_message_id=resolved_message_id, + ) def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str: text_prompt = "" @@ -787,37 +857,25 @@ class VibeAcpAgentLoop(AcpAgent): return text_prompt async def _run_agent_loop( - self, session: AcpSessionLoop, prompt: str, user_message_id: str | None = None + self, session: AcpSessionLoop, prompt: str, client_message_id: str | None = None ) -> AsyncGenerator[SessionUpdate]: rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd()) - async for event in session.agent_loop.act(rendered_prompt): - if isinstance(event, UserMessageEvent): - yield UserMessageChunk( - session_update="user_message_chunk", - content=TextContentBlock(type="text", text=""), - field_meta={ - "messageId": event.message_id, - **( - {"previousMessageId": user_message_id} - if user_message_id - else {} - ), - }, - ) - - elif isinstance(event, AssistantEvent): + async for event in session.agent_loop.act( + rendered_prompt, client_message_id=client_message_id + ): + if isinstance(event, AssistantEvent): yield AgentMessageChunk( session_update="agent_message_chunk", content=TextContentBlock(type="text", text=event.content), - field_meta={"messageId": event.message_id}, + message_id=event.message_id, ) elif isinstance(event, ReasoningEvent): yield AgentThoughtChunk( session_update="agent_thought_chunk", content=TextContentBlock(type="text", text=event.content), - field_meta={"messageId": event.message_id}, + message_id=event.message_id, ) elif isinstance(event, ToolCallEvent): @@ -859,6 +917,12 @@ class VibeAcpAgentLoop(AcpAgent): elif isinstance(event, AgentProfileChangedEvent): pass + @override + async def close_session( + self, session_id: str, **kwargs: Any + ) -> CloseSessionResponse | None: + raise NotImplementedMethodError("close_session") + @override async def cancel(self, session_id: str, **kwargs: Any) -> None: session = self._get_session(session_id) diff --git a/vibe/acp/utils.py b/vibe/acp/utils.py index cdf34a8..717c219 100644 --- a/vibe/acp/utils.py +++ b/vibe/acp/utils.py @@ -9,7 +9,6 @@ from acp.schema import ( ContentToolCallContent, ModelInfo, PermissionOption, - SessionConfigOption, SessionConfigOptionSelect, SessionConfigSelectOption, SessionMode, @@ -103,7 +102,7 @@ def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool: def make_mode_response( profiles: list[AgentProfile], current_mode_id: str -) -> tuple[SessionModeState, SessionConfigOption]: +) -> tuple[SessionModeState, SessionConfigOptionSelect]: session_modes: list[SessionMode] = [] config_options: list[SessionConfigSelectOption] = [] @@ -128,22 +127,20 @@ def make_mode_response( state = SessionModeState( current_mode_id=current_mode_id, available_modes=session_modes ) - config = SessionConfigOption( - root=SessionConfigOptionSelect( - id="mode", - name="Session Mode", - current_value=current_mode_id, - category="mode", - type="select", - options=config_options, - ) + config = SessionConfigOptionSelect( + id="mode", + name="Session Mode", + current_value=current_mode_id, + category="mode", + type="select", + options=config_options, ) return state, config def make_model_response( models: list[ModelConfig], current_model_id: str -) -> tuple[SessionModelState, SessionConfigOption]: +) -> tuple[SessionModelState, SessionConfigOptionSelect]: model_infos: list[ModelInfo] = [] config_options: list[SessionConfigSelectOption] = [] @@ -158,15 +155,13 @@ def make_model_response( state = SessionModelState( current_model_id=current_model_id, available_models=model_infos ) - config_option = SessionConfigOption( - root=SessionConfigOptionSelect( - id="model", - name="Model", - current_value=current_model_id, - category="model", - type="select", - options=config_options, - ) + config_option = SessionConfigOptionSelect( + id="model", + name="Model", + current_value=current_model_id, + category="model", + type="select", + options=config_options, ) return state, config_option @@ -257,7 +252,7 @@ def create_user_message_replay(msg: LLMMessage) -> UserMessageChunk: return UserMessageChunk( session_update="user_message_chunk", content=TextContentBlock(type="text", text=content), - field_meta={"messageId": msg.message_id} if msg.message_id else {}, + message_id=msg.message_id, ) @@ -269,7 +264,7 @@ def create_assistant_message_replay(msg: LLMMessage) -> AgentMessageChunk | None return AgentMessageChunk( session_update="agent_message_chunk", content=TextContentBlock(type="text", text=content), - field_meta={"messageId": msg.message_id} if msg.message_id else {}, + message_id=msg.message_id, ) @@ -280,7 +275,7 @@ def create_reasoning_replay(msg: LLMMessage) -> AgentThoughtChunk | None: return AgentThoughtChunk( session_update="agent_thought_chunk", content=TextContentBlock(type="text", text=msg.reasoning_content), - field_meta={"messageId": msg.message_id} if msg.message_id else {}, + message_id=msg.reasoning_message_id, ) diff --git a/vibe/cli/clipboard.py b/vibe/cli/clipboard.py index 3526f5e..f52061d 100644 --- a/vibe/cli/clipboard.py +++ b/vibe/cli/clipboard.py @@ -128,12 +128,10 @@ def _get_selected_texts(app: App) -> list[str]: selected_texts = [] for widget in app.query("*"): - if not hasattr(widget, "text_selection") or not widget.text_selection: - continue - - selection = widget.text_selection - try: + if not hasattr(widget, "text_selection") or not widget.text_selection: + continue + selection = widget.text_selection result = widget.get_selection(selection) except Exception: continue diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index ab3ac64..5381957 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -294,6 +294,7 @@ class VibeApp(App): # noqa: PLR0904 **kwargs: Any, ) -> None: super().__init__(**kwargs) + self.scroll_sensitivity_y = 1.0 self.agent_loop = agent_loop self._voice_manager: VoiceManagerPort = ( voice_manager or self._make_default_voice_manager() diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index 4d73e2f..a7f77bc 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -344,7 +344,9 @@ class AgentLoop: self.agent_profile, ) - async def act(self, msg: str) -> AsyncGenerator[BaseEvent]: + async def act( + self, msg: str, client_message_id: str | None = None + ) -> AsyncGenerator[BaseEvent]: self._clean_message_history() self.rewind_manager.create_checkpoint() try: @@ -352,7 +354,9 @@ class AgentLoop: except ValueError: model_name = None async with agent_span(model=model_name, session_id=self.session_id): - async for event in self._conversation_loop(msg): + async for event in self._conversation_loop( + msg, client_message_id=client_message_id + ): yield event @property @@ -511,8 +515,12 @@ class AgentLoop: } return headers - async def _conversation_loop(self, user_msg: str) -> AsyncGenerator[BaseEvent]: - user_message = LLMMessage(role=Role.user, content=user_msg) + async def _conversation_loop( + self, user_msg: str, client_message_id: str | None = None + ) -> AsyncGenerator[BaseEvent]: + user_message = LLMMessage( + role=Role.user, content=user_msg, message_id=client_message_id + ) self.messages.append(user_message) self.stats.steps += 1 self._current_user_message_id = user_message.message_id @@ -604,11 +612,14 @@ class AgentLoop: self, ) -> AsyncGenerator[AssistantEvent | ReasoningEvent | ToolCallEvent]: message_id: str | None = None + reasoning_message_id: str | None = None emitted_tool_call_ids = set[str]() async for chunk in self._chat_streaming(): if message_id is None: message_id = chunk.message.message_id + if reasoning_message_id is None: + reasoning_message_id = chunk.message.reasoning_message_id for event in self._build_tool_call_events( chunk.message.tool_calls, emitted_tool_call_ids @@ -618,7 +629,8 @@ class AgentLoop: if chunk.message.reasoning_content: yield ReasoningEvent( - content=chunk.message.reasoning_content, message_id=message_id + content=chunk.message.reasoning_content, + message_id=reasoning_message_id, ) if chunk.message.content: @@ -1066,7 +1078,6 @@ class AgentLoop: if len(self.messages) < ACCEPTABLE_HISTORY_SIZE: return self._fill_missing_tool_responses() - self._ensure_assistant_after_tools() def _fill_missing_tool_responses(self) -> None: i = 1 @@ -1114,16 +1125,6 @@ class AgentLoop: i += 1 - def _ensure_assistant_after_tools(self) -> None: - MIN_MESSAGE_SIZE = 2 - if len(self.messages) < MIN_MESSAGE_SIZE: - return - - last_msg = self.messages[-1] - if last_msg.role is Role.tool: - empty_assistant_msg = LLMMessage(role=Role.assistant, content="Understood.") - self.messages.append(empty_assistant_msg) - def _reset_session(self) -> None: self.session_id = str(uuid4()) self.session_logger.reset_session(self.session_id) diff --git a/vibe/core/llm/backend/generic.py b/vibe/core/llm/backend/generic.py index 34ec9fa..6ab4577 100644 --- a/vibe/core/llm/backend/generic.py +++ b/vibe/core/llm/backend/generic.py @@ -97,7 +97,11 @@ class OpenAIAdapter(APIAdapter): field_name = provider.reasoning_field_name converted_messages = [ self._reasoning_to_api( - msg.model_dump(exclude_none=True, exclude={"message_id"}), field_name + msg.model_dump( + exclude_none=True, + exclude={"message_id", "reasoning_message_id", "injected"}, + ), + field_name, ) for msg in merged_messages ] diff --git a/vibe/core/llm/exceptions.py b/vibe/core/llm/exceptions.py index b72010d..55fad7f 100644 --- a/vibe/core/llm/exceptions.py +++ b/vibe/core/llm/exceptions.py @@ -61,9 +61,13 @@ class BackendError(RuntimeError): return "Rate limit exceeded. Please wait a moment before trying again." rid = self.headers.get("x-request-id") or self.headers.get("request-id") - status_label = ( - f"{self.status} {HTTPStatus(self.status).phrase}" if self.status else "N/A" - ) + if self.status: + try: + status_label = f"{self.status} {HTTPStatus(self.status).phrase}" + except ValueError: + status_label = str(self.status) + else: + status_label = "N/A" parts = [ f"LLM backend error [{self.provider}]", f" status: {status_label}", diff --git a/vibe/core/prompts/cli.md b/vibe/core/prompts/cli.md index 15d3eb2..192ba69 100644 --- a/vibe/core/prompts/cli.md +++ b/vibe/core/prompts/cli.md @@ -1,7 +1,7 @@ You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools. -CRITICAL: Users complain you are too verbose. Your responses must be minimal. Most tasks need <100 words. Code speaks for itself. +CRITICAL: Users complain you are too verbose. Your responses must be minimal. Most tasks need <150 words. Code speaks for itself. -Phase 1 — Orient +Phase 1 - Orient Before ANY action: Restate the goal in one line. Determine the task type: @@ -13,25 +13,25 @@ Explore. Use available tools to understand affected code, dependencies, and conv Identify constraints: language, framework, test setup, and any user restrictions on scope. When given multiple file paths or a complex task: Do not start reading files immediately. First, summarize your understanding of the task and propose a short plan. Wait for the user to confirm before exploring any files. This prevents wasted effort on the wrong path. -Phase 2 — Plan (Change tasks only) +Phase 2 - Plan (Change tasks only) State your plan before writing code: List files to change and the specific change per file. Multi-file changes: numbered checklist. Single-file fix: one-line plan. No time estimates. Concrete actions only. -Phase 3 — Execute & Verify (Change tasks only) +Phase 3 - Execute & Verify (Change tasks only) Apply changes, then confirm they work: Edit one logical unit at a time. After each unit, verify: run tests, or read back the file to confirm the edit landed. Never claim completion without verification — a passing test, correct read-back, or successful build. -Hard Rules +Hard Rules: Never Commit Do not run `git commit`, `git push`, or `git add` unless the user explicitly asks you to. Saving files is sufficient — the user will review changes and commit themselves. Respect User Constraints -"No writes", "just analyze", "plan only", "don't touch X" — these are hard constraints. Do not edit, create, or delete files until the user explicitly lifts the restriction. Violation of explicit user instructions is the worst failure mode. +"No writes", "just analyze", "plan only", "don't touch X" - these are hard constraints. Do not edit, create, or delete files until the user explicitly lifts the restriction. Violation of explicit user instructions is the worst failure mode. Don't Remove What Wasn't Asked If user asks to fix X, do not rewrite, delete, or restructure Y. When in doubt, change less. @@ -58,21 +58,17 @@ No unsolicited tutorials. Do not explain concepts the user clearly knows. Structure First Lead every response with the most useful structured element — code, diagram, table, or tree. Prose comes after, not before. -For change tasks: -file_path:line_number -langcode +For change tasks, cite as: `file_path:line_number` followed by a fenced code block. Prefer Brevity State only what's necessary to complete the task. Code + file reference > explanation. If your response exceeds 300 words, remove explanations the user didn't request. For investigate tasks: -Start with a diagram, code reference, tree, or table — whichever conveys the answer fastest. -request → auth.verify() → permissions.check() → handler -See middleware/auth.py:45. Then 1-2 sentences of context if needed. +Start with a diagram, code reference, tree, or table - whichever conveys the answer fastest. +Then 1-2 sentences of context if needed. BAD: "The authentication flow works by first checking the token…" -GOOD: request → auth.verify() → permissions.check() → handler — see middleware/auth.py:45. -Visual Formats +GOOD: request → auth.verify() → permissions.check() → handler — see middleware/auth.py:45 Before responding with structural data, choose the right format: BAD: Bullet lists for hierarchy/tree @@ -84,15 +80,13 @@ GOOD: → A → B → C diagrams Interaction Design After completing a task, evaluate: does the user face a decision or tradeoff? If yes, end with ONE specific question or 2-3 options: - -Good: "Apply this fix to the other 3 endpoints?" -Good: "Two approaches: (a) migration, (b) recreate table. Which?" -Bad: "Does this look good?", "Anything else?", "Let me know" - +GOOD: "Apply this fix to the other 3 endpoints?" +GOOD: "Two approaches: (a) migration, (b) recreate table. Which?" +BAD: "Does this look good?", "Anything else?", "Let me know" If unambiguous and complete, end with the result. Length -Default to minimal responses. One-line fix → one-line response. Most tasks need <200 words. +Default to minimal responses. One-line fix → one-line response. Most tasks need <150 words. Elaborate only when: (1) user asks for explanation, (2) task involves architectural decisions, (3) multiple valid approaches exist. Code Modifications (Change tasks) @@ -107,12 +101,9 @@ When removing code, delete completely. No _unused renames, // removed comments, Security Fix injection, XSS, SQLi vulnerabilities immediately if spotted. -Code References -Cite as file_path:line_number. - Professional Conduct Prioritize technical accuracy over validating beliefs. Disagree when necessary. When uncertain, investigate before confirming. Your output must contain zero emoji. This includes smiley faces, icons, flags, symbols like ✅❌💡, and all other Unicode emoji. No over-the-top validation. -Stay focused on solving the problem regardless of user tone. Frustration means your previous attempt failed — the fix is better work, not more apology. +Stay focused on solving the problem regardless of user tone. Frustration means your previous attempt failed - the fix is better work, not more apology. diff --git a/vibe/core/types.py b/vibe/core/types.py index 72f8dbb..9fe8d94 100644 --- a/vibe/core/types.py +++ b/vibe/core/types.py @@ -218,6 +218,7 @@ class LLMMessage(BaseModel): injected: bool = False reasoning_content: Content | None = None reasoning_signature: str | None = None + reasoning_message_id: str | None = None tool_calls: list[ToolCall] | None = None name: str | None = None tool_call_id: str | None = None @@ -229,15 +230,20 @@ class LLMMessage(BaseModel): if isinstance(v, dict): v.setdefault("content", "") v.setdefault("role", "assistant") - if "message_id" not in v and v.get("role") != "tool": + if v.get("message_id") is None and v.get("role") != "tool": v["message_id"] = str(uuid4()) + if v.get("reasoning_message_id") is None and v.get("reasoning_content"): + v["reasoning_message_id"] = str(uuid4()) return v role = str(getattr(v, "role", "assistant")) + reasoning_content = getattr(v, "reasoning_content", None) return { "role": role, "content": getattr(v, "content", ""), - "reasoning_content": getattr(v, "reasoning_content", None), + "reasoning_content": reasoning_content, "reasoning_signature": getattr(v, "reasoning_signature", None), + "reasoning_message_id": getattr(v, "reasoning_message_id", None) + or (str(uuid4()) if reasoning_content else None), "tool_calls": getattr(v, "tool_calls", None), "name": getattr(v, "name", None), "tool_call_id": getattr(v, "tool_call_id", None), @@ -298,6 +304,8 @@ class LLMMessage(BaseModel): content=content, reasoning_content=reasoning_content, reasoning_signature=reasoning_signature, + reasoning_message_id=self.reasoning_message_id + or other.reasoning_message_id, tool_calls=list(tool_calls_map.values()) or None, name=self.name, tool_call_id=self.tool_call_id, diff --git a/vibe/core/utils/retry.py b/vibe/core/utils/retry.py index afc1c6a..f5e120e 100644 --- a/vibe/core/utils/retry.py +++ b/vibe/core/utils/retry.py @@ -9,7 +9,7 @@ import httpx def _is_retryable_http_error(e: Exception) -> bool: if isinstance(e, httpx.HTTPStatusError): - return e.response.status_code in {408, 409, 425, 429, 500, 502, 503, 504} + return e.response.status_code in {408, 409, 425, 429, 500, 502, 503, 504, 529} return False