v2.7.1 (#551)
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Jean-Malo Delignon <56539593+jean-malo@users.noreply.github.com> Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai> Co-authored-by: Quentin <torroba.q@gmail.com> Co-authored-by: angelapopopo <angele.lenglemetz@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
6a50d1d521
commit
54b9a17457
30 changed files with 1000 additions and 200 deletions
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ACP Server",
|
||||
|
|
|
|||
21
CHANGELOG.md
21
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = "."
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
156
tests/acp/test_message_id.py
Normal file
156
tests/acp/test_message_id.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
254
tests/acp/test_usage_update.py
Normal file
254
tests/acp/test_usage_update.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
74
tests/core/test_backend_error.py
Normal file
74
tests/core/test_backend_error.py
Normal file
|
|
@ -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
|
||||
31
tests/core/test_retry.py
Normal file
31
tests/core/test_retry.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
18
uv.lock
generated
18
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,8 +127,7 @@ def make_mode_response(
|
|||
state = SessionModeState(
|
||||
current_mode_id=current_mode_id, available_modes=session_modes
|
||||
)
|
||||
config = SessionConfigOption(
|
||||
root=SessionConfigOptionSelect(
|
||||
config = SessionConfigOptionSelect(
|
||||
id="mode",
|
||||
name="Session Mode",
|
||||
current_value=current_mode_id,
|
||||
|
|
@ -137,13 +135,12 @@ def make_mode_response(
|
|||
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,8 +155,7 @@ def make_model_response(
|
|||
state = SessionModelState(
|
||||
current_model_id=current_model_id, available_models=model_infos
|
||||
)
|
||||
config_option = SessionConfigOption(
|
||||
root=SessionConfigOptionSelect(
|
||||
config_option = SessionConfigOptionSelect(
|
||||
id="model",
|
||||
name="Model",
|
||||
current_value=current_model_id,
|
||||
|
|
@ -167,7 +163,6 @@ def make_model_response(
|
|||
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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -128,12 +128,10 @@ def _get_selected_texts(app: App) -> list[str]:
|
|||
selected_texts = []
|
||||
|
||||
for widget in app.query("*"):
|
||||
try:
|
||||
if not hasattr(widget, "text_selection") or not widget.text_selection:
|
||||
continue
|
||||
|
||||
selection = widget.text_selection
|
||||
|
||||
try:
|
||||
result = widget.get_selection(selection)
|
||||
except Exception:
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue