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:
Clément Drouin 2026-03-31 16:28:55 +02:00 committed by GitHub
parent 6a50d1d521
commit 54b9a17457
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1000 additions and 200 deletions

2
.vscode/launch.json vendored
View file

@ -1,5 +1,5 @@
{ {
"version": "2.7.0", "version": "2.7.1",
"configurations": [ "configurations": [
{ {
"name": "ACP Server", "name": "ACP Server",

View file

@ -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/), 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). 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 ## [2.7.0] - 2026-03-24
### Added ### Added

View file

@ -1,7 +1,7 @@
id = "mistral-vibe" id = "mistral-vibe"
name = "Mistral Vibe" name = "Mistral Vibe"
description = "Mistral's open-source coding assistant" description = "Mistral's open-source coding assistant"
version = "2.7.0" version = "2.7.1"
schema_version = 1 schema_version = 1
authors = ["Mistral AI"] authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe" repository = "https://github.com/mistralai/mistral-vibe"
@ -11,25 +11,25 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg" icon = "./icons/mistral_vibe.svg"
[agent_servers.mistral-vibe.targets.darwin-aarch64] [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" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64] [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" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64] [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" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64] [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" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-aarch64] [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" cmd = "./vibe-acp.exe"
[agent_servers.mistral-vibe.targets.windows-x86_64] [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" cmd = "./vibe-acp.exe"

View file

@ -1,6 +1,6 @@
[project] [project]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.7.0" version = "2.7.1"
description = "Minimal CLI coding agent by Mistral" description = "Minimal CLI coding agent by Mistral"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@ -27,7 +27,7 @@ classifiers = [
"Topic :: Utilities", "Topic :: Utilities",
] ]
dependencies = [ dependencies = [
"agent-client-protocol==0.8.1", "agent-client-protocol==0.9.0",
"anyio>=4.12.0", "anyio>=4.12.0",
"cachetools>=5.5.0", "cachetools>=5.5.0",
"cryptography>=44.0.0,<=46.0.3", "cryptography>=44.0.0,<=46.0.3",
@ -86,6 +86,9 @@ vibe-acp = "vibe.acp.entrypoint:main"
[tool.uv] [tool.uv]
exclude-newer = "7 days"
exclude-newer-package = { agent-client-protocol = false, mistralai = false }
package = true package = true
required-version = ">=0.8.0" required-version = ">=0.8.0"
@ -113,6 +116,7 @@ build = ["pyinstaller>=6.17.0", "truststore>=0.10.4"]
pythonVersion = "3.12" pythonVersion = "3.12"
reportMissingTypeStubs = false reportMissingTypeStubs = false
reportPrivateImportUsage = false reportPrivateImportUsage = false
include = ["vibe/**/*.py", "tests/**/*.py"] include = ["vibe/**/*.py", "tests/**/*.py"]
exclude = ["pyinstaller/"] exclude = ["pyinstaller/"]
venvPath = "." venvPath = "."

View file

@ -463,14 +463,6 @@ class TestSessionUpdates:
== "available_commands_update" == "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) text_response = await read_response(process)
assert text_response is not None assert text_response is not None
response = UpdateJsonRpcNotification.model_validate( response = UpdateJsonRpcNotification.model_validate(

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from acp.schema import AgentThoughtChunk, TextContentBlock from acp.schema import AgentMessageChunk, AgentThoughtChunk, TextContentBlock
import pytest import pytest
from tests.acp.conftest import _create_acp_agent from tests.acp.conftest import _create_acp_agent
@ -146,9 +146,16 @@ class TestACPAgentThought:
for update in fake_client._session_updates for update in fake_client._session_updates
if isinstance(update.update, AgentThoughtChunk) 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 assert len(thought_updates) == 1
thought_chunk = thought_updates[0].update thought_chunk = thought_updates[0].update
assert thought_chunk.field_meta is not None assert thought_chunk.message_id is not None
assert "messageId" in thought_chunk.field_meta
assert thought_chunk.field_meta["messageId"] is not None assert len(agent_updates) == 1
agent_chunk = agent_updates[0].update
assert thought_chunk.message_id != agent_chunk.message_id

View file

@ -28,7 +28,7 @@ class TestACPInitialize:
session_capabilities=SessionCapabilities(list=SessionListCapabilities()), session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
) )
assert response.agent_info == Implementation( 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 == [] assert response.auth_methods == []
@ -52,7 +52,7 @@ class TestACPInitialize:
session_capabilities=SessionCapabilities(list=SessionListCapabilities()), session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
) )
assert response.agent_info == Implementation( 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 assert response.auth_methods is not None

View file

@ -100,13 +100,11 @@ class TestLoadSession:
assert response.config_options is not None assert response.config_options is not None
assert len(response.config_options) == 2 assert len(response.config_options) == 2
assert response.config_options[0].root.id == "mode" assert response.config_options[0].id == "mode"
assert response.config_options[0].root.category == "mode" assert response.config_options[0].category == "mode"
assert response.config_options[0].root.current_value == BuiltinAgentName.DEFAULT assert response.config_options[0].current_value == BuiltinAgentName.DEFAULT
assert len(response.config_options[0].root.options) == 5 assert len(response.config_options[0].options) == 5
mode_option_values = { mode_option_values = {opt.value for opt in response.config_options[0].options}
opt.value for opt in response.config_options[0].root.options
}
assert mode_option_values == { assert mode_option_values == {
BuiltinAgentName.DEFAULT, BuiltinAgentName.DEFAULT,
BuiltinAgentName.CHAT, BuiltinAgentName.CHAT,
@ -114,13 +112,11 @@ class TestLoadSession:
BuiltinAgentName.PLAN, BuiltinAgentName.PLAN,
BuiltinAgentName.ACCEPT_EDITS, BuiltinAgentName.ACCEPT_EDITS,
} }
assert response.config_options[1].root.id == "model" assert response.config_options[1].id == "model"
assert response.config_options[1].root.category == "model" assert response.config_options[1].category == "model"
assert response.config_options[1].root.current_value == "devstral-latest" assert response.config_options[1].current_value == "devstral-latest"
assert len(response.config_options[1].root.options) == 2 assert len(response.config_options[1].options) == 2
model_option_values = { model_option_values = {opt.value for opt in response.config_options[1].options}
opt.value for opt in response.config_options[1].root.options
}
assert model_option_values == {"devstral-latest", "devstral-small"} assert model_option_values == {"devstral-latest", "devstral-small"}
@pytest.mark.asyncio @pytest.mark.asyncio
@ -342,3 +338,99 @@ class TestLoadSession:
assert user_updates[1].update.content.text == "Second message" assert user_updates[1].update.content.text == "Second message"
assert agent_updates[0].update.content.text == "First response" assert agent_updates[0].update.content.text == "First response"
assert agent_updates[1].update.content.text == "Second 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
)

View 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

View file

@ -103,11 +103,11 @@ class TestACPNewSession:
# Mode config option # Mode config option
mode_config = session_response.config_options[0] mode_config = session_response.config_options[0]
assert mode_config.root.id == "mode" assert mode_config.id == "mode"
assert mode_config.root.category == "mode" assert mode_config.category == "mode"
assert mode_config.root.current_value == BuiltinAgentName.DEFAULT assert mode_config.current_value == BuiltinAgentName.DEFAULT
assert len(mode_config.root.options) == 5 assert len(mode_config.options) == 5
mode_option_values = {opt.value for opt in mode_config.root.options} mode_option_values = {opt.value for opt in mode_config.options}
assert mode_option_values == { assert mode_option_values == {
BuiltinAgentName.DEFAULT, BuiltinAgentName.DEFAULT,
BuiltinAgentName.CHAT, BuiltinAgentName.CHAT,
@ -118,11 +118,11 @@ class TestACPNewSession:
# Model config option # Model config option
model_config = session_response.config_options[1] model_config = session_response.config_options[1]
assert model_config.root.id == "model" assert model_config.id == "model"
assert model_config.root.category == "model" assert model_config.category == "model"
assert model_config.root.current_value == "devstral-latest" assert model_config.current_value == "devstral-latest"
assert len(model_config.root.options) == 2 assert len(model_config.options) == 2
model_option_values = {opt.value for opt in model_config.root.options} model_option_values = {opt.value for opt in model_config.options}
assert model_option_values == {"devstral-latest", "devstral-small"} assert model_option_values == {"devstral-latest", "devstral-small"}
@pytest.mark.skip(reason="TODO: Fix this test") @pytest.mark.skip(reason="TODO: Fix this test")

View file

@ -148,6 +148,98 @@ class TestProxySetupCommand:
assert "HTTP_PROXY" in env_content assert "HTTP_PROXY" in env_content
assert "http://localhost:8080" 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 @pytest.mark.asyncio
async def test_proxy_setup_unsets_value( async def test_proxy_setup_unsets_value(
self, self,

View file

@ -83,8 +83,8 @@ class TestACPSetConfigOptionMode:
# Verify config_options reflect the new state # Verify config_options reflect the new state
mode_config = response.config_options[0] mode_config = response.config_options[0]
assert mode_config.root.id == "mode" assert mode_config.id == "mode"
assert mode_config.root.current_value == BuiltinAgentName.AUTO_APPROVE assert mode_config.current_value == BuiltinAgentName.AUTO_APPROVE
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_config_option_mode_to_plan( async def test_set_config_option_mode_to_plan(
@ -133,8 +133,8 @@ class TestACPSetConfigOptionMode:
) # Chat mode auto-approves read-only tools ) # Chat mode auto-approves read-only tools
mode_config = response.config_options[0] mode_config = response.config_options[0]
assert mode_config.root.id == "mode" assert mode_config.id == "mode"
assert mode_config.root.current_value == BuiltinAgentName.CHAT assert mode_config.current_value == BuiltinAgentName.CHAT
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_config_option_mode_invalid_returns_none( async def test_set_config_option_mode_invalid_returns_none(
@ -205,8 +205,8 @@ class TestACPSetConfigOptionModel:
# Verify config_options reflect the new state # Verify config_options reflect the new state
model_config = response.config_options[1] model_config = response.config_options[1]
assert model_config.root.id == "model" assert model_config.id == "model"
assert model_config.root.current_value == "devstral-small" assert model_config.current_value == "devstral-small"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_config_option_model_invalid_returns_none( async def test_set_config_option_model_invalid_returns_none(

View 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

View file

@ -38,6 +38,12 @@ class MockWidget:
return self._get_selection_result return self._get_selection_result
class MockWidgetNoScreen:
@property
def text_selection(self) -> object:
raise RuntimeError("node has no screen")
@pytest.fixture @pytest.fixture
def mock_app() -> App: def mock_app() -> App:
app = MagicMock(spec=App) app = MagicMock(spec=App)
@ -73,6 +79,7 @@ def mock_app() -> App:
], ],
"empty text", "empty text",
), ),
([MockWidgetNoScreen()], "widget with no screen (text_selection raises)"),
], ],
) )
def test_copy_selection_to_clipboard_no_notification( 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() 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") @patch("vibe.cli.clipboard._copy_to_clipboard")
def test_copy_selection_to_clipboard_success( def test_copy_selection_to_clipboard_success(
mock_copy_to_clipboard: MagicMock, mock_app: MagicMock mock_copy_to_clipboard: MagicMock, mock_app: MagicMock

View 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
View 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

View file

@ -6,7 +6,7 @@ from acp import (
Agent as AcpAgent, Agent as AcpAgent,
Client, Client,
CreateTerminalResponse, CreateTerminalResponse,
KillTerminalCommandResponse, KillTerminalResponse,
ReadTextFileResponse, ReadTextFileResponse,
ReleaseTerminalResponse, ReleaseTerminalResponse,
RequestPermissionResponse, RequestPermissionResponse,
@ -112,7 +112,7 @@ class FakeClient(Client):
async def kill_terminal( async def kill_terminal(
self, session_id: str, terminal_id: str, **kwargs: Any self, session_id: str, terminal_id: str, **kwargs: Any
) -> KillTerminalCommandResponse | None: ) -> KillTerminalResponse | None:
raise NotImplementedError() raise NotImplementedError()
async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]: async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:

View file

@ -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 @pytest.mark.asyncio
async def test_parallel_tool_calls_produce_correct_events( async def test_parallel_tool_calls_produce_correct_events(
telemetry_events: list[dict], telemetry_events: list[dict],

18
uv.lock generated
View file

@ -2,16 +2,24 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.12" 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]] [[package]]
name = "agent-client-protocol" name = "agent-client-protocol"
version = "0.8.1" version = "0.9.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { 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 = [ 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]] [[package]]
@ -779,7 +787,7 @@ wheels = [
[[package]] [[package]]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.7.0" version = "2.7.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "agent-client-protocol" }, { name = "agent-client-protocol" },
@ -841,7 +849,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ 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 = "anyio", specifier = ">=4.12.0" },
{ name = "cachetools", specifier = ">=5.5.0" }, { name = "cachetools", specifier = ">=5.5.0" },
{ name = "cryptography", specifier = ">=44.0.0,<=46.0.3" }, { name = "cryptography", specifier = ">=44.0.0,<=46.0.3" },

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
VIBE_ROOT = Path(__file__).parent VIBE_ROOT = Path(__file__).parent
__version__ = "2.7.0" __version__ = "2.7.1"

View file

@ -6,6 +6,7 @@ import os
from pathlib import Path from pathlib import Path
import sys import sys
from typing import Any, cast, override from typing import Any, cast, override
from uuid import uuid4
from acp import ( from acp import (
PROTOCOL_VERSION, PROTOCOL_VERSION,
@ -26,11 +27,14 @@ from acp.schema import (
AgentThoughtChunk, AgentThoughtChunk,
AllowedOutcome, AllowedOutcome,
AuthenticateResponse, AuthenticateResponse,
AuthMethod, AuthMethodAgent,
AvailableCommand, AvailableCommand,
AvailableCommandInput, AvailableCommandInput,
ClientCapabilities, ClientCapabilities,
CloseSessionResponse,
ContentToolCallContent, ContentToolCallContent,
Cost,
EnvVarAuthMethod,
ForkSessionResponse, ForkSessionResponse,
HttpMcpServer, HttpMcpServer,
Implementation, Implementation,
@ -43,12 +47,14 @@ from acp.schema import (
SessionListCapabilities, SessionListCapabilities,
SetSessionConfigOptionResponse, SetSessionConfigOptionResponse,
SseMcpServer, SseMcpServer,
TerminalAuthMethod,
TextContentBlock, TextContentBlock,
TextResourceContents, TextResourceContents,
ToolCallProgress, ToolCallProgress,
ToolCallUpdate, ToolCallUpdate,
UnstructuredCommandInput, UnstructuredCommandInput,
UserMessageChunk, Usage,
UsageUpdate,
) )
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@ -117,7 +123,6 @@ from vibe.core.types import (
ToolCallEvent, ToolCallEvent,
ToolResultEvent, ToolResultEvent,
ToolStreamEvent, ToolStreamEvent,
UserMessageEvent,
) )
from vibe.core.utils import ( from vibe.core.utils import (
CancellationReason, 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): class AcpSessionLoop(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
id: str id: str
@ -174,9 +185,10 @@ class VibeAcpAgentLoop(AcpAgent):
and self.client_capabilities.field_meta.get("terminal-auth") is True 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", id="vibe-setup",
name="Register your API Key", name="Register your API Key",
description="Register your API Key inside Mistral Vibe", description="Register your API Key inside Mistral Vibe",
@ -380,6 +392,39 @@ class VibeAcpAgentLoop(AcpAgent):
raise SessionNotFoundError(session_id) raise SessionNotFoundError(session_id)
return self.sessions[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: async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None:
if not msg.tool_calls: if not msg.tool_calls:
return return
@ -442,7 +487,7 @@ class VibeAcpAgentLoop(AcpAgent):
await self.client.session_update(session_id=session_id, update=update) await self.client.session_update(session_id=session_id, update=update)
async def _handle_proxy_setup_command( async def _handle_proxy_setup_command(
self, session_id: str, text_prompt: str self, session_id: str, text_prompt: str, message_id: str
) -> PromptResponse: ) -> PromptResponse:
args = text_prompt.strip()[len("/proxy-setup") :].strip() args = text_prompt.strip()[len("/proxy-setup") :].strip()
@ -465,11 +510,14 @@ class VibeAcpAgentLoop(AcpAgent):
update=AgentMessageChunk( update=AgentMessageChunk(
session_update="agent_message_chunk", session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=message), 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) session = self._get_session(session_id)
current = list(session.agent_loop.base_config.installed_agents) current = list(session.agent_loop.base_config.installed_agents)
if "lean" in current: if "lean" in current:
@ -492,11 +540,14 @@ class VibeAcpAgentLoop(AcpAgent):
update=AgentMessageChunk( update=AgentMessageChunk(
session_update="agent_message_chunk", session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=message), 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) session = self._get_session(session_id)
current = list(session.agent_loop.base_config.installed_agents) current = list(session.agent_loop.base_config.installed_agents)
if "lean" not in current: if "lean" not in current:
@ -519,9 +570,10 @@ class VibeAcpAgentLoop(AcpAgent):
update=AgentMessageChunk( update=AgentMessageChunk(
session_update="agent_message_chunk", session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=message), 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 @override
async def load_session( async def load_session(
@ -564,6 +616,7 @@ class VibeAcpAgentLoop(AcpAgent):
session = await self._create_acp_session(session_id, agent_loop) session = await self._create_acp_session(session_id, agent_loop)
await self._replay_conversation_history(session_id, non_system_messages) await self._replay_conversation_history(session_id, non_system_messages)
self._send_usage_update(session)
modes_state, modes_config = make_mode_response( modes_state, modes_config = make_mode_response(
list(agent_loop.agent_manager.available_agents.values()), list(agent_loop.agent_manager.available_agents.values()),
@ -635,14 +688,14 @@ class VibeAcpAgentLoop(AcpAgent):
@override @override
async def set_config_option( 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: ) -> SetSessionConfigOptionResponse | None:
session = self._get_session(session_id) session = self._get_session(session_id)
match config_id: match config_id:
case "mode": case "mode" if isinstance(value, str):
success = await self._apply_mode_change(session, value) success = await self._apply_mode_change(session, value)
case "model": case "model" if isinstance(value, str):
success = await self._apply_model_change(session, value) success = await self._apply_model_change(session, value)
case _: case _:
success = False success = False
@ -690,7 +743,11 @@ class VibeAcpAgentLoop(AcpAgent):
@override @override
async def prompt( 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: ) -> PromptResponse:
session = self._get_session(session_id) session = self._get_session(session_id)
@ -700,21 +757,24 @@ class VibeAcpAgentLoop(AcpAgent):
) )
text_prompt = self._build_text_prompt(prompt) text_prompt = self._build_text_prompt(prompt)
resolved_message_id = _resolved_user_message_id(message_id)
if text_prompt.strip().lower().startswith("/proxy-setup"): 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"): 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"): if text_prompt.strip().lower().startswith("/leanstall"):
return await self._handle_leanstall_command(session_id) return await self._handle_leanstall_command(session_id, resolved_message_id)
temp_user_message_id: str | None = kwargs.get("messageId")
async def agent_loop_task() -> None: async def agent_loop_task() -> None:
async for update in self._run_agent_loop( 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) await self.client.session_update(session_id=session.id, update=update)
@ -723,7 +783,12 @@ class VibeAcpAgentLoop(AcpAgent):
await session.task await session.task
except asyncio.CancelledError: 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: except CoreRateLimitError as e:
raise RateLimitError.from_core(e) from e raise RateLimitError.from_core(e) from e
@ -737,7 +802,12 @@ class VibeAcpAgentLoop(AcpAgent):
finally: finally:
session.task = None 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: def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str:
text_prompt = "" text_prompt = ""
@ -787,37 +857,25 @@ class VibeAcpAgentLoop(AcpAgent):
return text_prompt return text_prompt
async def _run_agent_loop( 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]: ) -> AsyncGenerator[SessionUpdate]:
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd()) rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
async for event in session.agent_loop.act(rendered_prompt): async for event in session.agent_loop.act(
if isinstance(event, UserMessageEvent): rendered_prompt, client_message_id=client_message_id
yield UserMessageChunk( ):
session_update="user_message_chunk", if isinstance(event, AssistantEvent):
content=TextContentBlock(type="text", text=""),
field_meta={
"messageId": event.message_id,
**(
{"previousMessageId": user_message_id}
if user_message_id
else {}
),
},
)
elif isinstance(event, AssistantEvent):
yield AgentMessageChunk( yield AgentMessageChunk(
session_update="agent_message_chunk", session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=event.content), content=TextContentBlock(type="text", text=event.content),
field_meta={"messageId": event.message_id}, message_id=event.message_id,
) )
elif isinstance(event, ReasoningEvent): elif isinstance(event, ReasoningEvent):
yield AgentThoughtChunk( yield AgentThoughtChunk(
session_update="agent_thought_chunk", session_update="agent_thought_chunk",
content=TextContentBlock(type="text", text=event.content), content=TextContentBlock(type="text", text=event.content),
field_meta={"messageId": event.message_id}, message_id=event.message_id,
) )
elif isinstance(event, ToolCallEvent): elif isinstance(event, ToolCallEvent):
@ -859,6 +917,12 @@ class VibeAcpAgentLoop(AcpAgent):
elif isinstance(event, AgentProfileChangedEvent): elif isinstance(event, AgentProfileChangedEvent):
pass pass
@override
async def close_session(
self, session_id: str, **kwargs: Any
) -> CloseSessionResponse | None:
raise NotImplementedMethodError("close_session")
@override @override
async def cancel(self, session_id: str, **kwargs: Any) -> None: async def cancel(self, session_id: str, **kwargs: Any) -> None:
session = self._get_session(session_id) session = self._get_session(session_id)

View file

@ -9,7 +9,6 @@ from acp.schema import (
ContentToolCallContent, ContentToolCallContent,
ModelInfo, ModelInfo,
PermissionOption, PermissionOption,
SessionConfigOption,
SessionConfigOptionSelect, SessionConfigOptionSelect,
SessionConfigSelectOption, SessionConfigSelectOption,
SessionMode, SessionMode,
@ -103,7 +102,7 @@ def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
def make_mode_response( def make_mode_response(
profiles: list[AgentProfile], current_mode_id: str profiles: list[AgentProfile], current_mode_id: str
) -> tuple[SessionModeState, SessionConfigOption]: ) -> tuple[SessionModeState, SessionConfigOptionSelect]:
session_modes: list[SessionMode] = [] session_modes: list[SessionMode] = []
config_options: list[SessionConfigSelectOption] = [] config_options: list[SessionConfigSelectOption] = []
@ -128,8 +127,7 @@ def make_mode_response(
state = SessionModeState( state = SessionModeState(
current_mode_id=current_mode_id, available_modes=session_modes current_mode_id=current_mode_id, available_modes=session_modes
) )
config = SessionConfigOption( config = SessionConfigOptionSelect(
root=SessionConfigOptionSelect(
id="mode", id="mode",
name="Session Mode", name="Session Mode",
current_value=current_mode_id, current_value=current_mode_id,
@ -137,13 +135,12 @@ def make_mode_response(
type="select", type="select",
options=config_options, options=config_options,
) )
)
return state, config return state, config
def make_model_response( def make_model_response(
models: list[ModelConfig], current_model_id: str models: list[ModelConfig], current_model_id: str
) -> tuple[SessionModelState, SessionConfigOption]: ) -> tuple[SessionModelState, SessionConfigOptionSelect]:
model_infos: list[ModelInfo] = [] model_infos: list[ModelInfo] = []
config_options: list[SessionConfigSelectOption] = [] config_options: list[SessionConfigSelectOption] = []
@ -158,8 +155,7 @@ def make_model_response(
state = SessionModelState( state = SessionModelState(
current_model_id=current_model_id, available_models=model_infos current_model_id=current_model_id, available_models=model_infos
) )
config_option = SessionConfigOption( config_option = SessionConfigOptionSelect(
root=SessionConfigOptionSelect(
id="model", id="model",
name="Model", name="Model",
current_value=current_model_id, current_value=current_model_id,
@ -167,7 +163,6 @@ def make_model_response(
type="select", type="select",
options=config_options, options=config_options,
) )
)
return state, config_option return state, config_option
@ -257,7 +252,7 @@ def create_user_message_replay(msg: LLMMessage) -> UserMessageChunk:
return UserMessageChunk( return UserMessageChunk(
session_update="user_message_chunk", session_update="user_message_chunk",
content=TextContentBlock(type="text", text=content), 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( return AgentMessageChunk(
session_update="agent_message_chunk", session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=content), 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( return AgentThoughtChunk(
session_update="agent_thought_chunk", session_update="agent_thought_chunk",
content=TextContentBlock(type="text", text=msg.reasoning_content), 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,
) )

View file

@ -128,12 +128,10 @@ def _get_selected_texts(app: App) -> list[str]:
selected_texts = [] selected_texts = []
for widget in app.query("*"): for widget in app.query("*"):
try:
if not hasattr(widget, "text_selection") or not widget.text_selection: if not hasattr(widget, "text_selection") or not widget.text_selection:
continue continue
selection = widget.text_selection selection = widget.text_selection
try:
result = widget.get_selection(selection) result = widget.get_selection(selection)
except Exception: except Exception:
continue continue

View file

@ -294,6 +294,7 @@ class VibeApp(App): # noqa: PLR0904
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.scroll_sensitivity_y = 1.0
self.agent_loop = agent_loop self.agent_loop = agent_loop
self._voice_manager: VoiceManagerPort = ( self._voice_manager: VoiceManagerPort = (
voice_manager or self._make_default_voice_manager() voice_manager or self._make_default_voice_manager()

View file

@ -344,7 +344,9 @@ class AgentLoop:
self.agent_profile, 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._clean_message_history()
self.rewind_manager.create_checkpoint() self.rewind_manager.create_checkpoint()
try: try:
@ -352,7 +354,9 @@ class AgentLoop:
except ValueError: except ValueError:
model_name = None model_name = None
async with agent_span(model=model_name, session_id=self.session_id): 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 yield event
@property @property
@ -511,8 +515,12 @@ class AgentLoop:
} }
return headers return headers
async def _conversation_loop(self, user_msg: str) -> AsyncGenerator[BaseEvent]: async def _conversation_loop(
user_message = LLMMessage(role=Role.user, content=user_msg) 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.messages.append(user_message)
self.stats.steps += 1 self.stats.steps += 1
self._current_user_message_id = user_message.message_id self._current_user_message_id = user_message.message_id
@ -604,11 +612,14 @@ class AgentLoop:
self, self,
) -> AsyncGenerator[AssistantEvent | ReasoningEvent | ToolCallEvent]: ) -> AsyncGenerator[AssistantEvent | ReasoningEvent | ToolCallEvent]:
message_id: str | None = None message_id: str | None = None
reasoning_message_id: str | None = None
emitted_tool_call_ids = set[str]() emitted_tool_call_ids = set[str]()
async for chunk in self._chat_streaming(): async for chunk in self._chat_streaming():
if message_id is None: if message_id is None:
message_id = chunk.message.message_id 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( for event in self._build_tool_call_events(
chunk.message.tool_calls, emitted_tool_call_ids chunk.message.tool_calls, emitted_tool_call_ids
@ -618,7 +629,8 @@ class AgentLoop:
if chunk.message.reasoning_content: if chunk.message.reasoning_content:
yield ReasoningEvent( 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: if chunk.message.content:
@ -1066,7 +1078,6 @@ class AgentLoop:
if len(self.messages) < ACCEPTABLE_HISTORY_SIZE: if len(self.messages) < ACCEPTABLE_HISTORY_SIZE:
return return
self._fill_missing_tool_responses() self._fill_missing_tool_responses()
self._ensure_assistant_after_tools()
def _fill_missing_tool_responses(self) -> None: def _fill_missing_tool_responses(self) -> None:
i = 1 i = 1
@ -1114,16 +1125,6 @@ class AgentLoop:
i += 1 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: def _reset_session(self) -> None:
self.session_id = str(uuid4()) self.session_id = str(uuid4())
self.session_logger.reset_session(self.session_id) self.session_logger.reset_session(self.session_id)

View file

@ -97,7 +97,11 @@ class OpenAIAdapter(APIAdapter):
field_name = provider.reasoning_field_name field_name = provider.reasoning_field_name
converted_messages = [ converted_messages = [
self._reasoning_to_api( 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 for msg in merged_messages
] ]

View file

@ -61,9 +61,13 @@ class BackendError(RuntimeError):
return "Rate limit exceeded. Please wait a moment before trying again." return "Rate limit exceeded. Please wait a moment before trying again."
rid = self.headers.get("x-request-id") or self.headers.get("request-id") rid = self.headers.get("x-request-id") or self.headers.get("request-id")
status_label = ( if self.status:
f"{self.status} {HTTPStatus(self.status).phrase}" if self.status else "N/A" try:
) status_label = f"{self.status} {HTTPStatus(self.status).phrase}"
except ValueError:
status_label = str(self.status)
else:
status_label = "N/A"
parts = [ parts = [
f"LLM backend error [{self.provider}]", f"LLM backend error [{self.provider}]",
f" status: {status_label}", f" status: {status_label}",

View file

@ -1,7 +1,7 @@
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools. 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: Before ANY action:
Restate the goal in one line. Restate the goal in one line.
Determine the task type: 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. 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. 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: State your plan before writing code:
List files to change and the specific change per file. List files to change and the specific change per file.
Multi-file changes: numbered checklist. Single-file fix: one-line plan. Multi-file changes: numbered checklist. Single-file fix: one-line plan.
No time estimates. Concrete actions only. 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: Apply changes, then confirm they work:
Edit one logical unit at a time. Edit one logical unit at a time.
After each unit, verify: run tests, or read back the file to confirm the edit landed. 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. Never claim completion without verification — a passing test, correct read-back, or successful build.
Hard Rules Hard Rules:
Never Commit 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. 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 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 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. 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 Structure First
Lead every response with the most useful structured element — code, diagram, table, or tree. Prose comes after, not before. Lead every response with the most useful structured element — code, diagram, table, or tree. Prose comes after, not before.
For change tasks: For change tasks, cite as: `file_path:line_number` followed by a fenced code block.
file_path:line_number
langcode
Prefer Brevity Prefer Brevity
State only what's necessary to complete the task. Code + file reference > explanation. 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. If your response exceeds 300 words, remove explanations the user didn't request.
For investigate tasks: For investigate tasks:
Start with a diagram, code reference, tree, or table — whichever conveys the answer fastest. Start with a diagram, code reference, tree, or table - whichever conveys the answer fastest.
request → auth.verify() → permissions.check() → handler Then 1-2 sentences of context if needed.
See middleware/auth.py:45. Then 1-2 sentences of context if needed.
BAD: "The authentication flow works by first checking the token…" BAD: "The authentication flow works by first checking the token…"
GOOD: request → auth.verify() → permissions.check() → handler — see middleware/auth.py:45. GOOD: request → auth.verify() → permissions.check() → handler — see middleware/auth.py:45
Visual Formats
Before responding with structural data, choose the right format: Before responding with structural data, choose the right format:
BAD: Bullet lists for hierarchy/tree BAD: Bullet lists for hierarchy/tree
@ -84,15 +80,13 @@ GOOD: → A → B → C diagrams
Interaction Design 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: 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: "Apply this fix to the other 3 endpoints?" GOOD: "Two approaches: (a) migration, (b) recreate table. Which?"
Good: "Two approaches: (a) migration, (b) recreate table. Which?" BAD: "Does this look good?", "Anything else?", "Let me know"
Bad: "Does this look good?", "Anything else?", "Let me know"
If unambiguous and complete, end with the result. If unambiguous and complete, end with the result.
Length 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. Elaborate only when: (1) user asks for explanation, (2) task involves architectural decisions, (3) multiple valid approaches exist.
Code Modifications (Change tasks) Code Modifications (Change tasks)
@ -107,12 +101,9 @@ When removing code, delete completely. No _unused renames, // removed comments,
Security Security
Fix injection, XSS, SQLi vulnerabilities immediately if spotted. Fix injection, XSS, SQLi vulnerabilities immediately if spotted.
Code References
Cite as file_path:line_number.
Professional Conduct Professional Conduct
Prioritize technical accuracy over validating beliefs. Disagree when necessary. Prioritize technical accuracy over validating beliefs. Disagree when necessary.
When uncertain, investigate before confirming. When uncertain, investigate before confirming.
Your output must contain zero emoji. This includes smiley faces, icons, flags, symbols like ✅❌💡, and all other Unicode emoji. Your output must contain zero emoji. This includes smiley faces, icons, flags, symbols like ✅❌💡, and all other Unicode emoji.
No over-the-top validation. 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.

View file

@ -218,6 +218,7 @@ class LLMMessage(BaseModel):
injected: bool = False injected: bool = False
reasoning_content: Content | None = None reasoning_content: Content | None = None
reasoning_signature: str | None = None reasoning_signature: str | None = None
reasoning_message_id: str | None = None
tool_calls: list[ToolCall] | None = None tool_calls: list[ToolCall] | None = None
name: str | None = None name: str | None = None
tool_call_id: str | None = None tool_call_id: str | None = None
@ -229,15 +230,20 @@ class LLMMessage(BaseModel):
if isinstance(v, dict): if isinstance(v, dict):
v.setdefault("content", "") v.setdefault("content", "")
v.setdefault("role", "assistant") 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()) 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 return v
role = str(getattr(v, "role", "assistant")) role = str(getattr(v, "role", "assistant"))
reasoning_content = getattr(v, "reasoning_content", None)
return { return {
"role": role, "role": role,
"content": getattr(v, "content", ""), "content": getattr(v, "content", ""),
"reasoning_content": getattr(v, "reasoning_content", None), "reasoning_content": reasoning_content,
"reasoning_signature": getattr(v, "reasoning_signature", None), "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), "tool_calls": getattr(v, "tool_calls", None),
"name": getattr(v, "name", None), "name": getattr(v, "name", None),
"tool_call_id": getattr(v, "tool_call_id", None), "tool_call_id": getattr(v, "tool_call_id", None),
@ -298,6 +304,8 @@ class LLMMessage(BaseModel):
content=content, content=content,
reasoning_content=reasoning_content, reasoning_content=reasoning_content,
reasoning_signature=reasoning_signature, 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, tool_calls=list(tool_calls_map.values()) or None,
name=self.name, name=self.name,
tool_call_id=self.tool_call_id, tool_call_id=self.tool_call_id,

View file

@ -9,7 +9,7 @@ import httpx
def _is_retryable_http_error(e: Exception) -> bool: def _is_retryable_http_error(e: Exception) -> bool:
if isinstance(e, httpx.HTTPStatusError): 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 return False