Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-Authored-By: Clément Siriex <clement.sirieix@mistral.ai>
Co-Authored-By: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-01-30 14:08:38 +01:00 committed by Mathias Gesbert
parent bd3497b1c0
commit 9809cfc831
32 changed files with 622 additions and 305 deletions

2
.vscode/launch.json vendored
View file

@ -1,5 +1,5 @@
{ {
"version": "2.0.1", "version": "2.0.2",
"configurations": [ "configurations": [
{ {
"name": "ACP Server", "name": "ACP Server",

View file

@ -5,11 +5,32 @@ 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.0.2] - 2026-01-30
### Added
- Allow environment variables to be overridden by dotenv files
- Display custom rate limit messages depending on plan type
### Changed
- Made plan offer message more discreet in UI
- Speed up latest session scan and harden validation
- Updated pytest-xdist configuration to schedule single test chunks
### Fixed
- Prevent duplicate messages in persisted sessions
- Fix ACP bash tool to pass full command string for chained commands
- Fix global agent prompt not being loaded correctly
- Do not propose to "resume" when there is nothing to resume
## [2.0.1] - 2026-01-28 ## [2.0.1] - 2026-01-28
### Fixed ### Fixed
- Encoding issues in Windows - Fix encoding issues in Windows
## [2.0.0] - 2026-01-27 ## [2.0.0] - 2026-01-27

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.0.1" version = "2.0.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.0.1/vibe-acp-darwin-aarch64-2.0.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-darwin-aarch64-2.0.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.0.1/vibe-acp-darwin-x86_64-2.0.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-darwin-x86_64-2.0.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.0.1/vibe-acp-linux-aarch64-2.0.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-linux-aarch64-2.0.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.0.1/vibe-acp-linux-x86_64-2.0.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-linux-x86_64-2.0.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.0.1/vibe-acp-windows-aarch64-2.0.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-windows-aarch64-2.0.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.0.1/vibe-acp-windows-x86_64-2.0.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-windows-x86_64-2.0.2.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.0.1" version = "2.0.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"
@ -159,5 +159,5 @@ max-nested-blocks = 4
ignore_decorators = ["@*"] ignore_decorators = ["@*"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-vvvv -q -n auto --durations=5 --import-mode=importlib" addopts = "-vvvv -q -n auto --durations=10 --import-mode=importlib --maxschedchunk=1"
timeout = 10 timeout = 10

View file

@ -125,42 +125,6 @@ class TestAcpBashBasic:
display = Bash.get_summary(args) display = Bash.get_summary(args)
assert display == "ls (timeout 10s)" assert display == "ls (timeout 10s)"
def test_parse_command_simple(self) -> None:
tool = Bash(config=BashToolConfig(), state=AcpBashState())
env, command, args = tool._parse_command("ls")
assert env == []
assert command == "ls"
assert args == []
def test_parse_command_with_args(self) -> None:
tool = Bash(config=BashToolConfig(), state=AcpBashState())
env, command, args = tool._parse_command("ls -la src")
assert env == []
assert command == "ls"
assert args == ["-la", "src"]
def test_parse_command_with_env(self) -> None:
tool = Bash(config=BashToolConfig(), state=AcpBashState())
env, command, args = tool._parse_command("NODE_ENV=test DEBUG=1 npm test")
assert len(env) == 2
assert env[0].name == "NODE_ENV"
assert env[0].value == "test"
assert env[1].name == "DEBUG"
assert env[1].value == "1"
assert command == "npm"
assert args == ["test"]
def test_parse_command_with_env_value_contains_equals(self) -> None:
tool = Bash(config=BashToolConfig(), state=AcpBashState())
env, command, args = tool._parse_command(
"PATH=/usr/bin:/usr/local/bin echo hello"
)
assert len(env) == 1
assert env[0].name == "PATH"
assert env[0].value == "/usr/bin:/usr/local/bin"
assert command == "echo"
assert args == ["hello"]
class TestAcpBashExecution: class TestAcpBashExecution:
@pytest.mark.asyncio @pytest.mark.asyncio
@ -181,8 +145,7 @@ class TestAcpBashExecution:
# Verify create_terminal was called correctly # Verify create_terminal was called correctly
params = mock_client._last_create_params params = mock_client._last_create_params
assert params["session_id"] == "test_session_123" assert params["session_id"] == "test_session_123"
assert params["command"] == "echo" assert params["command"] == "echo hello"
assert params["args"] == ["hello"]
assert params["cwd"] == str(Path.cwd()) # effective_workdir defaults to cwd assert params["cwd"] == str(Path.cwd()) # effective_workdir defaults to cwd
@pytest.mark.asyncio @pytest.mark.asyncio
@ -200,16 +163,7 @@ class TestAcpBashExecution:
await collect_result(tool.run(args)) await collect_result(tool.run(args))
params = mock_client._last_create_params params = mock_client._last_create_params
env = params["env"] assert params["command"] == "NODE_ENV=test npm run build"
assert env is not None
assert (
isinstance(env, list) and len(env) > 0 and isinstance(env[0], EnvVariable)
)
assert len(env) == 1
assert env[0].name == "NODE_ENV"
assert env[0].value == "test"
assert params["command"] == "npm"
assert params["args"] == ["run", "build"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_with_nonzero_exit_code(self, mock_client: MockClient) -> None: async def test_run_with_nonzero_exit_code(self, mock_client: MockClient) -> None:

View file

@ -25,7 +25,7 @@ class TestACPInitialize:
), ),
) )
assert response.agent_info == Implementation( assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.1" name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.2"
) )
assert response.auth_methods == [] assert response.auth_methods == []
@ -48,7 +48,7 @@ class TestACPInitialize:
), ),
) )
assert response.agent_info == Implementation( assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.1" name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.2"
) )
assert response.auth_methods is not None assert response.auth_methods is not None

View file

@ -5,7 +5,11 @@ import logging
import pytest import pytest
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from vibe.cli.plan_offer.decide_plan_offer import PlanOfferAction, decide_plan_offer from vibe.cli.plan_offer.decide_plan_offer import (
PlanOfferAction,
PlanType,
decide_plan_offer,
)
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
@ -18,14 +22,15 @@ async def test_proposes_upgrade_without_call_when_api_key_is_empty() -> None:
prompt_switching_to_pro_plan=False, prompt_switching_to_pro_plan=False,
) )
) )
action = await decide_plan_offer("", gateway) action, plan_type = await decide_plan_offer("", gateway)
assert action is PlanOfferAction.UPGRADE assert action is PlanOfferAction.UPGRADE
assert plan_type is PlanType.FREE
assert gateway.calls == [] assert gateway.calls == []
@pytest.mark.parametrize( @pytest.mark.parametrize(
("response", "expected"), ("response", "expected_action", "expected_plan_type"),
[ [
( (
WhoAmIResponse( WhoAmIResponse(
@ -34,6 +39,7 @@ async def test_proposes_upgrade_without_call_when_api_key_is_empty() -> None:
prompt_switching_to_pro_plan=False, prompt_switching_to_pro_plan=False,
), ),
PlanOfferAction.NONE, PlanOfferAction.NONE,
PlanType.PRO,
), ),
( (
WhoAmIResponse( WhoAmIResponse(
@ -42,6 +48,7 @@ async def test_proposes_upgrade_without_call_when_api_key_is_empty() -> None:
prompt_switching_to_pro_plan=False, prompt_switching_to_pro_plan=False,
), ),
PlanOfferAction.UPGRADE, PlanOfferAction.UPGRADE,
PlanType.FREE,
), ),
( (
WhoAmIResponse( WhoAmIResponse(
@ -50,18 +57,22 @@ async def test_proposes_upgrade_without_call_when_api_key_is_empty() -> None:
prompt_switching_to_pro_plan=True, prompt_switching_to_pro_plan=True,
), ),
PlanOfferAction.SWITCH_TO_PRO_KEY, PlanOfferAction.SWITCH_TO_PRO_KEY,
PlanType.PRO,
), ),
], ],
ids=["with-a-pro-plan", "without-a-pro-plan", "with-a-non-pro-key"], ids=["with-a-pro-plan", "without-a-pro-plan", "with-a-non-pro-key"],
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_proposes_an_action_based_on_current_plan_status( async def test_proposes_an_action_based_on_current_plan_status(
response: WhoAmIResponse, expected: PlanOfferAction response: WhoAmIResponse,
expected_action: PlanOfferAction,
expected_plan_type: PlanType,
) -> None: ) -> None:
gateway = FakeWhoAmIGateway(response) gateway = FakeWhoAmIGateway(response)
action = await decide_plan_offer("api-key", gateway) action, plan_type = await decide_plan_offer("api-key", gateway)
assert action is expected assert action is expected_action
assert plan_type is expected_plan_type
assert gateway.calls == ["api-key"] assert gateway.calls == ["api-key"]
@ -75,18 +86,20 @@ async def test_proposes_nothing_when_nothing_is_suggested() -> None:
) )
) )
action = await decide_plan_offer("api-key", gateway) action, plan_type = await decide_plan_offer("api-key", gateway)
assert action is PlanOfferAction.NONE assert action is PlanOfferAction.NONE
assert plan_type is PlanType.UNKNOWN
assert gateway.calls == ["api-key"] assert gateway.calls == ["api-key"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_proposes_upgrade_when_api_key_is_unauthorized() -> None: async def test_proposes_upgrade_when_api_key_is_unauthorized() -> None:
gateway = FakeWhoAmIGateway(unauthorized=True) gateway = FakeWhoAmIGateway(unauthorized=True)
action = await decide_plan_offer("bad-key", gateway) action, plan_type = await decide_plan_offer("bad-key", gateway)
assert action is PlanOfferAction.UPGRADE assert action is PlanOfferAction.UPGRADE
assert plan_type is PlanType.FREE
assert gateway.calls == ["bad-key"] assert gateway.calls == ["bad-key"]
@ -96,8 +109,9 @@ async def test_proposes_none_and_logs_warning_when_gateway_error_occurs(
) -> None: ) -> None:
gateway = FakeWhoAmIGateway(error=True) gateway = FakeWhoAmIGateway(error=True)
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
action = await decide_plan_offer("api-key", gateway) action, plan_type = await decide_plan_offer("api-key", gateway)
assert action is PlanOfferAction.NONE assert action is PlanOfferAction.NONE
assert plan_type is PlanType.UNKNOWN
assert gateway.calls == ["api-key"] assert gateway.calls == ["api-key"]
assert "Failed to fetch plan status." in caplog.text assert "Failed to fetch plan status." in caplog.text

View file

@ -0,0 +1,61 @@
from __future__ import annotations
from pathlib import Path
from vibe.core.config import load_dotenv_values
def _write_env_file(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8")
def test_skips_missing_file(tmp_path: Path) -> None:
environ = {"EXISTING": "1"}
missing_path = tmp_path / "missing.env"
load_dotenv_values(env_path=missing_path, environ=environ)
assert environ == {"EXISTING": "1"}
def test_sets_and_overrides_values(tmp_path: Path) -> None:
env_path = tmp_path / ".env"
_write_env_file(
env_path,
"\n".join([
"MISTRAL_API_KEY=new-key",
"HTTPS_PROXY=https://local-proxy:8080",
"OTHER=from-env",
"NEW_KEY=added",
"FOO=replace",
])
+ "\n",
)
environ = {
"MISTRAL_API_KEY": "old-key",
"HTTPS_PROXY": "old-https",
"OTHER": "keep",
"FOO": "keep",
}
load_dotenv_values(env_path=env_path, environ=environ)
assert environ["MISTRAL_API_KEY"] == "new-key"
assert environ["HTTPS_PROXY"] == "https://local-proxy:8080"
assert environ["OTHER"] == "from-env"
assert environ["NEW_KEY"] == "added"
assert environ["FOO"] == "replace"
def test_ignores_empty_values(tmp_path: Path) -> None:
env_path = tmp_path / ".env"
_write_env_file(
env_path, "\n".join(["EMPTY=", "MISTRAL_API_KEY=", "NO_VALUE"]) + "\n"
)
environ: dict[str, str] = {}
load_dotenv_values(env_path=env_path, environ=environ)
assert "EMPTY" not in environ
assert "MISTRAL_API_KEY" not in environ
assert "NO_VALUE" not in environ

View file

@ -1,51 +0,0 @@
from __future__ import annotations
from contextlib import contextmanager
from pathlib import Path
import tomllib
import tomli_w
from vibe.core import config
from vibe.core.config import VibeConfig
def _restore_dump_config(config_file: Path):
original_dump_config = VibeConfig.dump_config
def real_dump_config(cls, config_dict: dict) -> None:
try:
with config_file.open("wb") as f:
tomli_w.dump(config_dict, f)
except OSError:
config_file.write_text(
"\n".join(
f"{k} = {v!r}" for k, v in config_dict.items() if v is not None
),
encoding="utf-8",
)
VibeConfig.dump_config = classmethod(real_dump_config) # type: ignore[assignment]
return original_dump_config
@contextmanager
def _migrate_config_file(tmp_path: Path, content: str):
config_file = tmp_path / "config.toml"
config_file.write_text(content, encoding="utf-8")
original_config_file = config.CONFIG_FILE
original_dump_config = _restore_dump_config(config_file)
try:
config.CONFIG_FILE = config_file
VibeConfig._migrate()
yield config_file
finally:
config.CONFIG_FILE = original_config_file
VibeConfig.dump_config = original_dump_config
def _load_migrated_config(config_file: Path) -> dict:
with config_file.open("rb") as f:
return tomllib.load(f)

View file

@ -206,6 +206,83 @@ class TestSessionLoaderFindLatestSession:
assert result is not None assert result is not None
assert result == valid_session assert result == valid_session
def test_find_latest_session_skips_empty_messages_file(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
session_dir = Path(session_config.save_dir)
valid_session = create_test_session(session_dir, "valid123-session")
time.sleep(0.01)
empty_session = create_test_session(session_dir, "emptymss-session")
(empty_session / "messages.jsonl").write_text("")
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result == valid_session
def test_find_latest_session_skips_messages_json_not_dict(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
session_dir = Path(session_config.save_dir)
valid_session = create_test_session(session_dir, "valid123-session")
time.sleep(0.01)
invalid_session = create_test_session(session_dir, "msglist-session")
(invalid_session / "messages.jsonl").write_text("[]\n")
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result == valid_session
def test_find_latest_session_skips_metadata_json_not_dict(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
session_dir = Path(session_config.save_dir)
valid_session = create_test_session(session_dir, "valid123-session")
time.sleep(0.01)
invalid_session = create_test_session(session_dir, "metalist-session")
(invalid_session / "meta.json").write_text("[]")
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result == valid_session
def test_find_latest_session_skips_unreadable_messages_file(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
session_dir = Path(session_config.save_dir)
valid_session = create_test_session(session_dir, "valid123-session")
time.sleep(0.01)
unreadable_session = create_test_session(session_dir, "unreadab-session")
unreadable_messages = unreadable_session / "messages.jsonl"
unreadable_messages.chmod(0)
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result == valid_session
def test_find_latest_session_skips_unreadable_metadata_file(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
session_dir = Path(session_config.save_dir)
valid_session = create_test_session(session_dir, "valid123-session")
time.sleep(0.01)
unreadable_session = create_test_session(session_dir, "unreadab-session")
unreadable_metadata = unreadable_session / "meta.json"
unreadable_metadata.chmod(0)
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result == valid_session
class TestSessionLoaderFindSessionById: class TestSessionLoaderFindSessionById:
def test_find_session_by_id_exact_match( def test_find_session_by_id_exact_match(
@ -285,6 +362,27 @@ class TestSessionLoaderFindSessionById:
assert result is None assert result is None
class TestSessionLoaderDoesSessionExist:
def test_does_session_exist_no_messages(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
session_dir = Path(session_config.save_dir)
session_folder = create_test_session(session_dir, "test-session-123")
(session_folder / "messages.jsonl").unlink()
result = SessionLoader.does_session_exist("test-session-123", session_config)
assert result is None
def test_does_session_exist_success(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
session_dir = Path(session_config.save_dir)
session_folder = create_test_session(session_dir, "test-session-123")
result = SessionLoader.does_session_exist("test-session-123", session_config)
assert result == session_folder
class TestSessionLoaderLoadSession: class TestSessionLoaderLoadSession:
def test_load_session_success( def test_load_session_success(
self, session_config: SessionLoggingConfig, create_test_session self, session_config: SessionLoggingConfig, create_test_session

View file

@ -208,8 +208,7 @@ class TestSessionLoggerSaveInteraction:
steps=1, session_prompt_tokens=10, session_completion_tokens=20 steps=1, session_prompt_tokens=10, session_completion_tokens=20
) )
# Test that save_interaction returns a path when enabled await logger.save_interaction(
result = await logger.save_interaction(
messages=messages, messages=messages,
stats=stats, stats=stats,
base_config=mock_vibe_config, base_config=mock_vibe_config,
@ -217,11 +216,7 @@ class TestSessionLoggerSaveInteraction:
agent_profile=mock_agent_profile, agent_profile=mock_agent_profile,
) )
# Verify the result # Verify behavior via file system
assert result is not None
assert str(logger.session_dir) in result
# Verify that files were created
assert logger.session_dir is not None assert logger.session_dir is not None
messages_file = logger.session_dir / "messages.jsonl" messages_file = logger.session_dir / "messages.jsonl"
metadata_file = logger.session_dir / "meta.json" metadata_file = logger.session_dir / "meta.json"
@ -229,7 +224,6 @@ class TestSessionLoggerSaveInteraction:
assert messages_file.exists() assert messages_file.exists()
assert metadata_file.exists() assert metadata_file.exists()
# Verify that metadata contains expected data
with open(metadata_file) as f: with open(metadata_file) as f:
metadata = json.load(f) metadata = json.load(f)
assert metadata["session_id"] == session_id assert metadata["session_id"] == session_id
@ -261,7 +255,7 @@ class TestSessionLoggerSaveInteraction:
steps=1, session_prompt_tokens=10, session_completion_tokens=20 steps=1, session_prompt_tokens=10, session_completion_tokens=20
) )
result = await logger.save_interaction( await logger.save_interaction(
messages=messages, messages=messages,
stats=stats, stats=stats,
base_config=mock_vibe_config, base_config=mock_vibe_config,
@ -269,10 +263,9 @@ class TestSessionLoggerSaveInteraction:
agent_profile=mock_agent_profile, agent_profile=mock_agent_profile,
) )
assert result is not None
assert logger.session_dir is not None assert logger.session_dir is not None
metadata_file = logger.session_dir / "meta.json" metadata_file = logger.session_dir / "meta.json"
assert metadata_file.exists()
with open(metadata_file) as f: with open(metadata_file) as f:
metadata = json.load(f) metadata = json.load(f)
assert "system_prompt" in metadata assert "system_prompt" in metadata
@ -280,6 +273,7 @@ class TestSessionLoggerSaveInteraction:
assert metadata["system_prompt"]["role"] == "system" assert metadata["system_prompt"]["role"] == "system"
messages_file = logger.session_dir / "messages.jsonl" messages_file = logger.session_dir / "messages.jsonl"
assert messages_file.exists()
with open(messages_file) as f: with open(messages_file) as f:
lines = f.readlines() lines = f.readlines()
messages_data = [json.loads(line) for line in lines] messages_data = [json.loads(line) for line in lines]
@ -330,7 +324,7 @@ class TestSessionLoggerSaveInteraction:
steps=2, session_prompt_tokens=20, session_completion_tokens=40 steps=2, session_prompt_tokens=20, session_completion_tokens=40
) )
result = await logger.save_interaction( await logger.save_interaction(
messages=all_messages, messages=all_messages,
stats=updated_stats, stats=updated_stats,
base_config=mock_vibe_config, base_config=mock_vibe_config,
@ -338,17 +332,78 @@ class TestSessionLoggerSaveInteraction:
agent_profile=mock_agent_profile, agent_profile=mock_agent_profile,
) )
# Verify that the result is not None # Verify behavior via file system: metadata was updated
assert result is not None
# Verify that metadata was updated
assert logger.session_dir is not None assert logger.session_dir is not None
metadata_file = logger.session_dir / "meta.json" metadata_file = logger.session_dir / "meta.json"
assert metadata_file.exists()
with open(metadata_file) as f: with open(metadata_file) as f:
metadata = json.load(f) metadata = json.load(f)
assert metadata["total_messages"] == 4 assert metadata["total_messages"] == 4
assert metadata["stats"]["steps"] == updated_stats.steps assert metadata["stats"]["steps"] == updated_stats.steps
messages_file = logger.session_dir / "messages.jsonl"
assert messages_file.exists()
with open(messages_file) as f:
lines = f.readlines()
assert len(lines) == 4
@pytest.mark.asyncio
async def test_save_interaction_no_new_messages_is_noop(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
"""Test that save_interaction does nothing when there are no new messages."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
stats = AgentStats(
steps=1, session_prompt_tokens=10, session_completion_tokens=20
)
await logger.save_interaction(
messages=messages,
stats=stats,
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
assert logger.session_dir is not None
metadata_file = logger.session_dir / "meta.json"
messages_file = logger.session_dir / "messages.jsonl"
with open(metadata_file) as f:
meta_before = json.load(f)
with open(messages_file) as f:
lines_before = f.readlines()
# Call again with same messages: no new messages, should be no-op
await logger.save_interaction(
messages=messages,
stats=stats,
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
with open(metadata_file) as f:
meta_after = json.load(f)
with open(messages_file) as f:
lines_after = f.readlines()
assert len(lines_after) == len(lines_before) == 2
assert lines_after == lines_before
assert meta_after["total_messages"] == meta_before["total_messages"] == 2
assert meta_after == meta_before
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_interaction_no_user_messages( async def test_save_interaction_no_user_messages(
self, self,
@ -371,7 +426,7 @@ class TestSessionLoggerSaveInteraction:
steps=1, session_prompt_tokens=10, session_completion_tokens=20 steps=1, session_prompt_tokens=10, session_completion_tokens=20
) )
result = await logger.save_interaction( await logger.save_interaction(
messages=messages, messages=messages,
stats=stats, stats=stats,
base_config=mock_vibe_config, base_config=mock_vibe_config,
@ -379,13 +434,10 @@ class TestSessionLoggerSaveInteraction:
agent_profile=mock_agent_profile, agent_profile=mock_agent_profile,
) )
# Verify the result # Verify behavior via file system
assert result is not None
assert str(logger.session_dir) in result
# Verify that metadata contains expected data
assert logger.session_dir is not None assert logger.session_dir is not None
metadata_file = logger.session_dir / "meta.json" metadata_file = logger.session_dir / "meta.json"
assert metadata_file.exists()
with open(metadata_file) as f: with open(metadata_file) as f:
metadata = json.load(f) metadata = json.load(f)
assert metadata["session_id"] == session_id assert metadata["session_id"] == session_id
@ -393,6 +445,11 @@ class TestSessionLoggerSaveInteraction:
assert metadata["stats"]["steps"] == stats.steps assert metadata["stats"]["steps"] == stats.steps
assert metadata["title"] == "Untitled session" assert metadata["title"] == "Untitled session"
messages_file = logger.session_dir / "messages.jsonl"
assert messages_file.exists()
with open(messages_file) as f:
assert len(f.readlines()) == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_interaction_long_user_message( async def test_save_interaction_long_user_message(
self, self,
@ -417,7 +474,7 @@ class TestSessionLoggerSaveInteraction:
steps=1, session_prompt_tokens=10, session_completion_tokens=20 steps=1, session_prompt_tokens=10, session_completion_tokens=20
) )
result = await logger.save_interaction( await logger.save_interaction(
messages=messages, messages=messages,
stats=stats, stats=stats,
base_config=mock_vibe_config, base_config=mock_vibe_config,
@ -425,13 +482,10 @@ class TestSessionLoggerSaveInteraction:
agent_profile=mock_agent_profile, agent_profile=mock_agent_profile,
) )
# Verify the result # Verify behavior via file system
assert result is not None
assert str(logger.session_dir) in result
# Verify that metadata contains expected data
assert logger.session_dir is not None assert logger.session_dir is not None
metadata_file = logger.session_dir / "meta.json" metadata_file = logger.session_dir / "meta.json"
assert metadata_file.exists()
with open(metadata_file) as f: with open(metadata_file) as f:
metadata = json.load(f) metadata = json.load(f)
assert metadata["session_id"] == session_id assert metadata["session_id"] == session_id
@ -440,6 +494,11 @@ class TestSessionLoggerSaveInteraction:
expected_title = long_message[:50] + "" expected_title = long_message[:50] + ""
assert metadata["title"] == expected_title assert metadata["title"] == expected_title
messages_file = logger.session_dir / "messages.jsonl"
assert messages_file.exists()
with open(messages_file) as f:
assert len(f.readlines()) == 2
class TestSessionLoggerResetSession: class TestSessionLoggerResetSession:
def test_reset_session(self, session_config: SessionLoggingConfig) -> None: def test_reset_session(self, session_config: SessionLoggingConfig) -> None:

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

View file

@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from http import HTTPStatus
from typing import cast from typing import cast
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import httpx
import pytest import pytest
from tests.mock.utils import mock_llm_chunk from tests.mock.utils import mock_llm_chunk
@ -11,6 +13,7 @@ from tests.stubs.fake_backend import FakeBackend
from vibe.core.agent_loop import AgentLoop from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import SessionLoggingConfig, VibeConfig from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.llm.exceptions import BackendErrorBuilder
from vibe.core.middleware import ( from vibe.core.middleware import (
ConversationContext, ConversationContext,
MiddlewareAction, MiddlewareAction,
@ -25,6 +28,7 @@ from vibe.core.types import (
AssistantEvent, AssistantEvent,
FunctionCall, FunctionCall,
LLMMessage, LLMMessage,
RateLimitError,
ReasoningEvent, ReasoningEvent,
Role, Role,
ToolCall, ToolCall,
@ -369,7 +373,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
assert events[-1].skipped is True assert events[-1].skipped is True
assert events[-1].skip_reason is not None assert events[-1].skip_reason is not None
assert "<user_cancellation>" in events[-1].skip_reason assert "<user_cancellation>" in events[-1].skip_reason
assert agent.session_logger.save_interaction.await_count == 1 assert agent.session_logger.save_interaction.await_count >= 1
@pytest.mark.asyncio @pytest.mark.asyncio
@ -388,6 +392,34 @@ async def test_act_flushes_and_logs_when_streaming_errors(observer_capture) -> N
assert agent.session_logger.save_interaction.await_count == 1 assert agent.session_logger.save_interaction.await_count == 1
@pytest.mark.asyncio
async def test_rate_limit(observer_capture) -> None:
observed, observer = observer_capture
response = httpx.Response(HTTPStatus.TOO_MANY_REQUESTS)
backend_error = BackendErrorBuilder.build_http_error(
provider="mistral",
endpoint="test",
response=response,
headers=None,
model="test-model",
messages=[],
temperature=0.0,
has_tools=False,
tool_choice=None,
)
backend = FakeBackend(exception_to_raise=backend_error)
agent = AgentLoop(
make_config(), backend=backend, message_observer=observer, enable_streaming=True
)
agent.session_logger.save_interaction = AsyncMock(return_value=None)
with pytest.raises(RateLimitError):
[_ async for _ in agent.act("Trigger rate limit failure while streaming")]
assert [role for role, _ in observed] == [Role.system, Role.user]
assert agent.session_logger.save_interaction.await_count == 1
def _snapshot_events(events: list) -> list[tuple[str, str]]: def _snapshot_events(events: list) -> list[tuple[str, str]]:
return [ return [
(type(e).__name__, e.content) (type(e).__name__, e.content)

View file

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import pytest import pytest
from tests.mock.utils import mock_llm_chunk from tests.mock.utils import mock_llm_chunk
@ -16,6 +18,8 @@ from vibe.core.agents.models import (
_deep_merge, _deep_merge,
) )
from vibe.core.config import SessionLoggingConfig, VibeConfig from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.paths.config_paths import ConfigPath
from vibe.core.paths.global_paths import GlobalPath
from vibe.core.tools.base import ToolPermission from vibe.core.tools.base import ToolPermission
from vibe.core.types import ( from vibe.core.types import (
FunctionCall, FunctionCall,
@ -164,6 +168,43 @@ class TestAgentProfile:
} }
class TestAgentApplyToConfig:
def test_custom_prompt_found_in_global_when_missing_from_project(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Regression test for https://github.com/mistralai/mistral-vibe/issues/288
When a custom prompt .md file is absent from the project-local prompts
directory, the system_prompt property should fall back to the global
~/.vibe/prompts/ directory and load the file from there.
"""
project_prompts = tmp_path / "project" / ".vibe" / "prompts"
project_prompts.mkdir(parents=True)
global_prompts = tmp_path / "home" / ".vibe" / "prompts"
global_prompts.mkdir(parents=True)
(global_prompts / "cc.md").write_text("Global custom prompt")
monkeypatch.setattr(
"vibe.core.config.PROMPTS_DIR", ConfigPath(lambda: project_prompts)
)
monkeypatch.setattr(
"vibe.core.config.GLOBAL_PROMPTS_DIR", GlobalPath(lambda: global_prompts)
)
base = VibeConfig(include_project_context=False, include_prompt_detail=False)
agent = AgentProfile(
name="cc",
display_name="Cc",
description="",
safety=AgentSafety.NEUTRAL,
overrides={"system_prompt_id": "cc"},
)
result = agent.apply_to_config(base)
assert result.system_prompt_id == "cc"
assert result.system_prompt == "Global custom prompt"
class TestAgentProfileOverrides: class TestAgentProfileOverrides:
def test_default_agent_has_no_overrides(self) -> None: def test_default_agent_has_no_overrides(self) -> None:
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].overrides == {} assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].overrides == {}

2
uv.lock generated
View file

@ -673,7 +673,7 @@ wheels = [
[[package]] [[package]]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.0.1" version = "2.0.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "agent-client-protocol" }, { name = "agent-client-protocol" },

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.0.1" __version__ = "2.0.2"

View file

@ -65,7 +65,7 @@ from vibe.acp.utils import (
from vibe.core.agent_loop import AgentLoop from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName from vibe.core.agents.models import BuiltinAgentName
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_api_keys_from_env from vibe.core.config import MissingAPIKeyError, VibeConfig, load_dotenv_values
from vibe.core.tools.base import BaseToolConfig, ToolPermission from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import ( from vibe.core.types import (
ApprovalResponse, ApprovalResponse,
@ -176,7 +176,7 @@ class VibeAcpAgentLoop(AcpAgent):
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio], mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
**kwargs: Any, **kwargs: Any,
) -> NewSessionResponse: ) -> NewSessionResponse:
load_api_keys_from_env() load_dotenv_values()
os.chdir(cwd) os.chdir(cwd)
try: try:

View file

@ -3,10 +3,8 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from pathlib import Path from pathlib import Path
import shlex
from acp.schema import ( from acp.schema import (
EnvVariable,
TerminalToolCallContent, TerminalToolCallContent,
ToolCallProgress, ToolCallProgress,
ToolCallStart, ToolCallStart,
@ -40,14 +38,11 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
timeout = args.timeout or self.config.default_timeout timeout = args.timeout or self.config.default_timeout
max_bytes = self.config.max_output_bytes max_bytes = self.config.max_output_bytes
env, command, cmd_args = self._parse_command(args.command)
try: try:
terminal_handle = await client.create_terminal( terminal_handle = await client.create_terminal(
session_id=session_id, session_id=session_id,
command=command, command=args.command,
args=cmd_args,
env=env,
cwd=str(Path.cwd()), cwd=str(Path.cwd()),
output_byte_limit=max_bytes, output_byte_limit=max_bytes,
) )
@ -84,25 +79,6 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
except Exception as e: except Exception as e:
logger.error(f"Failed to release terminal: {e!r}") logger.error(f"Failed to release terminal: {e!r}")
def _parse_command(
self, command_str: str
) -> tuple[list[EnvVariable], str, list[str]]:
parts = shlex.split(command_str)
env: list[EnvVariable] = []
command: str = ""
args: list[str] = []
for part in parts:
if "=" in part and not command:
key, value = part.split("=", 1)
env.append(EnvVariable(name=key, value=value))
elif not command:
command = part
else:
args.append(part)
return env, command, args
@classmethod @classmethod
def get_summary(cls, args: BashArgs) -> str: def get_summary(cls, args: BashArgs) -> str:
summary = f"{args.command}" summary = f"{args.command}"

View file

@ -12,7 +12,7 @@ from vibe.core.config import (
MissingAPIKeyError, MissingAPIKeyError,
MissingPromptFileError, MissingPromptFileError,
VibeConfig, VibeConfig,
load_api_keys_from_env, load_dotenv_values,
) )
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE
from vibe.core.programmatic import run_programmatic from vibe.core.programmatic import run_programmatic
@ -122,7 +122,7 @@ def _load_messages_from_previous_session(
def run_cli(args: argparse.Namespace) -> None: def run_cli(args: argparse.Namespace) -> None:
load_api_keys_from_env() load_dotenv_values()
bootstrap_config_files() bootstrap_config_files()
if args.setup: if args.setup:

View file

@ -29,28 +29,36 @@ ACTION_TO_URL: dict[PlanOfferAction, str] = {
} }
class PlanType(StrEnum):
FREE = "free"
PRO = "pro"
UNKNOWN = "unknown"
async def decide_plan_offer( async def decide_plan_offer(
api_key: str | None, gateway: WhoAmIGateway api_key: str | None, gateway: WhoAmIGateway
) -> PlanOfferAction: ) -> tuple[PlanOfferAction, PlanType]:
if not api_key: if not api_key:
return PlanOfferAction.UPGRADE return PlanOfferAction.UPGRADE, PlanType.FREE
try: try:
response = await gateway.whoami(api_key) response = await gateway.whoami(api_key)
except WhoAmIGatewayUnauthorized: except WhoAmIGatewayUnauthorized:
return PlanOfferAction.UPGRADE return PlanOfferAction.UPGRADE, PlanType.FREE
except WhoAmIGatewayError: except WhoAmIGatewayError:
logger.warning("Failed to fetch plan status.", exc_info=True) logger.warning("Failed to fetch plan status.", exc_info=True)
return PlanOfferAction.NONE return PlanOfferAction.NONE, PlanType.UNKNOWN
return _action_from_response(response) return _action_and_plan_from_response(response)
def _action_from_response(response: WhoAmIResponse) -> PlanOfferAction: def _action_and_plan_from_response(
response: WhoAmIResponse,
) -> tuple[PlanOfferAction, PlanType]:
match response: match response:
case WhoAmIResponse(is_pro_plan=True): case WhoAmIResponse(is_pro_plan=True):
return PlanOfferAction.NONE return PlanOfferAction.NONE, PlanType.PRO
case WhoAmIResponse(prompt_switching_to_pro_plan=True): case WhoAmIResponse(prompt_switching_to_pro_plan=True):
return PlanOfferAction.SWITCH_TO_PRO_KEY return PlanOfferAction.SWITCH_TO_PRO_KEY, PlanType.PRO
case WhoAmIResponse(advertise_pro_plan=True): case WhoAmIResponse(advertise_pro_plan=True):
return PlanOfferAction.UPGRADE return PlanOfferAction.UPGRADE, PlanType.FREE
case _: case _:
return PlanOfferAction.NONE return PlanOfferAction.NONE, PlanType.UNKNOWN

View file

@ -23,6 +23,7 @@ from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
from vibe.cli.plan_offer.decide_plan_offer import ( from vibe.cli.plan_offer.decide_plan_offer import (
ACTION_TO_URL, ACTION_TO_URL,
PlanOfferAction, PlanOfferAction,
PlanType,
decide_plan_offer, decide_plan_offer,
) )
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway
@ -74,12 +75,19 @@ from vibe.core.agents import AgentProfile
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig from vibe.core.config import VibeConfig
from vibe.core.paths.config_paths import HISTORY_FILE from vibe.core.paths.config_paths import HISTORY_FILE
from vibe.core.session.session_loader import SessionLoader
from vibe.core.tools.base import ToolPermission from vibe.core.tools.base import ToolPermission
from vibe.core.tools.builtins.ask_user_question import ( from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs, AskUserQuestionArgs,
AskUserQuestionResult, AskUserQuestionResult,
) )
from vibe.core.types import AgentStats, ApprovalResponse, LLMMessage, Role from vibe.core.types import (
AgentStats,
ApprovalResponse,
LLMMessage,
RateLimitError,
Role,
)
from vibe.core.utils import ( from vibe.core.utils import (
CancellationReason, CancellationReason,
get_user_cancellation_message, get_user_cancellation_message,
@ -594,8 +602,16 @@ class VibeApp(App): # noqa: PLR0904
await self._loading_widget.remove() await self._loading_widget.remove()
if self.event_handler: if self.event_handler:
self.event_handler.stop_current_tool_call() self.event_handler.stop_current_tool_call()
message = str(e)
if isinstance(e, RateLimitError):
if self.plan_type == PlanType.FREE:
message = "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
else:
message = "Rate limits exceeded. Please wait a moment before trying again."
await self._mount_and_scroll( await self._mount_and_scroll(
ErrorMessage(str(e), collapsed=self._tools_collapsed) ErrorMessage(message, collapsed=self._tools_collapsed)
) )
finally: finally:
self._agent_running = False self._agent_running = False
@ -773,6 +789,12 @@ class VibeApp(App): # noqa: PLR0904
return None return None
if not self.agent_loop.session_logger.session_id: if not self.agent_loop.session_logger.session_id:
return None return None
session_config = self.agent_loop.session_logger.session_config
session_path = SessionLoader.does_session_exist(
self.agent_loop.session_logger.session_id, session_config
)
if session_path is None:
return None
return self.agent_loop.session_logger.session_id[:8] return self.agent_loop.session_logger.session_id[:8]
async def _exit_app(self) -> None: async def _exit_app(self) -> None:
@ -1023,7 +1045,8 @@ class VibeApp(App): # noqa: PLR0904
async def _maybe_show_plan_offer(self) -> None: async def _maybe_show_plan_offer(self) -> None:
if self._plan_offer_shown: if self._plan_offer_shown:
return return
action = await self._resolve_plan_offer_action() action, plan_type = await self._resolve_plan_offer_action()
self.plan_type = plan_type
if action is PlanOfferAction.NONE: if action is PlanOfferAction.NONE:
return return
url = ACTION_TO_URL[action] url = ACTION_TO_URL[action]
@ -1035,12 +1058,12 @@ class VibeApp(App): # noqa: PLR0904
await self._mount_and_scroll(PlanOfferMessage(text)) await self._mount_and_scroll(PlanOfferMessage(text))
self._plan_offer_shown = True self._plan_offer_shown = True
async def _resolve_plan_offer_action(self) -> PlanOfferAction: async def _resolve_plan_offer_action(self) -> tuple[PlanOfferAction, PlanType]:
try: try:
active_model = self.config.get_active_model() active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model) provider = self.config.get_provider_for_model(active_model)
except ValueError: except ValueError:
return PlanOfferAction.NONE return PlanOfferAction.NONE, PlanType.UNKNOWN
api_key_env = provider.api_key_env_var api_key_env = provider.api_key_env_var
api_key = getenv(api_key_env) if api_key_env else None api_key = getenv(api_key_env) if api_key_env else None
@ -1051,7 +1074,7 @@ class VibeApp(App): # noqa: PLR0904
logger.warning( logger.warning(
"Plan-offer check failed (%s).", type(exc).__name__, exc_info=True "Plan-offer check failed (%s).", type(exc).__name__, exc_info=True
) )
return PlanOfferAction.NONE return PlanOfferAction.NONE, PlanType.UNKNOWN
async def _finalize_current_streaming_message(self) -> None: async def _finalize_current_streaming_message(self) -> None:
if self._current_streaming_reasoning is not None: if self._current_streaming_reasoning is not None:

View file

@ -949,21 +949,24 @@ WelcomeBanner {
.whats-new-message { .whats-new-message {
background: $surface; background: $surface;
border-left: solid $warning; border-left: solid $warning;
margin-bottom: 1;
} }
.plan-offer-message { .plan-offer-message {
background: $surface; background: transparent;
border-left: solid $warning; border: none;
height: auto; height: auto;
text-align: center; padding: 0;
content-align: center middle; margin-top: 0;
padding: 1 2; color: $foreground-muted;
margin-top: 1; text-style: dim;
Markdown > *:last-child { Markdown > *:last-child {
margin-bottom: 0; margin-bottom: 0;
link-style: bold underline; link-style: none;
link-color: $foreground-muted;
link-background-hover: transparent; link-background-hover: transparent;
link-style-hover: underline;
link-color-hover: $warning; link-color-hover: $warning;
} }
} }

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import AsyncGenerator, Callable from collections.abc import AsyncGenerator, Callable
from enum import StrEnum, auto from enum import StrEnum, auto
from http import HTTPStatus
from threading import Thread from threading import Thread
import time import time
from typing import cast from typing import cast
@ -14,6 +15,7 @@ from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import AgentProfile, BuiltinAgentName from vibe.core.agents.models import AgentProfile, BuiltinAgentName
from vibe.core.config import VibeConfig from vibe.core.config import VibeConfig
from vibe.core.llm.backend.factory import BACKEND_FACTORY from vibe.core.llm.backend.factory import BACKEND_FACTORY
from vibe.core.llm.exceptions import BackendError
from vibe.core.llm.format import APIToolFormatHandler, ResolvedMessage, ResolvedToolCall from vibe.core.llm.format import APIToolFormatHandler, ResolvedMessage, ResolvedToolCall
from vibe.core.llm.types import BackendLike from vibe.core.llm.types import BackendLike
from vibe.core.middleware import ( from vibe.core.middleware import (
@ -54,6 +56,7 @@ from vibe.core.types import (
LLMChunk, LLMChunk,
LLMMessage, LLMMessage,
LLMUsage, LLMUsage,
RateLimitError,
ReasoningEvent, ReasoningEvent,
Role, Role,
SyncApprovalCallback, SyncApprovalCallback,
@ -95,6 +98,10 @@ class AgentLoopLLMResponseError(AgentLoopError):
"""Raised when LLM response is malformed or missing expected data.""" """Raised when LLM response is malformed or missing expected data."""
def _should_raise_rate_limit_error(e: Exception) -> bool:
return isinstance(e, BackendError) and e.status == HTTPStatus.TOO_MANY_REQUESTS
class AgentLoop: class AgentLoop:
def __init__( def __init__(
self, self,
@ -193,7 +200,18 @@ class AgentLoop:
def add_message(self, message: LLMMessage) -> None: def add_message(self, message: LLMMessage) -> None:
self.messages.append(message) self.messages.append(message)
def _flush_new_messages(self) -> None: async def _save_messages(self) -> None:
await self.session_logger.save_interaction(
self.messages,
self.stats,
self._base_config,
self.tool_manager,
self.agent_profile,
)
async def _flush_new_messages(self) -> None:
await self._save_messages()
if not self.message_observer: if not self.message_observer:
return return
@ -308,12 +326,11 @@ class AgentLoop:
if is_user_cancellation_event(event): if is_user_cancellation_event(event):
user_cancelled = True user_cancelled = True
yield event yield event
await self._flush_new_messages()
last_message = self.messages[-1] last_message = self.messages[-1]
should_break_loop = last_message.role != Role.tool should_break_loop = last_message.role != Role.tool
self._flush_new_messages()
if user_cancelled: if user_cancelled:
return return
@ -327,14 +344,7 @@ class AgentLoop:
return return
finally: finally:
self._flush_new_messages() await self._flush_new_messages()
await self.session_logger.save_interaction(
self.messages,
self.stats,
self._base_config,
self.tool_manager,
self.agent_profile,
)
async def _perform_llm_turn(self) -> AsyncGenerator[BaseEvent, None]: async def _perform_llm_turn(self) -> AsyncGenerator[BaseEvent, None]:
if self.enable_streaming: if self.enable_streaming:
@ -593,6 +603,9 @@ class AgentLoop:
return LLMChunk(message=processed_message, usage=result.usage) return LLMChunk(message=processed_message, usage=result.usage)
except Exception as e: except Exception as e:
if _should_raise_rate_limit_error(e):
raise RateLimitError(provider.name, active_model.name) from e
raise RuntimeError( raise RuntimeError(
f"API error from {provider.name} (model: {active_model.name}): {e}" f"API error from {provider.name} (model: {active_model.name}): {e}"
) from e ) from e
@ -642,6 +655,9 @@ class AgentLoop:
self.messages.append(chunk_agg.message) self.messages.append(chunk_agg.message)
except Exception as e: except Exception as e:
if _should_raise_rate_limit_error(e):
raise RateLimitError(provider.name, active_model.name) from e
raise RuntimeError( raise RuntimeError(
f"API error from {provider.name} (model: {active_model.name}): {e}" f"API error from {provider.name} (model: {active_model.name}): {e}"
) from e ) from e

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import MutableMapping
from enum import StrEnum, auto from enum import StrEnum, auto
import os import os
from pathlib import Path from pathlib import Path
@ -19,18 +20,28 @@ from pydantic_settings import (
) )
import tomli_w import tomli_w
from vibe.core.paths.config_paths import CONFIG_DIR, CONFIG_FILE, PROMPT_DIR from vibe.core.paths.config_paths import CONFIG_DIR, CONFIG_FILE, PROMPTS_DIR
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR from vibe.core.paths.global_paths import (
GLOBAL_ENV_FILE,
GLOBAL_PROMPTS_DIR,
SESSION_LOG_DIR,
)
from vibe.core.prompts import SystemPrompt from vibe.core.prompts import SystemPrompt
from vibe.core.tools.base import BaseToolConfig from vibe.core.tools.base import BaseToolConfig
def load_api_keys_from_env() -> None: def load_dotenv_values(
if GLOBAL_ENV_FILE.path.is_file(): env_path: Path = GLOBAL_ENV_FILE.path,
env_vars = dotenv_values(GLOBAL_ENV_FILE.path) environ: MutableMapping[str, str] = os.environ,
) -> None:
if not env_path.is_file():
return
env_vars = dotenv_values(env_path)
for key, value in env_vars.items(): for key, value in env_vars.items():
if value: if not value:
os.environ.setdefault(key, value) continue
environ.update({key: value})
class MissingAPIKeyError(RuntimeError): class MissingAPIKeyError(RuntimeError):
@ -43,11 +54,17 @@ class MissingAPIKeyError(RuntimeError):
class MissingPromptFileError(RuntimeError): class MissingPromptFileError(RuntimeError):
def __init__(self, system_prompt_id: str, prompt_dir: str) -> None: def __init__(
self, system_prompt_id: str, prompt_dir: str, global_prompt_dir: str
) -> None:
extra_global_prompt_dir = (
f" or {global_prompt_dir}" if global_prompt_dir != prompt_dir else ""
)
super().__init__( super().__init__(
f"Invalid system_prompt_id value: '{system_prompt_id}'. " f"Invalid system_prompt_id value: '{system_prompt_id}'. "
f"Must be one of the available prompts ({', '.join(f'{p.name.lower()}' for p in SystemPrompt)}), " f"Must be one of the available prompts ({', '.join(f'{p.name.lower()}' for p in SystemPrompt)}), "
f"or correspond to a .md file in {prompt_dir}" f"or correspond to a .md file in {prompt_dir}{extra_global_prompt_dir}"
) )
self.system_prompt_id = system_prompt_id self.system_prompt_id = system_prompt_id
self.prompt_dir = prompt_dir self.prompt_dir = prompt_dir
@ -392,11 +409,17 @@ class VibeConfig(BaseSettings):
except KeyError: except KeyError:
pass pass
custom_sp_path = (PROMPT_DIR.path / self.system_prompt_id).with_suffix(".md") for current_prompt_dir in [PROMPTS_DIR.path, GLOBAL_PROMPTS_DIR.path]:
if not custom_sp_path.is_file(): custom_sp_path = (current_prompt_dir / self.system_prompt_id).with_suffix(
raise MissingPromptFileError(self.system_prompt_id, str(PROMPT_DIR.path)) ".md"
)
if custom_sp_path.is_file():
return custom_sp_path.read_text() return custom_sp_path.read_text()
raise MissingPromptFileError(
self.system_prompt_id, str(PROMPTS_DIR.path), str(GLOBAL_PROMPTS_DIR.path)
)
def get_active_model(self) -> ModelConfig: def get_active_model(self) -> ModelConfig:
for model in self.models: for model in self.models:
if model.alias == self.active_model: if model.alias == self.active_model:

View file

@ -62,5 +62,5 @@ def unlock_config_paths() -> None:
CONFIG_FILE = ConfigPath(lambda: _resolve_config_path("config.toml", "file")) CONFIG_FILE = ConfigPath(lambda: _resolve_config_path("config.toml", "file"))
CONFIG_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent) CONFIG_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent)
PROMPT_DIR = ConfigPath(lambda: _resolve_config_path("prompts", "dir")) PROMPTS_DIR = ConfigPath(lambda: _resolve_config_path("prompts", "dir"))
HISTORY_FILE = ConfigPath(lambda: _resolve_config_path("vibehistory", "file")) HISTORY_FILE = ConfigPath(lambda: _resolve_config_path("vibehistory", "file"))

View file

@ -31,6 +31,7 @@ GLOBAL_ENV_FILE = GlobalPath(lambda: VIBE_HOME.path / ".env")
GLOBAL_TOOLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "tools") GLOBAL_TOOLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "tools")
GLOBAL_SKILLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "skills") GLOBAL_SKILLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "skills")
GLOBAL_AGENTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "agents") GLOBAL_AGENTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "agents")
GLOBAL_PROMPTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "prompts")
SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session") SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session")
TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml") TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml")
LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs") LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs")

View file

@ -22,40 +22,48 @@ class SessionLoader:
return False return False
try: try:
with open(metadata_path, encoding="utf-8", errors="ignore") as f: with metadata_path.open("r", encoding="utf-8", errors="ignore") as f:
metadata = json.load(f) metadata = json.load(f)
if not isinstance(metadata, dict): if not isinstance(metadata, dict):
return False return False
with open(messages_path, encoding="utf-8", errors="ignore") as f: with messages_path.open("r", encoding="utf-8", errors="ignore") as f:
lines = f.readlines() has_messages = False
if not lines: for line in f:
has_messages = True
message = json.loads(line)
if not isinstance(message, dict):
return False return False
messages = [json.loads(line) for line in lines] if not has_messages:
if not isinstance(messages, list) or not all(
isinstance(msg, dict) for msg in messages
):
return False return False
except json.JSONDecodeError: except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return False return False
return True return True
@staticmethod @staticmethod
def latest_session(session_dirs: list[Path]) -> Path | None: def latest_session(session_dirs: list[Path]) -> Path | None:
latest_dir = None sessions_with_mtime: list[tuple[Path, float]] = []
latest_mtime = 0
for session in session_dirs: for session in session_dirs:
if not SessionLoader._is_valid_session(session): messages_path = session / MESSAGES_FILENAME
if not messages_path.is_file():
continue
try:
mtime = messages_path.stat().st_mtime
sessions_with_mtime.append((session, mtime))
except OSError:
continue continue
messages_path = session / MESSAGES_FILENAME if not sessions_with_mtime:
mtime = messages_path.stat().st_mtime return None
if mtime > latest_mtime:
latest_mtime = mtime
latest_dir = session
return latest_dir sessions_with_mtime.sort(key=lambda x: x[1], reverse=True)
for session, _mtime in sessions_with_mtime:
if SessionLoader._is_valid_session(session):
return session
return None
@staticmethod @staticmethod
def find_latest_session(config: SessionLoggingConfig) -> Path | None: def find_latest_session(config: SessionLoggingConfig) -> Path | None:
@ -72,15 +80,32 @@ class SessionLoader:
def find_session_by_id( def find_session_by_id(
session_id: str, config: SessionLoggingConfig session_id: str, config: SessionLoggingConfig
) -> Path | None: ) -> Path | None:
save_dir = Path(config.save_dir) matches = SessionLoader._find_session_dirs_by_short_id(session_id, config)
if not save_dir.exists():
return None
short_id = session_id[:8]
matches = list(save_dir.glob(f"{config.session_prefix}_*_{short_id}"))
return SessionLoader.latest_session(matches) return SessionLoader.latest_session(matches)
@staticmethod
def does_session_exist(
session_id: str, config: SessionLoggingConfig
) -> Path | None:
for session_dir in SessionLoader._find_session_dirs_by_short_id(
session_id, config
):
if (session_dir / MESSAGES_FILENAME).is_file():
return session_dir
return None
@staticmethod
def _find_session_dirs_by_short_id(
session_id: str, config: SessionLoggingConfig
) -> list[Path]:
save_dir = Path(config.save_dir)
if not save_dir.exists():
return []
short_id = session_id[:8]
return list(save_dir.glob(f"{config.session_prefix}_*_{short_id}"))
@staticmethod @staticmethod
def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]: def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]:
# Load session messages from MESSAGES_FILENAME # Load session messages from MESSAGES_FILENAME
@ -94,7 +119,7 @@ class SessionLoader:
f"Error reading session messages at {filepath}: {e}" f"Error reading session messages at {filepath}: {e}"
) from e ) from e
if not len(content): if not content:
raise ValueError( raise ValueError(
f"Session messages file is empty (may have been corrupted by interruption): " f"Session messages file is empty (may have been corrupted by interruption): "
f"{filepath}" f"{filepath}"

View file

@ -148,7 +148,7 @@ class SessionLogger:
@staticmethod @staticmethod
async def persist_metadata(metadata: Any, session_dir: Path) -> None: async def persist_metadata(metadata: Any, session_dir: Path) -> None:
temp_metadata_filepath = None temp_metadata_filepath = None
metadata_filepath = session_dir / "meta.json" metadata_filepath = session_dir / METADATA_FILENAME
try: try:
async with NamedTemporaryFile( async with NamedTemporaryFile(
mode="w", mode="w",
@ -201,12 +201,12 @@ class SessionLogger:
base_config: VibeConfig, base_config: VibeConfig,
tool_manager: ToolManager, tool_manager: ToolManager,
agent_profile: AgentProfile, agent_profile: AgentProfile,
) -> str | None: ) -> None:
if not self.enabled or self.session_dir is None: if not self.enabled or self.session_dir is None:
return None return
if self.session_metadata is None: if self.session_metadata is None:
return None return
# If the session directory does not exist, create it # If the session directory does not exist, create it
try: try:
@ -232,14 +232,14 @@ class SessionLogger:
) from e ) from e
try: try:
non_system_messages = [m for m in messages if m.role != Role.system]
# Append new messages # Append new messages
new_messages = messages[old_total_messages:] new_messages = non_system_messages[old_total_messages:]
messages_data = [ if len(new_messages) == 0:
m.model_dump(exclude_none=True) return
for m in new_messages
if m.role != Role.system messages_data = [m.model_dump(exclude_none=True) for m in new_messages]
]
await SessionLogger.persist_messages(messages_data, self.session_dir) await SessionLogger.persist_messages(messages_data, self.session_dir)
# If message update succeeded, write metadata # If message update succeeded, write metadata
@ -256,12 +256,12 @@ class SessionLogger:
] ]
title = self._get_title(messages) title = self._get_title(messages)
if messages[0].role == Role.system: system_prompt = (
system_prompt = messages[0].model_dump() messages[0].model_dump()
total_messages = len(messages[1:]) if len(messages) > 0 and messages[0].role == Role.system
else: else None
system_prompt = None )
total_messages = len(messages) total_messages = len(non_system_messages)
metadata_dump = { metadata_dump = {
**self.session_metadata.model_dump(), **self.session_metadata.model_dump(),
@ -286,8 +286,6 @@ class SessionLogger:
finally: finally:
self.cleanup_tmp_files() self.cleanup_tmp_files()
return str(self.session_dir)
def reset_session(self, session_id: str) -> None: def reset_session(self, session_id: str) -> None:
"""Clear existing session info and setup a new session""" """Clear existing session info and setup a new session"""
if not self.enabled: if not self.enabled:

View file

@ -382,3 +382,12 @@ type SyncApprovalCallback = Callable[
type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
type UserInputCallback = Callable[[BaseModel], Awaitable[BaseModel]] type UserInputCallback = Callable[[BaseModel], Awaitable[BaseModel]]
class RateLimitError(Exception):
def __init__(self, provider: str, model: str) -> None:
self.provider = provider
self.model = model
super().__init__(
"Rate limits exceeded. Please wait a moment before trying again."
)

View file

@ -0,0 +1,5 @@
# What's New
- **Config:** Variables defined in the `.env` file in your global `.vibe` folder now override environment variables
- **Sessions:** Fixed message duplication in persisted sessions
- **Resume:** Only shown when a session is available to resume