parent
7b8a66bc6c
commit
5ea69b547b
14 changed files with 88 additions and 378 deletions
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "2.6.1",
|
"version": "2.6.2",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "ACP Server",
|
"name": "ACP Server",
|
||||||
|
|
|
||||||
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -5,6 +5,17 @@ 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.6.2] - 2026-03-23
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Pinned agent-client-protocol dependency back to 0.8.1
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Context usage updates via ACP
|
||||||
|
|
||||||
|
|
||||||
## [2.6.1] - 2026-03-23
|
## [2.6.1] - 2026-03-23
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -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.6.1"
|
version = "2.6.2"
|
||||||
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.6.1/vibe-acp-darwin-aarch64-2.6.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.2/vibe-acp-darwin-aarch64-2.6.2.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.6.1/vibe-acp-darwin-x86_64-2.6.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.2/vibe-acp-darwin-x86_64-2.6.2.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.6.1/vibe-acp-linux-aarch64-2.6.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.2/vibe-acp-linux-aarch64-2.6.2.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.6.1/vibe-acp-linux-x86_64-2.6.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.2/vibe-acp-linux-x86_64-2.6.2.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.6.1/vibe-acp-windows-aarch64-2.6.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.2/vibe-acp-windows-aarch64-2.6.2.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.6.1/vibe-acp-windows-x86_64-2.6.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.2/vibe-acp-windows-x86_64-2.6.2.zip"
|
||||||
cmd = "./vibe-acp.exe"
|
cmd = "./vibe-acp.exe"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "2.6.1"
|
version = "2.6.2"
|
||||||
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.9.0a1",
|
"agent-client-protocol==0.8.1",
|
||||||
"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",
|
||||||
|
|
@ -113,7 +113,6 @@ 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 = "."
|
||||||
|
|
|
||||||
|
|
@ -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.6.1"
|
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.6.2"
|
||||||
)
|
)
|
||||||
|
|
||||||
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.6.1"
|
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.6.2"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.auth_methods is not None
|
assert response.auth_methods is not None
|
||||||
|
|
|
||||||
|
|
@ -100,11 +100,13 @@ 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].id == "mode"
|
assert response.config_options[0].root.id == "mode"
|
||||||
assert response.config_options[0].category == "mode"
|
assert response.config_options[0].root.category == "mode"
|
||||||
assert response.config_options[0].current_value == BuiltinAgentName.DEFAULT
|
assert response.config_options[0].root.current_value == BuiltinAgentName.DEFAULT
|
||||||
assert len(response.config_options[0].options) == 5
|
assert len(response.config_options[0].root.options) == 5
|
||||||
mode_option_values = {opt.value for opt in response.config_options[0].options}
|
mode_option_values = {
|
||||||
|
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,
|
||||||
|
|
@ -112,11 +114,13 @@ class TestLoadSession:
|
||||||
BuiltinAgentName.PLAN,
|
BuiltinAgentName.PLAN,
|
||||||
BuiltinAgentName.ACCEPT_EDITS,
|
BuiltinAgentName.ACCEPT_EDITS,
|
||||||
}
|
}
|
||||||
assert response.config_options[1].id == "model"
|
assert response.config_options[1].root.id == "model"
|
||||||
assert response.config_options[1].category == "model"
|
assert response.config_options[1].root.category == "model"
|
||||||
assert response.config_options[1].current_value == "devstral-latest"
|
assert response.config_options[1].root.current_value == "devstral-latest"
|
||||||
assert len(response.config_options[1].options) == 2
|
assert len(response.config_options[1].root.options) == 2
|
||||||
model_option_values = {opt.value for opt in response.config_options[1].options}
|
model_option_values = {
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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.id == "mode"
|
assert mode_config.root.id == "mode"
|
||||||
assert mode_config.category == "mode"
|
assert mode_config.root.category == "mode"
|
||||||
assert mode_config.current_value == BuiltinAgentName.DEFAULT
|
assert mode_config.root.current_value == BuiltinAgentName.DEFAULT
|
||||||
assert len(mode_config.options) == 5
|
assert len(mode_config.root.options) == 5
|
||||||
mode_option_values = {opt.value for opt in mode_config.options}
|
mode_option_values = {opt.value for opt in mode_config.root.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.id == "model"
|
assert model_config.root.id == "model"
|
||||||
assert model_config.category == "model"
|
assert model_config.root.category == "model"
|
||||||
assert model_config.current_value == "devstral-latest"
|
assert model_config.root.current_value == "devstral-latest"
|
||||||
assert len(model_config.options) == 2
|
assert len(model_config.root.options) == 2
|
||||||
model_option_values = {opt.value for opt in model_config.options}
|
model_option_values = {opt.value for opt in model_config.root.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")
|
||||||
|
|
|
||||||
|
|
@ -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.id == "mode"
|
assert mode_config.root.id == "mode"
|
||||||
assert mode_config.current_value == BuiltinAgentName.AUTO_APPROVE
|
assert mode_config.root.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.id == "mode"
|
assert mode_config.root.id == "mode"
|
||||||
assert mode_config.current_value == BuiltinAgentName.CHAT
|
assert mode_config.root.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.id == "model"
|
assert model_config.root.id == "model"
|
||||||
assert model_config.current_value == "devstral-small"
|
assert model_config.root.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(
|
||||||
|
|
|
||||||
|
|
@ -1,254 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -6,7 +6,7 @@ from acp import (
|
||||||
Agent as AcpAgent,
|
Agent as AcpAgent,
|
||||||
Client,
|
Client,
|
||||||
CreateTerminalResponse,
|
CreateTerminalResponse,
|
||||||
KillTerminalResponse,
|
KillTerminalCommandResponse,
|
||||||
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
|
||||||
) -> KillTerminalResponse | None:
|
) -> KillTerminalCommandResponse | 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]:
|
||||||
|
|
|
||||||
10
uv.lock
generated
10
uv.lock
generated
|
|
@ -4,14 +4,14 @@ requires-python = ">=3.12"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-client-protocol"
|
name = "agent-client-protocol"
|
||||||
version = "0.9.0a1"
|
version = "0.8.1"
|
||||||
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/dc/1ec56897b461fbdb844c9bff3abbbe225bfe8cda020dc2449101a6d76592/agent_client_protocol-0.9.0a1.tar.gz", hash = "sha256:9e6fc8b72df465279470920d679c871e0c658f69212e345563cc69b17906b606", size = 70423, upload-time = "2026-03-19T18:44:47.117Z" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/59/b794d5247aac2693a562ddd6eb2092581331496815967a6bca6bbf086b4f/agent_client_protocol-0.9.0a1-py3-none-any.whl", hash = "sha256:3e0962df15c3c7dd2957daea2f47db5e644fd897e77180e492f0a27d9fdb7bf4", size = 55945, upload-time = "2026-03-19T18:44:45.924Z" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -770,7 +770,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "2.6.1"
|
version = "2.6.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "agent-client-protocol" },
|
{ name = "agent-client-protocol" },
|
||||||
|
|
@ -832,7 +832,7 @@ dev = [
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "agent-client-protocol", specifier = ">=0.9.0a1" },
|
{ name = "agent-client-protocol", specifier = "==0.8.1" },
|
||||||
{ 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" },
|
||||||
|
|
|
||||||
|
|
@ -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.6.1"
|
__version__ = "2.6.2"
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,11 @@ from acp.schema import (
|
||||||
AgentThoughtChunk,
|
AgentThoughtChunk,
|
||||||
AllowedOutcome,
|
AllowedOutcome,
|
||||||
AuthenticateResponse,
|
AuthenticateResponse,
|
||||||
AuthMethodAgent,
|
AuthMethod,
|
||||||
AvailableCommand,
|
AvailableCommand,
|
||||||
AvailableCommandInput,
|
AvailableCommandInput,
|
||||||
ClientCapabilities,
|
ClientCapabilities,
|
||||||
CloseSessionResponse,
|
|
||||||
ContentToolCallContent,
|
ContentToolCallContent,
|
||||||
Cost,
|
|
||||||
EnvVarAuthMethod,
|
|
||||||
ForkSessionResponse,
|
ForkSessionResponse,
|
||||||
HttpMcpServer,
|
HttpMcpServer,
|
||||||
Implementation,
|
Implementation,
|
||||||
|
|
@ -46,14 +43,11 @@ from acp.schema import (
|
||||||
SessionListCapabilities,
|
SessionListCapabilities,
|
||||||
SetSessionConfigOptionResponse,
|
SetSessionConfigOptionResponse,
|
||||||
SseMcpServer,
|
SseMcpServer,
|
||||||
TerminalAuthMethod,
|
|
||||||
TextContentBlock,
|
TextContentBlock,
|
||||||
TextResourceContents,
|
TextResourceContents,
|
||||||
ToolCallProgress,
|
ToolCallProgress,
|
||||||
ToolCallUpdate,
|
ToolCallUpdate,
|
||||||
UnstructuredCommandInput,
|
UnstructuredCommandInput,
|
||||||
Usage,
|
|
||||||
UsageUpdate,
|
|
||||||
UserMessageChunk,
|
UserMessageChunk,
|
||||||
)
|
)
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
@ -180,10 +174,9 @@ 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: list[EnvVarAuthMethod | TerminalAuthMethod | AuthMethodAgent] = (
|
auth_methods = (
|
||||||
[
|
[
|
||||||
TerminalAuthMethod(
|
AuthMethod(
|
||||||
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",
|
||||||
|
|
@ -387,39 +380,6 @@ 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
|
||||||
|
|
@ -604,7 +564,6 @@ 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()),
|
||||||
|
|
@ -731,11 +690,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
|
|
||||||
@override
|
@override
|
||||||
async def prompt(
|
async def prompt(
|
||||||
self,
|
self, prompt: list[ContentBlock], session_id: str, **kwargs: Any
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -768,10 +723,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
await session.task
|
await session.task
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
self._send_usage_update(session)
|
return PromptResponse(stop_reason="cancelled")
|
||||||
return PromptResponse(
|
|
||||||
stop_reason="cancelled", usage=self._build_usage(session)
|
|
||||||
)
|
|
||||||
|
|
||||||
except CoreRateLimitError as e:
|
except CoreRateLimitError as e:
|
||||||
raise RateLimitError.from_core(e) from e
|
raise RateLimitError.from_core(e) from e
|
||||||
|
|
@ -785,8 +737,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
finally:
|
finally:
|
||||||
session.task = None
|
session.task = None
|
||||||
|
|
||||||
self._send_usage_update(session)
|
return PromptResponse(stop_reason="end_turn")
|
||||||
return PromptResponse(stop_reason="end_turn", usage=self._build_usage(session))
|
|
||||||
|
|
||||||
def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str:
|
def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str:
|
||||||
text_prompt = ""
|
text_prompt = ""
|
||||||
|
|
@ -908,12 +859,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from acp.schema import (
|
||||||
ContentToolCallContent,
|
ContentToolCallContent,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
PermissionOption,
|
PermissionOption,
|
||||||
|
SessionConfigOption,
|
||||||
SessionConfigOptionSelect,
|
SessionConfigOptionSelect,
|
||||||
SessionConfigSelectOption,
|
SessionConfigSelectOption,
|
||||||
SessionMode,
|
SessionMode,
|
||||||
|
|
@ -102,7 +103,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, SessionConfigOptionSelect]:
|
) -> tuple[SessionModeState, SessionConfigOption]:
|
||||||
session_modes: list[SessionMode] = []
|
session_modes: list[SessionMode] = []
|
||||||
config_options: list[SessionConfigSelectOption] = []
|
config_options: list[SessionConfigSelectOption] = []
|
||||||
|
|
||||||
|
|
@ -127,7 +128,8 @@ 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 = SessionConfigOptionSelect(
|
config = SessionConfigOption(
|
||||||
|
root=SessionConfigOptionSelect(
|
||||||
id="mode",
|
id="mode",
|
||||||
name="Session Mode",
|
name="Session Mode",
|
||||||
current_value=current_mode_id,
|
current_value=current_mode_id,
|
||||||
|
|
@ -135,12 +137,13 @@ 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, SessionConfigOptionSelect]:
|
) -> tuple[SessionModelState, SessionConfigOption]:
|
||||||
model_infos: list[ModelInfo] = []
|
model_infos: list[ModelInfo] = []
|
||||||
config_options: list[SessionConfigSelectOption] = []
|
config_options: list[SessionConfigSelectOption] = []
|
||||||
|
|
||||||
|
|
@ -155,7 +158,8 @@ 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 = SessionConfigOptionSelect(
|
config_option = SessionConfigOption(
|
||||||
|
root=SessionConfigOptionSelect(
|
||||||
id="model",
|
id="model",
|
||||||
name="Model",
|
name="Model",
|
||||||
current_value=current_model_id,
|
current_value=current_model_id,
|
||||||
|
|
@ -163,6 +167,7 @@ def make_model_response(
|
||||||
type="select",
|
type="select",
|
||||||
options=config_options,
|
options=config_options,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
return state, config_option
|
return state, config_option
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue