diff --git a/.vscode/launch.json b/.vscode/launch.json
index d6fc1ff..b137122 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -1,5 +1,5 @@
{
- "version": "2.4.2",
+ "version": "2.5.0",
"configurations": [
{
"name": "ACP Server",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 92a2cca..7c49f8c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.5.0] - 2026-03-16
+
+### Added
+
+- Dedicated theorem proving agent powered by leanstral, setup with /leanstall
+- More advanced AGENTS.md support:
+ - AGENTS.md in ~/.vibe/ folder for user-level agent instructions
+ - AGENTS.md for subfolders and in parent folders
+- Mistral Code API key info displayed in CLI banner
+- Voice mode with real-time transcription support
+- Parallel tool execution for improved performance
+- Structured ACP error classes for better error handling
+
+### Changed
+
+- Bash allowlist/denylist now active on Windows
+- Auto-completion relevance improved with better filename and path matching
+- History navigation no longer filters by prefix
+- Updated to Mistral SDK v2 import structure
+- Removed `find` from bash default allowlist to prevent -exec abuse
+
+### Fixed
+
+- Improved scrolling performance
+- Web search tool now infers server URL from provider config
+
+
## [2.4.2] - 2026-03-12
### Added
diff --git a/README.md b/README.md
index 828a3fa..858725b 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,7 @@ curl -LsSf https://mistral.ai/vibe/install.sh | bash
**Windows**
First, install uv
+
```bash
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```
@@ -65,6 +66,7 @@ pip install mistral-vibe
- [Interactive Mode](#interactive-mode)
- [Trust Folder System](#trust-folder-system)
- [Programmatic Mode](#programmatic-mode)
+- [Voice Mode](#voice-mode)
- [Slash Commands](#slash-commands)
- [Built-in Slash Commands](#built-in-slash-commands)
- [Custom Slash Commands via Skills](#custom-slash-commands-via-skills)
@@ -184,7 +186,6 @@ Most modern terminals should work, but older or minimal terminal emulators may h
```
3. If this is your first time running Vibe, it will:
-
- Create a default configuration file at `~/.vibe/config.toml`
- Prompt you to enter your API key if it's not already configured
- Save your API key to `~/.vibe/.env` for future use
@@ -263,6 +264,30 @@ Example:
vibe --prompt "Analyze the codebase" --max-turns 5 --max-price 1.0 --output json
```
+## Voice Mode
+
+> [!WARNING]
+> Voice mode is experimental and may change in future releases.
+
+Voice mode allows you to dictate input using your microphone instead of typing.
+
+### Activating Voice Mode
+
+Toggle voice mode on or off with the `/voice` slash command:
+
+```
+> /voice
+```
+
+### Recording Shortcuts
+
+| Shortcut | Action |
+| -------- | ---------------- |
+| `Ctrl+R` | Start recording |
+| Any key | Stop recording |
+| `Escape` | Cancel recording |
+| `Ctrl+C` | Cancel recording |
+
## Slash Commands
Use slash commands for meta-actions and configuration changes during a session.
diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml
index 21ddf11..8255175 100644
--- a/distribution/zed/extension.toml
+++ b/distribution/zed/extension.toml
@@ -1,7 +1,7 @@
id = "mistral-vibe"
name = "Mistral Vibe"
description = "Mistral's open-source coding assistant"
-version = "2.4.2"
+version = "2.5.0"
schema_version = 1
authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe"
@@ -11,25 +11,25 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg"
[agent_servers.mistral-vibe.targets.darwin-aarch64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.2/vibe-acp-darwin-aarch64-2.4.2.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-darwin-aarch64-2.5.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.2/vibe-acp-darwin-x86_64-2.4.2.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-darwin-x86_64-2.5.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.2/vibe-acp-linux-aarch64-2.4.2.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-linux-aarch64-2.5.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.2/vibe-acp-linux-x86_64-2.4.2.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-linux-x86_64-2.5.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-aarch64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.2/vibe-acp-windows-aarch64-2.4.2.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-windows-aarch64-2.5.0.zip"
cmd = "./vibe-acp.exe"
[agent_servers.mistral-vibe.targets.windows-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.2/vibe-acp-windows-x86_64-2.4.2.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-windows-x86_64-2.5.0.zip"
cmd = "./vibe-acp.exe"
diff --git a/pyproject.toml b/pyproject.toml
index f6cfa70..e86878a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
-version = "2.4.2"
+version = "2.5.0"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
@@ -38,7 +38,7 @@ dependencies = [
"keyring>=25.6.0",
"markdownify>=1.2.2",
"mcp>=1.14.0",
- "mistralai==1.12.4",
+ "mistralai==2.0.0",
"packaging>=24.1",
"pexpect>=4.9.0",
"pydantic>=2.12.4",
@@ -48,12 +48,14 @@ dependencies = [
"pyyaml>=6.0.0",
"requests>=2.20.0",
"rich>=14.0.0",
- "textual>=7.4.0",
+ "sounddevice>=0.5.1",
+ "textual>=8.1.1",
"textual-speedups>=0.2.1",
"tomli-w>=1.2.0",
"tree-sitter>=0.25.2",
"tree-sitter-bash>=0.25.1",
"watchfiles>=1.1.1",
+ "websockets>=13.0",
"zstandard>=0.25.0",
]
diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py
index c414873..b8ce362 100644
--- a/tests/acp/test_initialize.py
+++ b/tests/acp/test_initialize.py
@@ -28,7 +28,7 @@ class TestACPInitialize:
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
)
assert response.agent_info == Implementation(
- name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.4.2"
+ name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.5.0"
)
assert response.auth_methods == []
@@ -52,7 +52,7 @@ class TestACPInitialize:
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
)
assert response.agent_info == Implementation(
- name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.4.2"
+ name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.5.0"
)
assert response.auth_methods is not None
diff --git a/tests/acp/test_multi_session.py b/tests/acp/test_multi_session.py
index 521e12f..9cfad26 100644
--- a/tests/acp/test_multi_session.py
+++ b/tests/acp/test_multi_session.py
@@ -51,7 +51,8 @@ class TestMultiSessionCore:
)
assert isinstance(exc_info.value, RequestError)
- assert str(exc_info.value) == "Invalid params"
+ assert exc_info.value.code == -32602
+ assert "Session not found" in str(exc_info.value)
@pytest.mark.asyncio
async def test_simultaneous_message_processing(
diff --git a/tests/acp/test_proxy_setup_acp.py b/tests/acp/test_proxy_setup_acp.py
index 2eb743e..e952a6a 100644
--- a/tests/acp/test_proxy_setup_acp.py
+++ b/tests/acp/test_proxy_setup_acp.py
@@ -51,9 +51,11 @@ class TestAvailableCommandsUpdate:
assert len(available_commands_updates) == 1
update = available_commands_updates[0].update
- assert len(update.available_commands) == 1
- assert update.available_commands[0].name == "proxy-setup"
- assert "proxy" in update.available_commands[0].description.lower()
+ proxy_cmd = next(
+ (c for c in update.available_commands if c.name == "proxy-setup"), None
+ )
+ assert proxy_cmd is not None
+ assert "proxy" in proxy_cmd.description.lower()
class TestProxySetupCommand:
diff --git a/tests/audio_recorder/test_audio_recorder.py b/tests/audio_recorder/test_audio_recorder.py
new file mode 100644
index 0000000..73f986c
--- /dev/null
+++ b/tests/audio_recorder/test_audio_recorder.py
@@ -0,0 +1,451 @@
+from __future__ import annotations
+
+import asyncio
+import io
+import struct
+import time
+from unittest.mock import MagicMock, patch
+import wave
+
+import pytest
+
+try:
+ import sounddevice as sd
+except OSError:
+ pytest.skip("PortAudio library not available", allow_module_level=True)
+
+from vibe.core.audio_recorder.audio_recorder import AudioRecorder
+from vibe.core.audio_recorder.audio_recorder_port import (
+ AlreadyRecordingError,
+ AudioBackendUnavailableError,
+ AudioRecording,
+ IncompatibleSampleRateError,
+ NoAudioInputDeviceError,
+ RecordingMode,
+)
+
+
+def _make_pcm_frames(value: int, n_samples: int = 1024) -> bytes:
+ """Create raw PCM int16 bytes with all samples set to `value`."""
+ return struct.pack(f"<{n_samples}h", *([value] * n_samples))
+
+
+def _get_callback(mock_stream_cls: MagicMock):
+ """Extract the callback kwarg passed to the mocked RawInputStream."""
+ return mock_stream_cls.call_args.kwargs["callback"]
+
+
+class TestAudioRecorderInitialState:
+ def test_not_recording(self) -> None:
+ recorder = AudioRecorder()
+ assert recorder.is_recording is False
+
+ def test_peak_is_zero(self) -> None:
+ recorder = AudioRecorder()
+ assert recorder.peak == 0.0
+
+
+class TestBufferMode:
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ def test_start_sets_recording_state(self, mock_stream_cls: MagicMock) -> None:
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.BUFFER)
+ assert recorder.is_recording is True
+ mock_stream_cls.return_value.start.assert_called_once()
+
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ def test_start_when_already_recording_raises(
+ self, mock_stream_cls: MagicMock
+ ) -> None:
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.BUFFER)
+
+ with pytest.raises(AlreadyRecordingError):
+ recorder.start(RecordingMode.BUFFER)
+
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ def test_stop_returns_valid_wav(self, mock_stream_cls: MagicMock) -> None:
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.BUFFER)
+
+ callback = _get_callback(mock_stream_cls)
+ pcm_data = _make_pcm_frames(5000)
+ callback(pcm_data, 1024, {}, sd.CallbackFlags())
+
+ result = recorder.stop()
+
+ assert recorder.is_recording is False
+ assert len(result.data) > 0
+ assert result.data[:4] == b"RIFF"
+
+ with wave.open(io.BytesIO(result.data), "rb") as wf:
+ assert wf.getnchannels() == 1
+ assert wf.getsampwidth() == 2
+ assert wf.getframerate() == 48_000
+ assert wf.getnframes() == 1024
+
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ def test_stop_returns_positive_duration(self, mock_stream_cls: MagicMock) -> None:
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.BUFFER)
+ callback = _get_callback(mock_stream_cls)
+ callback(_make_pcm_frames(100), 1024, {}, sd.CallbackFlags())
+
+ result = recorder.stop()
+ assert result.duration > 0.0
+
+ def test_stop_when_not_recording_returns_empty(self) -> None:
+ recorder = AudioRecorder()
+ result = recorder.stop()
+ assert result.data == b""
+ assert result.duration == 0.0
+
+ @pytest.mark.asyncio
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ async def test_buffer_mode_audio_stream_yields_nothing(
+ self, mock_stream_cls: MagicMock
+ ) -> None:
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.BUFFER)
+
+ callback = _get_callback(mock_stream_cls)
+ callback(_make_pcm_frames(5000), 1024, {}, sd.CallbackFlags())
+
+ collected: list[bytes] = []
+ async for chunk in recorder.audio_stream():
+ collected.append(chunk)
+
+ assert collected == []
+
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ def test_can_record_multiple_times(self, mock_stream_cls: MagicMock) -> None:
+ recorder = AudioRecorder()
+
+ recorder.start(RecordingMode.BUFFER)
+ callback = _get_callback(mock_stream_cls)
+ callback(_make_pcm_frames(5000), 1024, {}, sd.CallbackFlags())
+ result1 = recorder.stop()
+ assert len(result1.data) > 0
+
+ recorder.start(RecordingMode.BUFFER)
+ callback = _get_callback(mock_stream_cls)
+ callback(_make_pcm_frames(3000), 1024, {}, sd.CallbackFlags())
+ result2 = recorder.stop()
+ assert len(result2.data) > 0
+
+ assert result1.data[:4] == b"RIFF"
+ assert result2.data[:4] == b"RIFF"
+
+
+class TestStreamMode:
+ @pytest.mark.asyncio
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ async def test_audio_stream_yields_chunks(self, mock_stream_cls: MagicMock) -> None:
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.STREAM)
+
+ callback = _get_callback(mock_stream_cls)
+ chunk1 = _make_pcm_frames(1000, n_samples=512)
+ chunk2 = _make_pcm_frames(2000, n_samples=512)
+
+ collected: list[bytes] = []
+
+ async def consume() -> None:
+ async for chunk in recorder.audio_stream():
+ collected.append(chunk)
+
+ task = asyncio.create_task(consume())
+
+ callback(chunk1, 512, {}, sd.CallbackFlags())
+ callback(chunk2, 512, {}, sd.CallbackFlags())
+ await asyncio.sleep(0.05)
+
+ recorder.stop()
+ await task
+
+ assert len(collected) == 2
+ assert collected[0] == chunk1
+ assert collected[1] == chunk2
+
+ @pytest.mark.asyncio
+ async def test_audio_stream_without_start_returns_nothing(self) -> None:
+ recorder = AudioRecorder()
+ collected: list[bytes] = []
+ async for chunk in recorder.audio_stream():
+ collected.append(chunk)
+ assert collected == []
+
+ @pytest.mark.asyncio
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ async def test_stream_audio_does_not_leak_into_buffer_recording(
+ self, mock_stream_cls: MagicMock
+ ) -> None:
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.STREAM)
+
+ callback = _get_callback(mock_stream_cls)
+ callback(_make_pcm_frames(1000), 1024, {}, sd.CallbackFlags())
+
+ async def consume() -> None:
+ async for _ in recorder.audio_stream():
+ pass
+
+ task = asyncio.create_task(consume())
+ await asyncio.sleep(0.05)
+
+ recorder.stop()
+ await task
+
+ recorder.start(RecordingMode.BUFFER)
+ result = recorder.stop()
+
+ with wave.open(io.BytesIO(result.data), "rb") as wf:
+ assert wf.getnframes() == 0
+
+ @pytest.mark.asyncio
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ async def test_stop_from_event_loop_does_not_block(
+ self, mock_stream_cls: MagicMock
+ ) -> None:
+ """stop() called from the event loop thread must not block waiting for drain."""
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.STREAM)
+
+ callback = _get_callback(mock_stream_cls)
+ callback(_make_pcm_frames(1000), 1024, {}, sd.CallbackFlags())
+
+ collected: list[bytes] = []
+
+ async def consume() -> None:
+ async for chunk in recorder.audio_stream():
+ collected.append(chunk)
+
+ task = asyncio.create_task(consume())
+ await asyncio.sleep(0.05)
+
+ start = time.monotonic()
+ result = recorder.stop()
+ elapsed = time.monotonic() - start
+
+ await task
+
+ assert elapsed < 1.0
+ assert result.data == b""
+ assert result.duration > 0.0
+ assert len(collected) == 1
+
+ @pytest.mark.asyncio
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ async def test_stop_returns_empty_data_in_stream_mode(
+ self, mock_stream_cls: MagicMock
+ ) -> None:
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.STREAM)
+
+ async def consume() -> None:
+ async for _ in recorder.audio_stream():
+ pass
+
+ task = asyncio.create_task(consume())
+ await asyncio.sleep(0.01)
+
+ result = recorder.stop()
+ await task
+
+ assert result.data == b""
+ assert result.duration > 0.0
+
+ @pytest.mark.asyncio
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ async def test_stop_without_drain_returns_promptly(
+ self, mock_stream_cls: MagicMock
+ ) -> None:
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.STREAM)
+
+ callback = _get_callback(mock_stream_cls)
+ callback(_make_pcm_frames(1000), 1024, {}, sd.CallbackFlags())
+
+ start = time.monotonic()
+ result = recorder.stop(wait_for_queue_drained=False)
+ elapsed = time.monotonic() - start
+
+ assert elapsed < 1.0
+ assert result.data == b""
+ assert result.duration > 0.0
+ assert recorder.is_recording is False
+
+
+class TestCancel:
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ def test_cancel_discards_audio(self, mock_stream_cls: MagicMock) -> None:
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.BUFFER)
+
+ callback = _get_callback(mock_stream_cls)
+ callback(_make_pcm_frames(5000), 1024, {}, sd.CallbackFlags())
+
+ recorder.cancel()
+ assert recorder.is_recording is False
+ mock_stream_cls.return_value.stop.assert_called_once()
+ mock_stream_cls.return_value.close.assert_called_once()
+
+ def test_cancel_when_not_recording_is_noop(self) -> None:
+ recorder = AudioRecorder()
+ recorder.cancel()
+ assert recorder.is_recording is False
+
+
+class TestMaxDuration:
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ def test_auto_stops_after_max_duration(self, mock_stream_cls: MagicMock) -> None:
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.BUFFER, max_duration=0.1)
+
+ callback = _get_callback(mock_stream_cls)
+ callback(_make_pcm_frames(5000), 1024, {}, sd.CallbackFlags())
+
+ time.sleep(0.3)
+
+ assert recorder.is_recording is False
+
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ def test_on_expire_receives_audio(self, mock_stream_cls: MagicMock) -> None:
+ """on_expire callback receives the WAV data when the timer fires."""
+ received: list[AudioRecording] = []
+ recorder = AudioRecorder()
+ recorder.start(
+ RecordingMode.BUFFER, max_duration=0.1, on_expire=received.append
+ )
+
+ callback = _get_callback(mock_stream_cls)
+ callback(_make_pcm_frames(5000), 1024, {}, sd.CallbackFlags())
+
+ time.sleep(0.3)
+ assert recorder.is_recording is False
+ assert len(received) == 1
+ assert received[0].data[:4] == b"RIFF"
+
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ def test_manual_stop_prevents_on_expire(self, mock_stream_cls: MagicMock) -> None:
+ expired: list[AudioRecording] = []
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.BUFFER, max_duration=0.2, on_expire=expired.append)
+
+ callback = _get_callback(mock_stream_cls)
+ callback(_make_pcm_frames(5000), 1024, {}, sd.CallbackFlags())
+
+ result = recorder.stop()
+ assert recorder.is_recording is False
+ assert result.data[:4] == b"RIFF"
+
+ time.sleep(0.35)
+ assert expired == []
+ assert recorder.is_recording is False
+
+
+class TestPeak:
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ def test_peak_updates_from_callback(self, mock_stream_cls: MagicMock) -> None:
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.BUFFER)
+
+ callback = _get_callback(mock_stream_cls)
+ callback(_make_pcm_frames(16_384), 1024, {}, sd.CallbackFlags())
+
+ assert recorder.peak == pytest.approx(16_384 / 32_768, abs=0.01)
+
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ def test_peak_clamps_to_one(self, mock_stream_cls: MagicMock) -> None:
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.BUFFER)
+
+ callback = _get_callback(mock_stream_cls)
+ callback(_make_pcm_frames(32_767), 1024, {}, sd.CallbackFlags())
+
+ assert recorder.peak <= 1.0
+
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ def test_silent_audio_has_zero_peak(self, mock_stream_cls: MagicMock) -> None:
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.BUFFER)
+
+ callback = _get_callback(mock_stream_cls)
+ callback(_make_pcm_frames(0), 1024, {}, sd.CallbackFlags())
+
+ assert recorder.peak == 0.0
+
+
+class TestGuardAudioInput:
+ def test_guard_returns_sample_rate_when_compatible(self) -> None:
+ with (
+ patch(
+ "vibe.core.audio_recorder.audio_recorder.sd.query_devices"
+ ) as mock_query,
+ patch("vibe.core.audio_recorder.audio_recorder.sd.check_input_settings"),
+ ):
+ mock_query.return_value = {"default_samplerate": 48000.0}
+ result = AudioRecorder._guard_audio_input(48000, 1)
+ assert result == 48000
+
+ def test_guard_raises_when_no_input_device(self) -> None:
+ with patch(
+ "vibe.core.audio_recorder.audio_recorder.sd.query_devices",
+ side_effect=sd.PortAudioError(-1),
+ ):
+ with pytest.raises(NoAudioInputDeviceError):
+ AudioRecorder._guard_audio_input(48000, 1)
+
+ def test_guard_raises_with_fallback_when_rate_incompatible(self) -> None:
+ with (
+ patch(
+ "vibe.core.audio_recorder.audio_recorder.sd.query_devices"
+ ) as mock_query,
+ patch(
+ "vibe.core.audio_recorder.audio_recorder.sd.check_input_settings",
+ side_effect=sd.PortAudioError(-1),
+ ),
+ ):
+ mock_query.return_value = {"default_samplerate": 16000.0}
+ with pytest.raises(IncompatibleSampleRateError) as exc_info:
+ AudioRecorder._guard_audio_input(48000, 1)
+ assert exc_info.value.fallback_sample_rate == 16000
+
+ def test_start_raises_when_no_sounddevice(self) -> None:
+ with patch("vibe.core.audio_recorder.audio_recorder.sd", None):
+ recorder = AudioRecorder()
+ with pytest.raises(AudioBackendUnavailableError):
+ recorder.start(RecordingMode.BUFFER)
+
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ def test_start_raises_when_no_device(self, mock_stream_cls: MagicMock) -> None:
+ with patch(
+ "vibe.core.audio_recorder.audio_recorder.sd.query_devices",
+ side_effect=sd.PortAudioError(-1),
+ ):
+ recorder = AudioRecorder()
+ with pytest.raises(NoAudioInputDeviceError):
+ recorder.start(RecordingMode.BUFFER)
+ assert recorder.is_recording is False
+ mock_stream_cls.assert_not_called()
+
+ @patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
+ def test_start_retries_with_fallback_sample_rate(
+ self, mock_stream_cls: MagicMock
+ ) -> None:
+ with (
+ patch(
+ "vibe.core.audio_recorder.audio_recorder.sd.query_devices"
+ ) as mock_query,
+ patch(
+ "vibe.core.audio_recorder.audio_recorder.sd.check_input_settings"
+ ) as mock_check,
+ ):
+ mock_query.return_value = {"default_samplerate": 16000.0}
+ mock_check.side_effect = sd.PortAudioError(-1)
+
+ recorder = AudioRecorder()
+ recorder.start(RecordingMode.BUFFER)
+
+ assert recorder.is_recording is True
+ assert mock_stream_cls.call_args.kwargs["samplerate"] == 16000
diff --git a/tests/autocompletion/test_path_completer_fuzzy.py b/tests/autocompletion/test_path_completer_fuzzy.py
index d2ef941..30d1b05 100644
--- a/tests/autocompletion/test_path_completer_fuzzy.py
+++ b/tests/autocompletion/test_path_completer_fuzzy.py
@@ -4,7 +4,9 @@ from pathlib import Path
import pytest
+import vibe.core.autocompletion.completers as completers_module
from vibe.core.autocompletion.completers import PathCompleter
+from vibe.core.autocompletion.fuzzy import fuzzy_match as real_fuzzy_match
@pytest.fixture()
@@ -120,3 +122,229 @@ def test_fuzzy_matches_directory_traversal(file_tree: Path) -> None:
assert "@src/main.py" in results
assert "@src/core/" in results
assert "@src/utils/" in results
+
+
+def test_directory_prefix_can_match_from_a_nested_path_segment(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ (tmp_path / "vibe" / "acp").mkdir(parents=True)
+ (tmp_path / "vibe" / "acp" / "entrypoint.py").write_text("", encoding="utf-8")
+ (tmp_path / "vibe" / "myacp").mkdir(parents=True)
+ (tmp_path / "vibe" / "myacp" / "entrypoint.py").write_text("", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+
+ results = PathCompleter().get_completions("@acp/", cursor_pos=5)
+
+ assert "@vibe/acp/entrypoint.py" in results
+ assert "@vibe/myacp/entrypoint.py" not in results
+
+
+def test_prefers_exact_filename_match_over_other_path_matches(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ (tmp_path / "src").mkdir(parents=True)
+ (tmp_path / "src" / "chat-input.tsx").write_text("", encoding="utf-8")
+ (tmp_path / "src" / "features").mkdir(parents=True)
+ (tmp_path / "src" / "features" / "chat-input-state.ts").write_text(
+ "", encoding="utf-8"
+ )
+ (tmp_path / "docs").mkdir()
+ (tmp_path / "docs" / "chat-input.md").write_text("", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+
+ results = PathCompleter().get_completions("@chat-input", cursor_pos=11)
+
+ assert results[0] == "@src/chat-input.tsx"
+
+
+def test_keeps_late_strong_match_when_target_matches_is_small(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ for i in range(10):
+ (tmp_path / f"prefix_{i}_chatnoise.txt").write_text("", encoding="utf-8")
+ (tmp_path / "src").mkdir()
+ (tmp_path / "src" / "chat-input.tsx").write_text("", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+
+ results = PathCompleter(target_matches=5).get_completions("@chati", cursor_pos=6)
+
+ assert results[0] == "@src/chat-input.tsx"
+
+
+def test_prefers_source_file_over_lock_file_for_stem_query(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ (tmp_path / "pkg").mkdir()
+ (tmp_path / "pkg" / "foo.lock").write_text("", encoding="utf-8")
+ (tmp_path / "src").mkdir()
+ (tmp_path / "src" / "foo.py").write_text("", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+
+ results = PathCompleter().get_completions("@foo", cursor_pos=4)
+
+ assert results[0] == "@src/foo.py"
+
+
+def test_prefers_exact_filename_matches_for_filename_query(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ (
+ tmp_path
+ / "ts"
+ / "apps"
+ / "le-chat-web"
+ / "src"
+ / "app"
+ / "chat"
+ / "_components"
+ ).mkdir(parents=True)
+ (
+ tmp_path
+ / "ts"
+ / "apps"
+ / "le-chat-web"
+ / "src"
+ / "app"
+ / "chat"
+ / "_components"
+ / "chat-input.tsx"
+ ).write_text("", encoding="utf-8")
+ (tmp_path / "ts" / "apps" / "le-chat-web" / "src" / "components").mkdir(
+ parents=True
+ )
+ (
+ tmp_path
+ / "ts"
+ / "apps"
+ / "le-chat-web"
+ / "src"
+ / "components"
+ / "chat-input.tsx"
+ ).write_text("", encoding="utf-8")
+ (
+ tmp_path / "ts" / "apps" / "le-chat-mobile" / "components" / "SearchInput.tsx"
+ ).parent.mkdir(parents=True)
+ (
+ tmp_path / "ts" / "apps" / "le-chat-mobile" / "components" / "SearchInput.tsx"
+ ).write_text("", encoding="utf-8")
+ (
+ tmp_path
+ / "ts"
+ / "apps"
+ / "le-chat-web"
+ / "src"
+ / "app"
+ / "chat"
+ / "_components"
+ / "message-input.tsx"
+ ).write_text("", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+
+ results = PathCompleter().get_completions("@chat-input.tsx", cursor_pos=16)
+
+ assert set(results[:2]) == {
+ "@ts/apps/le-chat-web/src/app/chat/_components/chat-input.tsx",
+ "@ts/apps/le-chat-web/src/components/chat-input.tsx",
+ }
+ assert results.index("@ts/apps/le-chat-mobile/components/SearchInput.tsx") > 1
+ assert (
+ results.index("@ts/apps/le-chat-web/src/app/chat/_components/message-input.tsx")
+ > 1
+ )
+
+
+def test_exact_path_query_ranks_children_ahead_of_unrelated_fuzzy_matches(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ (tmp_path / "scripts" / "zephyr" / "generators").mkdir(parents=True)
+ (tmp_path / "zephyr" / "generators" / "tests").mkdir(parents=True)
+ (tmp_path / "zephyr" / "generators" / "prompts").mkdir(parents=True)
+ (tmp_path / "zephyr" / "generators" / "common.py").write_text("", encoding="utf-8")
+ (tmp_path / "zephyr" / "generators" / "README.md").write_text("", encoding="utf-8")
+ (
+ tmp_path
+ / "zephyr"
+ / "datasets"
+ / "synthetic_sp_up_conflict"
+ / "grounded_policies"
+ / "hotel"
+ ).mkdir(parents=True)
+ (
+ tmp_path
+ / "zephyr"
+ / "datasets"
+ / "synthetic_sp_up_conflict"
+ / "grounded_policies"
+ / "hotel"
+ / "generators.py"
+ ).write_text("", encoding="utf-8")
+ (
+ tmp_path
+ / "zephyr"
+ / "datasets"
+ / "synthetic_sp_up_conflict"
+ / "grounded_policies"
+ / "retail"
+ ).mkdir(parents=True)
+ (
+ tmp_path
+ / "zephyr"
+ / "datasets"
+ / "synthetic_sp_up_conflict"
+ / "grounded_policies"
+ / "retail"
+ / "generators.py"
+ ).write_text("", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+
+ results = PathCompleter().get_completions("@zephyr/generators", cursor_pos=18)
+
+ assert results[0] == "@zephyr/generators/"
+ assert results.index("@zephyr/generators/common.py") < results.index(
+ "@zephyr/datasets/synthetic_sp_up_conflict/grounded_policies/hotel/generators.py"
+ )
+ assert results.index("@zephyr/generators/prompts/") < results.index(
+ "@zephyr/datasets/synthetic_sp_up_conflict/grounded_policies/retail/generators.py"
+ )
+ assert results.index("@zephyr/generators/tests/") < results.index(
+ "@zephyr/datasets/synthetic_sp_up_conflict/grounded_policies/hotel/generators.py"
+ )
+
+
+def test_skips_fuzzy_scoring_for_entries_missing_required_query_characters(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ (tmp_path / "src").mkdir()
+ (tmp_path / "src" / "chat-input.tsx").write_text("", encoding="utf-8")
+ for name in ("alpha.txt", "theta.txt", "notes.md"):
+ (tmp_path / name).write_text("", encoding="utf-8")
+ for i in range(20):
+ (tmp_path / f"zzzz_{i}.txt").write_text("", encoding="utf-8")
+
+ monkeypatch.chdir(tmp_path)
+
+ fuzzy_calls: list[str] = []
+
+ def counting_fuzzy_match(
+ pattern: str, text: str, text_lower: str | None = None
+ ) -> object:
+ fuzzy_calls.append(text)
+ return real_fuzzy_match(pattern, text, text_lower)
+
+ monkeypatch.setattr(completers_module, "fuzzy_match", counting_fuzzy_match)
+
+ results = PathCompleter().get_completions("@chati", cursor_pos=6)
+
+ assert results[0] == "@src/chat-input.tsx"
+ assert fuzzy_calls == ["src/chat-input.tsx"]
+
+
+def test_non_ascii_queries_still_match_when_ascii_prefilter_is_disabled(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ (tmp_path / "café.txt").write_text("", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+
+ results = PathCompleter().get_completions("@café", cursor_pos=5)
+
+ assert results == ["@café.txt"]
diff --git a/tests/autocompletion/test_ui_chat_autocompletion.py b/tests/autocompletion/test_ui_chat_autocompletion.py
index e0a005d..0df34f7 100644
--- a/tests/autocompletion/test_ui_chat_autocompletion.py
+++ b/tests/autocompletion/test_ui_chat_autocompletion.py
@@ -156,7 +156,7 @@ async def test_path_completion_popup_lists_files_and_directories(
await pilot.press(*"@s")
popup_content = str(popup.render())
- assert "@src/" in popup_content
+ assert "src/" in popup_content
assert popup.styles.display == "block"
@@ -177,16 +177,16 @@ async def test_path_completion_popup_shows_up_to_ten_results(
await pilot.press(*"@src/core/extra/")
popup_content = str(popup.render())
- assert "@src/core/extra/extra_file_1.py" in popup_content
- assert "@src/core/extra/extra_file_10.py" in popup_content
- assert "@src/core/extra/extra_file_11.py" in popup_content
- assert "@src/core/extra/extra_file_12.py" in popup_content
- assert "@src/core/extra/extra_file_2.py" in popup_content
- assert "@src/core/extra/extra_file_3.py" in popup_content
- assert "@src/core/extra/extra_file_4.py" in popup_content
- assert "@src/core/extra/extra_file_5.py" in popup_content
- assert "@src/core/extra/extra_file_6.py" in popup_content
- assert "@src/core/extra/extra_file_7.py" in popup_content
+ assert "src/core/extra/extra_file_1.py" in popup_content
+ assert "src/core/extra/extra_file_10.py" in popup_content
+ assert "src/core/extra/extra_file_11.py" in popup_content
+ assert "src/core/extra/extra_file_12.py" in popup_content
+ assert "src/core/extra/extra_file_2.py" in popup_content
+ assert "src/core/extra/extra_file_3.py" in popup_content
+ assert "src/core/extra/extra_file_4.py" in popup_content
+ assert "src/core/extra/extra_file_5.py" in popup_content
+ assert "src/core/extra/extra_file_6.py" in popup_content
+ assert "src/core/extra/extra_file_7.py" in popup_content
assert popup.styles.display == "block"
@@ -230,7 +230,7 @@ async def test_fuzzy_matches_subsequence_characters(
await pilot.press(*"@src/utils/handling")
popup_content = str(popup.render())
- assert "@src/utils/error_handling.py" in popup_content
+ assert "src/utils/error_handling.py" in popup_content
assert popup.styles.display == "block"
@@ -244,7 +244,7 @@ async def test_fuzzy_matches_word_boundaries(
await pilot.press(*"@src/utils/eh")
popup_content = str(popup.render())
- assert "@src/utils/error_handling.py" in popup_content
+ assert "src/utils/error_handling.py" in popup_content
assert popup.styles.display == "block"
@@ -258,7 +258,7 @@ async def test_finds_files_recursively_by_filename(
await pilot.press(*"@entryp")
popup_content = str(popup.render())
- assert "@vibe/acp/entrypoint.py" in popup_content
+ assert "vibe/acp/entrypoint.py" in popup_content
assert popup.styles.display == "block"
@@ -272,7 +272,7 @@ async def test_finds_files_recursively_with_partial_path(
await pilot.press(*"@acp/entry")
popup_content = str(popup.render())
- assert "@vibe/acp/entrypoint.py" in popup_content
+ assert "vibe/acp/entrypoint.py" in popup_content
assert popup.styles.display == "block"
diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py
index 8cc4e02..6961395 100644
--- a/tests/backend/test_backend.py
+++ b/tests/backend/test_backend.py
@@ -16,7 +16,7 @@ import json
from unittest.mock import MagicMock, patch
import httpx
-from mistralai.utils.retries import BackoffStrategy, RetryConfig
+from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
import pytest
import respx
@@ -426,7 +426,7 @@ class TestMistralRetry:
async def test_client_creation_includes_timeout_and_retry_config(self):
backend = self._create_test_backend()
- with patch("mistralai.Mistral") as mock_mistral_class:
+ with patch("vibe.core.llm.backend.mistral.Mistral") as mock_mistral_class:
mock_mistral_class.return_value = MagicMock()
backend._get_client()
mock_mistral_class.assert_called_once_with(
diff --git a/tests/backend/test_reasoning_adapter.py b/tests/backend/test_reasoning_adapter.py
index a7810e4..719a3bf 100644
--- a/tests/backend/test_reasoning_adapter.py
+++ b/tests/backend/test_reasoning_adapter.py
@@ -77,7 +77,7 @@ class TestThinkingBlocksConversion:
reasoning_content="Let me think...",
),
]
- payload = _prepare(adapter, provider, messages)
+ payload = _prepare(adapter, provider, messages, thinking="medium")
msg = payload["messages"][1]
assert msg["content"] == [
{
@@ -87,6 +87,18 @@ class TestThinkingBlocksConversion:
{"type": "text", "text": "Answer"},
]
+ def test_reasoning_stripped_when_thinking_off(self, adapter, provider):
+ messages = [
+ LLMMessage(role=Role.user, content="Hi"),
+ LLMMessage(
+ role=Role.assistant,
+ content="Answer",
+ reasoning_content="Let me think...",
+ ),
+ ]
+ payload = _prepare(adapter, provider, messages, thinking="off")
+ assert payload["messages"][1]["content"] == "Answer"
+
def test_assistant_without_reasoning_is_plain_string(self, adapter, provider):
messages = [
LLMMessage(role=Role.user, content="Hi"),
@@ -111,7 +123,7 @@ class TestThinkingBlocksConversion:
],
),
]
- payload = _prepare(adapter, provider, messages)
+ payload = _prepare(adapter, provider, messages, thinking="medium")
msg = payload["messages"][1]
assert msg["content"][0]["type"] == "thinking"
assert msg["content"][1] == {"type": "text", "text": "Let me search."}
diff --git a/tests/cli/plan_offer/test_http_whoami_gateway.py b/tests/cli/plan_offer/test_http_whoami_gateway.py
index ed09ac3..dce3e3a 100644
--- a/tests/cli/plan_offer/test_http_whoami_gateway.py
+++ b/tests/cli/plan_offer/test_http_whoami_gateway.py
@@ -145,3 +145,28 @@ async def test_raises_on_invalid_boolean_string(respx_mock: respx.MockRouter) ->
with pytest.raises(WhoAmIGatewayError):
await gateway.whoami("api-key")
+
+
+@pytest.mark.asyncio
+async def test_return_unknown_plan_on_unsupported_plan_type(
+ respx_mock: respx.MockRouter,
+) -> None:
+ respx_mock.get("http://test/api/vibe/whoami").mock(
+ return_value=httpx.Response(
+ 200,
+ json={
+ "plan_type": "SOMETHING",
+ "plan_name": "INDIVIDUAL",
+ "prompt_switching_to_pro_plan": "false",
+ },
+ )
+ )
+
+ gateway = HttpWhoAmIGateway(base_url="http://test")
+
+ response = await gateway.whoami("api-key")
+ assert response == WhoAmIResponse(
+ plan_type=WhoAmIPlanType.UNKNOWN,
+ plan_name="INDIVIDUAL",
+ prompt_switching_to_pro_plan=False,
+ )
diff --git a/tests/cli/textual_ui/test_streaming_message_buffer.py b/tests/cli/textual_ui/test_streaming_message_buffer.py
new file mode 100644
index 0000000..016f785
--- /dev/null
+++ b/tests/cli/textual_ui/test_streaming_message_buffer.py
@@ -0,0 +1,319 @@
+from __future__ import annotations
+
+import pytest
+
+from vibe.cli.textual_ui.widgets.messages import StreamingMessageBase
+
+
+class FakeStream:
+ def __init__(self) -> None:
+ self.written: list[str] = []
+ self.stopped = False
+
+ async def write(self, content: str) -> None:
+ self.written.append(content)
+
+ async def stop(self) -> None:
+ self.stopped = True
+
+ @property
+ def all_written(self) -> str:
+ return "".join(self.written)
+
+
+class MessageTestDouble(StreamingMessageBase):
+ """Minimal test double for StreamingMessageBase that bypasses Textual internals."""
+
+ def __init__(self, at_bottom: bool = True, should_write: bool = True) -> None:
+ # Initialise only the fields used by the buffer logic — no Textual setup.
+ self._content = ""
+ self._content_initialized = False
+ self._to_write_buffer = ""
+ self._stream = None
+ self._markdown = None
+ self._at_bottom = at_bottom
+ self._should_write = should_write
+ self._fake_stream: FakeStream = FakeStream()
+
+ # --- overrides used by the buffer logic ---
+
+ def _ensure_stream(self) -> FakeStream: # type: ignore[override]
+ if self._stream is None:
+ self._stream = self._fake_stream # type: ignore[assignment]
+ return self._fake_stream
+
+ def _is_chat_at_bottom(self) -> bool:
+ return self._at_bottom
+
+ def _should_write_content(self) -> bool:
+ return self._should_write
+
+
+def make_msg(*, at_bottom: bool = True, should_write: bool = True) -> MessageTestDouble:
+ return MessageTestDouble(at_bottom=at_bottom, should_write=should_write)
+
+
+class TestAppendContent:
+ @pytest.mark.asyncio
+ async def test_at_bottom_writes_directly_no_buffer(self) -> None:
+ msg = make_msg(at_bottom=True)
+
+ await msg.append_content("hello")
+
+ assert msg._fake_stream.all_written == "hello"
+ assert msg._to_write_buffer == ""
+
+ @pytest.mark.asyncio
+ async def test_scrolled_away_buffers_without_writing(self) -> None:
+ msg = make_msg(at_bottom=False)
+
+ await msg.append_content("hello")
+
+ assert msg._fake_stream.all_written == ""
+ assert msg._to_write_buffer == "hello"
+
+ @pytest.mark.asyncio
+ async def test_multiple_chunks_scrolled_away_accumulate(self) -> None:
+ msg = make_msg(at_bottom=False)
+
+ await msg.append_content("foo")
+ await msg.append_content(" bar")
+
+ assert msg._fake_stream.all_written == ""
+ assert msg._to_write_buffer == "foo bar"
+
+ @pytest.mark.asyncio
+ async def test_scroll_back_flushes_buffer_with_new_chunk(self) -> None:
+ msg = make_msg(at_bottom=False)
+ await msg.append_content("buffered")
+
+ msg._at_bottom = True
+ await msg.append_content(" new")
+
+ # Both buffered and new chunk written together in one write call.
+ assert msg._fake_stream.all_written == "buffered new"
+ assert msg._to_write_buffer == ""
+
+ @pytest.mark.asyncio
+ async def test_scroll_back_subsequent_chunks_written_directly(self) -> None:
+ msg = make_msg(at_bottom=False)
+ await msg.append_content("a")
+ await msg.append_content("b")
+
+ msg._at_bottom = True
+ await msg.append_content("c")
+ await msg.append_content("d")
+
+ assert msg._fake_stream.all_written == "abc" + "d"
+ assert msg._to_write_buffer == ""
+
+ @pytest.mark.asyncio
+ async def test_empty_content_is_ignored(self) -> None:
+ msg = make_msg()
+
+ await msg.append_content("")
+
+ assert msg._fake_stream.all_written == ""
+ assert msg._to_write_buffer == ""
+
+ @pytest.mark.asyncio
+ async def test_should_write_false_skips_stream_and_buffer(self) -> None:
+ msg = make_msg(should_write=False)
+
+ await msg.append_content("invisible")
+
+ assert msg._fake_stream.all_written == ""
+ assert msg._to_write_buffer == ""
+ # Content still accumulates in _content for later full-replay.
+ assert msg._content == "invisible"
+
+
+class TestStopStream:
+ @pytest.mark.asyncio
+ async def test_flushes_remaining_buffer(self) -> None:
+ msg = make_msg(at_bottom=False)
+ await msg.append_content("pending")
+
+ await msg.stop_stream()
+
+ assert msg._fake_stream.all_written == "pending"
+ assert msg._to_write_buffer == ""
+
+ @pytest.mark.asyncio
+ async def test_stops_the_stream(self) -> None:
+ msg = make_msg(at_bottom=False)
+ await msg.append_content("x")
+ await msg.stop_stream()
+
+ assert msg._fake_stream.stopped is True
+
+ @pytest.mark.asyncio
+ async def test_idempotent_no_double_write(self) -> None:
+ msg = make_msg(at_bottom=False)
+ await msg.append_content("once")
+
+ await msg.stop_stream()
+ await msg.stop_stream()
+
+ assert msg._fake_stream.all_written == "once"
+
+ @pytest.mark.asyncio
+ async def test_does_not_write_when_should_write_false(self) -> None:
+ msg = make_msg(at_bottom=True, should_write=False)
+ # Manually poke the buffer to verify the guard is respected.
+ msg._to_write_buffer = "invisible"
+
+ await msg.stop_stream()
+
+ assert msg._fake_stream.all_written == ""
+ assert msg._to_write_buffer == ""
+
+ @pytest.mark.asyncio
+ async def test_empty_buffer_no_extra_write(self) -> None:
+ msg = make_msg()
+ await msg.append_content("live") # written directly, buffer stays empty
+
+ await msg.stop_stream()
+
+ # Only the original direct write, no spurious extra write from stop_stream.
+ assert msg._fake_stream.written == ["live"]
+
+
+class TestWriteInitialContent:
+ @pytest.mark.asyncio
+ async def test_writes_accumulated_content(self) -> None:
+ msg = make_msg()
+ msg._content = "full content"
+
+ await msg.write_initial_content()
+
+ assert msg._fake_stream.all_written == "full content"
+
+ @pytest.mark.asyncio
+ async def test_is_idempotent_second_call_writes_nothing(self) -> None:
+ msg = make_msg()
+ msg._content = "content"
+
+ await msg.write_initial_content()
+ await msg.write_initial_content()
+
+ assert msg._fake_stream.all_written == "content"
+
+ @pytest.mark.asyncio
+ async def test_already_initialized_flag_is_noop(self) -> None:
+ msg = make_msg()
+ msg._content = "something"
+ msg._content_initialized = True
+
+ await msg.write_initial_content()
+
+ assert msg._fake_stream.all_written == ""
+
+ @pytest.mark.asyncio
+ async def test_empty_content_writes_nothing(self) -> None:
+ msg = make_msg()
+
+ await msg.write_initial_content()
+
+ assert msg._fake_stream.all_written == ""
+
+ @pytest.mark.asyncio
+ async def test_should_write_false_writes_nothing(self) -> None:
+ msg = make_msg(should_write=False)
+ msg._content = "hidden"
+
+ await msg.write_initial_content()
+
+ assert msg._fake_stream.all_written == ""
+
+ @pytest.mark.asyncio
+ async def test_clears_buffer_after_writing(self) -> None:
+ msg = make_msg(at_bottom=False)
+ await msg.append_content("buffered chunk")
+
+ await msg.write_initial_content()
+
+ assert msg._to_write_buffer == ""
+
+
+class TestNoDoubleWrite:
+ @pytest.mark.asyncio
+ async def test_write_initial_then_stop_stream_no_duplication(self) -> None:
+ """Regression: buffer must be cleared after write_initial_content so
+ stop_stream does not re-write the same content.
+ """
+ msg = make_msg(at_bottom=False)
+ await msg.append_content("part one ")
+ await msg.append_content("part two")
+
+ await msg.write_initial_content()
+ await msg.stop_stream()
+
+ written = msg._fake_stream.all_written
+ assert written == "part one part two", (
+ f"Expected content written exactly once, got: {written!r}"
+ )
+
+ @pytest.mark.asyncio
+ async def test_write_initial_before_streaming_then_buffered_then_stop(self) -> None:
+ """Reflects the real call order: write_initial_content is called once at
+ mount time (stream is pristine, _content is empty), then streaming begins.
+ Buffered content must be flushed exactly once by stop_stream.
+
+ Note: calling write_initial_content AFTER live content has already been
+ written to the stream is not a supported usage — it would duplicate the
+ already-written portion because write_initial_content replays the full
+ _content. In practice this never occurs because _mount_and_scroll calls
+ write_initial_content immediately on mount, before streaming starts.
+ """
+ msg = make_msg(at_bottom=False)
+
+ # At mount time content is empty — write_initial_content is a no-op
+ # but arms _content_initialized so it can never write again.
+ await msg.write_initial_content()
+
+ await msg.append_content("buffered chunk one ")
+ await msg.append_content("buffered chunk two")
+
+ await msg.stop_stream()
+
+ written = msg._fake_stream.all_written
+ assert written == "buffered chunk one buffered chunk two", (
+ f"Expected buffered content written exactly once, got: {written!r}"
+ )
+
+ @pytest.mark.asyncio
+ async def test_write_initial_before_streaming_at_bottom_then_stop(self) -> None:
+ """write_initial_content at mount (no-op), streaming at bottom writes
+ directly, stop_stream has nothing extra to flush.
+ """
+ msg = make_msg(at_bottom=True)
+
+ await msg.write_initial_content()
+
+ await msg.append_content("live one ")
+ await msg.append_content("live two")
+
+ await msg.stop_stream()
+
+ assert msg._fake_stream.all_written == "live one live two"
+
+ @pytest.mark.asyncio
+ async def test_stop_stream_twice_no_double_write(self) -> None:
+ msg = make_msg(at_bottom=False)
+ await msg.append_content("data")
+
+ await msg.stop_stream()
+ await msg.stop_stream()
+
+ assert msg._fake_stream.all_written == "data"
+
+ @pytest.mark.asyncio
+ async def test_write_initial_twice_no_double_write(self) -> None:
+ msg = make_msg()
+ msg._content = "once"
+
+ await msg.write_initial_content()
+ await msg.write_initial_content()
+
+ assert msg._fake_stream.all_written == "once"
diff --git a/tests/conftest.py b/tests/conftest.py
index 5ad57e5..8199819 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -9,6 +9,7 @@ import tomli_w
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from tests.stubs.fake_backend import FakeBackend
+from tests.stubs.fake_voice_manager import FakeVoiceManager
from tests.update_notifier.adapters.fake_update_cache_repository import (
FakeUpdateCacheRepository,
)
@@ -75,6 +76,22 @@ def config_dir(
return config_dir
+@pytest.fixture(autouse=True)
+def _reset_trusted_folders_manager(config_dir: Path) -> None:
+ """Prevent the singleton from writing to the real ~/.vibe/trusted_folders.toml.
+
+ The module-level ``trusted_folders_manager`` captures its file path at import
+ time (before any monkeypatch), so it would otherwise target the real home
+ directory. Redirect it to the temp config dir used by the ``config_dir``
+ fixture.
+ """
+ from vibe.core.trusted_folders import trusted_folders_manager
+
+ trusted_folders_manager._file_path = config_dir / "trusted_folders.toml"
+ trusted_folders_manager._trusted = []
+ trusted_folders_manager._untrusted = []
+
+
@pytest.fixture(autouse=True)
def _init_harness_files_manager():
reset_harness_files_manager()
@@ -215,6 +232,7 @@ def build_test_vibe_app(
resolved_current_version = (
CORE_VERSION if current_version is None else current_version
)
+ voice_manager = kwargs.pop("voice_manager", FakeVoiceManager())
return VibeApp(
agent_loop=resolved_agent_loop,
@@ -223,5 +241,6 @@ def build_test_vibe_app(
update_cache_repository=resolved_update_cache_repository,
plan_offer_gateway=resolved_plan_offer_gateway,
initial_prompt=kwargs.pop("initial_prompt", None),
+ voice_manager=voice_manager,
**kwargs,
)
diff --git a/tests/core/test_config_paths.py b/tests/core/test_config_paths.py
index f614aba..841ee3d 100644
--- a/tests/core/test_config_paths.py
+++ b/tests/core/test_config_paths.py
@@ -237,6 +237,236 @@ class TestUserAgentsDirs:
assert mgr.user_agents_dirs == [agents_dir]
+class TestLoadProjectDocs:
+ def test_returns_empty_when_project_not_in_sources(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ mgr = HarnessFilesManager(sources=("user",))
+ assert mgr.load_project_docs() == []
+
+ def test_returns_empty_when_not_trusted(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: False)
+ (tmp_path / "AGENTS.md").write_text("# Hello", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user", "project"))
+ assert mgr.load_project_docs() == []
+
+ def test_returns_single_doc_when_trust_root_is_cwd(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
+ monkeypatch.setattr(
+ trusted_folders_manager, "find_trust_root", lambda _: tmp_path.resolve()
+ )
+ (tmp_path / "AGENTS.md").write_text("# Root doc", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user", "project"))
+ docs = mgr.load_project_docs()
+ assert len(docs) == 1
+ assert docs[0] == (tmp_path.resolve(), "# Root doc")
+
+ def test_walks_up_to_trust_root(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ child = tmp_path / "sub" / "deep"
+ child.mkdir(parents=True)
+ monkeypatch.chdir(child)
+ monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
+ monkeypatch.setattr(
+ trusted_folders_manager, "find_trust_root", lambda _: tmp_path.resolve()
+ )
+ (tmp_path / "AGENTS.md").write_text("# Root", encoding="utf-8")
+ (child / "AGENTS.md").write_text("# Child", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user", "project"))
+ docs = mgr.load_project_docs()
+ # outermost first
+ assert docs[0] == (tmp_path.resolve(), "# Root")
+ assert docs[-1] == (child.resolve(), "# Child")
+
+ def test_skips_dirs_without_agents_md(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ child = tmp_path / "sub" / "deep"
+ child.mkdir(parents=True)
+ monkeypatch.chdir(child)
+ monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
+ monkeypatch.setattr(
+ trusted_folders_manager, "find_trust_root", lambda _: tmp_path.resolve()
+ )
+ # Only root has AGENTS.md, intermediate "sub" does not
+ (tmp_path / "AGENTS.md").write_text("# Root", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user", "project"))
+ docs = mgr.load_project_docs()
+ assert len(docs) == 1
+ assert docs[0] == (tmp_path.resolve(), "# Root")
+
+ def test_stops_at_trust_root_boundary(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ trust_root = tmp_path / "root"
+ child = trust_root / "sub"
+ child.mkdir(parents=True)
+ monkeypatch.chdir(child)
+ monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
+ monkeypatch.setattr(
+ trusted_folders_manager, "find_trust_root", lambda _: trust_root.resolve()
+ )
+ # Place AGENTS.md above trust root — should NOT be loaded
+ (tmp_path / "AGENTS.md").write_text("# Above root", encoding="utf-8")
+ (trust_root / "AGENTS.md").write_text("# At root", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user", "project"))
+ docs = mgr.load_project_docs()
+ assert len(docs) == 1
+ assert docs[0][0] == trust_root.resolve()
+
+ def test_returns_empty_when_trust_root_not_ancestor(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ child = tmp_path / "project" / "sub"
+ child.mkdir(parents=True)
+ trust_root = tmp_path / "other-root"
+ trust_root.mkdir()
+ monkeypatch.chdir(child)
+ monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
+ monkeypatch.setattr(
+ trusted_folders_manager, "find_trust_root", lambda _: trust_root.resolve()
+ )
+ (tmp_path / "AGENTS.md").write_text("# Outside", encoding="utf-8")
+ (child / "AGENTS.md").write_text("# Child", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user", "project"))
+ assert mgr.load_project_docs() == []
+
+ def test_ignores_empty_agents_md(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
+ monkeypatch.setattr(
+ trusted_folders_manager, "find_trust_root", lambda _: tmp_path.resolve()
+ )
+ (tmp_path / "AGENTS.md").write_text(" \n ", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user", "project"))
+ assert mgr.load_project_docs() == []
+
+
+class TestFindSubdirectoryAgentsMd:
+ def test_returns_empty_when_project_not_in_sources(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ sub = tmp_path / "sub"
+ sub.mkdir()
+ (sub / "AGENTS.md").write_text("# Sub", encoding="utf-8")
+ target = sub / "file.py"
+ target.write_text("", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user",))
+ assert mgr.find_subdirectory_agents_md(target) == []
+
+ def test_returns_empty_when_not_trusted(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(trusted_folders_manager, "find_trust_root", lambda _: None)
+ sub = tmp_path / "sub"
+ sub.mkdir()
+ (sub / "AGENTS.md").write_text("# Sub", encoding="utf-8")
+ target = sub / "file.py"
+ target.write_text("", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user", "project"))
+ assert mgr.find_subdirectory_agents_md(target) == []
+
+ def test_returns_empty_when_file_outside_cwd(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ cwd = tmp_path / "project"
+ cwd.mkdir()
+ monkeypatch.chdir(cwd)
+ monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
+ outside = tmp_path / "other" / "file.py"
+ outside.parent.mkdir(parents=True)
+ outside.write_text("", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user", "project"))
+ assert mgr.find_subdirectory_agents_md(outside) == []
+
+ def test_returns_empty_when_file_directly_in_cwd(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
+ (tmp_path / "AGENTS.md").write_text("# Root", encoding="utf-8")
+ target = tmp_path / "file.py"
+ target.write_text("", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user", "project"))
+ # cwd-level AGENTS.md is handled by load_project_docs, not this method
+ assert mgr.find_subdirectory_agents_md(target) == []
+
+ def test_returns_agents_md_from_file_parent(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
+ sub = tmp_path / "sub"
+ sub.mkdir()
+ (sub / "AGENTS.md").write_text("# Sub instructions", encoding="utf-8")
+ target = sub / "file.py"
+ target.write_text("", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user", "project"))
+ docs = mgr.find_subdirectory_agents_md(target)
+ assert len(docs) == 1
+ assert docs[0] == (sub.resolve(), "# Sub instructions")
+
+ def test_returns_multiple_agents_md_outermost_first(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
+ outer = tmp_path / "a"
+ inner = outer / "b"
+ inner.mkdir(parents=True)
+ (outer / "AGENTS.md").write_text("# Outer", encoding="utf-8")
+ (inner / "AGENTS.md").write_text("# Inner", encoding="utf-8")
+ target = inner / "file.py"
+ target.write_text("", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user", "project"))
+ docs = mgr.find_subdirectory_agents_md(target)
+ assert len(docs) == 2
+ assert docs[0] == (outer.resolve(), "# Outer")
+ assert docs[1] == (inner.resolve(), "# Inner")
+
+ def test_skips_dirs_without_agents_md(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
+ outer = tmp_path / "a"
+ inner = outer / "b"
+ inner.mkdir(parents=True)
+ # Only inner has AGENTS.md
+ (inner / "AGENTS.md").write_text("# Inner", encoding="utf-8")
+ target = inner / "file.py"
+ target.write_text("", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user", "project"))
+ docs = mgr.find_subdirectory_agents_md(target)
+ assert len(docs) == 1
+ assert docs[0] == (inner.resolve(), "# Inner")
+
+ def test_ignores_empty_agents_md(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
+ sub = tmp_path / "sub"
+ sub.mkdir()
+ (sub / "AGENTS.md").write_text(" \n ", encoding="utf-8")
+ target = sub / "file.py"
+ target.write_text("", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user", "project"))
+ assert mgr.find_subdirectory_agents_md(target) == []
+
+
class TestProjectSkillsDirs:
def test_returns_empty_list_when_no_skills_dirs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -332,3 +562,33 @@ class TestProjectSkillsDirs:
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.project_skills_dirs == [tmp_path / ".vibe" / "skills"]
+
+
+class TestLoadUserDoc:
+ def test_returns_empty_when_user_not_in_sources(self) -> None:
+ mgr = HarnessFilesManager(sources=("project",))
+ assert mgr.load_user_doc() == ""
+
+ def test_returns_empty_when_file_does_not_exist(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setattr("vibe.core.paths._vibe_home._DEFAULT_VIBE_HOME", tmp_path)
+ mgr = HarnessFilesManager(sources=("user",))
+ assert mgr.load_user_doc() == ""
+
+ def test_returns_file_content_when_file_exists(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setattr("vibe.core.paths._vibe_home._DEFAULT_VIBE_HOME", tmp_path)
+ (tmp_path / "AGENTS.md").write_text("# User instructions", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user",))
+ assert mgr.load_user_doc() == "# User instructions"
+
+ def test_returns_empty_string_for_whitespace_only_file(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # load_user_doc strips — consistent with _collect_agents_md
+ monkeypatch.setattr("vibe.core.paths._vibe_home._DEFAULT_VIBE_HOME", tmp_path)
+ (tmp_path / "AGENTS.md").write_text(" \n ", encoding="utf-8")
+ mgr = HarnessFilesManager(sources=("user",))
+ assert mgr.load_user_doc() == ""
diff --git a/tests/core/test_config_resolution.py b/tests/core/test_config_resolution.py
index 4d1e633..0a37568 100644
--- a/tests/core/test_config_resolution.py
+++ b/tests/core/test_config_resolution.py
@@ -1,11 +1,13 @@
from __future__ import annotations
from pathlib import Path
+import tomllib
import pytest
+import tomli_w
from tests.conftest import build_test_vibe_config
-from vibe.core.config import ModelConfig
+from vibe.core.config import ModelConfig, VibeConfig
from vibe.core.config.harness_files import (
HarnessFilesManager,
init_harness_files_manager,
@@ -84,6 +86,60 @@ class TestResolveConfigFile:
assert mgr.config_file == VIBE_HOME.path / "config.toml"
+class TestMigrateRemovesFindFromBashAllowlist:
+ def test_removes_find_from_config_file(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("VIBE_HOME", str(tmp_path))
+ config_file = tmp_path / "config.toml"
+ data = {"tools": {"bash": {"allowlist": ["echo", "find", "ls"]}}}
+ with config_file.open("wb") as f:
+ tomli_w.dump(data, f)
+
+ reset_harness_files_manager()
+ init_harness_files_manager("user")
+ VibeConfig._migrate()
+
+ with config_file.open("rb") as f:
+ result = tomllib.load(f)
+ assert "find" not in result["tools"]["bash"]["allowlist"]
+ assert result["tools"]["bash"]["allowlist"] == ["echo", "ls"]
+
+ def test_noop_when_find_not_present(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("VIBE_HOME", str(tmp_path))
+ config_file = tmp_path / "config.toml"
+ data = {"tools": {"bash": {"allowlist": ["echo", "ls"]}}}
+ with config_file.open("wb") as f:
+ tomli_w.dump(data, f)
+
+ reset_harness_files_manager()
+ init_harness_files_manager("user")
+ VibeConfig._migrate()
+
+ with config_file.open("rb") as f:
+ result = tomllib.load(f)
+ assert result["tools"]["bash"]["allowlist"] == ["echo", "ls"]
+
+ def test_noop_when_no_bash_tools_section(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("VIBE_HOME", str(tmp_path))
+ config_file = tmp_path / "config.toml"
+ data = {"active_model": "test"}
+ with config_file.open("wb") as f:
+ tomli_w.dump(data, f)
+
+ reset_harness_files_manager()
+ init_harness_files_manager("user")
+ VibeConfig._migrate()
+
+ with config_file.open("rb") as f:
+ result = tomllib.load(f)
+ assert "tools" not in result
+
+
class TestAutoCompactThresholdFallback:
def test_model_without_explicit_threshold_inherits_global(self) -> None:
model = ModelConfig(name="m", provider="p", alias="m")
@@ -119,3 +175,52 @@ class TestAutoCompactThresholdFallback:
auto_compact_threshold=75_000, models=[model], active_model="m"
)
assert cfg2.get_active_model().auto_compact_threshold == 75_000
+
+
+class TestCompactionModel:
+ def test_get_compaction_model_returns_active_when_unset(self) -> None:
+ cfg = build_test_vibe_config()
+ assert cfg.get_compaction_model() == cfg.get_active_model()
+
+ def test_get_compaction_model_returns_configured_model(self) -> None:
+ compaction = ModelConfig(
+ name="compact-model", provider="mistral", alias="compact"
+ )
+ cfg = build_test_vibe_config(compaction_model=compaction)
+ assert cfg.get_compaction_model().name == "compact-model"
+
+ def test_compaction_model_provider_must_match_active(self) -> None:
+ from vibe.core.config import ProviderConfig
+
+ compaction = ModelConfig(
+ name="compact-model", provider="other", alias="compact"
+ )
+ providers = [
+ ProviderConfig(
+ name="mistral",
+ api_base="https://api.mistral.ai/v1",
+ api_key_env_var="MISTRAL_API_KEY",
+ ),
+ ProviderConfig(
+ name="other",
+ api_base="https://other.ai/v1",
+ api_key_env_var="MISTRAL_API_KEY",
+ ),
+ ]
+ with pytest.raises(ValueError, match="must share the same provider"):
+ build_test_vibe_config(compaction_model=compaction, providers=providers)
+
+ def test_compaction_model_provider_must_exist(self) -> None:
+ compaction = ModelConfig(
+ name="compact-model", provider="missing-provider", alias="compact"
+ )
+ with pytest.raises(
+ ValueError,
+ match="Provider 'missing-provider' for model 'compact-model' not found in configuration",
+ ):
+ build_test_vibe_config(compaction_model=compaction)
+
+ def test_compaction_model_excluded_from_model_dump_when_none(self) -> None:
+ cfg = build_test_vibe_config()
+ dumped = cfg.model_dump()
+ assert "compaction_model" not in dumped
diff --git a/tests/core/test_transcribe_config.py b/tests/core/test_transcribe_config.py
new file mode 100644
index 0000000..47004a1
--- /dev/null
+++ b/tests/core/test_transcribe_config.py
@@ -0,0 +1,115 @@
+from __future__ import annotations
+
+import pytest
+
+from tests.conftest import build_test_vibe_config
+from vibe.core.config import (
+ DEFAULT_TRANSCRIBE_MODELS,
+ DEFAULT_TRANSCRIBE_PROVIDERS,
+ TranscribeClient,
+ TranscribeModelConfig,
+ TranscribeProviderConfig,
+)
+
+
+class TestTranscribeConfigDefaults:
+ def test_default_transcribe_providers_loaded(self) -> None:
+ config = build_test_vibe_config()
+ assert len(config.transcribe_providers) == len(DEFAULT_TRANSCRIBE_PROVIDERS)
+ assert config.transcribe_providers[0].name == "mistral"
+ assert config.transcribe_providers[0].api_base == "wss://api.mistral.ai"
+
+ def test_default_transcribe_models_loaded(self) -> None:
+ config = build_test_vibe_config()
+ assert len(config.transcribe_models) == len(DEFAULT_TRANSCRIBE_MODELS)
+ assert config.transcribe_models[0].alias == "voxtral-realtime"
+ assert (
+ config.transcribe_models[0].name == "voxtral-mini-transcribe-realtime-2602"
+ )
+
+ def test_default_active_transcribe_model(self) -> None:
+ config = build_test_vibe_config()
+ assert config.active_transcribe_model == "voxtral-realtime"
+
+
+class TestGetActiveTranscribeModel:
+ def test_resolves_by_alias(self) -> None:
+ config = build_test_vibe_config()
+ model = config.get_active_transcribe_model()
+ assert model.alias == "voxtral-realtime"
+ assert model.name == "voxtral-mini-transcribe-realtime-2602"
+
+ def test_raises_for_unknown_alias(self) -> None:
+ config = build_test_vibe_config(active_transcribe_model="nonexistent")
+ with pytest.raises(ValueError, match="not found in configuration"):
+ config.get_active_transcribe_model()
+
+
+class TestGetTranscribeProviderForModel:
+ def test_resolves_by_name(self) -> None:
+ config = build_test_vibe_config()
+ model = config.get_active_transcribe_model()
+ provider = config.get_transcribe_provider_for_model(model)
+ assert provider.name == "mistral"
+ assert provider.api_base == "wss://api.mistral.ai"
+
+ def test_raises_for_unknown_provider(self) -> None:
+ config = build_test_vibe_config(
+ transcribe_models=[
+ TranscribeModelConfig(
+ name="test-model", provider="nonexistent", alias="test"
+ )
+ ],
+ active_transcribe_model="test",
+ )
+ model = config.get_active_transcribe_model()
+ with pytest.raises(ValueError, match="not found in configuration"):
+ config.get_transcribe_provider_for_model(model)
+
+
+class TestTranscribeModelUniqueness:
+ def test_duplicate_aliases_raise(self) -> None:
+ with pytest.raises(ValueError, match="Duplicate transcribe model alias"):
+ build_test_vibe_config(
+ transcribe_models=[
+ TranscribeModelConfig(
+ name="model-a", provider="mistral", alias="same-alias"
+ ),
+ TranscribeModelConfig(
+ name="model-b", provider="mistral", alias="same-alias"
+ ),
+ ],
+ active_transcribe_model="same-alias",
+ )
+
+
+class TestTranscribeModelConfig:
+ def test_alias_defaults_to_name(self) -> None:
+ model = TranscribeModelConfig.model_validate({
+ "name": "my-model",
+ "provider": "mistral",
+ })
+ assert model.alias == "my-model"
+
+ def test_explicit_alias(self) -> None:
+ model = TranscribeModelConfig(
+ name="my-model", provider="mistral", alias="custom-alias"
+ )
+ assert model.alias == "custom-alias"
+
+ def test_default_values(self) -> None:
+ model = TranscribeModelConfig(
+ name="my-model", provider="mistral", alias="my-model"
+ )
+ assert model.sample_rate == 16000
+ assert model.encoding == "pcm_s16le"
+ assert model.language == "en"
+ assert model.target_streaming_delay_ms == 500
+
+
+class TestTranscribeProviderConfig:
+ def test_default_values(self) -> None:
+ provider = TranscribeProviderConfig(name="test")
+ assert provider.api_base == "wss://api.mistral.ai"
+ assert provider.api_key_env_var == ""
+ assert provider.client == TranscribeClient.MISTRAL
diff --git a/tests/core/test_trusted_folders.py b/tests/core/test_trusted_folders.py
index aaeebe6..f20e7ae 100644
--- a/tests/core/test_trusted_folders.py
+++ b/tests/core/test_trusted_folders.py
@@ -7,7 +7,7 @@ from unittest.mock import patch
import pytest
import tomli_w
-from vibe.core.paths import AGENTS_MD_FILENAMES, TRUSTED_FOLDERS_FILE
+from vibe.core.paths import AGENTS_MD_FILENAME, TRUSTED_FOLDERS_FILE
from vibe.core.trusted_folders import (
TrustedFoldersManager,
has_agents_md_file,
@@ -209,6 +209,99 @@ class TestTrustedFoldersManager:
assert manager.is_trusted(tmp_path) is True
+class TestIsTrustedInheritance:
+ """Tests for the walk-up trust inheritance behaviour."""
+
+ def test_child_of_trusted_folder_returns_true(self, tmp_path: Path) -> None:
+ manager = TrustedFoldersManager()
+ manager.add_trusted(tmp_path)
+ child = tmp_path / "sub" / "deep"
+ child.mkdir(parents=True)
+ assert manager.is_trusted(child) is True
+
+ def test_child_of_untrusted_folder_returns_false(self, tmp_path: Path) -> None:
+ manager = TrustedFoldersManager()
+ manager.add_untrusted(tmp_path)
+ child = tmp_path / "sub"
+ child.mkdir()
+ assert manager.is_trusted(child) is False
+
+ def test_most_specific_ancestor_wins(self, tmp_path: Path) -> None:
+ parent = tmp_path / "parent"
+ child = parent / "child"
+ child.mkdir(parents=True)
+
+ manager = TrustedFoldersManager()
+ manager.add_trusted(parent)
+ manager.add_untrusted(child)
+
+ assert manager.is_trusted(parent) is True
+ assert manager.is_trusted(child) is False
+ assert manager.is_trusted(child / "grandchild") is False
+
+ def test_untrusted_parent_trusted_child(self, tmp_path: Path) -> None:
+ parent = tmp_path / "parent"
+ child = parent / "child"
+ child.mkdir(parents=True)
+
+ manager = TrustedFoldersManager()
+ manager.add_untrusted(parent)
+ manager.add_trusted(child)
+
+ assert manager.is_trusted(parent) is False
+ assert manager.is_trusted(child) is True
+ assert manager.is_trusted(child / "grandchild") is True
+
+ def test_deep_nesting_inherits_trust(self, tmp_path: Path) -> None:
+ manager = TrustedFoldersManager()
+ manager.add_trusted(tmp_path)
+ deep = tmp_path / "a" / "b" / "c" / "d"
+ deep.mkdir(parents=True)
+ assert manager.is_trusted(deep) is True
+
+ def test_no_match_returns_none(self, tmp_path: Path) -> None:
+ manager = TrustedFoldersManager()
+ assert manager.is_trusted(tmp_path / "unknown") is None
+
+
+class TestFindTrustRoot:
+ def test_returns_path_when_path_is_trusted(self, tmp_path: Path) -> None:
+ manager = TrustedFoldersManager()
+ manager.add_trusted(tmp_path)
+ assert manager.find_trust_root(tmp_path) == tmp_path.resolve()
+
+ def test_returns_ancestor_when_child(self, tmp_path: Path) -> None:
+ manager = TrustedFoldersManager()
+ manager.add_trusted(tmp_path)
+ child = tmp_path / "sub" / "deep"
+ child.mkdir(parents=True)
+ assert manager.find_trust_root(child) == tmp_path.resolve()
+
+ def test_returns_none_when_no_trusted_ancestor(self, tmp_path: Path) -> None:
+ manager = TrustedFoldersManager()
+ assert manager.find_trust_root(tmp_path) is None
+
+ def test_returns_closest_trusted_ancestor(self, tmp_path: Path) -> None:
+ parent = tmp_path / "parent"
+ child = parent / "child"
+ child.mkdir(parents=True)
+ manager = TrustedFoldersManager()
+ manager.add_trusted(tmp_path)
+ manager.add_trusted(parent)
+ # child should find parent (closest), not tmp_path
+ assert manager.find_trust_root(child) == parent.resolve()
+
+ def test_ignores_untrusted_ancestors(self, tmp_path: Path) -> None:
+ parent = tmp_path / "parent"
+ child = parent / "child"
+ child.mkdir(parents=True)
+ manager = TrustedFoldersManager()
+ manager.add_untrusted(parent)
+ manager.add_trusted(tmp_path)
+ # find_trust_root skips untrusted, finds tmp_path
+ assert manager.find_trust_root(child) == tmp_path.resolve()
+
+
class TestHasAgentsMdFile:
def test_returns_false_for_empty_directory(self, tmp_path: Path) -> None:
assert has_agents_md_file(tmp_path) is False
@@ -217,21 +310,13 @@ class TestHasAgentsMdFile:
(tmp_path / "AGENTS.md").write_text("# Agents", encoding="utf-8")
assert has_agents_md_file(tmp_path) is True
- def test_returns_true_when_vibe_md_exists(self, tmp_path: Path) -> None:
- (tmp_path / "VIBE.md").write_text("# Vibe", encoding="utf-8")
- assert has_agents_md_file(tmp_path) is True
-
- def test_returns_true_when_dot_vibe_md_exists(self, tmp_path: Path) -> None:
- (tmp_path / ".vibe.md").write_text("# Vibe", encoding="utf-8")
- assert has_agents_md_file(tmp_path) is True
-
def test_returns_false_when_only_other_files_exist(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("", encoding="utf-8")
(tmp_path / ".vibe").mkdir()
assert has_agents_md_file(tmp_path) is False
- def test_agents_md_filenames_constant(self) -> None:
- assert AGENTS_MD_FILENAMES == ["AGENTS.md", "VIBE.md", ".vibe.md"]
+ def test_agents_md_filename_constant(self) -> None:
+ assert AGENTS_MD_FILENAME == "AGENTS.md"
class TestHasTrustableContent:
@@ -244,10 +329,9 @@ class TestHasTrustableContent:
assert has_trustable_content(tmp_path) is True
def test_returns_true_when_agents_md_filename_exists(self, tmp_path: Path) -> None:
- for name in AGENTS_MD_FILENAMES:
- (tmp_path / name).write_text("", encoding="utf-8")
- assert has_trustable_content(tmp_path) is True
- (tmp_path / name).unlink()
+ (tmp_path / AGENTS_MD_FILENAME).write_text("", encoding="utf-8")
+ assert has_trustable_content(tmp_path) is True
+ (tmp_path / AGENTS_MD_FILENAME).unlink()
def test_returns_false_when_no_trustable_content(self, tmp_path: Path) -> None:
(tmp_path / "other.txt").write_text("", encoding="utf-8")
diff --git a/tests/core/tools/builtins/test_read_file.py b/tests/core/tools/builtins/test_read_file.py
new file mode 100644
index 0000000..0857b99
--- /dev/null
+++ b/tests/core/tools/builtins/test_read_file.py
@@ -0,0 +1,136 @@
+from __future__ import annotations
+
+from collections.abc import Iterator
+from pathlib import Path
+
+import pytest
+
+from vibe.core.config.harness_files import (
+ init_harness_files_manager,
+ reset_harness_files_manager,
+)
+from vibe.core.tools.builtins.read_file import (
+ ReadFile,
+ ReadFileResult,
+ ReadFileState,
+ ReadFileToolConfig,
+)
+from vibe.core.trusted_folders import trusted_folders_manager
+from vibe.core.utils import VIBE_WARNING_TAG
+
+
+@pytest.fixture()
+def _setup_manager(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
+ """Initialize harness files manager for tests, reset after."""
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
+ monkeypatch.setattr(
+ trusted_folders_manager, "find_trust_root", lambda _: tmp_path.resolve()
+ )
+ reset_harness_files_manager()
+ init_harness_files_manager("user", "project")
+ yield
+ reset_harness_files_manager()
+
+
+def _make_read_file() -> ReadFile:
+ return ReadFile(config=ReadFileToolConfig(), state=ReadFileState())
+
+
+class TestGetResultExtra:
+ @pytest.mark.usefixtures("_setup_manager")
+ def test_returns_none_when_no_agents_md(self, tmp_path: Path) -> None:
+ sub = tmp_path / "sub"
+ sub.mkdir()
+ target = sub / "file.py"
+ target.write_text("hello", encoding="utf-8")
+
+ tool = _make_read_file()
+ result = ReadFileResult(
+ path=str(target), content="hello", lines_read=1, was_truncated=False
+ )
+ assert tool.get_result_extra(result) is None
+
+ @pytest.mark.usefixtures("_setup_manager")
+ def test_returns_tagged_content_when_agents_md_found(self, tmp_path: Path) -> None:
+ sub = tmp_path / "sub"
+ sub.mkdir()
+ (sub / "AGENTS.md").write_text("# Sub instructions", encoding="utf-8")
+ target = sub / "file.py"
+ target.write_text("hello", encoding="utf-8")
+
+ tool = _make_read_file()
+ result = ReadFileResult(
+ path=str(target), content="hello", lines_read=1, was_truncated=False
+ )
+ annotation = tool.get_result_extra(result)
+ assert annotation is not None
+ assert f"<{VIBE_WARNING_TAG}>" in annotation
+ assert f"{VIBE_WARNING_TAG}>" in annotation
+ assert "# Sub instructions" in annotation
+ assert "project instructions for this directory" in annotation
+
+ @pytest.mark.usefixtures("_setup_manager")
+ def test_deduplicates_across_calls(self, tmp_path: Path) -> None:
+ sub = tmp_path / "sub"
+ sub.mkdir()
+ (sub / "AGENTS.md").write_text("# Sub", encoding="utf-8")
+ file1 = sub / "a.py"
+ file2 = sub / "b.py"
+ file1.write_text("a", encoding="utf-8")
+ file2.write_text("b", encoding="utf-8")
+
+ tool = _make_read_file()
+
+ result1 = ReadFileResult(
+ path=str(file1), content="a", lines_read=1, was_truncated=False
+ )
+ assert tool.get_result_extra(result1) is not None
+
+ # Second call for a different file in the same dir → no duplicate
+ result2 = ReadFileResult(
+ path=str(file2), content="b", lines_read=1, was_truncated=False
+ )
+ assert tool.get_result_extra(result2) is None
+
+ @pytest.mark.usefixtures("_setup_manager")
+ def test_injects_new_dir_after_dedup(self, tmp_path: Path) -> None:
+ sub_a = tmp_path / "a"
+ sub_b = tmp_path / "b"
+ sub_a.mkdir()
+ sub_b.mkdir()
+ (sub_a / "AGENTS.md").write_text("# A", encoding="utf-8")
+ (sub_b / "AGENTS.md").write_text("# B", encoding="utf-8")
+ file_a = sub_a / "f.py"
+ file_b = sub_b / "f.py"
+ file_a.write_text("", encoding="utf-8")
+ file_b.write_text("", encoding="utf-8")
+
+ tool = _make_read_file()
+
+ r1 = ReadFileResult(
+ path=str(file_a), content="", lines_read=0, was_truncated=False
+ )
+ ann1 = tool.get_result_extra(r1)
+ assert ann1 is not None
+ assert "# A" in ann1
+
+ # Different subdirectory → should inject its AGENTS.md
+ r2 = ReadFileResult(
+ path=str(file_b), content="", lines_read=0, was_truncated=False
+ )
+ ann2 = tool.get_result_extra(r2)
+ assert ann2 is not None
+ assert "# B" in ann2
+
+ def test_returns_none_when_manager_not_initialized(self, tmp_path: Path) -> None:
+ reset_harness_files_manager()
+ tool = _make_read_file()
+ result = ReadFileResult(
+ path=str(tmp_path / "file.py"),
+ content="",
+ lines_read=0,
+ was_truncated=False,
+ )
+ assert tool.get_result_extra(result) is None
+ reset_harness_files_manager()
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_indicator_shown.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_indicator_shown.svg
new file mode 100644
index 0000000..5ff233d
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_indicator_shown.svg
@@ -0,0 +1,201 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_not_started_when_voice_disabled.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_not_started_when_voice_disabled.svg
new file mode 100644
index 0000000..a100082
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_not_started_when_voice_disabled.svg
@@ -0,0 +1,200 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_stopped_after_keypress.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_stopped_after_keypress.svg
new file mode 100644
index 0000000..94e5d41
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_stopped_after_keypress.svg
@@ -0,0 +1,200 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_then_conversation.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_then_conversation.svg
new file mode 100644
index 0000000..e9764e2
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_then_conversation.svg
@@ -0,0 +1,201 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_transcription_populates_text_area.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_transcription_populates_text_area.svg
new file mode 100644
index 0000000..50a0292
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_transcription_populates_text_area.svg
@@ -0,0 +1,201 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_disable.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_disable.svg
new file mode 100644
index 0000000..289a82b
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_disable.svg
@@ -0,0 +1,201 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_enable.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_enable.svg
new file mode 100644
index 0000000..8023575
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_enable.svg
@@ -0,0 +1,201 @@
+
diff --git a/tests/snapshots/test_ui_snapshot_voice_mode.py b/tests/snapshots/test_ui_snapshot_voice_mode.py
new file mode 100644
index 0000000..dadad2e
--- /dev/null
+++ b/tests/snapshots/test_ui_snapshot_voice_mode.py
@@ -0,0 +1,148 @@
+from __future__ import annotations
+
+from textual.pilot import Pilot
+
+from tests.mock.utils import mock_llm_chunk
+from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
+from tests.snapshots.snap_compare import SnapCompare
+from tests.stubs.fake_backend import FakeBackend
+from tests.stubs.fake_voice_manager import FakeVoiceManager
+from vibe.cli.textual_ui.widgets.chat_input.body import ChatInputBody
+
+
+class VoiceEnableApp(BaseSnapshotTestApp):
+ def __init__(self) -> None:
+ super().__init__(voice_manager=FakeVoiceManager(is_voice_ready=False))
+
+
+class VoiceDisableApp(BaseSnapshotTestApp):
+ def __init__(self) -> None:
+ super().__init__(voice_manager=FakeVoiceManager(is_voice_ready=True))
+
+
+class RecordingActiveApp(BaseSnapshotTestApp):
+ """Voice enabled — used for recording snapshot tests."""
+
+ def __init__(self) -> None:
+ super().__init__(voice_manager=FakeVoiceManager(is_voice_ready=True))
+
+
+class VoiceDisabledRecordingAttemptApp(BaseSnapshotTestApp):
+ """Voice disabled — ctrl+r should be ignored."""
+
+ def __init__(self) -> None:
+ super().__init__(voice_manager=FakeVoiceManager(is_voice_ready=False))
+
+
+class RecordingThenConversationApp(BaseSnapshotTestApp):
+ """Voice enabled + FakeBackend that returns an LLM response."""
+
+ def __init__(self) -> None:
+ fake_backend = FakeBackend(mock_llm_chunk(content="I'm ready to help you."))
+ super().__init__(
+ voice_manager=FakeVoiceManager(is_voice_ready=True), backend=fake_backend
+ )
+
+
+def test_snapshot_voice_enable(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.press(*"/voice")
+ await pilot.press("enter")
+ await pilot.pause(0.4)
+
+ assert snap_compare(
+ "test_ui_snapshot_voice_mode.py:VoiceEnableApp",
+ terminal_size=(120, 36),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_voice_disable(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.press(*"/voice")
+ await pilot.press("enter")
+ await pilot.pause(0.4)
+
+ assert snap_compare(
+ "test_ui_snapshot_voice_mode.py:VoiceDisableApp",
+ terminal_size=(120, 36),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_recording_indicator_shown(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.press(*"hello")
+ await pilot.pause(0.2)
+ await pilot.press("ctrl+r")
+ await pilot.pause(0.5)
+
+ assert snap_compare(
+ "test_ui_snapshot_voice_mode.py:RecordingActiveApp",
+ terminal_size=(120, 36),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_recording_stopped_after_keypress(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.press(*"hello")
+ await pilot.pause(0.2)
+ await pilot.press("ctrl+r")
+ await pilot.pause(0.5)
+ await pilot.press("a")
+ await pilot.pause(0.5)
+
+ assert snap_compare(
+ "test_ui_snapshot_voice_mode.py:RecordingActiveApp",
+ terminal_size=(120, 36),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_recording_not_started_when_voice_disabled(
+ snap_compare: SnapCompare,
+) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.press(*"hello")
+ await pilot.pause(0.2)
+ await pilot.press("ctrl+r")
+ await pilot.pause(0.5)
+
+ assert snap_compare(
+ "test_ui_snapshot_voice_mode.py:VoiceDisabledRecordingAttemptApp",
+ terminal_size=(120, 36),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_recording_then_conversation(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.press(*"hello")
+ await pilot.pause(0.2)
+ await pilot.press("ctrl+r")
+ await pilot.pause(0.5)
+ await pilot.press("a")
+ await pilot.pause(0.5)
+ await pilot.press("enter")
+ await pilot.pause(0.4)
+
+ assert snap_compare(
+ "test_ui_snapshot_voice_mode.py:RecordingThenConversationApp",
+ terminal_size=(120, 36),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_transcription_populates_text_area(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.press("ctrl+r")
+ await pilot.pause(0.3)
+ pilot.app.query_one(ChatInputBody).on_transcribe_text("Hello world from voice")
+ await pilot.pause(0.3)
+
+ assert snap_compare(
+ "test_ui_snapshot_voice_mode.py:RecordingActiveApp",
+ terminal_size=(120, 36),
+ run_before=run_before,
+ )
diff --git a/tests/stubs/fake_audio_recorder.py b/tests/stubs/fake_audio_recorder.py
new file mode 100644
index 0000000..4bc4fa3
--- /dev/null
+++ b/tests/stubs/fake_audio_recorder.py
@@ -0,0 +1,65 @@
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator, Callable
+
+from vibe.core.audio_recorder import (
+ AlreadyRecordingError,
+ AudioRecording,
+ RecordingMode,
+)
+
+FAKE_WAV_DATA = b"RIFF\x00\x00\x00\x00WAVEfmt "
+
+
+class FakeAudioRecorder:
+ def __init__(self, *, fake_wav_data: bytes = FAKE_WAV_DATA) -> None:
+ self._recording = False
+ self._peak = 0.0
+ self._mode = RecordingMode.BUFFER
+ self._fake_wav_data = fake_wav_data
+ self._stream_chunks: list[bytes] = []
+
+ @property
+ def is_recording(self) -> bool:
+ return self._recording
+
+ @property
+ def peak(self) -> float:
+ return self._peak
+
+ @property
+ def mode(self) -> RecordingMode:
+ return self._mode
+
+ def start(
+ self,
+ mode: RecordingMode,
+ *,
+ sample_rate: int = 48_000,
+ channels: int = 1,
+ max_duration: float = 300.0,
+ on_expire: Callable[[AudioRecording], object] | None = None,
+ ) -> None:
+ if self._recording:
+ raise AlreadyRecordingError("Already recording")
+ self._recording = True
+ self._mode = mode
+
+ def stop(self, *, wait_for_queue_drained: bool = True) -> AudioRecording:
+ if not self._recording:
+ return AudioRecording(data=b"", duration=0.0)
+ self._recording = False
+ return AudioRecording(data=self._fake_wav_data, duration=1.0)
+
+ def cancel(self) -> None:
+ self._recording = False
+
+ async def audio_stream(self) -> AsyncGenerator[bytes, None]:
+ for chunk in self._stream_chunks:
+ yield chunk
+
+ def set_peak(self, value: float) -> None:
+ self._peak = value
+
+ def set_stream_chunks(self, chunks: list[bytes]) -> None:
+ self._stream_chunks = chunks
diff --git a/tests/stubs/fake_transcribe_client.py b/tests/stubs/fake_transcribe_client.py
new file mode 100644
index 0000000..7cd13c2
--- /dev/null
+++ b/tests/stubs/fake_transcribe_client.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+from typing import Any
+
+from vibe.core.transcribe.transcribe_client_port import TranscribeEvent
+
+
+class FakeTranscribeClient:
+ def __init__(
+ self, *_args: Any, events: list[TranscribeEvent] | None = None, **_kwargs: Any
+ ) -> None:
+ self._events: list[TranscribeEvent] = events or []
+
+ def set_events(self, events: list[TranscribeEvent]) -> None:
+ self._events = events
+
+ async def transcribe(
+ self, audio_stream: AsyncIterator[bytes]
+ ) -> AsyncIterator[TranscribeEvent]:
+ for event in self._events:
+ yield event
diff --git a/tests/stubs/fake_voice_manager.py b/tests/stubs/fake_voice_manager.py
new file mode 100644
index 0000000..607d817
--- /dev/null
+++ b/tests/stubs/fake_voice_manager.py
@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+from tests.stubs.fake_audio_recorder import FakeAudioRecorder
+from vibe.cli.voice_manager import VoiceToggleResult
+from vibe.cli.voice_manager.voice_manager_port import (
+ TranscribeState,
+ VoiceManagerListener,
+)
+from vibe.core.audio_recorder import AudioRecorderPort
+from vibe.core.audio_recorder.audio_recorder_port import RecordingMode
+
+
+class FakeVoiceManager:
+ def __init__(
+ self,
+ *,
+ is_voice_ready: bool = False,
+ audio_recorder: AudioRecorderPort | None = None,
+ ) -> None:
+ self._enabled = is_voice_ready
+ self._audio_recorder: AudioRecorderPort = audio_recorder or FakeAudioRecorder()
+ self._transcribe_state = TranscribeState.IDLE
+ self._listeners: list[VoiceManagerListener] = []
+
+ @property
+ def is_enabled(self) -> bool:
+ return self._enabled
+
+ @property
+ def transcribe_state(self) -> TranscribeState:
+ return self._transcribe_state
+
+ @property
+ def peak(self) -> float:
+ return self._audio_recorder.peak
+
+ def toggle_voice_mode(self) -> VoiceToggleResult:
+ self._enabled = not self._enabled
+ if not self._enabled:
+ self.cancel_recording()
+ for listener in self._listeners:
+ listener.on_voice_mode_change(self._enabled)
+ return VoiceToggleResult(enabled=self._enabled)
+
+ def start_recording(self, mode: RecordingMode = RecordingMode.STREAM) -> None:
+ self._set_state(TranscribeState.RECORDING)
+
+ async def stop_recording(self) -> None:
+ self._set_state(TranscribeState.FLUSHING)
+ self._set_state(TranscribeState.IDLE)
+
+ def cancel_recording(self) -> None:
+ if self._transcribe_state == TranscribeState.IDLE:
+ return
+ if self._transcribe_state == TranscribeState.RECORDING:
+ pass # fake: no actual audio to cancel
+ self._set_state(TranscribeState.IDLE)
+
+ def add_listener(self, listener: VoiceManagerListener) -> None:
+ if listener not in self._listeners:
+ self._listeners.append(listener)
+
+ def remove_listener(self, listener: VoiceManagerListener) -> None:
+ try:
+ self._listeners.remove(listener)
+ except ValueError:
+ pass
+
+ def _set_state(self, state: TranscribeState) -> None:
+ if self._transcribe_state == state:
+ return
+ self._transcribe_state = state
+ for listener in self._listeners:
+ listener.on_transcribe_state_change(state)
diff --git a/tests/test_agent_auto_compact.py b/tests/test_agent_auto_compact.py
index f313f97..1d88a5d 100644
--- a/tests/test_agent_auto_compact.py
+++ b/tests/test_agent_auto_compact.py
@@ -9,6 +9,7 @@ from tests.conftest import (
)
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
+from vibe.core.config import ModelConfig
from vibe.core.types import (
AssistantEvent,
CompactEndEvent,
@@ -125,3 +126,56 @@ async def test_compact_replaces_messages_with_summary() -> None:
assert agent.messages[0].role == Role.system
assert agent.messages[-1].role == Role.assistant
assert agent.messages[-1].content == ""
+
+
+class _ModelTrackingBackend(FakeBackend):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.requested_models: list[ModelConfig] = []
+
+ async def complete(self, *, model, **kwargs):
+ self.requested_models.append(model)
+ return await super().complete(model=model, **kwargs)
+
+
+@pytest.mark.asyncio
+async def test_compact_uses_compaction_model() -> None:
+ """When compaction_model is set, compact() uses it instead of active_model."""
+ compaction = ModelConfig(
+ name="compaction-model",
+ provider="mistral",
+ alias="compaction",
+ auto_compact_threshold=1,
+ )
+ backend = _ModelTrackingBackend([
+ [mock_llm_chunk(content="")],
+ [mock_llm_chunk(content="")],
+ ])
+ cfg = build_test_vibe_config(
+ models=make_test_models(auto_compact_threshold=1), compaction_model=compaction
+ )
+ agent = build_test_agent_loop(config=cfg, backend=backend)
+ agent.stats.context_tokens = 2
+
+ [_ async for _ in agent.act("Hello")]
+
+ assert backend.requested_models[0].name == "compaction-model"
+ assert backend.requested_models[1].name != "compaction-model"
+
+
+@pytest.mark.asyncio
+async def test_compact_uses_active_model_when_no_compaction_model() -> None:
+ """Without compaction_model, compact() falls back to the active model."""
+ backend = _ModelTrackingBackend([
+ [mock_llm_chunk(content="")],
+ [mock_llm_chunk(content="")],
+ ])
+ cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=1))
+ agent = build_test_agent_loop(config=cfg, backend=backend)
+ agent.stats.context_tokens = 2
+
+ [_ async for _ in agent.act("Hello")]
+
+ active = cfg.get_active_model()
+ assert backend.requested_models[0].name == active.name
+ assert backend.requested_models[1].name == active.name
diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py
index bf93ea4..8a25bd0 100644
--- a/tests/test_agent_backend.py
+++ b/tests/test_agent_backend.py
@@ -111,6 +111,17 @@ async def test_updates_tokens_stats_based_on_backend_response_streaming(
assert agent.stats.context_tokens == 275
+@pytest.mark.asyncio
+async def test_passes_session_id_to_backend(vibe_config: VibeConfig):
+ backend = FakeBackend([mock_llm_chunk(content="Response")])
+ agent = build_test_agent_loop(config=vibe_config, backend=backend)
+
+ [_ async for _ in agent.act("Hello")]
+
+ assert len(backend.requests_metadata) > 0
+ assert backend.requests_metadata[0] == {"session_id": agent.session_id}
+
+
@pytest.mark.asyncio
async def test_passes_entrypoint_metadata_to_backend(vibe_config: VibeConfig):
metadata = EntrypointMetadata(
@@ -135,6 +146,7 @@ async def test_passes_entrypoint_metadata_to_backend(vibe_config: VibeConfig):
"agent_version": "2.0.0",
"client_name": "vibe_ide",
"client_version": "0.5.0",
+ "session_id": agent.session_id,
}
diff --git a/tests/test_agent_observer_streaming.py b/tests/test_agent_observer_streaming.py
index 19632cf..926adef 100644
--- a/tests/test_agent_observer_streaming.py
+++ b/tests/test_agent_observer_streaming.py
@@ -7,6 +7,7 @@ from typing import cast
from unittest.mock import AsyncMock, Mock
import httpx
+from pydantic import BaseModel
import pytest
from tests.conftest import build_test_agent_loop, build_test_vibe_config
@@ -395,12 +396,16 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
)
middleware = CountingMiddleware()
agent.middleware_pipeline.add(middleware)
- agent.set_approval_callback(
- lambda _name, _args, _id: (
+
+ async def _reject_callback(
+ _name: str, _args: BaseModel, _id: str
+ ) -> tuple[ApprovalResponse, str | None]:
+ return (
ApprovalResponse.NO,
str(get_user_cancellation_message(CancellationReason.OPERATION_CANCELLED)),
)
- )
+
+ agent.set_approval_callback(_reject_callback)
agent.session_logger.save_interaction = AsyncMock(return_value=None)
events = [event async for event in agent.act("Cancel mid stream?")]
@@ -418,6 +423,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
assert events[-1].skipped is True
assert events[-1].skip_reason is not None
assert "" in events[-1].skip_reason
+ assert events[-1].cancelled is True
assert agent.session_logger.save_interaction.await_count >= 1
diff --git a/tests/test_agent_tool_call.py b/tests/test_agent_tool_call.py
index aa4b2c3..0f403f6 100644
--- a/tests/test_agent_tool_call.py
+++ b/tests/test_agent_tool_call.py
@@ -16,13 +16,13 @@ from vibe.core.config import VibeConfig
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tools.builtins.todo import TodoItem
from vibe.core.types import (
+ ApprovalCallback,
ApprovalResponse,
AssistantEvent,
BaseEvent,
FunctionCall,
LLMMessage,
Role,
- SyncApprovalCallback,
ToolCall,
ToolCallEvent,
ToolResultEvent,
@@ -58,7 +58,7 @@ def make_agent_loop(
auto_approve: bool = True,
todo_permission: ToolPermission = ToolPermission.ALWAYS,
backend: FakeBackend,
- approval_callback: SyncApprovalCallback | None = None,
+ approval_callback: ApprovalCallback | None = None,
) -> AgentLoop:
agent_name = (
BuiltinAgentName.AUTO_APPROVE if auto_approve else BuiltinAgentName.DEFAULT
@@ -165,7 +165,7 @@ async def test_tool_call_requires_approval_if_not_auto_approved(
@pytest.mark.asyncio
async def test_tool_call_approved_by_callback(telemetry_events: list[dict]) -> None:
- def approval_callback(
+ async def approval_callback(
_tool_name: str, _args: BaseModel, _tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
return (ApprovalResponse.YES, None)
@@ -209,7 +209,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
) -> None:
custom_feedback = "User declined tool execution"
- def approval_callback(
+ async def approval_callback(
_tool_name: str, _args: BaseModel, _tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
return (ApprovalResponse.NO, custom_feedback)
@@ -237,6 +237,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
assert events[3].error is None
assert events[3].result is None
assert events[3].skip_reason == custom_feedback
+ assert events[3].cancelled is False
assert agent_loop.stats.tool_calls_rejected == 1
assert agent_loop.stats.tool_calls_agreed == 0
assert agent_loop.stats.tool_calls_succeeded == 0
@@ -297,7 +298,7 @@ async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> No
callback_invocations = []
agent_ref: AgentLoop | None = None
- def approval_callback(
+ async def approval_callback(
tool_name: str, _args: BaseModel, _tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
callback_invocations.append(tool_name)
@@ -441,9 +442,9 @@ async def test_tool_call_with_exceeding_max_todos() -> None:
async def test_tool_call_can_be_interrupted() -> None:
"""Test that tool calls can be interrupted via asyncio.CancelledError.
- Note: KeyboardInterrupt is no longer handled here as ctrl+C now quits the app directly.
+ When a tool raises CancelledError, the error is captured as a cancellation event
+ and the agent loop stops gracefully after the current tool batch completes.
"""
- exception_class = asyncio.CancelledError
tool_call = ToolCall(
id="call_8", index=0, function=FunctionCall(name="stub_tool", arguments="{}")
)
@@ -460,12 +461,11 @@ async def test_tool_call_can_be_interrupted() -> None:
agent_loop.tool_manager._available["stub_tool"] = FakeTool
stub_tool_instance = agent_loop.tool_manager.get("stub_tool")
assert isinstance(stub_tool_instance, FakeTool)
- stub_tool_instance._exception_to_raise = exception_class()
+ stub_tool_instance._exception_to_raise = asyncio.CancelledError()
events: list[BaseEvent] = []
- with pytest.raises(exception_class):
- async for ev in agent_loop.act("Execute tool"):
- events.append(ev)
+ async for ev in agent_loop.act("Execute tool"):
+ events.append(ev)
tool_result_event = next(
(e for e in events if isinstance(e, ToolResultEvent)), None
@@ -473,6 +473,11 @@ async def test_tool_call_can_be_interrupted() -> None:
assert tool_result_event is not None
assert tool_result_event.error is not None
assert "execution interrupted by user" in tool_result_event.error.lower()
+ assert agent_loop.stats.tool_calls_failed == 1
+
+ # Agent loop should stop after cancellation — no second LLM turn
+ assistant_events = [e for e in events if isinstance(e, AssistantEvent)]
+ assert len(assistant_events) == 1
@pytest.mark.asyncio
@@ -529,3 +534,353 @@ async def test_ensure_assistant_after_tool_appends_understood() -> None:
idx = next(i for i, m in enumerate(agent_loop.messages) if m.role == Role.tool)
assert agent_loop.messages[idx + 1].role == Role.assistant
assert agent_loop.messages[idx + 1].content == "Understood."
+
+
+@pytest.mark.asyncio
+async def test_parallel_tool_calls_produce_correct_events(
+ telemetry_events: list[dict],
+) -> None:
+ """Two tool calls in one LLM response should execute in parallel and produce correct events."""
+ tool_call_1 = make_todo_tool_call("call_p1", index=0)
+ tool_call_2 = make_todo_tool_call("call_p2", index=1)
+ backend = FakeBackend([
+ [
+ mock_llm_chunk(
+ content="Let me check two things.",
+ tool_calls=[tool_call_1, tool_call_2],
+ )
+ ],
+ [mock_llm_chunk(content="Both done.")],
+ ])
+ agent_loop = make_agent_loop(auto_approve=True, backend=backend)
+
+ events = await act_and_collect_events(agent_loop, "Check two things")
+
+ event_types = [type(e) for e in events]
+ # UserMessage, Assistant, ToolCall, ToolCall, then two ToolResults (order may vary), then Assistant
+ assert event_types[0] is UserMessageEvent
+ assert event_types[1] is AssistantEvent
+ # Both ToolCallEvents emitted upfront
+ assert event_types[2] is ToolCallEvent
+ assert event_types[3] is ToolCallEvent
+ tool_call_events = [e for e in events if isinstance(e, ToolCallEvent)]
+ assert {e.tool_call_id for e in tool_call_events} == {"call_p1", "call_p2"}
+ # Both ToolResultEvents present
+ tool_result_events = [e for e in events if isinstance(e, ToolResultEvent)]
+ assert len(tool_result_events) == 2
+ assert {e.tool_call_id for e in tool_result_events} == {"call_p1", "call_p2"}
+ for tool_result in tool_result_events:
+ assert tool_result.error is None
+ assert tool_result.skipped is False
+ assert tool_result.result is not None
+ # Final assistant message
+ assert event_types[-1] is AssistantEvent
+ assert isinstance(events[-1], AssistantEvent)
+ assert events[-1].content == "Both done."
+ # Verify conversation history has both tool responses
+ tool_msgs = [m for m in agent_loop.messages if m.role == Role.tool]
+ assert {m.tool_call_id for m in tool_msgs} == {"call_p1", "call_p2"}
+ assert agent_loop.stats.tool_calls_succeeded == 2
+
+ tool_finished = [
+ e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
+ ]
+ assert len(tool_finished) == 2
+
+
+@pytest.mark.asyncio
+async def test_parallel_tool_calls_with_approval_callback(
+ telemetry_events: list[dict],
+) -> None:
+ """Two parallel tool calls requiring approval should both succeed when approved."""
+ approval_calls: list[str] = []
+
+ async def approval_callback(
+ tool_name: str, _args: BaseModel, tool_call_id: str
+ ) -> tuple[ApprovalResponse, str | None]:
+ approval_calls.append(tool_call_id)
+ return (ApprovalResponse.YES, None)
+
+ tool_call_1 = make_todo_tool_call("call_a1", index=0)
+ tool_call_2 = make_todo_tool_call("call_a2", index=1)
+ agent_loop = make_agent_loop(
+ auto_approve=False,
+ todo_permission=ToolPermission.ASK,
+ approval_callback=approval_callback,
+ backend=FakeBackend([
+ [
+ mock_llm_chunk(
+ content="Checking two.", tool_calls=[tool_call_1, tool_call_2]
+ )
+ ],
+ [mock_llm_chunk(content="Both approved and done.")],
+ ]),
+ )
+
+ events = await act_and_collect_events(agent_loop, "Check two things")
+
+ # Both should have been approved
+ assert set(approval_calls) == {"call_a1", "call_a2"}
+ tool_result_events = [e for e in events if isinstance(e, ToolResultEvent)]
+ assert len(tool_result_events) == 2
+ for tool_result in tool_result_events:
+ assert tool_result.error is None
+ assert tool_result.skipped is False
+ assert tool_result.result is not None
+ assert agent_loop.stats.tool_calls_agreed == 2
+ assert agent_loop.stats.tool_calls_succeeded == 2
+
+
+@pytest.mark.asyncio
+async def test_parallel_approvals_can_run_concurrently() -> None:
+ """The core does not serialize approval callbacks — that is a CLI-layer concern.
+ Parallel tool calls may invoke the approval callback concurrently.
+ """
+ concurrency = 0
+ max_concurrency = 0
+
+ async def approval_callback(
+ tool_name: str, _args: BaseModel, tool_call_id: str
+ ) -> tuple[ApprovalResponse, str | None]:
+ nonlocal concurrency, max_concurrency
+ concurrency += 1
+ max_concurrency = max(max_concurrency, concurrency)
+ await asyncio.sleep(0.01)
+ concurrency -= 1
+ return (ApprovalResponse.YES, None)
+
+ tool_calls = [make_todo_tool_call(f"call_s{i}", index=i) for i in range(3)]
+ agent_loop = make_agent_loop(
+ auto_approve=False,
+ todo_permission=ToolPermission.ASK,
+ approval_callback=approval_callback,
+ backend=FakeBackend([
+ [mock_llm_chunk(content="Three tools.", tool_calls=tool_calls)],
+ [mock_llm_chunk(content="All done.")],
+ ]),
+ )
+
+ await act_and_collect_events(agent_loop, "Go")
+
+ assert max_concurrency > 1
+ assert agent_loop.stats.tool_calls_agreed == 3
+ assert agent_loop.stats.tool_calls_succeeded == 3
+
+
+@pytest.mark.asyncio
+async def test_parallel_mixed_approval_and_rejection(
+ telemetry_events: list[dict],
+) -> None:
+ """One tool approved, one rejected — both should produce correct events."""
+
+ async def approval_callback(
+ tool_name: str, _args: BaseModel, tool_call_id: str
+ ) -> tuple[ApprovalResponse, str | None]:
+ if tool_call_id == "call_yes":
+ return (ApprovalResponse.YES, None)
+ return (ApprovalResponse.NO, "Denied by user")
+
+ tc_yes = make_todo_tool_call("call_yes", index=0)
+ tc_no = make_todo_tool_call("call_no", index=1)
+ agent_loop = make_agent_loop(
+ auto_approve=False,
+ todo_permission=ToolPermission.ASK,
+ approval_callback=approval_callback,
+ backend=FakeBackend([
+ [mock_llm_chunk(content="Two tools.", tool_calls=[tc_yes, tc_no])],
+ [mock_llm_chunk(content="Mixed results.")],
+ ]),
+ )
+
+ events = await act_and_collect_events(agent_loop, "Go")
+
+ tool_results = [e for e in events if isinstance(e, ToolResultEvent)]
+ assert len(tool_results) == 2
+ results_by_id = {e.tool_call_id: e for e in tool_results}
+ assert results_by_id["call_yes"].error is None
+ assert results_by_id["call_yes"].skipped is False
+ assert results_by_id["call_yes"].result is not None
+ assert results_by_id["call_no"].skipped is True
+ assert results_by_id["call_no"].skip_reason == "Denied by user"
+ assert results_by_id["call_no"].cancelled is False
+ assert agent_loop.stats.tool_calls_agreed == 1
+ assert agent_loop.stats.tool_calls_rejected == 1
+ assert agent_loop.stats.tool_calls_succeeded == 1
+
+ tool_finished = [
+ e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
+ ]
+ assert len(tool_finished) == 2
+
+
+@pytest.mark.asyncio
+async def test_parallel_three_tools_all_succeed(telemetry_events: list[dict]) -> None:
+ """Three parallel tool calls should all complete successfully."""
+ tool_calls = [make_todo_tool_call(f"call_t{i}", index=i) for i in range(3)]
+ agent_loop = make_agent_loop(
+ auto_approve=True,
+ backend=FakeBackend([
+ [mock_llm_chunk(content="Three tools.", tool_calls=tool_calls)],
+ [mock_llm_chunk(content="All three done.")],
+ ]),
+ )
+
+ events = await act_and_collect_events(agent_loop, "Go")
+
+ tool_call_events = [e for e in events if isinstance(e, ToolCallEvent)]
+ tool_result_events = [e for e in events if isinstance(e, ToolResultEvent)]
+ assert len(tool_call_events) == 3
+ assert len(tool_result_events) == 3
+ assert {e.tool_call_id for e in tool_call_events} == {
+ "call_t0",
+ "call_t1",
+ "call_t2",
+ }
+ assert {e.tool_call_id for e in tool_result_events} == {
+ "call_t0",
+ "call_t1",
+ "call_t2",
+ }
+ for tool_result in tool_result_events:
+ assert tool_result.error is None
+ assert tool_result.result is not None
+ assert agent_loop.stats.tool_calls_succeeded == 3
+ tool_msgs = [m for m in agent_loop.messages if m.role == Role.tool]
+ assert len(tool_msgs) == 3
+
+ tool_finished = [
+ e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
+ ]
+ assert len(tool_finished) == 3
+
+
+@pytest.mark.asyncio
+async def test_parallel_one_tool_error_does_not_block_others() -> None:
+ """If one parallel tool fails, the other should still succeed."""
+ tc_good = make_todo_tool_call("call_good", index=0)
+ tc_bad = make_todo_tool_call(
+ "call_bad", index=1, arguments='{"action": "invalid_action"}'
+ )
+ agent_loop = make_agent_loop(
+ auto_approve=True,
+ backend=FakeBackend([
+ [mock_llm_chunk(content="Two tools.", tool_calls=[tc_good, tc_bad])],
+ [mock_llm_chunk(content="Done.")],
+ ]),
+ )
+
+ events = await act_and_collect_events(agent_loop, "Go")
+
+ tool_results = [e for e in events if isinstance(e, ToolResultEvent)]
+ assert len(tool_results) == 2
+ results_by_id = {e.tool_call_id: e for e in tool_results}
+ assert results_by_id["call_good"].error is None
+ assert results_by_id["call_good"].result is not None
+ assert results_by_id["call_bad"].error is not None
+ assert results_by_id["call_bad"].result is None
+ assert agent_loop.stats.tool_calls_succeeded == 1
+ assert agent_loop.stats.tool_calls_failed == 1
+
+
+@pytest.mark.asyncio
+async def test_parallel_all_rejected_no_callback() -> None:
+ """Parallel tools with no approval callback should all be skipped."""
+ tc1 = make_todo_tool_call("call_nc1", index=0)
+ tc2 = make_todo_tool_call("call_nc2", index=1)
+ agent_loop = make_agent_loop(
+ auto_approve=False,
+ todo_permission=ToolPermission.ASK,
+ backend=FakeBackend([
+ [mock_llm_chunk(content="Two tools.", tool_calls=[tc1, tc2])],
+ [mock_llm_chunk(content="Cannot proceed.")],
+ ]),
+ )
+
+ events = await act_and_collect_events(agent_loop, "Go")
+
+ tool_results = [e for e in events if isinstance(e, ToolResultEvent)]
+ assert len(tool_results) == 2
+ for tool_result in tool_results:
+ assert tool_result.skipped is True
+ assert tool_result.result is None
+ assert agent_loop.stats.tool_calls_rejected == 2
+ assert agent_loop.stats.tool_calls_succeeded == 0
+
+
+@pytest.mark.asyncio
+async def test_parallel_all_permission_never() -> None:
+ """Parallel tools with NEVER permission skip without calling the approval callback."""
+ approval_calls: list[str] = []
+
+ async def approval_callback(
+ tool_name: str, _args: BaseModel, tool_call_id: str
+ ) -> tuple[ApprovalResponse, str | None]:
+ approval_calls.append(tool_call_id)
+ return (ApprovalResponse.YES, None)
+
+ tc1 = make_todo_tool_call("call_nv1", index=0)
+ tc2 = make_todo_tool_call("call_nv2", index=1)
+ agent_loop = make_agent_loop(
+ auto_approve=False,
+ todo_permission=ToolPermission.NEVER,
+ approval_callback=approval_callback,
+ backend=FakeBackend([
+ [mock_llm_chunk(content="Two tools.", tool_calls=[tc1, tc2])],
+ [mock_llm_chunk(content="Both disabled.")],
+ ]),
+ )
+
+ events = await act_and_collect_events(agent_loop, "Go")
+
+ assert len(approval_calls) == 0
+ tool_results = [e for e in events if isinstance(e, ToolResultEvent)]
+ assert len(tool_results) == 2
+ for tool_result in tool_results:
+ assert tool_result.skipped is True
+ assert "permanently disabled" in (tool_result.skip_reason or "").lower()
+ assert agent_loop.stats.tool_calls_rejected == 2
+
+
+@pytest.mark.asyncio
+async def test_parallel_tool_call_events_emitted_before_results() -> None:
+ """All ToolCallEvents must appear before any ToolResultEvent in the event stream."""
+ tool_calls = [make_todo_tool_call(f"call_o{i}", index=i) for i in range(3)]
+ agent_loop = make_agent_loop(
+ auto_approve=True,
+ backend=FakeBackend([
+ [mock_llm_chunk(content="Three tools.", tool_calls=tool_calls)],
+ [mock_llm_chunk(content="Done.")],
+ ]),
+ )
+
+ events = await act_and_collect_events(agent_loop, "Go")
+
+ last_call_idx = max(i for i, e in enumerate(events) if isinstance(e, ToolCallEvent))
+ first_result_idx = min(
+ i for i, e in enumerate(events) if isinstance(e, ToolResultEvent)
+ )
+ assert last_call_idx < first_result_idx
+
+
+@pytest.mark.asyncio
+async def test_parallel_conversation_history_has_all_tool_messages() -> None:
+ """All parallel tool results must appear in the conversation messages."""
+ tool_calls = [make_todo_tool_call(f"call_h{i}", index=i) for i in range(4)]
+ agent_loop = make_agent_loop(
+ auto_approve=True,
+ backend=FakeBackend([
+ [mock_llm_chunk(content="Four tools.", tool_calls=tool_calls)],
+ [mock_llm_chunk(content="All four done.")],
+ ]),
+ )
+
+ await act_and_collect_events(agent_loop, "Go")
+
+ tool_msgs = [m for m in agent_loop.messages if m.role == Role.tool]
+ assert {m.tool_call_id for m in tool_msgs} == {
+ "call_h0",
+ "call_h1",
+ "call_h2",
+ "call_h3",
+ }
+ assert agent_loop.stats.tool_calls_succeeded == 4
diff --git a/tests/test_agents.py b/tests/test_agents.py
index 7867f51..7496ec7 100644
--- a/tests/test_agents.py
+++ b/tests/test_agents.py
@@ -155,6 +155,7 @@ class TestAgentProfile:
BuiltinAgentName.PLAN,
BuiltinAgentName.ACCEPT_EDITS,
BuiltinAgentName.AUTO_APPROVE,
+ BuiltinAgentName.LEAN,
}
@@ -536,6 +537,26 @@ class TestAgentManagerFiltering:
assert "auto-approve" in agents
assert "explore" in agents
+ def test_install_required_agents_hidden_by_default(self) -> None:
+ config = build_test_vibe_config(
+ include_project_context=False, include_prompt_detail=False
+ )
+ manager = AgentManager(lambda: config)
+
+ agents = manager.available_agents
+ assert "lean" not in agents
+
+ def test_install_required_agents_visible_when_installed(self) -> None:
+ config = build_test_vibe_config(
+ include_project_context=False,
+ include_prompt_detail=False,
+ installed_agents=["lean"],
+ )
+ manager = AgentManager(lambda: config)
+
+ agents = manager.available_agents
+ assert "lean" in agents
+
def test_get_subagents_respects_filtering(self) -> None:
config = build_test_vibe_config(
include_project_context=False,
diff --git a/tests/test_history_manager.py b/tests/test_history_manager.py
index 71d5545..32aa2b9 100644
--- a/tests/test_history_manager.py
+++ b/tests/test_history_manager.py
@@ -19,7 +19,7 @@ def test_history_manager_normalizes_loaded_entries_like_numbers_to_strings(
)
manager = HistoryManager(history_file)
- result = manager.get_previous(current_input="", prefix="1")
+ result = manager.get_previous(current_input="")
assert result == "123"
@@ -35,11 +35,11 @@ def test_history_manager_retains_a_fixed_number_of_entries(tmp_path: Path) -> No
reloaded = HistoryManager(history_file)
- assert reloaded.get_previous(current_input="", prefix="") == "fourth"
- assert reloaded.get_previous(current_input="", prefix="") == "third"
- assert reloaded.get_previous(current_input="", prefix="") == "second"
+ assert reloaded.get_previous(current_input="") == "fourth"
+ assert reloaded.get_previous(current_input="") == "third"
+ assert reloaded.get_previous(current_input="") == "second"
# "first" is not proposed as we defined number of entries to 3
- assert reloaded.get_previous(current_input="", prefix="") is None
+ assert reloaded.get_previous(current_input="") is None
def test_history_manager_filters_invalid_and_duplicated_entries(tmp_path: Path) -> None:
@@ -54,11 +54,11 @@ def test_history_manager_filters_invalid_and_duplicated_entries(tmp_path: Path)
reloaded = HistoryManager(history_file)
- assert reloaded.get_previous(current_input="", prefix="") == "third"
- assert reloaded.get_previous(current_input="", prefix="") == "second"
- assert reloaded.get_previous(current_input="", prefix="") == "first"
- assert reloaded.get_previous(current_input="", prefix="") is None
- assert reloaded.get_previous(current_input="", prefix="") is None
+ assert reloaded.get_previous(current_input="") == "third"
+ assert reloaded.get_previous(current_input="") == "second"
+ assert reloaded.get_previous(current_input="") == "first"
+ assert reloaded.get_previous(current_input="") is None
+ assert reloaded.get_previous(current_input="") is None
def test_history_manager_filters_commands(tmp_path: Path) -> None:
@@ -69,9 +69,8 @@ def test_history_manager_filters_commands(tmp_path: Path) -> None:
reloaded = HistoryManager(history_file)
- assert reloaded.get_previous(current_input="", prefix="/") == None
- assert reloaded.get_previous(current_input="", prefix="") == "first"
- assert reloaded.get_previous(current_input="", prefix="") is None
+ assert reloaded.get_previous(current_input="") == "first"
+ assert reloaded.get_previous(current_input="") is None
def test_history_manager_allows_navigation_round_trip(tmp_path: Path) -> None:
@@ -88,7 +87,9 @@ def test_history_manager_allows_navigation_round_trip(tmp_path: Path) -> None:
assert manager.get_next() is None
-def test_history_manager_prefix_filtering(tmp_path: Path) -> None:
+def test_history_manager_preserves_original_draft_during_navigation(
+ tmp_path: Path,
+) -> None:
history_file = tmp_path / "history.jsonl"
manager = HistoryManager(history_file)
@@ -96,6 +97,7 @@ def test_history_manager_prefix_filtering(tmp_path: Path) -> None:
manager.add("bar")
manager.add("fizz")
- assert manager.get_previous(current_input="", prefix="f") == "fizz"
- assert manager.get_previous(current_input="", prefix="f") == "foo"
- assert manager.get_previous(current_input="", prefix="f") is None
+ assert manager.get_previous(current_input="draft") == "fizz"
+ assert manager.get_previous(current_input="overwritten draft") == "bar"
+ assert manager.get_next() == "fizz"
+ assert manager.get_next() == "draft"
diff --git a/tests/test_reasoning_content.py b/tests/test_reasoning_content.py
index 17af062..46f6772 100644
--- a/tests/test_reasoning_content.py
+++ b/tests/test_reasoning_content.py
@@ -3,7 +3,12 @@ from __future__ import annotations
from unittest.mock import MagicMock
import httpx
-import mistralai
+from mistralai.client.models import (
+ AssistantMessage,
+ ContentChunk,
+ TextChunk,
+ ThinkChunk,
+)
import pytest
import respx
@@ -38,8 +43,8 @@ class TestMistralMapperParseContent:
def test_parse_content_text_chunk_returns_content_only(self):
mapper = MistralMapper()
- content: list[mistralai.ContentChunk] = [
- mistralai.TextChunk(type="text", text="Hello from text chunk")
+ content: list[ContentChunk] = [
+ TextChunk(type="text", text="Hello from text chunk")
]
result = mapper.parse_content(content)
@@ -50,12 +55,12 @@ class TestMistralMapperParseContent:
def test_parse_content_thinking_chunk_extracts_reasoning(self):
mapper = MistralMapper()
- content: list[mistralai.ContentChunk] = [
- mistralai.ThinkChunk(
+ content: list[ContentChunk] = [
+ ThinkChunk(
type="thinking",
- thinking=[mistralai.TextChunk(type="text", text="Let me think...")],
+ thinking=[TextChunk(type="text", text="Let me think...")],
),
- mistralai.TextChunk(type="text", text="The answer is 42."),
+ TextChunk(type="text", text="The answer is 42."),
]
result = mapper.parse_content(content)
@@ -66,16 +71,16 @@ class TestMistralMapperParseContent:
def test_parse_content_multiple_thinking_chunks_concatenates(self):
mapper = MistralMapper()
- content: list[mistralai.ContentChunk] = [
- mistralai.ThinkChunk(
+ content: list[ContentChunk] = [
+ ThinkChunk(
type="thinking",
- thinking=[mistralai.TextChunk(type="text", text="First thought. ")],
+ thinking=[TextChunk(type="text", text="First thought. ")],
),
- mistralai.ThinkChunk(
+ ThinkChunk(
type="thinking",
- thinking=[mistralai.TextChunk(type="text", text="Second thought.")],
+ thinking=[TextChunk(type="text", text="Second thought.")],
),
- mistralai.TextChunk(type="text", text="Final answer."),
+ TextChunk(type="text", text="Final answer."),
]
result = mapper.parse_content(content)
@@ -86,10 +91,10 @@ class TestMistralMapperParseContent:
def test_parse_content_thinking_only_returns_empty_content(self):
mapper = MistralMapper()
- content: list[mistralai.ContentChunk] = [
- mistralai.ThinkChunk(
+ content: list[ContentChunk] = [
+ ThinkChunk(
type="thinking",
- thinking=[mistralai.TextChunk(type="text", text="Just thinking...")],
+ thinking=[TextChunk(type="text", text="Just thinking...")],
)
]
@@ -99,7 +104,7 @@ class TestMistralMapperParseContent:
def test_parse_content_empty_list_returns_empty(self):
mapper = MistralMapper()
- content: list[mistralai.ContentChunk] = []
+ content: list[ContentChunk] = []
result = mapper.parse_content(content)
@@ -113,7 +118,7 @@ class TestMistralMapperPrepareMessage:
result = mapper.prepare_message(msg)
- assert isinstance(result, mistralai.AssistantMessage)
+ assert isinstance(result, AssistantMessage)
assert result.content == "Hello!"
def test_prepare_assistant_message_with_reasoning_creates_chunks(self):
@@ -126,20 +131,20 @@ class TestMistralMapperPrepareMessage:
result = mapper.prepare_message(msg)
- assert isinstance(result, mistralai.AssistantMessage)
+ assert isinstance(result, AssistantMessage)
assert isinstance(result.content, list)
assert len(result.content) == 2
think_chunk = result.content[0]
- assert isinstance(think_chunk, mistralai.ThinkChunk)
+ assert isinstance(think_chunk, ThinkChunk)
assert think_chunk.type == "thinking"
assert len(think_chunk.thinking) == 1
inner_chunk = think_chunk.thinking[0]
- assert isinstance(inner_chunk, mistralai.TextChunk)
+ assert isinstance(inner_chunk, TextChunk)
assert inner_chunk.text == "Let me calculate..."
text_chunk = result.content[1]
- assert isinstance(text_chunk, mistralai.TextChunk)
+ assert isinstance(text_chunk, TextChunk)
assert text_chunk.type == "text"
assert text_chunk.text == "The answer is 42."
@@ -151,20 +156,20 @@ class TestMistralMapperPrepareMessage:
result = mapper.prepare_message(msg)
- assert isinstance(result, mistralai.AssistantMessage)
+ assert isinstance(result, AssistantMessage)
assert isinstance(result.content, list)
assert len(result.content) == 2
think_chunk = result.content[0]
- assert isinstance(think_chunk, mistralai.ThinkChunk)
+ assert isinstance(think_chunk, ThinkChunk)
assert think_chunk.type == "thinking"
assert len(think_chunk.thinking) == 1
inner_chunk = think_chunk.thinking[0]
- assert isinstance(inner_chunk, mistralai.TextChunk)
+ assert isinstance(inner_chunk, TextChunk)
assert inner_chunk.text == "Just thinking..."
text_chunk = result.content[1]
- assert isinstance(text_chunk, mistralai.TextChunk)
+ assert isinstance(text_chunk, TextChunk)
assert text_chunk.text == ""
diff --git a/tests/test_ui_input_history.py b/tests/test_ui_input_history.py
index ffa07a6..79b8d27 100644
--- a/tests/test_ui_input_history.py
+++ b/tests/test_ui_input_history.py
@@ -53,6 +53,23 @@ async def test_ui_navigation_through_input_history(
assert chat_input.value == ""
+@pytest.mark.asyncio
+async def test_ui_navigation_restores_partially_typed_draft_after_round_trip(
+ vibe_app: VibeApp, history_file: Path
+) -> None:
+ async with vibe_app.run_test() as pilot:
+ inject_history_file(vibe_app, history_file)
+ chat_input = vibe_app.query_one(ChatInputContainer)
+
+ await pilot.press(*"he")
+ assert chat_input.value == "he"
+
+ await pilot.press("up")
+ assert chat_input.value == "how are you?"
+ await pilot.press("down")
+ assert chat_input.value == "he"
+
+
@pytest.mark.asyncio
async def test_ui_does_nothing_if_command_completion_is_active(
vibe_app: VibeApp, history_file: Path
diff --git a/tests/tools/test_bash.py b/tests/tools/test_bash.py
index 438edf2..de316a5 100644
--- a/tests/tools/test_bash.py
+++ b/tests/tools/test_bash.py
@@ -76,6 +76,13 @@ async def test_decodes_non_utf8_bytes(bash):
assert result.stderr == ""
+def test_find_not_in_default_allowlist():
+ bash_tool = Bash(config=BashToolConfig(), state=BaseToolState())
+ # find -exec runs arbitrary commands; must not be allowlisted by default
+ permission = bash_tool.resolve_permission(BashArgs(command="find . -exec id \\;"))
+ assert permission is not ToolPermission.ALWAYS
+
+
def test_resolve_permission():
config = BashToolConfig(allowlist=["echo", "pwd"], denylist=["rm"])
bash_tool = Bash(config=config, state=BaseToolState())
@@ -89,3 +96,92 @@ def test_resolve_permission():
assert denylisted is ToolPermission.NEVER
assert mixed is None
assert empty is None
+
+
+class TestResolvePermissionWindowsSyntax:
+ """Verify allowlist/denylist works with Windows-style commands."""
+
+ def _make_bash(self, **kwargs) -> Bash:
+ config = BashToolConfig(**kwargs)
+ return Bash(config=config, state=BaseToolState())
+
+ def test_dir_with_windows_flags_allowlisted(self):
+ bash_tool = self._make_bash(allowlist=["dir"])
+ result = bash_tool.resolve_permission(BashArgs(command="dir /s /b"))
+ assert result is ToolPermission.ALWAYS
+
+ def test_type_command_allowlisted(self):
+ bash_tool = self._make_bash(allowlist=["type"])
+ result = bash_tool.resolve_permission(BashArgs(command="type file.txt"))
+ assert result is ToolPermission.ALWAYS
+
+ def test_findstr_allowlisted(self):
+ bash_tool = self._make_bash(allowlist=["findstr"])
+ result = bash_tool.resolve_permission(
+ BashArgs(command="findstr /s pattern *.txt")
+ )
+ assert result is ToolPermission.ALWAYS
+
+ def test_ver_allowlisted(self):
+ bash_tool = self._make_bash(allowlist=["ver"])
+ result = bash_tool.resolve_permission(BashArgs(command="ver"))
+ assert result is ToolPermission.ALWAYS
+
+ def test_where_allowlisted(self):
+ bash_tool = self._make_bash(allowlist=["where"])
+ result = bash_tool.resolve_permission(BashArgs(command="where python"))
+ assert result is ToolPermission.ALWAYS
+
+ def test_cmd_k_denylisted(self):
+ bash_tool = self._make_bash(denylist=["cmd /k"])
+ result = bash_tool.resolve_permission(BashArgs(command="cmd /k something"))
+ assert result is ToolPermission.NEVER
+
+ def test_powershell_noexit_denylisted(self):
+ bash_tool = self._make_bash(denylist=["powershell -NoExit"])
+ result = bash_tool.resolve_permission(BashArgs(command="powershell -NoExit"))
+ assert result is ToolPermission.NEVER
+
+ def test_notepad_denylisted(self):
+ bash_tool = self._make_bash(denylist=["notepad"])
+ result = bash_tool.resolve_permission(BashArgs(command="notepad file.txt"))
+ assert result is ToolPermission.NEVER
+
+ def test_cmd_standalone_denylisted(self):
+ bash_tool = self._make_bash(denylist_standalone=["cmd"])
+ result = bash_tool.resolve_permission(BashArgs(command="cmd"))
+ assert result is ToolPermission.NEVER
+
+ def test_powershell_standalone_denylisted(self):
+ bash_tool = self._make_bash(denylist_standalone=["powershell"])
+ result = bash_tool.resolve_permission(BashArgs(command="powershell"))
+ assert result is ToolPermission.NEVER
+
+ def test_powershell_cmdlet_asks(self):
+ bash_tool = self._make_bash(allowlist=["dir", "echo"])
+ result = bash_tool.resolve_permission(BashArgs(command="Get-ChildItem -Path ."))
+ assert result is None
+
+ def test_mixed_allowed_and_unknown_asks(self):
+ bash_tool = self._make_bash(allowlist=["git status"])
+ result = bash_tool.resolve_permission(
+ BashArgs(command="git status && npm install")
+ )
+ assert result is None
+
+ def test_chained_windows_commands_all_allowed(self):
+ bash_tool = self._make_bash(allowlist=["dir", "echo"])
+ result = bash_tool.resolve_permission(BashArgs(command="dir /s && echo done"))
+ assert result is ToolPermission.ALWAYS
+
+ def test_chained_commands_one_denied(self):
+ bash_tool = self._make_bash(allowlist=["dir"], denylist=["rm"])
+ result = bash_tool.resolve_permission(BashArgs(command="dir /s && rm -rf /"))
+ assert result is ToolPermission.NEVER
+
+ def test_piped_windows_commands(self):
+ bash_tool = self._make_bash(allowlist=["findstr", "type"])
+ result = bash_tool.resolve_permission(
+ BashArgs(command="type file.txt | findstr pattern")
+ )
+ assert result is ToolPermission.ALWAYS
diff --git a/tests/tools/test_invoke_context.py b/tests/tools/test_invoke_context.py
index 28e0f17..dbf44b2 100644
--- a/tests/tools/test_invoke_context.py
+++ b/tests/tools/test_invoke_context.py
@@ -45,7 +45,7 @@ class TestInvokeContext:
assert ctx.approval_callback is None
def test_approval_callback_can_be_set(self) -> None:
- def dummy_callback(
+ async def dummy_callback(
_tool_name: str, _args: BaseModel, _tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
return ApprovalResponse.YES, None
@@ -75,7 +75,7 @@ class TestToolInvokeWithContext:
@pytest.mark.asyncio
async def test_invoke_with_approval_callback(self, simple_tool: SimpleTool) -> None:
- def dummy_callback(
+ async def dummy_callback(
_tool_name: str, _args: BaseModel, _tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
return ApprovalResponse.YES, None
diff --git a/tests/tools/test_websearch.py b/tests/tools/test_websearch.py
index 67731f5..7a30196 100644
--- a/tests/tools/test_websearch.py
+++ b/tests/tools/test_websearch.py
@@ -2,7 +2,15 @@ from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
-import mistralai
+from mistralai.client import Mistral
+from mistralai.client.errors import SDKError
+from mistralai.client.models import (
+ ConversationResponse,
+ ConversationUsageInfo,
+ MessageOutputEntry,
+ TextChunk,
+ ToolReferenceChunk,
+)
import pytest
from tests.mock.utils import collect_result
@@ -13,13 +21,13 @@ from vibe.core.tools.builtins.websearch import WebSearch, WebSearchArgs, WebSear
def _make_response(
content: list | None = None, outputs: list | None = None
-) -> mistralai.ConversationResponse:
+) -> ConversationResponse:
if outputs is None:
- outputs = [mistralai.MessageOutputEntry(content=content or [])]
- return mistralai.ConversationResponse(
+ outputs = [MessageOutputEntry(content=content or [])]
+ return ConversationResponse(
conversation_id="test",
outputs=outputs,
- usage=mistralai.ConversationUsageInfo(
+ usage=ConversationUsageInfo(
prompt_tokens=10, completion_tokens=20, total_tokens=30
),
)
@@ -34,7 +42,7 @@ def websearch(monkeypatch):
def test_parse_text_chunks(websearch):
response = _make_response(
- content=[mistralai.TextChunk(text="Hello "), mistralai.TextChunk(text="world")]
+ content=[TextChunk(text="Hello "), TextChunk(text="world")]
)
result = websearch._parse_response(response)
assert result.answer == "Hello world"
@@ -44,16 +52,12 @@ def test_parse_text_chunks(websearch):
def test_parse_sources_deduped(websearch):
response = _make_response(
content=[
- mistralai.TextChunk(text="Answer"),
- mistralai.ToolReferenceChunk(
- tool="web_search", title="Site A", url="https://a.com"
- ),
- mistralai.ToolReferenceChunk(
+ TextChunk(text="Answer"),
+ ToolReferenceChunk(tool="web_search", title="Site A", url="https://a.com"),
+ ToolReferenceChunk(
tool="web_search", title="Site A duplicate", url="https://a.com"
),
- mistralai.ToolReferenceChunk(
- tool="web_search", title="Site B", url="https://b.com"
- ),
+ ToolReferenceChunk(tool="web_search", title="Site B", url="https://b.com"),
]
)
result = websearch._parse_response(response)
@@ -67,8 +71,8 @@ def test_parse_sources_deduped(websearch):
def test_parse_skips_source_without_url(websearch):
response = _make_response(
content=[
- mistralai.TextChunk(text="Answer"),
- mistralai.ToolReferenceChunk(tool="web_search", title="No URL"),
+ TextChunk(text="Answer"),
+ ToolReferenceChunk(tool="web_search", title="No URL"),
]
)
result = websearch._parse_response(response)
@@ -82,16 +86,14 @@ def test_parse_empty_text_raises(websearch):
def test_parse_whitespace_only_raises(websearch):
- response = _make_response(content=[mistralai.TextChunk(text=" ")])
+ response = _make_response(content=[TextChunk(text=" ")])
with pytest.raises(ToolError, match="No text in agent response"):
websearch._parse_response(response)
def test_parse_skips_non_message_entries(websearch):
response = _make_response(
- outputs=[
- mistralai.MessageOutputEntry(content=[mistralai.TextChunk(text="Answer")])
- ]
+ outputs=[MessageOutputEntry(content=[TextChunk(text="Answer")])]
)
result = websearch._parse_response(response)
assert result.answer == "Answer"
@@ -110,18 +112,18 @@ async def test_run_missing_api_key(monkeypatch):
async def test_run_returns_parsed_result(websearch):
response = _make_response(
content=[
- mistralai.TextChunk(text="The answer"),
- mistralai.ToolReferenceChunk(
+ TextChunk(text="The answer"),
+ ToolReferenceChunk(
tool="web_search", title="Source", url="https://example.com"
),
]
)
mock_start = AsyncMock(return_value=response)
- with patch.object(mistralai.Mistral, "beta", create=True) as mock_beta:
+ with patch.object(Mistral, "beta", create=True) as mock_beta:
mock_beta.conversations.start_async = mock_start
- with patch.object(mistralai.Mistral, "__aenter__", return_value=None):
- with patch.object(mistralai.Mistral, "__aexit__", return_value=None):
+ with patch.object(Mistral, "__aenter__", return_value=None):
+ with patch.object(Mistral, "__aexit__", return_value=None):
result = await collect_result(
websearch.run(WebSearchArgs(query="test query"))
)
@@ -142,12 +144,12 @@ async def test_run_sdk_error_wrapped(websearch):
mock_response.text = "error"
mock_response.headers = httpx.Headers({"content-type": "application/json"})
- with patch.object(mistralai.Mistral, "beta", create=True) as mock_beta:
+ with patch.object(Mistral, "beta", create=True) as mock_beta:
mock_beta.conversations.start_async = AsyncMock(
- side_effect=mistralai.SDKError("API failed", mock_response)
+ side_effect=SDKError("API failed", mock_response)
)
- with patch.object(mistralai.Mistral, "__aenter__", return_value=None):
- with patch.object(mistralai.Mistral, "__aexit__", return_value=None):
+ with patch.object(Mistral, "__aenter__", return_value=None):
+ with patch.object(Mistral, "__aexit__", return_value=None):
with pytest.raises(ToolError, match="Mistral API error"):
await collect_result(websearch.run(WebSearchArgs(query="test")))
diff --git a/tests/transcribe/test_transcribe_client.py b/tests/transcribe/test_transcribe_client.py
new file mode 100644
index 0000000..ef62a9b
--- /dev/null
+++ b/tests/transcribe/test_transcribe_client.py
@@ -0,0 +1,157 @@
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+from unittest.mock import MagicMock
+
+import pytest
+
+from vibe.core.config import TranscribeModelConfig, TranscribeProviderConfig
+from vibe.core.transcribe import (
+ MistralTranscribeClient,
+ TranscribeDone,
+ TranscribeError,
+ TranscribeSessionCreated,
+ TranscribeTextDelta,
+)
+
+
+def _make_provider() -> TranscribeProviderConfig:
+ return TranscribeProviderConfig(
+ name="mistral",
+ api_base="https://api.mistral.ai",
+ api_key_env_var="MISTRAL_API_KEY",
+ )
+
+
+def _make_model() -> TranscribeModelConfig:
+ return TranscribeModelConfig(
+ name="mistral-small-transcribe",
+ alias="foo",
+ provider="mistral",
+ encoding="pcm_s16le",
+ sample_rate=16_000,
+ target_streaming_delay_ms=200,
+ )
+
+
+async def _empty_audio_stream() -> AsyncIterator[bytes]:
+ return
+ yield
+
+
+def _make_sdk_session_created() -> MagicMock:
+ from mistralai.client.models import RealtimeTranscriptionSessionCreated
+
+ return MagicMock(spec=RealtimeTranscriptionSessionCreated)
+
+
+def _make_sdk_text_delta(text: str) -> MagicMock:
+ from mistralai.client.models import TranscriptionStreamTextDelta
+
+ m = MagicMock(spec=TranscriptionStreamTextDelta)
+ m.text = text
+ return m
+
+
+def _make_sdk_done(text: str) -> MagicMock:
+ from mistralai.client.models import TranscriptionStreamDone
+
+ m = MagicMock(spec=TranscriptionStreamDone)
+ m.text = text
+ return m
+
+
+def _make_sdk_error(message: str) -> MagicMock:
+ from mistralai.client.models import RealtimeTranscriptionError
+
+ m = MagicMock(spec=RealtimeTranscriptionError)
+ m.error = MagicMock()
+ m.error.message = message
+ return m
+
+
+def _make_sdk_unknown() -> MagicMock:
+ from mistralai.extra.realtime import UnknownRealtimeEvent
+
+ return MagicMock(spec=UnknownRealtimeEvent)
+
+
+async def _collect(client: MistralTranscribeClient) -> list[object]:
+ events: list[object] = []
+ async for event in client.transcribe(_empty_audio_stream()):
+ events.append(event)
+ return events
+
+
+def _patch_sdk(sdk_events: list[object]) -> MagicMock:
+ async def _fake_stream(**_kwargs: object) -> AsyncIterator[object]:
+ for e in sdk_events:
+ yield e
+
+ mock_client = MagicMock()
+ mock_client.audio.realtime.transcribe_stream = _fake_stream
+ return mock_client
+
+
+class TestEventMapping:
+ @pytest.mark.asyncio
+ async def test_session_created(self) -> None:
+ mock_client = _patch_sdk([_make_sdk_session_created()])
+ client = MistralTranscribeClient(_make_provider(), _make_model())
+ client._client = mock_client
+
+ events = await _collect(client)
+
+ assert len(events) == 1
+ assert isinstance(events[0], TranscribeSessionCreated)
+
+ @pytest.mark.asyncio
+ async def test_text_delta(self) -> None:
+ mock_client = _patch_sdk([_make_sdk_text_delta("hello")])
+ client = MistralTranscribeClient(_make_provider(), _make_model())
+ client._client = mock_client
+
+ events = await _collect(client)
+
+ assert len(events) == 1
+ assert isinstance(events[0], TranscribeTextDelta)
+ assert events[0].text == "hello"
+
+ @pytest.mark.asyncio
+ async def test_done(self) -> None:
+ mock_client = _patch_sdk([_make_sdk_done("full text")])
+ client = MistralTranscribeClient(_make_provider(), _make_model())
+ client._client = mock_client
+
+ events = await _collect(client)
+
+ assert len(events) == 1
+ assert isinstance(events[0], TranscribeDone)
+
+ @pytest.mark.asyncio
+ async def test_error(self) -> None:
+ mock_client = _patch_sdk([_make_sdk_error("something broke")])
+ client = MistralTranscribeClient(_make_provider(), _make_model())
+ client._client = mock_client
+
+ events = await _collect(client)
+
+ assert len(events) == 1
+ assert isinstance(events[0], TranscribeError)
+ assert events[0].message == "something broke"
+
+ @pytest.mark.asyncio
+ async def test_unknown_event_is_skipped(self) -> None:
+ mock_client = _patch_sdk([
+ _make_sdk_session_created(),
+ _make_sdk_unknown(),
+ _make_sdk_text_delta("hi"),
+ ])
+ client = MistralTranscribeClient(_make_provider(), _make_model())
+ client._client = mock_client
+
+ events = await _collect(client)
+
+ assert len(events) == 2
+ assert isinstance(events[0], TranscribeSessionCreated)
+ assert isinstance(events[1], TranscribeTextDelta)
diff --git a/tests/voice_manager/__init__.py b/tests/voice_manager/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/voice_manager/test_voice_manager.py b/tests/voice_manager/test_voice_manager.py
new file mode 100644
index 0000000..9148adf
--- /dev/null
+++ b/tests/voice_manager/test_voice_manager.py
@@ -0,0 +1,394 @@
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+
+from tests.conftest import build_test_vibe_config
+from tests.stubs.fake_audio_recorder import FakeAudioRecorder
+from tests.stubs.fake_transcribe_client import FakeTranscribeClient
+from vibe.cli.voice_manager.voice_manager import VoiceManager
+from vibe.cli.voice_manager.voice_manager_port import (
+ RecordingStartError,
+ TranscribeState,
+ VoiceManagerListener,
+ VoiceToggleResult,
+)
+from vibe.core.audio_recorder.audio_recorder_port import (
+ AudioBackendUnavailableError,
+ NoAudioInputDeviceError,
+)
+from vibe.core.config import VibeConfig
+from vibe.core.transcribe.transcribe_client_port import (
+ TranscribeDone,
+ TranscribeError,
+ TranscribeSessionCreated,
+ TranscribeTextDelta,
+)
+
+
+class StateListener(VoiceManagerListener):
+ def __init__(self) -> None:
+ self.state_changes: list[TranscribeState] = []
+ self.voice_mode_changes: list[bool] = []
+ self.transcribed_texts: list[str] = []
+
+ def on_transcribe_state_change(self, state: TranscribeState) -> None:
+ self.state_changes.append(state)
+
+ def on_voice_mode_change(self, enabled: bool) -> None:
+ self.voice_mode_changes.append(enabled)
+
+ def on_transcribe_text(self, text: str) -> None:
+ self.transcribed_texts.append(text)
+
+
+def _make_manager(
+ *,
+ voice_mode_enabled: bool = True,
+ transcribe_client: FakeTranscribeClient | None = None,
+) -> tuple[VoiceManager, FakeAudioRecorder, FakeTranscribeClient]:
+ recorder = FakeAudioRecorder()
+ client = transcribe_client or FakeTranscribeClient()
+ config = build_test_vibe_config(voice_mode_enabled=voice_mode_enabled)
+ manager = VoiceManager(
+ config_getter=lambda: config, audio_recorder=recorder, transcribe_client=client
+ )
+ return manager, recorder, client
+
+
+class TestStartRecording:
+ @pytest.mark.asyncio
+ async def test_start_sets_state_to_recording(self) -> None:
+ manager, _, _ = _make_manager()
+ manager.start_recording()
+ assert manager.transcribe_state == TranscribeState.RECORDING
+
+ @pytest.mark.asyncio
+ async def test_start_starts_audio_recorder_in_stream_mode(self) -> None:
+ manager, recorder, _ = _make_manager()
+ manager.start_recording()
+ assert recorder.is_recording
+
+ @pytest.mark.asyncio
+ async def test_start_noop_when_not_idle(self) -> None:
+ manager, recorder, _ = _make_manager()
+ manager.start_recording()
+ recorder.set_peak(0.5)
+ manager.start_recording()
+ assert recorder.peak == 0.5
+ assert manager.transcribe_state == TranscribeState.RECORDING
+
+ @pytest.mark.asyncio
+ async def test_start_raises_when_no_audio_input(self) -> None:
+ def raise_no_input(*a, **kw):
+ raise NoAudioInputDeviceError("no device")
+
+ manager, recorder, _ = _make_manager()
+ recorder.start = raise_no_input
+ with pytest.raises(RecordingStartError, match="No audio input device found"):
+ manager.start_recording()
+ assert manager.transcribe_state == TranscribeState.IDLE
+
+ @pytest.mark.asyncio
+ async def test_start_raises_when_no_backend(self) -> None:
+ def raise_no_backend(*a, **kw):
+ raise AudioBackendUnavailableError("no sd")
+
+ manager, recorder, _ = _make_manager()
+ recorder.start = raise_no_backend
+ with pytest.raises(RecordingStartError, match="Audio backend is unavailable"):
+ manager.start_recording()
+ assert manager.transcribe_state == TranscribeState.IDLE
+
+ @pytest.mark.asyncio
+ async def test_start_raises_when_no_transcribe_client(self) -> None:
+ recorder = FakeAudioRecorder()
+ config = build_test_vibe_config(voice_mode_enabled=True)
+ manager = VoiceManager(
+ config_getter=lambda: config,
+ audio_recorder=recorder,
+ transcribe_client=None,
+ )
+ with pytest.raises(
+ RecordingStartError, match="Transcribe client is not available"
+ ):
+ manager.start_recording()
+ assert manager.transcribe_state == TranscribeState.IDLE
+ assert not recorder.is_recording
+
+
+class TestStopRecording:
+ @pytest.mark.asyncio
+ async def test_stop_transitions_through_flushing_to_idle(self) -> None:
+ manager, _, _ = _make_manager()
+ listener = StateListener()
+ manager.add_listener(listener)
+
+ manager.start_recording()
+ await manager.stop_recording()
+
+ assert listener.state_changes == [
+ TranscribeState.RECORDING,
+ TranscribeState.FLUSHING,
+ TranscribeState.IDLE,
+ ]
+
+ @pytest.mark.asyncio
+ async def test_stop_noop_when_not_recording(self) -> None:
+ manager, _, _ = _make_manager()
+ listener = StateListener()
+ manager.add_listener(listener)
+ await manager.stop_recording()
+ assert listener.state_changes == []
+
+ @pytest.mark.asyncio
+ async def test_stop_recovers_after_transcription_timeout(self) -> None:
+ import asyncio
+
+ class HangingTranscribeClient:
+ def __init__(self, provider=None, model=None) -> None:
+ pass
+
+ async def transcribe(self, audio_stream):
+ await asyncio.Event().wait()
+ return
+ yield # makes this an async generator
+
+ recorder = FakeAudioRecorder()
+ config = build_test_vibe_config(voice_mode_enabled=True)
+ manager = VoiceManager(
+ config_getter=lambda: config,
+ audio_recorder=recorder,
+ transcribe_client=HangingTranscribeClient(),
+ )
+ manager.start_recording()
+
+ with patch(
+ "vibe.cli.voice_manager.voice_manager.TRANSCRIPTION_DRAIN_TIMEOUT", 0.01
+ ):
+ await manager.stop_recording()
+
+ assert manager.transcribe_state == TranscribeState.IDLE
+
+ @pytest.mark.asyncio
+ async def test_stop_recovers_when_no_audio_was_sent(self) -> None:
+ client = FakeTranscribeClient(
+ events=[
+ TranscribeSessionCreated(),
+ TranscribeError(
+ message="Cannot flush audio before sending any audio bytes"
+ ),
+ ]
+ )
+ manager, recorder, _ = _make_manager(transcribe_client=client)
+ listener = StateListener()
+ manager.add_listener(listener)
+
+ manager.start_recording()
+ await manager.stop_recording()
+
+ assert manager.transcribe_state == TranscribeState.IDLE
+ assert not recorder.is_recording
+
+
+class TestCancelRecording:
+ @pytest.mark.asyncio
+ async def test_cancel_from_recording(self) -> None:
+ manager, recorder, _ = _make_manager()
+ manager.start_recording()
+ manager.cancel_recording()
+ assert manager.transcribe_state == TranscribeState.IDLE
+ assert not recorder.is_recording
+
+ @pytest.mark.asyncio
+ async def test_cancel_then_start_not_corrupted_by_stale_finally(self) -> None:
+ import asyncio
+
+ class HangingTranscribeClient:
+ def __init__(self, provider=None, model=None) -> None:
+ pass
+
+ async def transcribe(self, audio_stream):
+ await asyncio.Event().wait()
+ return
+ yield
+
+ recorder = FakeAudioRecorder()
+ config = build_test_vibe_config(voice_mode_enabled=True)
+ manager = VoiceManager(
+ config_getter=lambda: config,
+ audio_recorder=recorder,
+ transcribe_client=HangingTranscribeClient(),
+ )
+
+ manager.start_recording()
+ manager.cancel_recording()
+ manager.start_recording()
+
+ await asyncio.sleep(0)
+
+ assert manager.transcribe_state == TranscribeState.RECORDING
+
+ def test_cancel_noop_when_idle(self) -> None:
+ manager, _, _ = _make_manager()
+ listener = StateListener()
+ manager.add_listener(listener)
+ manager.cancel_recording()
+ assert listener.state_changes == []
+
+
+class TestToggleVoiceMode:
+ @patch.object(VibeConfig, "save_updates")
+ def test_toggle_enables(self, _mock_save) -> None:
+ manager, _, _ = _make_manager(voice_mode_enabled=False)
+ result = manager.toggle_voice_mode()
+ assert result == VoiceToggleResult(enabled=True)
+
+ @patch.object(VibeConfig, "save_updates")
+ def test_toggle_disables(self, _mock_save) -> None:
+ manager, _, _ = _make_manager(voice_mode_enabled=True)
+ result = manager.toggle_voice_mode()
+ assert result == VoiceToggleResult(enabled=False)
+
+ @pytest.mark.asyncio
+ @patch.object(VibeConfig, "save_updates")
+ async def test_toggle_disable_cancels_active_recording(self, _mock_save) -> None:
+ manager, _, _ = _make_manager(voice_mode_enabled=True)
+ manager.start_recording()
+ manager.toggle_voice_mode()
+ assert manager.transcribe_state == TranscribeState.IDLE
+
+
+class TestListeners:
+ @pytest.mark.asyncio
+ async def test_listener_notified_on_state_change(self) -> None:
+ manager, _, _ = _make_manager()
+ listener = StateListener()
+ manager.add_listener(listener)
+ manager.start_recording()
+ assert listener.state_changes == [TranscribeState.RECORDING]
+
+ @patch.object(VibeConfig, "save_updates")
+ def test_listener_notified_on_voice_mode_change(self, _mock_save) -> None:
+ manager, _, _ = _make_manager(voice_mode_enabled=False)
+ listener = StateListener()
+ manager.add_listener(listener)
+ manager.toggle_voice_mode()
+ assert listener.voice_mode_changes == [True]
+
+ @pytest.mark.asyncio
+ async def test_remove_listener(self) -> None:
+ manager, _, _ = _make_manager()
+ listener = StateListener()
+ manager.add_listener(listener)
+ manager.remove_listener(listener)
+ manager.start_recording()
+ assert listener.state_changes == []
+
+ def test_remove_nonexistent_listener_no_error(self) -> None:
+ manager, _, _ = _make_manager()
+ listener = StateListener()
+ manager.remove_listener(listener)
+
+
+class TestPeak:
+ def test_peak_delegates_to_audio_recorder(self) -> None:
+ manager, recorder, _ = _make_manager()
+ recorder.set_peak(0.42)
+ assert manager.peak == 0.42
+
+
+class TestTranscription:
+ @pytest.mark.asyncio
+ async def test_text_deltas_notify_listeners(self) -> None:
+ client = FakeTranscribeClient(
+ events=[
+ TranscribeSessionCreated(),
+ TranscribeTextDelta(text="hello "),
+ TranscribeTextDelta(text="world"),
+ TranscribeDone(),
+ ]
+ )
+ manager, _, _ = _make_manager(transcribe_client=client)
+ listener = StateListener()
+ manager.add_listener(listener)
+
+ manager.start_recording()
+ await manager.stop_recording()
+
+ assert listener.transcribed_texts == ["hello ", "world"]
+
+ @pytest.mark.asyncio
+ async def test_transcription_error_does_not_crash(self) -> None:
+ client = FakeTranscribeClient(
+ events=[
+ TranscribeSessionCreated(),
+ TranscribeTextDelta(text="partial"),
+ TranscribeError(message="something broke"),
+ TranscribeDone(),
+ ]
+ )
+ manager, _, _ = _make_manager(transcribe_client=client)
+ listener = StateListener()
+ manager.add_listener(listener)
+
+ manager.start_recording()
+ await manager.stop_recording()
+
+ assert "partial" in listener.transcribed_texts
+
+ @pytest.mark.asyncio
+ async def test_cancel_during_transcription(self) -> None:
+ client = FakeTranscribeClient(
+ events=[TranscribeSessionCreated(), TranscribeTextDelta(text="hello")]
+ )
+ manager, _, _ = _make_manager(transcribe_client=client)
+ listener = StateListener()
+ manager.add_listener(listener)
+
+ manager.start_recording()
+ manager.cancel_recording()
+
+ assert manager.transcribe_state == TranscribeState.IDLE
+
+ @pytest.mark.asyncio
+ async def test_session_created_is_silent(self) -> None:
+ client = FakeTranscribeClient(
+ events=[TranscribeSessionCreated(), TranscribeDone()]
+ )
+ manager, _, _ = _make_manager(transcribe_client=client)
+ listener = StateListener()
+ manager.add_listener(listener)
+
+ manager.start_recording()
+ await manager.stop_recording()
+
+ assert listener.transcribed_texts == []
+
+ @pytest.mark.asyncio
+ async def test_transcription_exception_stops_recorder(self) -> None:
+ import asyncio
+
+ class CrashingTranscribeClient:
+ def __init__(self, provider=None, model=None) -> None:
+ pass
+
+ async def transcribe(self, audio_stream):
+ raise RuntimeError("network error")
+ yield # makes this an async generator
+
+ recorder = FakeAudioRecorder()
+ config = build_test_vibe_config(voice_mode_enabled=True)
+ manager = VoiceManager(
+ config_getter=lambda: config,
+ audio_recorder=recorder,
+ transcribe_client=CrashingTranscribeClient(),
+ )
+ manager.start_recording()
+ assert recorder.is_recording
+
+ await asyncio.sleep(0)
+
+ assert manager.transcribe_state == TranscribeState.IDLE
+ assert not recorder.is_recording
diff --git a/uv.lock b/uv.lock
index bb03376..d7ed58f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -398,18 +398,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" },
]
-[[package]]
-name = "googleapis-common-protos"
-version = "1.72.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "protobuf" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
-]
-
[[package]]
name = "h11"
version = "0.16.0"
@@ -507,15 +495,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
-[[package]]
-name = "invoke"
-version = "2.2.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762, upload-time = "2025-10-11T00:36:35.172Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" },
-]
-
[[package]]
name = "jaraco-classes"
version = "3.4.0"
@@ -779,7 +758,7 @@ wheels = [
[[package]]
name = "mistral-vibe"
-version = "2.4.2"
+version = "2.5.0"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
@@ -803,12 +782,14 @@ dependencies = [
{ name = "pyyaml" },
{ name = "requests" },
{ name = "rich" },
+ { name = "sounddevice" },
{ name = "textual" },
{ name = "textual-speedups" },
{ name = "tomli-w" },
{ name = "tree-sitter" },
{ name = "tree-sitter-bash" },
{ name = "watchfiles" },
+ { name = "websockets" },
{ name = "zstandard" },
]
@@ -845,7 +826,7 @@ requires-dist = [
{ name = "keyring", specifier = ">=25.6.0" },
{ name = "markdownify", specifier = ">=1.2.2" },
{ name = "mcp", specifier = ">=1.14.0" },
- { name = "mistralai", specifier = "==1.12.4" },
+ { name = "mistralai", specifier = "==2.0.0" },
{ name = "packaging", specifier = ">=24.1" },
{ name = "pexpect", specifier = ">=4.9.0" },
{ name = "pydantic", specifier = ">=2.12.4" },
@@ -855,12 +836,14 @@ requires-dist = [
{ name = "pyyaml", specifier = ">=6.0.0" },
{ name = "requests", specifier = ">=2.20.0" },
{ name = "rich", specifier = ">=14.0.0" },
- { name = "textual", specifier = ">=7.4.0" },
+ { name = "sounddevice", specifier = ">=0.5.1" },
+ { name = "textual", specifier = ">=8.1.1" },
{ name = "textual-speedups", specifier = ">=0.2.1" },
{ name = "tomli-w", specifier = ">=1.2.0" },
{ name = "tree-sitter", specifier = ">=0.25.2" },
{ name = "tree-sitter-bash", specifier = ">=0.25.1" },
{ name = "watchfiles", specifier = ">=1.1.1" },
+ { name = "websockets", specifier = ">=13.0" },
{ name = "zstandard", specifier = ">=0.25.0" },
]
@@ -884,23 +867,20 @@ dev = [
[[package]]
name = "mistralai"
-version = "1.12.4"
+version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "eval-type-backport" },
{ name = "httpx" },
- { name = "invoke" },
{ name = "opentelemetry-api" },
- { name = "opentelemetry-exporter-otlp-proto-http" },
- { name = "opentelemetry-sdk" },
+ { name = "opentelemetry-semantic-conventions" },
{ name = "pydantic" },
{ name = "python-dateutil" },
- { name = "pyyaml" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/aa/12/c3476c53e907255b5f485f085ba50dd9a84b40fe662e9a888d6ded26fa7b/mistralai-1.12.4.tar.gz", hash = "sha256:e52b53bab58025dcd208eeac13e3c3df5778d4112eeca1f08124096c7738929f", size = 243129, upload-time = "2026-02-20T17:55:13.73Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/5c/22fd7d1ec7e333f83dc5e2d0b176952a5d9a1f08519898c55616c92a81d8/mistralai-2.0.0.tar.gz", hash = "sha256:acb7937a53119ece67f4978809d4cf630fbf54b4dfe85c0eeae778ac40850fab", size = 317705, upload-time = "2026-03-10T17:12:48.616Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c9/f9/98d825105c450b9c67c27026caa374112b7e466c18331601d02ca278a01b/mistralai-1.12.4-py3-none-any.whl", hash = "sha256:7b69fcbc306436491ad3377fbdead527c9f3a0ce145ec029bf04c6308ff2cca6", size = 509321, upload-time = "2026-02-20T17:55:15.27Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/95/1587d555837bf635db28e2acee366cc47edc473cd3155515be14acced91b/mistralai-2.0.0-py3-none-any.whl", hash = "sha256:e551fc36d60d4c969140e37f10eab04986480e487f357c900da05d740b9a0baf", size = 709642, upload-time = "2026-03-10T17:12:50.104Z" },
]
[[package]]
@@ -968,62 +948,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
]
-[[package]]
-name = "opentelemetry-exporter-otlp-proto-common"
-version = "1.39.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "opentelemetry-proto" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" },
-]
-
-[[package]]
-name = "opentelemetry-exporter-otlp-proto-http"
-version = "1.39.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "googleapis-common-protos" },
- { name = "opentelemetry-api" },
- { name = "opentelemetry-exporter-otlp-proto-common" },
- { name = "opentelemetry-proto" },
- { name = "opentelemetry-sdk" },
- { name = "requests" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" },
-]
-
-[[package]]
-name = "opentelemetry-proto"
-version = "1.39.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "protobuf" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" },
-]
-
-[[package]]
-name = "opentelemetry-sdk"
-version = "1.39.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "opentelemetry-api" },
- { name = "opentelemetry-semantic-conventions" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" },
-]
-
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.60b1"
@@ -1101,21 +1025,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
]
-[[package]]
-name = "protobuf"
-version = "6.33.5"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" },
- { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" },
- { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" },
- { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" },
- { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" },
- { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" },
- { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
-]
-
[[package]]
name = "ptyprocess"
version = "0.7.0"
@@ -1759,6 +1668,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" },
]
+[[package]]
+name = "sounddevice"
+version = "0.5.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191, upload-time = "2026-01-23T18:36:43.529Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807, upload-time = "2026-01-23T18:36:35.649Z" },
+ { url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557, upload-time = "2026-01-23T18:36:37.41Z" },
+ { url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765, upload-time = "2026-01-23T18:36:39.047Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324, upload-time = "2026-01-23T18:36:40.496Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" },
+]
+
[[package]]
name = "soupsieve"
version = "2.8.1"
@@ -1808,7 +1733,7 @@ wheels = [
[[package]]
name = "textual"
-version = "8.0.0"
+version = "8.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
@@ -1818,9 +1743,9 @@ dependencies = [
{ name = "rich" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f7/08/1e1f705825359590ddfaeda57653bd518c4ff7a96bb2c3239ba1b6fc4c51/textual-8.0.0.tar.gz", hash = "sha256:ce48f83a3d686c0fac0e80bf9136e1f8851c653aa6a4502e43293a151df18809", size = 1595895, upload-time = "2026-02-16T17:12:14.215Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/23/8c709655c5f2208ee82ab81b8104802421865535c278a7649b842b129db1/textual-8.1.1.tar.gz", hash = "sha256:eef0256a6131f06a20ad7576412138c1f30f92ddeedd055953c08d97044bc317", size = 1843002, upload-time = "2026-03-10T10:01:38.493Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d3/be/e191c2a15da20530fde03564564e3e4b4220eb9d687d4014957e5c6a5e85/textual-8.0.0-py3-none-any.whl", hash = "sha256:8908f4ebe93a6b4f77ca7262197784a52162bc88b05f4ecf50ac93a92d49bb8f", size = 718904, upload-time = "2026-02-16T17:12:11.962Z" },
+ { url = "https://files.pythonhosted.org/packages/50/21/421b02bf5943172b7a9320712a5e0d74a02a8f7597284e3f8b5b06c70b8d/textual-8.1.1-py3-none-any.whl", hash = "sha256:6712f96e335cd782e76193dee16b9c8875fe0699d923bc8d3f1228fd23e773a6", size = 719598, upload-time = "2026-03-10T10:01:48.318Z" },
]
[[package]]
@@ -2122,6 +2047,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
+[[package]]
+name = "websockets"
+version = "16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
+ { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
+ { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
+ { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
+ { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
+ { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
+ { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
+ { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
+ { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
+ { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
+ { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
+ { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
+]
+
[[package]]
name = "zipp"
version = "3.23.0"
diff --git a/vibe/__init__.py b/vibe/__init__.py
index aab0ddc..f2dd2b5 100644
--- a/vibe/__init__.py
+++ b/vibe/__init__.py
@@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
-__version__ = "2.4.2"
+__version__ = "2.5.0"
diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py
index 58e283e..b07cde1 100644
--- a/vibe/acp/acp_agent_loop.py
+++ b/vibe/acp/acp_agent_loop.py
@@ -15,7 +15,6 @@ from acp import (
LoadSessionResponse,
NewSessionResponse,
PromptResponse,
- RequestError,
SetSessionModelResponse,
SetSessionModeResponse,
run_agent,
@@ -55,6 +54,17 @@ from pydantic import BaseModel, ConfigDict
from vibe import VIBE_ROOT, __version__
from vibe.acp.acp_logger import acp_message_observer
+from vibe.acp.exceptions import (
+ ConfigurationError,
+ ConversationLimitError,
+ InternalError,
+ InvalidRequestError,
+ NotImplementedMethodError,
+ RateLimitError,
+ SessionLoadError,
+ SessionNotFoundError,
+ UnauthenticatedError,
+)
from vibe.acp.tools.base import BaseAcpTool
from vibe.acp.tools.session_update import (
tool_call_session_update,
@@ -93,13 +103,14 @@ from vibe.core.proxy_setup import (
from vibe.core.session.session_loader import SessionLoader
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import (
+ ApprovalCallback,
ApprovalResponse,
AssistantEvent,
- AsyncApprovalCallback,
CompactEndEvent,
CompactStartEvent,
EntrypointMetadata,
LLMMessage,
+ RateLimitError as CoreRateLimitError,
ReasoningEvent,
Role,
ToolCallEvent,
@@ -107,7 +118,11 @@ from vibe.core.types import (
ToolStreamEvent,
UserMessageEvent,
)
-from vibe.core.utils import CancellationReason, get_user_cancellation_message
+from vibe.core.utils import (
+ CancellationReason,
+ ConversationLimitException,
+ get_user_cancellation_message,
+)
class AcpSessionLoop(BaseModel):
@@ -201,7 +216,7 @@ class VibeAcpAgentLoop(AcpAgent):
async def authenticate(
self, method_id: str, **kwargs: Any
) -> AuthenticateResponse | None:
- raise NotImplementedError("Not implemented yet")
+ raise NotImplementedMethodError("authenticate")
def _build_entrypoint_metadata(self) -> EntrypointMetadata:
return EntrypointMetadata(
@@ -219,9 +234,9 @@ class VibeAcpAgentLoop(AcpAgent):
config.tool_paths.extend(self._get_acp_tool_overrides())
return config
except MissingAPIKeyError as e:
- raise RequestError.auth_required({
- "message": "You must be authenticated before creating a session"
- }) from e
+ raise UnauthenticatedError.from_missing_api_key(e) from e
+ except Exception as e:
+ raise ConfigurationError(str(e)) from e
async def _create_acp_session(
self, session_id: str, agent_loop: AgentLoop
@@ -295,7 +310,7 @@ class VibeAcpAgentLoop(AcpAgent):
for override in overrides
]
- def _create_approval_callback(self, session_id: str) -> AsyncApprovalCallback:
+ def _create_approval_callback(self, session_id: str) -> ApprovalCallback:
session = self._get_session(session_id)
def _handle_permission_selection(
@@ -347,7 +362,7 @@ class VibeAcpAgentLoop(AcpAgent):
def _get_session(self, session_id: str) -> AcpSessionLoop:
if session_id not in self.sessions:
- raise RequestError.invalid_params({"session": "Not found"})
+ raise SessionNotFoundError(session_id)
return self.sessions[session_id]
async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None:
@@ -395,7 +410,17 @@ class VibeAcpAgentLoop(AcpAgent):
hint="KEY value to set, KEY to unset, or empty for help"
)
),
- )
+ ),
+ AvailableCommand(
+ name="leanstall",
+ description="Install the Lean 4 agent (leanstral)",
+ input=AvailableCommandInput(root=UnstructuredCommandInput(hint="")),
+ ),
+ AvailableCommand(
+ name="unleanstall",
+ description="Uninstall the Lean 4 agent",
+ input=AvailableCommandInput(root=UnstructuredCommandInput(hint="")),
+ ),
]
update = update_available_commands(commands)
@@ -429,6 +454,60 @@ class VibeAcpAgentLoop(AcpAgent):
)
return PromptResponse(stop_reason="end_turn")
+ async def _handle_leanstall_command(self, session_id: str) -> PromptResponse:
+ session = self._get_session(session_id)
+ current = list(session.agent_loop.base_config.installed_agents)
+ if "lean" in current:
+ message = "Lean agent is already installed."
+ else:
+ VibeConfig.save_updates({"installed_agents": [*current, "lean"]})
+ new_config = VibeConfig.load(
+ tool_paths=session.agent_loop.config.tool_paths,
+ disabled_tools=["ask_user_question", "exit_plan_mode"],
+ )
+ await session.agent_loop.reload_with_initial_messages(
+ base_config=new_config
+ )
+ message = (
+ "Lean agent installed. Start a new session to switch to Lean mode."
+ )
+
+ await self.client.session_update(
+ session_id=session_id,
+ update=AgentMessageChunk(
+ session_update="agent_message_chunk",
+ content=TextContentBlock(type="text", text=message),
+ ),
+ )
+ return PromptResponse(stop_reason="end_turn")
+
+ async def _handle_unleanstall_command(self, session_id: str) -> PromptResponse:
+ session = self._get_session(session_id)
+ current = list(session.agent_loop.base_config.installed_agents)
+ if "lean" not in current:
+ message = "Lean agent is not installed."
+ else:
+ VibeConfig.save_updates({
+ "installed_agents": [a for a in current if a != "lean"]
+ })
+ new_config = VibeConfig.load(
+ tool_paths=session.agent_loop.config.tool_paths,
+ disabled_tools=["ask_user_question", "exit_plan_mode"],
+ )
+ await session.agent_loop.reload_with_initial_messages(
+ base_config=new_config
+ )
+ message = "Lean agent uninstalled."
+
+ await self.client.session_update(
+ session_id=session_id,
+ update=AgentMessageChunk(
+ session_update="agent_message_chunk",
+ content=TextContentBlock(type="text", text=message),
+ ),
+ )
+ return PromptResponse(stop_reason="end_turn")
+
@override
async def load_session(
self,
@@ -446,16 +525,12 @@ class VibeAcpAgentLoop(AcpAgent):
session_id, config.session_logging
)
if session_dir is None:
- raise RequestError.invalid_params({
- "session_id": f"Session not found: {session_id}"
- })
+ raise SessionNotFoundError(session_id)
try:
loaded_messages, _ = SessionLoader.load_session(session_dir)
- except ValueError as e:
- raise RequestError.invalid_params({
- "session_id": f"Failed to load session: {e}"
- }) from e
+ except Exception as e:
+ raise SessionLoadError(session_id, str(e)) from e
agent_loop = AgentLoop(
config=config,
@@ -605,7 +680,7 @@ class VibeAcpAgentLoop(AcpAgent):
session = self._get_session(session_id)
if session.task is not None:
- raise RuntimeError(
+ raise InvalidRequestError(
"Concurrent prompts are not supported yet, wait for agent loop to finish"
)
@@ -614,6 +689,12 @@ class VibeAcpAgentLoop(AcpAgent):
if text_prompt.strip().lower().startswith("/proxy-setup"):
return await self._handle_proxy_setup_command(session_id, text_prompt)
+ if text_prompt.strip().lower().startswith("/unleanstall"):
+ return await self._handle_unleanstall_command(session_id)
+
+ if text_prompt.strip().lower().startswith("/leanstall"):
+ return await self._handle_leanstall_command(session_id)
+
temp_user_message_id: str | None = kwargs.get("messageId")
async def agent_loop_task() -> None:
@@ -629,16 +710,14 @@ class VibeAcpAgentLoop(AcpAgent):
except asyncio.CancelledError:
return PromptResponse(stop_reason="cancelled")
- except Exception as e:
- await self.client.session_update(
- session_id=session_id,
- update=AgentMessageChunk(
- session_update="agent_message_chunk",
- content=TextContentBlock(type="text", text=f"Error: {e!s}"),
- ),
- )
+ except CoreRateLimitError as e:
+ raise RateLimitError.from_core(e) from e
- return PromptResponse(stop_reason="refusal")
+ except ConversationLimitException as e:
+ raise ConversationLimitError(str(e)) from e
+
+ except Exception as e:
+ raise InternalError(str(e)) from e
finally:
session.task = None
@@ -687,7 +766,9 @@ class VibeAcpAgentLoop(AcpAgent):
block_prompt = "\n".join(parts)
text_prompt = f"{text_prompt}{separator}{block_prompt}"
case _:
- raise ValueError(f"Unsupported content block type: {block.type}")
+ raise InvalidRequestError(
+ f"We currently don't support {block.type} content blocks"
+ )
return text_prompt
async def _run_agent_loop(
@@ -775,7 +856,7 @@ class VibeAcpAgentLoop(AcpAgent):
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
**kwargs: Any,
) -> ForkSessionResponse:
- raise NotImplementedError()
+ raise NotImplementedMethodError("fork_session")
@override
async def resume_session(
@@ -785,15 +866,15 @@ class VibeAcpAgentLoop(AcpAgent):
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
**kwargs: Any,
) -> ResumeSessionResponse:
- raise NotImplementedError()
+ raise NotImplementedMethodError("resume_session")
@override
async def ext_method(self, method: str, params: dict) -> dict:
- raise NotImplementedError()
+ raise NotImplementedMethodError("ext_method")
@override
async def ext_notification(self, method: str, params: dict) -> None:
- raise NotImplementedError()
+ raise NotImplementedMethodError("ext_notification")
@override
def on_connect(self, conn: Client) -> None:
diff --git a/vibe/acp/exceptions.py b/vibe/acp/exceptions.py
new file mode 100644
index 0000000..c188cdc
--- /dev/null
+++ b/vibe/acp/exceptions.py
@@ -0,0 +1,121 @@
+"""Structured ACP error classes for the Vibe agent.
+
+Error codes follow JSON-RPC 2.0 (https://www.jsonrpc.org/specification#error_object)
+and ACP error handling (https://agentclientprotocol.com/protocol/overview#error-handling):
+
+ -32700 Parse error (JSON-RPC standard)
+ -32600 Invalid request (JSON-RPC standard)
+ -32601 Method not found (JSON-RPC standard)
+ -32602 Invalid params (JSON-RPC standard)
+ -32603 Internal error (JSON-RPC standard)
+ -32000 to -32099 Server errors (JSON-RPC implementation-defined)
+ -31xxx Application errors (Vibe-specific, outside reserved range)
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from acp import RequestError
+
+from vibe.core.config import MissingAPIKeyError
+from vibe.core.types import RateLimitError as CoreRateLimitError
+
+# JSON-RPC 2.0 standard codes
+UNAUTHENTICATED = -32000
+METHOD_NOT_FOUND = -32601
+INVALID_PARAMS = -32602
+INTERNAL_ERROR = -32603
+
+# Vibe application codes (outside JSON-RPC reserved range)
+RATE_LIMITED = -31001
+CONFIGURATION_ERROR = -31002
+CONVERSATION_LIMIT = -31003
+
+
+class VibeRequestError(RequestError):
+ code: int
+
+ def __init__(self, message: str, data: dict[str, Any] | None = None) -> None:
+ super().__init__(self.code, message, data)
+
+
+class UnauthenticatedError(VibeRequestError):
+ code = UNAUTHENTICATED
+
+ def __init__(self, detail: str) -> None:
+ super().__init__(message=detail)
+
+ @classmethod
+ def from_missing_api_key(cls, exc: MissingAPIKeyError) -> UnauthenticatedError:
+ return cls(f"Missing API key for {exc.provider_name} provider.")
+
+
+class NotImplementedMethodError(VibeRequestError):
+ code = METHOD_NOT_FOUND
+
+ def __init__(self, method: str) -> None:
+ super().__init__(
+ message=f"Method not implemented: {method}", data={"method": method}
+ )
+
+
+class InvalidRequestError(VibeRequestError):
+ code = INVALID_PARAMS
+
+ def __init__(self, detail: str) -> None:
+ super().__init__(message=detail)
+
+
+class SessionNotFoundError(VibeRequestError):
+ code = INVALID_PARAMS
+
+ def __init__(self, session_id: str) -> None:
+ super().__init__(
+ message=f"Session not found: {session_id}", data={"session_id": session_id}
+ )
+
+
+class SessionLoadError(VibeRequestError):
+ code = INVALID_PARAMS
+
+ def __init__(self, session_id: str, detail: str) -> None:
+ super().__init__(
+ message=f"Failed to load session {session_id}: {detail}",
+ data={"session_id": session_id},
+ )
+
+
+class RateLimitError(VibeRequestError):
+ code = RATE_LIMITED
+
+ def __init__(self, provider: str, model: str) -> None:
+ super().__init__(
+ message=f"Rate limit exceeded for {provider} (model: {model}).",
+ data={"provider": provider, "model": model},
+ )
+
+ @classmethod
+ def from_core(cls, exc: CoreRateLimitError) -> RateLimitError:
+ return cls(exc.provider, exc.model)
+
+
+class ConfigurationError(VibeRequestError):
+ code = CONFIGURATION_ERROR
+
+ def __init__(self, detail: str) -> None:
+ super().__init__(message=detail)
+
+
+class ConversationLimitError(VibeRequestError):
+ code = CONVERSATION_LIMIT
+
+ def __init__(self, detail: str) -> None:
+ super().__init__(message=detail)
+
+
+class InternalError(VibeRequestError):
+ code = INTERNAL_ERROR
+
+ def __init__(self, detail: str) -> None:
+ super().__init__(message=detail or "Internal error")
diff --git a/vibe/acp/tools/builtins/read_file.py b/vibe/acp/tools/builtins/read_file.py
index 920dc3b..af20697 100644
--- a/vibe/acp/tools/builtins/read_file.py
+++ b/vibe/acp/tools/builtins/read_file.py
@@ -4,18 +4,19 @@ from pathlib import Path
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
-from vibe.core.tools.base import BaseToolState, ToolError
+from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.read_file import (
ReadFile as CoreReadFileTool,
ReadFileArgs,
ReadFileResult,
+ ReadFileState,
_ReadResult,
)
ReadFileResult = ReadFileResult
-class AcpReadFileState(BaseToolState, AcpToolState):
+class AcpReadFileState(ReadFileState, AcpToolState):
pass
diff --git a/vibe/acp/tools/session_update.py b/vibe/acp/tools/session_update.py
index 415e0ae..378dc09 100644
--- a/vibe/acp/tools/session_update.py
+++ b/vibe/acp/tools/session_update.py
@@ -57,11 +57,14 @@ def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
def tool_result_session_update(event: ToolResultEvent) -> SessionUpdate | None:
if is_user_cancellation_event(event):
tool_status = "failed"
- raw_output = (
- TaggedText.from_string(event.skip_reason).message
- if event.skip_reason
- else None
- )
+ if event.skip_reason:
+ raw_output = TaggedText.from_string(event.skip_reason).message
+ elif event.error:
+ raw_output = TaggedText.from_string(event.error).message
+ elif event.result:
+ raw_output = event.result.model_dump_json()
+ else:
+ raw_output = None
elif event.result:
tool_status = "completed"
raw_output = event.result.model_dump_json()
diff --git a/vibe/cli/commands.py b/vibe/cli/commands.py
index 3e7deac..5c36f27 100644
--- a/vibe/cli/commands.py
+++ b/vibe/cli/commands.py
@@ -77,6 +77,21 @@ class CommandRegistry:
description="Browse and resume past sessions",
handler="_show_session_picker",
),
+ "voice": Command(
+ aliases=frozenset(["/voice"]),
+ description="Toggle voice mode on/off",
+ handler="_toggle_voice_mode",
+ ),
+ "leanstall": Command(
+ aliases=frozenset(["/leanstall"]),
+ description="Install the Lean 4 agent (leanstral)",
+ handler="_install_lean",
+ ),
+ "unleanstall": Command(
+ aliases=frozenset(["/unleanstall"]),
+ description="Uninstall the Lean 4 agent",
+ handler="_uninstall_lean",
+ ),
}
for command in excluded_commands:
diff --git a/vibe/cli/history_manager.py b/vibe/cli/history_manager.py
index 5d6d3c4..ee46c5d 100644
--- a/vibe/cli/history_manager.py
+++ b/vibe/cli/history_manager.py
@@ -58,7 +58,7 @@ class HistoryManager:
self._save_history()
self.reset_navigation()
- def get_previous(self, current_input: str, prefix: str = "") -> str | None:
+ def get_previous(self, current_input: str) -> str | None:
if not self._entries:
return None
@@ -66,21 +66,19 @@ class HistoryManager:
self._temp_input = current_input
self._current_index = len(self._entries)
- for i in range(self._current_index - 1, -1, -1):
- if self._entries[i].startswith(prefix):
- self._current_index = i
- return self._entries[i]
+ if self._current_index <= 0:
+ return None
- return None
+ self._current_index -= 1
+ return self._entries[self._current_index]
- def get_next(self, prefix: str = "") -> str | None:
+ def get_next(self) -> str | None:
if self._current_index == -1:
return None
- for i in range(self._current_index + 1, len(self._entries)):
- if self._entries[i].startswith(prefix):
- self._current_index = i
- return self._entries[i]
+ if self._current_index < len(self._entries) - 1:
+ self._current_index += 1
+ return self._entries[self._current_index]
result = self._temp_input
self.reset_navigation()
diff --git a/vibe/cli/plan_offer/decide_plan_offer.py b/vibe/cli/plan_offer/decide_plan_offer.py
index 12715f8..faefe78 100644
--- a/vibe/cli/plan_offer/decide_plan_offer.py
+++ b/vibe/cli/plan_offer/decide_plan_offer.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from enum import StrEnum
import logging
from os import getenv
@@ -19,6 +20,11 @@ UPGRADE_URL = CONSOLE_CLI_URL
SWITCH_TO_PRO_KEY_URL = CONSOLE_CLI_URL
+class MistralCodePlanName(StrEnum):
+ FREE = "F"
+ ENTERPRISE = "E"
+
+
class PlanInfo:
plan_type: WhoAmIPlanType
plan_name: str
@@ -51,6 +57,18 @@ class PlanInfo:
def is_chat_pro_plan(self) -> bool:
return self.plan_type == WhoAmIPlanType.CHAT
+ def is_free_mistral_code_plan(self) -> bool:
+ return (
+ self.plan_type == WhoAmIPlanType.MISTRAL_CODE
+ and self.plan_name.upper() == MistralCodePlanName.FREE
+ )
+
+ def is_mistral_code_enterprise_plan(self) -> bool:
+ return (
+ self.plan_type == WhoAmIPlanType.MISTRAL_CODE
+ and self.plan_name.upper() == MistralCodePlanName.ENTERPRISE
+ )
+
async def decide_plan_offer(api_key: str | None, gateway: WhoAmIGateway) -> PlanInfo:
if not api_key:
@@ -79,11 +97,14 @@ def plan_offer_cta(payload: PlanInfo | None) -> str | None:
return
if payload.prompt_switching_to_pro_plan:
return f"### Switch to your [Le Chat Pro API key]({SWITCH_TO_PRO_KEY_URL})"
- if payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}:
+ if (
+ payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
+ or payload.is_free_mistral_code_plan()
+ ):
return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({UPGRADE_URL})"
-def plan_title(payload: PlanInfo | None) -> str | None:
+def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911
if not payload:
return None
if payload.is_chat_pro_plan():
@@ -92,4 +113,8 @@ def plan_title(payload: PlanInfo | None) -> str | None:
return "[API] Experiment plan"
if payload.is_paid_api_plan():
return "[API] Scale plan"
+ if payload.is_free_mistral_code_plan():
+ return "Mistral Code Free"
+ if payload.is_mistral_code_enterprise_plan():
+ return "Mistral Code Enterprise"
return None
diff --git a/vibe/cli/plan_offer/ports/whoami_gateway.py b/vibe/cli/plan_offer/ports/whoami_gateway.py
index 8a23dbd..4327c36 100644
--- a/vibe/cli/plan_offer/ports/whoami_gateway.py
+++ b/vibe/cli/plan_offer/ports/whoami_gateway.py
@@ -11,6 +11,7 @@ from pydantic import TypeAdapter, ValidationError
class WhoAmIPlanType(StrEnum):
API = "API"
CHAT = "CHAT"
+ MISTRAL_CODE = "MISTRAL_CODE"
UNKNOWN = "UNKNOWN"
UNAUTHORIZED = "UNAUTHORIZED"
diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py
index 5d6bcc5..800d35d 100644
--- a/vibe/cli/textual_ui/app.py
+++ b/vibe/cli/textual_ui/app.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
from enum import StrEnum, auto
+import gc
import os
from pathlib import Path
import signal
@@ -87,8 +88,11 @@ from vibe.cli.update_notifier import (
should_show_whats_new,
)
from vibe.cli.update_notifier.update import do_update
+from vibe.cli.voice_manager import VoiceManager, VoiceManagerPort
+from vibe.cli.voice_manager.voice_manager_port import TranscribeState
from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents import AgentProfile
+from vibe.core.audio_recorder import AudioRecorder
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import Backend, VibeConfig
from vibe.core.logger import logger
@@ -112,6 +116,7 @@ from vibe.core.tools.builtins.ask_user_question import (
Choice,
Question,
)
+from vibe.core.transcribe import make_transcribe_client
from vibe.core.types import (
AgentStats,
ApprovalResponse,
@@ -147,7 +152,11 @@ class ChatScroll(VerticalScroll):
@property
def is_at_bottom(self) -> bool:
- return self.scroll_offset.y >= (self.max_scroll_y - 3)
+ return self.scroll_target_y >= (self.max_scroll_y - 3)
+
+ def _check_anchor(self) -> None:
+ if self._anchored and self._anchor_released and self.is_at_bottom:
+ self._anchor_released = False
def update_node_styles(self, animate: bool = True) -> None:
pass
@@ -196,6 +205,7 @@ async def prune_oldest_children(
class VibeApp(App): # noqa: PLR0904
ENABLE_COMMAND_PALETTE = False
CSS_PATH = "app.tcss"
+ PAUSE_GC_ON_SCROLL: ClassVar[bool] = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "clear_quit", "Quit", show=False),
@@ -222,10 +232,14 @@ class VibeApp(App): # noqa: PLR0904
current_version: str = CORE_VERSION,
plan_offer_gateway: WhoAmIGateway | None = None,
terminal_notifier: NotificationPort | None = None,
+ voice_manager: VoiceManagerPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.agent_loop = agent_loop
+ self._voice_manager: VoiceManagerPort = (
+ voice_manager or self._make_default_voice_manager()
+ )
self._terminal_notifier = terminal_notifier or TextualNotificationAdapter(
self,
get_enabled=lambda: self.config.enable_notifications,
@@ -238,6 +252,7 @@ class VibeApp(App): # noqa: PLR0904
self._loading_widget: LoadingWidget | None = None
self._pending_approval: asyncio.Future | None = None
self._pending_question: asyncio.Future | None = None
+ self._user_interaction_lock = asyncio.Lock()
self.event_handler: EventHandler | None = None
@@ -296,6 +311,7 @@ class VibeApp(App): # noqa: PLR0904
skill_entries_getter=self._get_skill_entries,
file_watcher_for_autocomplete_getter=self._is_file_watcher_enabled,
nuage_enabled=self.config.nuage_enabled,
+ voice_manager=self._voice_manager,
)
with Horizontal(id="bottom-bar"):
@@ -346,6 +362,9 @@ class VibeApp(App): # noqa: PLR0904
if self._initial_prompt or self._teleport_on_start:
self.call_after_refresh(self._process_initial_prompt)
+ gc.collect()
+ gc.freeze()
+
def _process_initial_prompt(self) -> None:
if self._teleport_on_start:
self.run_worker(
@@ -402,8 +421,6 @@ class VibeApp(App): # noqa: PLR0904
if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result((ApprovalResponse.YES, None))
- await self._switch_to_input_app()
-
async def on_approval_app_approval_granted_always_tool(
self, message: ApprovalApp.ApprovalGrantedAlwaysTool
) -> None:
@@ -414,8 +431,6 @@ class VibeApp(App): # noqa: PLR0904
if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result((ApprovalResponse.YES, None))
- await self._switch_to_input_app()
-
async def on_approval_app_approval_rejected(
self, message: ApprovalApp.ApprovalRejected
) -> None:
@@ -425,8 +440,6 @@ class VibeApp(App): # noqa: PLR0904
)
self._pending_approval.set_result((ApprovalResponse.NO, feedback))
- await self._switch_to_input_app()
-
if self._loading_widget and self._loading_widget.parent:
await self._remove_loading_widget()
@@ -435,16 +448,11 @@ class VibeApp(App): # noqa: PLR0904
result = AskUserQuestionResult(answers=message.answers, cancelled=False)
self._pending_question.set_result(result)
- await self._switch_to_input_app()
-
async def on_question_app_cancelled(self, message: QuestionApp.Cancelled) -> None:
if self._pending_question and not self._pending_question.done():
result = AskUserQuestionResult(answers=[], cancelled=True)
self._pending_question.set_result(result)
- await self._switch_to_input_app()
- await self._interrupt_agent_loop()
-
async def _remove_loading_widget(self) -> None:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
@@ -682,26 +690,32 @@ class VibeApp(App): # noqa: PLR0904
if self._is_tool_enabled_in_main_agent(tool):
return (ApprovalResponse.YES, None)
- self._pending_approval = asyncio.Future()
- self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
- with paused_timer(self._loading_widget):
- await self._switch_to_approval_app(tool, args)
- result = await self._pending_approval
-
- self._pending_approval = None
- return result
+ async with self._user_interaction_lock:
+ self._pending_approval = asyncio.Future()
+ self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
+ try:
+ with paused_timer(self._loading_widget):
+ await self._switch_to_approval_app(tool, args)
+ result = await self._pending_approval
+ return result
+ finally:
+ self._pending_approval = None
+ await self._switch_to_input_app()
async def _user_input_callback(self, args: BaseModel) -> BaseModel:
question_args = cast(AskUserQuestionArgs, args)
- self._pending_question = asyncio.Future()
- self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
- with paused_timer(self._loading_widget):
- await self._switch_to_question_app(question_args)
- result = await self._pending_question
-
- self._pending_question = None
- return result
+ async with self._user_interaction_lock:
+ self._pending_question = asyncio.Future()
+ self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
+ try:
+ with paused_timer(self._loading_widget):
+ await self._switch_to_question_app(question_args)
+ result = await self._pending_question
+ return result
+ finally:
+ self._pending_question = None
+ await self._switch_to_input_app()
async def _handle_agent_loop_turn(self, prompt: str) -> None:
self._agent_running = True
@@ -756,10 +770,11 @@ class VibeApp(App): # noqa: PLR0904
self._terminal_notifier.notify(NotificationContext.COMPLETE)
def _rate_limit_message(self) -> str:
- upgrade_to_pro = self._plan_info and self._plan_info.plan_type in {
- WhoAmIPlanType.API,
- WhoAmIPlanType.UNAUTHORIZED,
- }
+ upgrade_to_pro = self._plan_info and (
+ self._plan_info.plan_type
+ in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
+ or self._plan_info.is_free_mistral_code_plan()
+ )
if upgrade_to_pro:
return "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
return "Rate limits exceeded. Please wait a moment before trying again."
@@ -862,6 +877,16 @@ class VibeApp(App): # noqa: PLR0904
self._interrupt_requested = True
+ if self._pending_approval and not self._pending_approval.done():
+ feedback = str(
+ get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
+ )
+ self._pending_approval.set_result((ApprovalResponse.NO, feedback))
+ if self._pending_question and not self._pending_question.done():
+ self._pending_question.set_result(
+ AskUserQuestionResult(answers=[], cancelled=True)
+ )
+
if self._agent_task and not self._agent_task.done():
self._agent_task.cancel()
try:
@@ -1035,6 +1060,28 @@ class VibeApp(App): # noqa: PLR0904
)
)
+ async def _install_lean(self) -> None:
+ current = list(self.agent_loop.base_config.installed_agents)
+ if "lean" in current:
+ await self._mount_and_scroll(
+ UserCommandMessage("Lean agent is already installed.")
+ )
+ return
+ VibeConfig.save_updates({"installed_agents": sorted([*current, "lean"])})
+ await self._reload_config()
+
+ async def _uninstall_lean(self) -> None:
+ current = list(self.agent_loop.base_config.installed_agents)
+ if "lean" not in current:
+ await self._mount_and_scroll(
+ UserCommandMessage("Lean agent is not installed.")
+ )
+ return
+ VibeConfig.save_updates({
+ "installed_agents": [a for a in current if a != "lean"]
+ })
+ await self._reload_config()
+
async def _clear_history(self) -> None:
try:
self._reset_ui_state()
@@ -1166,6 +1213,33 @@ class VibeApp(App): # noqa: PLR0904
ErrorMessage(result.message, collapsed=self._tools_collapsed)
)
+ def _make_default_voice_manager(self) -> VoiceManager:
+ try:
+ model = self.config.get_active_transcribe_model()
+ provider = self.config.get_transcribe_provider_for_model(model)
+ transcribe_client = make_transcribe_client(provider, model)
+ except (ValueError, KeyError) as exc:
+ logger.error(
+ "Failed to initialize transcription, check transcribe model configuration",
+ exc_info=exc,
+ )
+ transcribe_client = None
+
+ return VoiceManager(
+ lambda: self.config,
+ audio_recorder=AudioRecorder(),
+ transcribe_client=transcribe_client,
+ )
+
+ async def _toggle_voice_mode(self) -> None:
+ result = self._voice_manager.toggle_voice_mode()
+ self.agent_loop.refresh_config()
+ if result.enabled:
+ msg = "Voice mode enabled. Press ctrl+r to start recording."
+ else:
+ msg = "Voice mode disabled."
+ await self._mount_and_scroll(UserCommandMessage(msg))
+
async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
bottom_container = self.query_one("#bottom-app-container")
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
@@ -1291,7 +1365,11 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_user_cancelled_action("interrupt_agent")
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
- def action_interrupt(self) -> None:
+ def action_interrupt(self) -> None: # noqa: PLR0911
+ if self._voice_manager.transcribe_state != TranscribeState.IDLE:
+ self._voice_manager.cancel_recording()
+ return
+
current_time = time.monotonic()
if self._current_bottom_app == BottomApp.Config:
@@ -1424,6 +1502,7 @@ class VibeApp(App): # noqa: PLR0904
self._chat_input_container
and self._switch_agent_generation == my_gen
):
+ self.call_from_thread(self._refresh_banner)
self.call_from_thread(
setattr, self._chat_input_container, "switching_mode", False
)
diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss
index cec8202..4dfc57f 100644
--- a/vibe/cli/textual_ui/app.tcss
+++ b/vibe/cli/textual_ui/app.tcss
@@ -86,6 +86,7 @@ TextArea > .text-area--cursor {
#completion-popup {
width: 100%;
padding: 1;
+ padding-left: 3;
color: ansi_default;
}
@@ -112,6 +113,11 @@ TextArea > .text-area--cursor {
border: solid ansi_red;
border-title-color: ansi_red;
}
+
+ &.border-recording {
+ border: solid $mistral_orange;
+ border-title-color: $mistral_orange;
+ }
}
#input-body {
@@ -119,7 +125,8 @@ TextArea > .text-area--cursor {
}
#prompt,
-#prompt-spinner {
+#prompt-spinner,
+#recording-indicator {
width: auto;
background: transparent;
color: $mistral_orange;
@@ -137,6 +144,10 @@ TextArea > .text-area--cursor {
border: none;
padding: 0;
scrollbar-visibility: hidden;
+
+ &.recording {
+ color: ansi_bright_black;
+ }
}
ToastRack {
diff --git a/vibe/cli/textual_ui/handlers/event_handler.py b/vibe/cli/textual_ui/handlers/event_handler.py
index 9e0e47e..0bdbff8 100644
--- a/vibe/cli/textual_ui/handlers/event_handler.py
+++ b/vibe/cli/textual_ui/handlers/event_handler.py
@@ -82,6 +82,7 @@ class EventHandler:
skip_reason=TaggedText.from_string(event.skip_reason).message
if event.skip_reason
else None,
+ cancelled=event.cancelled,
duration=event.duration,
tool_call_id=event.tool_call_id,
)
diff --git a/vibe/cli/textual_ui/recording/__init__.py b/vibe/cli/textual_ui/recording/__init__.py
new file mode 100644
index 0000000..2914e32
--- /dev/null
+++ b/vibe/cli/textual_ui/recording/__init__.py
@@ -0,0 +1,5 @@
+from __future__ import annotations
+
+from vibe.cli.textual_ui.recording.recording_indicator import RecordingIndicator
+
+__all__ = ["RecordingIndicator"]
diff --git a/vibe/cli/textual_ui/recording/recording_indicator.py b/vibe/cli/textual_ui/recording/recording_indicator.py
new file mode 100644
index 0000000..ddd07c3
--- /dev/null
+++ b/vibe/cli/textual_ui/recording/recording_indicator.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from textual.timer import Timer
+from textual.widgets import Static
+
+from vibe.cli.voice_manager.voice_manager_port import (
+ TranscribeState,
+ VoiceManagerListener,
+ VoiceManagerPort,
+)
+
+PEAK_BLOCKS = "▁▂▃▄▅▆▇█"
+FILL_BLOCKS = "▏▎▍▌▋▊▉█"
+PEAK_POLL_INTERVAL = 0.05
+
+
+class RecordingIndicator(VoiceManagerListener, Static):
+ def __init__(self, voice_manager: VoiceManagerPort) -> None:
+ super().__init__(PEAK_BLOCKS[0], id="recording-indicator")
+ self._voice_manager = voice_manager
+ self._peak_timer: Timer | None = None
+ self._flushing_animation_timer: Timer | None = None
+
+ def on_mount(self) -> None:
+ self._voice_manager.add_listener(self)
+ if self._voice_manager.transcribe_state == TranscribeState.RECORDING:
+ self._start_peak_polling()
+
+ def on_unmount(self) -> None:
+ self._voice_manager.remove_listener(self)
+ self._stop_peak_polling()
+ self._stop_flushing_animation_timer()
+
+ def on_transcribe_state_change(self, state: TranscribeState) -> None:
+ match state:
+ case TranscribeState.RECORDING:
+ self._start_peak_polling()
+ case TranscribeState.FLUSHING:
+ self._stop_peak_polling()
+ self._start_flushing_animation_timer()
+ case TranscribeState.IDLE:
+ self._stop_peak_polling()
+ self._stop_flushing_animation_timer()
+
+ def _start_peak_polling(self) -> None:
+ self._peak_timer = self.set_interval(
+ PEAK_POLL_INTERVAL, self._poll_peak, pause=False
+ )
+
+ def _poll_peak(self) -> None:
+ if self._voice_manager.transcribe_state != TranscribeState.RECORDING:
+ return
+ index = min(
+ int(self._voice_manager.peak * len(PEAK_BLOCKS)), len(PEAK_BLOCKS) - 1
+ )
+ self.update(PEAK_BLOCKS[index])
+
+ def _stop_peak_polling(self) -> None:
+ if self._peak_timer:
+ self._peak_timer.stop()
+ self._peak_timer = None
+
+ def _start_flushing_animation_timer(self) -> None:
+ self._processing_index = 0
+ self.update(FILL_BLOCKS[0])
+ self._flushing_animation_timer = self.set_interval(
+ 0.1, self._advance_flushing_animation
+ )
+
+ def _advance_flushing_animation(self) -> None:
+ if self._voice_manager.transcribe_state != TranscribeState.FLUSHING:
+ return
+ self._processing_index = (self._processing_index + 1) % len(FILL_BLOCKS)
+ self.update(FILL_BLOCKS[self._processing_index])
+
+ def _stop_flushing_animation_timer(self) -> None:
+ if self._flushing_animation_timer:
+ self._flushing_animation_timer.stop()
+ self._flushing_animation_timer = None
diff --git a/vibe/cli/textual_ui/widgets/chat_input/body.py b/vibe/cli/textual_ui/widgets/chat_input/body.py
index 636bcfd..4be7ebc 100644
--- a/vibe/cli/textual_ui/widgets/chat_input/body.py
+++ b/vibe/cli/textual_ui/widgets/chat_input/body.py
@@ -11,9 +11,16 @@ from textual.widget import Widget
from textual.widgets import Static
from vibe.cli.history_manager import HistoryManager
+from vibe.cli.textual_ui.recording.recording_indicator import RecordingIndicator
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea, InputMode
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
+from vibe.cli.voice_manager.voice_manager_port import (
+ TranscribeState,
+ VoiceManagerListener,
+ VoiceManagerPort,
+)
+from vibe.core.logger import logger
class _PromptSpinner(SpinnerMixin, Static):
@@ -29,7 +36,7 @@ class _PromptSpinner(SpinnerMixin, Static):
self.start_spinner_timer()
-class ChatInputBody(Widget):
+class ChatInputBody(VoiceManagerListener, Widget):
class Submitted(Message):
def __init__(self, value: str) -> None:
self.value = value
@@ -39,6 +46,7 @@ class ChatInputBody(Widget):
self,
history_file: Path | None = None,
nuage_enabled: bool = False,
+ voice_manager: VoiceManagerPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
@@ -46,6 +54,8 @@ class ChatInputBody(Widget):
self.prompt_widget: NoMarkupStatic | None = None
self._nuage_enabled = nuage_enabled
self._switching_mode = False
+ self._voice_manager = voice_manager
+ self._recording_indicator: RecordingIndicator | None = None
if history_file:
self.history = HistoryManager(history_file)
@@ -60,13 +70,21 @@ class ChatInputBody(Widget):
yield self.prompt_widget
self.input_widget = ChatTextArea(
- id="input", nuage_enabled=self._nuage_enabled
+ id="input",
+ nuage_enabled=self._nuage_enabled,
+ voice_manager=self._voice_manager,
)
yield self.input_widget
def on_mount(self) -> None:
if self.input_widget:
self.input_widget.focus()
+ if self._voice_manager:
+ self._voice_manager.add_listener(self)
+
+ def on_unmount(self) -> None:
+ if self._voice_manager:
+ self._voice_manager.remove_listener(self)
def _parse_mode_and_text(self, text: str) -> tuple[InputMode, str]:
if text.startswith("!"):
@@ -103,7 +121,6 @@ class ChatInputBody(Widget):
cursor_pos = (0, col)
self.input_widget.move_cursor(cursor_pos)
- self.input_widget._last_cursor_col = col
self.input_widget._cursor_pos_after_load = cursor_pos
self.input_widget._cursor_moved_since_load = False
@@ -111,7 +128,7 @@ class ChatInputBody(Widget):
self._notify_completion_reset()
def on_chat_text_area_history_previous(
- self, event: ChatTextArea.HistoryPrevious
+ self, _event: ChatTextArea.HistoryPrevious
) -> None:
if not self.history or not self.input_widget:
return
@@ -119,52 +136,25 @@ class ChatInputBody(Widget):
if self.history._current_index == -1:
self.input_widget._original_text = self.input_widget.text
- if (
- self.history._current_index != -1
- and self.input_widget._last_used_prefix is not None
- and self.input_widget._last_used_prefix != event.prefix
- ):
- self.history.reset_navigation()
-
- self.input_widget._last_used_prefix = event.prefix
- previous = self.history.get_previous(
- self.input_widget._original_text, prefix=event.prefix
- )
+ previous = self.history.get_previous(self.input_widget._original_text)
if previous is not None:
self._load_history_entry(previous)
- def on_chat_text_area_history_next(self, event: ChatTextArea.HistoryNext) -> None:
+ def on_chat_text_area_history_next(self, _event: ChatTextArea.HistoryNext) -> None:
if not self.history or not self.input_widget:
return
if self.history._current_index == -1:
return
- if (
- self.input_widget._last_used_prefix is not None
- and self.input_widget._last_used_prefix != event.prefix
- ):
- self.history.reset_navigation()
+ next_entry = self.history.get_next()
+ if next_entry is not None:
+ self._load_history_entry(next_entry)
- self.input_widget._last_used_prefix = event.prefix
-
- has_next = any(
- self.history._entries[i].startswith(event.prefix)
- for i in range(self.history._current_index + 1, len(self.history._entries))
- )
-
- original_matches = self.input_widget._original_text.startswith(event.prefix)
-
- if has_next or original_matches:
- next_entry = self.history.get_next(prefix=event.prefix)
- if next_entry is not None:
- cursor_col = (
- len(event.prefix) if self.history._current_index == -1 else None
- )
- self._load_history_entry(next_entry, cursor_col=cursor_col)
-
- def on_chat_text_area_history_reset(self, event: ChatTextArea.HistoryReset) -> None:
+ def on_chat_text_area_history_reset(
+ self, _event: ChatTextArea.HistoryReset
+ ) -> None:
if self.history:
self.history.reset_navigation()
if self.input_widget:
@@ -250,3 +240,68 @@ class ChatInputBody(Widget):
if cursor_offset is not None:
self.input_widget.set_cursor_offset(max(0, min(cursor_offset, len(text))))
+
+ def on_transcribe_state_change(self, state: TranscribeState) -> None:
+ if state == TranscribeState.RECORDING:
+ self._start_recording_ui()
+ elif state == TranscribeState.IDLE:
+ self._stop_recording_ui()
+
+ def on_transcribe_text(self, text: str) -> None:
+ if not self.input_widget:
+ return
+ self.input_widget.insert(text)
+
+ def _start_recording_ui(self) -> None:
+ if not self._voice_manager:
+ return
+
+ try:
+ self.screen.get_widget_by_id("input-box").add_class("border-recording")
+
+ if self.input_widget:
+ self.input_widget.cursor_blink = False
+ self.input_widget.add_class("recording")
+ if self.prompt_widget:
+ self.prompt_widget.display = False
+ self._recording_indicator = RecordingIndicator(self._voice_manager)
+ self.query_one(Horizontal).mount(self._recording_indicator, before=0)
+ except Exception as e:
+ logger.error("Failed to start recording UI", exc_info=e)
+ self._reset_recording_ui()
+
+ def _stop_recording_ui(self) -> None:
+ try:
+ self.screen.get_widget_by_id("input-box").remove_class("border-recording")
+
+ if self.input_widget:
+ self.input_widget.cursor_blink = True
+ self.input_widget.remove_class("recording")
+ if self.prompt_widget:
+ self.prompt_widget.display = True
+ self._update_prompt()
+ if self._recording_indicator:
+ self._recording_indicator.remove()
+ self._recording_indicator = None
+ except Exception as e:
+ logger.error("Failed to stop recording UI", exc_info=e)
+ self._reset_recording_ui()
+
+ def _reset_recording_ui(self) -> None:
+ try:
+ self.screen.get_widget_by_id("input-box").remove_class("border-recording")
+ except Exception:
+ pass
+
+ if self.input_widget:
+ self.input_widget.cursor_blink = True
+ self.input_widget.remove_class("recording")
+ if self.prompt_widget:
+ self.prompt_widget.display = True
+ self._update_prompt()
+ if self._recording_indicator:
+ try:
+ self._recording_indicator.remove()
+ except Exception:
+ pass
+ self._recording_indicator = None
diff --git a/vibe/cli/textual_ui/widgets/chat_input/completion_popup.py b/vibe/cli/textual_ui/widgets/chat_input/completion_popup.py
index 7ed81b5..41c6838 100644
--- a/vibe/cli/textual_ui/widgets/chat_input/completion_popup.py
+++ b/vibe/cli/textual_ui/widgets/chat_input/completion_popup.py
@@ -27,7 +27,7 @@ class CompletionPopup(Static):
label_style = "bold reverse" if idx == selected else "bold"
description_style = "italic" if idx == selected else "dim"
- text.append(label, style=label_style)
+ text.append(self._display_label(label), style=label_style)
if description:
text.append(" ")
text.append(description, style=description_style)
@@ -41,3 +41,8 @@ class CompletionPopup(Static):
def show(self) -> None:
self.styles.display = "block"
+
+ def _display_label(self, label: str) -> str:
+ if label.startswith("@"):
+ return label[1:]
+ return label
diff --git a/vibe/cli/textual_ui/widgets/chat_input/container.py b/vibe/cli/textual_ui/widgets/chat_input/container.py
index db20463..e501afc 100644
--- a/vibe/cli/textual_ui/widgets/chat_input/container.py
+++ b/vibe/cli/textual_ui/widgets/chat_input/container.py
@@ -17,6 +17,7 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
)
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
+from vibe.cli.voice_manager.voice_manager_port import VoiceManagerPort
from vibe.core.agents import AgentSafety
from vibe.core.autocompletion.completers import CommandCompleter, PathCompleter
@@ -44,6 +45,7 @@ class ChatInputContainer(Vertical):
skill_entries_getter: Callable[[], list[tuple[str, str]]] | None = None,
file_watcher_for_autocomplete_getter: Callable[[], bool] | None = None,
nuage_enabled: bool = False,
+ voice_manager: VoiceManagerPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
@@ -56,6 +58,7 @@ class ChatInputContainer(Vertical):
file_watcher_for_autocomplete_getter
)
self._nuage_enabled = nuage_enabled
+ self._voice_manager = voice_manager
self._completion_manager = MultiCompletionManager([
SlashCommandController(CommandCompleter(self._get_slash_entries), self),
@@ -90,6 +93,7 @@ class ChatInputContainer(Vertical):
history_file=self._history_file,
id="input-body",
nuage_enabled=self._nuage_enabled,
+ voice_manager=self._voice_manager,
)
yield self._body
diff --git a/vibe/cli/textual_ui/widgets/chat_input/text_area.py b/vibe/cli/textual_ui/widgets/chat_input/text_area.py
index 2fdf95a..ba2b453 100644
--- a/vibe/cli/textual_ui/widgets/chat_input/text_area.py
+++ b/vibe/cli/textual_ui/widgets/chat_input/text_area.py
@@ -13,6 +13,11 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
MultiCompletionManager,
)
from vibe.cli.textual_ui.widgets.vscode_compat import patch_vscode_space
+from vibe.cli.voice_manager.voice_manager_port import (
+ RecordingStartError,
+ TranscribeState,
+ VoiceManagerPort,
+)
InputMode = Literal["!", "/", ">", "&"]
@@ -37,14 +42,10 @@ class ChatTextArea(TextArea):
super().__init__()
class HistoryPrevious(Message):
- def __init__(self, prefix: str) -> None:
- self.prefix = prefix
- super().__init__()
+ pass
class HistoryNext(Message):
- def __init__(self, prefix: str) -> None:
- self.prefix = prefix
- super().__init__()
+ pass
class HistoryReset(Message):
"""Message sent when history navigation should be reset."""
@@ -56,20 +57,23 @@ class ChatTextArea(TextArea):
self.mode = mode
super().__init__()
- def __init__(self, nuage_enabled: bool = False, **kwargs: Any) -> None:
+ def __init__(
+ self,
+ nuage_enabled: bool = False,
+ voice_manager: VoiceManagerPort | None = None,
+ **kwargs: Any,
+ ) -> None:
super().__init__(**kwargs)
self._nuage_enabled = nuage_enabled
self._input_mode: InputMode = self.DEFAULT_MODE
- self._history_prefix: str | None = None
self._last_text = ""
self._navigating_history = False
- self._last_cursor_col: int = 0
- self._last_used_prefix: str | None = None
self._original_text: str = ""
self._cursor_pos_after_load: tuple[int, int] | None = None
self._cursor_moved_since_load: bool = False
self._completion_manager: MultiCompletionManager | None = None
self._app_has_focus: bool = True
+ self._voice_manager = voice_manager
def on_blur(self, event: events.Blur) -> None:
if self._app_has_focus:
@@ -100,7 +104,6 @@ class ChatTextArea(TextArea):
def on_text_area_changed(self, event: TextArea.Changed) -> None:
if not self._navigating_history and self.text != self._last_text:
- self._reset_prefix()
self._original_text = ""
self._cursor_pos_after_load = None
self._cursor_moved_since_load = False
@@ -114,11 +117,6 @@ class ChatTextArea(TextArea):
self.get_full_text(), self._get_full_cursor_offset()
)
- def _reset_prefix(self, *, clear_last_used: bool = True) -> None:
- self._history_prefix = None
- if clear_last_used:
- self._last_used_prefix = None
-
def _mark_cursor_moved_if_needed(self) -> None:
if (
self._cursor_pos_after_load is not None
@@ -126,20 +124,8 @@ class ChatTextArea(TextArea):
and self.cursor_location != self._cursor_pos_after_load
):
self._cursor_moved_since_load = True
- self._reset_prefix(clear_last_used=False)
-
- def _get_prefix_up_to_cursor(self) -> str:
- cursor_row, cursor_col = self.cursor_location
- lines = self.text.split("\n")
- if cursor_row < len(lines):
- visible_prefix = lines[cursor_row][:cursor_col]
- if cursor_row == 0 and self._input_mode != self.DEFAULT_MODE:
- return self._input_mode + visible_prefix
- return visible_prefix
- return ""
def _handle_history_up(self) -> bool:
- _, cursor_col = self.cursor_location
history_loaded_and_cursor_unmoved = (
self._cursor_pos_after_load is not None
and not self._cursor_moved_since_load
@@ -150,63 +136,61 @@ class ChatTextArea(TextArea):
)
if should_intercept:
- if self._history_prefix is not None and cursor_col != self._last_cursor_col:
- self._reset_prefix()
- self._last_cursor_col = 0
-
- if self._history_prefix is None:
- self._history_prefix = self._get_prefix_up_to_cursor()
-
self._navigating_history = True
- self.post_message(self.HistoryPrevious(self._history_prefix))
+ self.post_message(self.HistoryPrevious())
return True
return False
def _is_on_loaded_history_entry(self) -> bool:
return self._cursor_pos_after_load is not None
- def _has_history_prefix(self) -> bool:
- return self._history_prefix is not None
-
- def _has_history_navigation_context(self) -> bool:
- return self._is_on_loaded_history_entry() or self._has_history_prefix()
-
def _should_intercept_history_down(self) -> bool:
- history_loaded_and_cursor_unmoved = (
- self._is_on_loaded_history_entry() and not self._cursor_moved_since_load
- )
- if history_loaded_and_cursor_unmoved and self._has_history_prefix():
+ if self._is_on_loaded_history_entry() and not self._cursor_moved_since_load:
return True
if not self.navigator.is_last_wrapped_line(self.cursor_location):
return False
- return self._has_history_navigation_context()
+ return self._is_on_loaded_history_entry()
def _handle_history_down(self) -> bool:
- _, cursor_col = self.cursor_location
-
if not self._should_intercept_history_down():
return False
- navigating_loaded_history = self._is_on_loaded_history_entry()
-
- if self._history_prefix is not None and cursor_col != self._last_cursor_col:
- if not navigating_loaded_history:
- self._reset_prefix()
- self._last_cursor_col = 0
-
- if self._history_prefix is None:
- if navigating_loaded_history and self._last_used_prefix is not None:
- self._history_prefix = self._last_used_prefix
- else:
- self._history_prefix = self._get_prefix_up_to_cursor()
-
self._navigating_history = True
- self.post_message(self.HistoryNext(self._history_prefix))
+ self.post_message(self.HistoryNext())
return True
+ async def _handle_voice_key(self, event: events.Key) -> bool:
+ if not self._voice_manager:
+ return False
+
+ # Handle key pressed during audio recording
+ if self._voice_manager.transcribe_state != TranscribeState.IDLE:
+ event.prevent_default()
+ event.stop()
+ if event.key == "ctrl+c": # Escape is handled in app.py
+ self._voice_manager.cancel_recording()
+ elif self._voice_manager.transcribe_state == TranscribeState.RECORDING:
+ await self._voice_manager.stop_recording()
+ return True
+
+ # Handle audio record keybind
+ if self._voice_manager.is_enabled and event.key == "ctrl+r":
+ event.prevent_default()
+ event.stop()
+ try:
+ self._voice_manager.start_recording()
+ except RecordingStartError as e:
+ self.notify(str(e), severity="warning")
+ return True
+
+ return False
+
async def _on_key(self, event: events.Key) -> None: # noqa: PLR0911
+ if await self._handle_voice_key(event):
+ return
+
self._mark_cursor_moved_if_needed()
manager = self._completion_manager
@@ -223,7 +207,6 @@ class ChatTextArea(TextArea):
event.stop()
value = self.get_full_text().strip()
if value:
- self._reset_prefix()
self.post_message(self.Submitted(value))
return
@@ -232,7 +215,6 @@ class ChatTextArea(TextArea):
event.stop()
value = self.get_full_text().strip()
if value:
- self._reset_prefix()
self.post_message(self.Submitted(value))
return
@@ -323,7 +305,6 @@ class ChatTextArea(TextArea):
self.move_cursor((last_row, len(lines[last_row])))
def reset_history_state(self) -> None:
- self._reset_prefix()
self._original_text = ""
self._cursor_pos_after_load = None
self._cursor_moved_since_load = False
diff --git a/vibe/cli/textual_ui/widgets/messages.py b/vibe/cli/textual_ui/widgets/messages.py
index 207efa6..81aacb9 100644
--- a/vibe/cli/textual_ui/widgets/messages.py
+++ b/vibe/cli/textual_ui/widgets/messages.py
@@ -1,6 +1,9 @@
from __future__ import annotations
-from typing import Any
+from typing import TYPE_CHECKING, Any, cast
+
+if TYPE_CHECKING:
+ from vibe.cli.textual_ui.app import ChatScroll
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
@@ -67,6 +70,7 @@ class StreamingMessageBase(Static):
self._markdown: Markdown | None = None
self._stream: MarkdownStream | None = None
self._content_initialized = False
+ self._to_write_buffer = ""
def _get_markdown(self) -> Markdown:
if self._markdown is None:
@@ -80,23 +84,46 @@ class StreamingMessageBase(Static):
self._stream = Markdown.get_stream(self._get_markdown())
return self._stream
+ def _is_chat_at_bottom(self) -> bool:
+ try:
+ chat = cast("ChatScroll", self.app.query_one("#chat"))
+ return chat.is_at_bottom
+ except Exception:
+ return True
+
async def append_content(self, content: str) -> None:
if not content:
return
self._content += content
- if self._should_write_content():
+
+ if not self._should_write_content():
+ return
+
+ if self._is_chat_at_bottom():
+ to_write = self._to_write_buffer + content
+ self._to_write_buffer = ""
stream = self._ensure_stream()
- await stream.write(content)
+ await stream.write(to_write)
+ return
+
+ self._to_write_buffer += content
async def write_initial_content(self) -> None:
if self._content_initialized:
return
+ self._content_initialized = True
if self._content and self._should_write_content():
stream = self._ensure_stream()
await stream.write(self._content)
+ self._to_write_buffer = ""
async def stop_stream(self) -> None:
+ if self._to_write_buffer and self._should_write_content():
+ stream = self._ensure_stream()
+ await stream.write(self._to_write_buffer)
+ self._to_write_buffer = ""
+
if self._stream is None:
return
@@ -187,6 +214,7 @@ class ReasoningMessage(SpinnerMixin, StreamingMessageBase):
await self._markdown.update("")
stream = self._ensure_stream()
await stream.write(self._content)
+ self._to_write_buffer = ""
class UserCommandMessage(Static):
diff --git a/vibe/cli/voice_manager/__init__.py b/vibe/cli/voice_manager/__init__.py
new file mode 100644
index 0000000..683cd6a
--- /dev/null
+++ b/vibe/cli/voice_manager/__init__.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from vibe.cli.voice_manager.voice_manager import VoiceManager
+from vibe.cli.voice_manager.voice_manager_port import (
+ RecordingStartError,
+ VoiceManagerPort,
+ VoiceToggleResult,
+)
+
+__all__ = [
+ "RecordingStartError",
+ "VoiceManager",
+ "VoiceManagerPort",
+ "VoiceToggleResult",
+]
diff --git a/vibe/cli/voice_manager/voice_manager.py b/vibe/cli/voice_manager/voice_manager.py
new file mode 100644
index 0000000..49287e2
--- /dev/null
+++ b/vibe/cli/voice_manager/voice_manager.py
@@ -0,0 +1,186 @@
+from __future__ import annotations
+
+from asyncio import CancelledError, Task, create_task, wait_for
+from collections.abc import Callable
+
+from vibe.cli.voice_manager.voice_manager_port import (
+ RecordingStartError,
+ TranscribeState,
+ VoiceManagerListener,
+ VoiceToggleResult,
+)
+from vibe.core.audio_recorder import AudioRecorderPort
+from vibe.core.audio_recorder.audio_recorder_port import (
+ AlreadyRecordingError,
+ AudioBackendUnavailableError,
+ NoAudioInputDeviceError,
+ RecordingMode,
+)
+from vibe.core.config import VibeConfig
+from vibe.core.logger import logger
+from vibe.core.transcribe.transcribe_client_port import (
+ TranscribeClientPort,
+ TranscribeDone,
+ TranscribeError,
+ TranscribeSessionCreated,
+ TranscribeTextDelta,
+)
+
+TRANSCRIPTION_DRAIN_TIMEOUT = 10.0
+
+
+class VoiceManager:
+ def __init__(
+ self,
+ config_getter: Callable[[], VibeConfig],
+ audio_recorder: AudioRecorderPort,
+ transcribe_client: TranscribeClientPort | None,
+ ) -> None:
+ self._config_getter = config_getter
+ self._audio_recorder = audio_recorder
+ self._transcribe_client = transcribe_client
+ self._transcribe_state = TranscribeState.IDLE
+ self._transcribe_task: Task[None] | None = None
+ self._listeners: list[VoiceManagerListener] = []
+
+ @property
+ def is_enabled(self) -> bool:
+ return self._config_getter().voice_mode_enabled
+
+ @property
+ def transcribe_state(self) -> TranscribeState:
+ return self._transcribe_state
+
+ @property
+ def peak(self) -> float:
+ return self._audio_recorder.peak
+
+ def toggle_voice_mode(self) -> VoiceToggleResult:
+ new_state = not self.is_enabled
+ if not new_state:
+ self.cancel_recording()
+
+ VibeConfig.save_updates({"voice_mode_enabled": new_state})
+
+ for listener in self._listeners:
+ try:
+ listener.on_voice_mode_change(new_state)
+ except Exception:
+ logger.error("Listener raised during voice mode change", exc_info=True)
+
+ return VoiceToggleResult(enabled=new_state)
+
+ def start_recording(self, mode: RecordingMode = RecordingMode.STREAM) -> None:
+ if self._transcribe_state != TranscribeState.IDLE:
+ return
+
+ if self._transcribe_client is None:
+ logger.warning(
+ "Failed to start recording as the transcribe client is missing"
+ )
+ raise RecordingStartError("Transcribe client is not available")
+
+ model = self._config_getter().get_active_transcribe_model()
+
+ try:
+ self._audio_recorder.start(mode, sample_rate=model.sample_rate)
+ except AlreadyRecordingError:
+ raise RecordingStartError("Recording is already in progress")
+ except AudioBackendUnavailableError:
+ raise RecordingStartError("Audio backend is unavailable")
+ except NoAudioInputDeviceError:
+ raise RecordingStartError("No audio input device found")
+
+ self._set_state(TranscribeState.RECORDING)
+ self._transcribe_task = create_task(self._run_transcription())
+
+ async def stop_recording(self) -> None:
+ if self._transcribe_state != TranscribeState.RECORDING:
+ return
+ should_flush_queue = self._audio_recorder.mode == RecordingMode.STREAM
+
+ if should_flush_queue:
+ self._set_state(TranscribeState.FLUSHING)
+ self._audio_recorder.stop(wait_for_queue_drained=should_flush_queue)
+
+ if self._transcribe_task is not None:
+ try:
+ await wait_for(
+ self._transcribe_task, timeout=TRANSCRIPTION_DRAIN_TIMEOUT
+ )
+ except TimeoutError:
+ logger.warning("Transcription task timed out, cancelling")
+ self._transcribe_task.cancel()
+ except CancelledError:
+ pass
+ self._transcribe_task = None
+
+ if self._transcribe_state != TranscribeState.IDLE:
+ self._set_state(TranscribeState.IDLE)
+
+ def cancel_recording(self) -> None:
+ if self._transcribe_state == TranscribeState.IDLE:
+ return
+
+ self._audio_recorder.cancel()
+
+ if self._transcribe_task is not None:
+ self._transcribe_task.cancel()
+ self._transcribe_task = None
+
+ self._set_state(TranscribeState.IDLE)
+
+ def add_listener(self, listener: VoiceManagerListener) -> None:
+ if listener not in self._listeners:
+ self._listeners.append(listener)
+
+ def remove_listener(self, listener: VoiceManagerListener) -> None:
+ try:
+ self._listeners.remove(listener)
+ except ValueError:
+ pass
+
+ async def _run_transcription(self) -> None:
+ if self._transcribe_client is None:
+ return
+
+ try:
+ audio_stream = self._audio_recorder.audio_stream()
+
+ async for event in self._transcribe_client.transcribe(audio_stream):
+ match event:
+ case TranscribeTextDelta(text=text):
+ for listener in self._listeners:
+ try:
+ listener.on_transcribe_text(text)
+ except Exception:
+ logger.error(
+ "Listener raised during transcribe text",
+ exc_info=True,
+ )
+ case TranscribeDone():
+ pass
+ case TranscribeError(message=msg):
+ raise RuntimeError(msg)
+ case TranscribeSessionCreated():
+ pass
+ if self._transcribe_state != TranscribeState.IDLE:
+ self._set_state(TranscribeState.IDLE)
+ except CancelledError:
+ raise
+ except Exception as exc:
+ logger.error("Transcription failed", exc_info=exc)
+ self._audio_recorder.cancel()
+ if self._transcribe_state != TranscribeState.IDLE:
+ self._set_state(TranscribeState.IDLE)
+
+ def _set_state(self, state: TranscribeState) -> None:
+ if self._transcribe_state == state:
+ return
+
+ self._transcribe_state = state
+ for listener in self._listeners:
+ try:
+ listener.on_transcribe_state_change(state)
+ except Exception:
+ logger.error("Listener raised during state change", exc_info=True)
diff --git a/vibe/cli/voice_manager/voice_manager_port.py b/vibe/cli/voice_manager/voice_manager_port.py
new file mode 100644
index 0000000..0224973
--- /dev/null
+++ b/vibe/cli/voice_manager/voice_manager_port.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import StrEnum, auto
+from typing import Protocol
+
+from vibe.core.audio_recorder.audio_recorder_port import RecordingMode
+
+
+class TranscribeState(StrEnum):
+ IDLE = auto()
+ RECORDING = auto()
+ FLUSHING = auto()
+
+
+@dataclass(frozen=True, slots=True)
+class VoiceToggleResult:
+ enabled: bool
+
+
+class RecordingStartError(Exception):
+ pass
+
+
+class VoiceManagerListener:
+ def on_transcribe_state_change(self, state: TranscribeState) -> None:
+ pass
+
+ def on_voice_mode_change(self, enabled: bool) -> None:
+ pass
+
+ def on_transcribe_text(self, text: str) -> None:
+ pass
+
+
+class VoiceManagerPort(Protocol):
+ @property
+ def is_enabled(self) -> bool: ...
+
+ @property
+ def transcribe_state(self) -> TranscribeState: ...
+
+ @property
+ def peak(self) -> float: ...
+
+ def toggle_voice_mode(self) -> VoiceToggleResult: ...
+
+ def start_recording(self, mode: RecordingMode = RecordingMode.STREAM) -> None: ...
+
+ async def stop_recording(self) -> None: ...
+
+ def cancel_recording(self) -> None: ...
+
+ def add_listener(self, listener: VoiceManagerListener) -> None: ...
+
+ def remove_listener(self, listener: VoiceManagerListener) -> None: ...
diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py
index 110e8fa..42a5da6 100644
--- a/vibe/core/agent_loop.py
+++ b/vibe/core/agent_loop.py
@@ -2,13 +2,14 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable, Generator
+import contextlib
from enum import StrEnum, auto
from http import HTTPStatus
import json
from pathlib import Path
from threading import Thread
import time
-from typing import TYPE_CHECKING, Any, Literal, cast
+from typing import TYPE_CHECKING, Any, Literal
from uuid import uuid4
from pydantic import BaseModel
@@ -16,7 +17,7 @@ from pydantic import BaseModel
from vibe.cli.terminal_setup import detect_terminal
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
-from vibe.core.config import Backend, ProviderConfig, VibeConfig
+from vibe.core.config import Backend, ModelConfig, ProviderConfig, VibeConfig
from vibe.core.llm.backend.factory import BACKEND_FACTORY
from vibe.core.llm.exceptions import BackendError
from vibe.core.llm.format import (
@@ -66,7 +67,6 @@ from vibe.core.types import (
ApprovalCallback,
ApprovalResponse,
AssistantEvent,
- AsyncApprovalCallback,
BaseEvent,
CompactEndEvent,
CompactStartEvent,
@@ -78,7 +78,6 @@ from vibe.core.types import (
RateLimitError,
ReasoningEvent,
Role,
- SyncApprovalCallback,
ToolCall,
ToolCallEvent,
ToolResultEvent,
@@ -87,6 +86,7 @@ from vibe.core.types import (
UserMessageEvent,
)
from vibe.core.utils import (
+ CANCELLATION_TAG,
TOOL_ERROR_TAG,
VIBE_STOP_EVENT_TAG,
CancellationReason,
@@ -217,6 +217,10 @@ class AgentLoop:
def agent_profile(self) -> AgentProfile:
return self.agent_manager.active_profile
+ @property
+ def base_config(self) -> VibeConfig:
+ return self._base_config
+
@property
def config(self) -> VibeConfig:
return self.agent_manager.config
@@ -239,6 +243,10 @@ class AgentLoop:
self.config.tools[tool_name].permission = permission
self.tool_manager.invalidate_tool(tool_name)
+ def refresh_config(self) -> None:
+ self._base_config = VibeConfig.load()
+ self.agent_manager.invalidate_config()
+
def emit_new_session_telemetry(self) -> None:
entrypoint = (
self.entrypoint_metadata.agent_entrypoint
@@ -348,15 +356,9 @@ class AgentLoop:
if self._max_price is not None:
self.middleware_pipeline.add(PriceLimitMiddleware(self._max_price))
- active_model = self.config.get_active_model()
- if active_model.auto_compact_threshold > 0:
- self.middleware_pipeline.add(
- AutoCompactMiddleware(active_model.auto_compact_threshold)
- )
- if self.config.context_warnings:
- self.middleware_pipeline.add(
- ContextWarningMiddleware(0.5, active_model.auto_compact_threshold)
- )
+ self.middleware_pipeline.add(AutoCompactMiddleware())
+ if self.config.context_warnings:
+ self.middleware_pipeline.add(ContextWarningMiddleware(0.5))
self.middleware_pipeline.add(
ReadOnlyAgentMiddleware(
@@ -425,6 +427,10 @@ class AgentLoop:
messages=self.messages, stats=self.stats, config=self.config
)
+ def _build_metadata(self) -> dict[str, str]:
+ base = self.entrypoint_metadata.model_dump() if self.entrypoint_metadata else {}
+ return base | {"session_id": self.session_id}
+
def _get_extra_headers(self, provider: ProviderConfig) -> dict[str, str]:
headers: dict[str, str] = {
"user-agent": get_user_agent(provider.backend),
@@ -577,39 +583,35 @@ class AgentLoop:
tool_instance = self.tool_manager.get(tool_call.tool_name)
except Exception as exc:
error_msg = f"Error getting tool '{tool_call.tool_name}': {exc}"
- yield ToolResultEvent(
- tool_name=tool_call.tool_name,
- tool_class=tool_call.tool_class,
- error=error_msg,
- tool_call_id=tool_call.call_id,
- )
- self._handle_tool_response(tool_call, error_msg, "failure")
+ yield self._tool_failure_event(tool_call, error_msg)
return
- decision = await self._should_execute_tool(
- tool_instance, tool_call.validated_args, tool_call.call_id
- )
-
- if decision.verdict == ToolExecutionResponse.SKIP:
- self.stats.tool_calls_rejected += 1
- skip_reason = decision.feedback or str(
- get_user_cancellation_message(
- CancellationReason.TOOL_SKIPPED, tool_call.tool_name
- )
- )
- yield ToolResultEvent(
- tool_name=tool_call.tool_name,
- tool_class=tool_call.tool_class,
- skipped=True,
- skip_reason=skip_reason,
- tool_call_id=tool_call.call_id,
- )
- self._handle_tool_response(tool_call, skip_reason, "skipped", decision)
- return
-
- self.stats.tool_calls_agreed += 1
-
+ decision: ToolDecision | None = None
try:
+ decision = await self._should_execute_tool(
+ tool_instance, tool_call.validated_args, tool_call.call_id
+ )
+
+ if decision.verdict == ToolExecutionResponse.SKIP:
+ self.stats.tool_calls_rejected += 1
+ skip_reason = decision.feedback or str(
+ get_user_cancellation_message(
+ CancellationReason.TOOL_SKIPPED, tool_call.tool_name
+ )
+ )
+ yield ToolResultEvent(
+ tool_name=tool_call.tool_name,
+ tool_class=tool_call.tool_class,
+ skipped=True,
+ skip_reason=skip_reason,
+ cancelled=f"<{CANCELLATION_TAG}>" in skip_reason,
+ tool_call_id=tool_call.call_id,
+ )
+ self._handle_tool_response(tool_call, skip_reason, "skipped", decision)
+ return
+
+ self.stats.tool_calls_agreed += 1
+
start_time = time.perf_counter()
result_model = None
async for item in tool_instance.invoke(
@@ -637,6 +639,9 @@ class AgentLoop:
result_dict = result_model.model_dump()
text = "\n".join(f"{k}: {v}" for k, v in result_dict.items())
+ extra = tool_instance.get_result_extra(result_model)
+ if extra:
+ text += "\n\n" + extra
self._handle_tool_response(
tool_call, text, "success", decision, result_dict
)
@@ -644,6 +649,7 @@ class AgentLoop:
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
result=result_model,
+ cancelled=getattr(result_model, "cancelled", False),
duration=duration,
tool_call_id=tool_call.call_id,
)
@@ -653,35 +659,27 @@ class AgentLoop:
cancel = str(
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
)
- yield ToolResultEvent(
- tool_name=tool_call.tool_name,
- tool_class=tool_call.tool_class,
- error=cancel,
- tool_call_id=tool_call.call_id,
- )
- self._handle_tool_response(tool_call, cancel, "failure", decision)
+ self.stats.tool_calls_failed += 1
+ yield self._tool_failure_event(tool_call, cancel, decision, cancelled=True)
raise
- except (ToolError, ToolPermissionError) as exc:
+ except Exception as exc:
error_msg = f"<{TOOL_ERROR_TAG}>{tool_instance.get_name()} failed: {exc}{TOOL_ERROR_TAG}>"
- yield ToolResultEvent(
- tool_name=tool_call.tool_name,
- tool_class=tool_call.tool_class,
- error=error_msg,
- tool_call_id=tool_call.call_id,
- )
if isinstance(exc, ToolPermissionError):
self.stats.tool_calls_agreed -= 1
self.stats.tool_calls_rejected += 1
else:
self.stats.tool_calls_failed += 1
- self._handle_tool_response(tool_call, error_msg, "failure", decision)
+ yield self._tool_failure_event(tool_call, error_msg, decision)
async def _handle_tool_calls(
self, resolved: ResolvedMessage
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
async for event in self._emit_failed_tool_events(resolved.failed_calls):
yield event
+ if not resolved.tool_calls:
+ return
+
for tool_call in resolved.tool_calls:
yield ToolCallEvent(
tool_name=tool_call.tool_name,
@@ -689,8 +687,62 @@ class AgentLoop:
args=tool_call.validated_args,
tool_call_id=tool_call.call_id,
)
- async for event in self._process_one_tool_call(tool_call):
+
+ async for event in self._run_tools_concurrently(resolved.tool_calls):
+ yield event
+
+ async def _execute_tool_to_queue(
+ self,
+ tc: ResolvedToolCall,
+ queue: asyncio.Queue[ToolCallEvent | ToolResultEvent | ToolStreamEvent | None],
+ ) -> None:
+ """Run a single tool call, sending events to the queue."""
+ async for event in self._process_one_tool_call(tc):
+ await queue.put(event)
+
+ async def _run_tools_concurrently(
+ self, tool_calls: list[ResolvedToolCall]
+ ) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
+ """Execute multiple tool calls concurrently, yielding events as they arrive."""
+ queue: asyncio.Queue[
+ ToolCallEvent | ToolResultEvent | ToolStreamEvent | None
+ ] = asyncio.Queue()
+
+ tasks = [
+ asyncio.create_task(self._execute_tool_to_queue(tc, queue))
+ for tc in tool_calls
+ ]
+
+ async def _signal_when_all_done() -> None:
+ try:
+ await asyncio.gather(*tasks, return_exceptions=True)
+ finally:
+ await queue.put(None)
+
+ monitor = asyncio.create_task(_signal_when_all_done())
+
+ try:
+ while True:
+ event = await queue.get()
+ if event is None:
+ break
yield event
+ except GeneratorExit:
+ for t in tasks:
+ if not t.done():
+ t.cancel()
+ raise
+ except asyncio.CancelledError:
+ for t in tasks:
+ if not t.done():
+ t.cancel()
+ await asyncio.gather(*tasks, return_exceptions=True)
+ raise
+ finally:
+ if not monitor.done():
+ monitor.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await monitor
def _handle_tool_response(
self,
@@ -714,8 +766,27 @@ class AgentLoop:
result=result,
)
- async def _chat(self, max_tokens: int | None = None) -> LLMChunk:
- active_model = self.config.get_active_model()
+ def _tool_failure_event(
+ self,
+ tool_call: ResolvedToolCall,
+ error_msg: str,
+ decision: ToolDecision | None = None,
+ cancelled: bool = False,
+ ) -> ToolResultEvent:
+ """Create a ToolResultEvent for a failed tool and record the failure."""
+ self._handle_tool_response(tool_call, error_msg, "failure", decision)
+ return ToolResultEvent(
+ tool_name=tool_call.tool_name,
+ tool_class=tool_call.tool_class,
+ error=error_msg,
+ cancelled=cancelled,
+ tool_call_id=tool_call.call_id,
+ )
+
+ async def _chat(
+ self, max_tokens: int | None = None, model_override: ModelConfig | None = None
+ ) -> LLMChunk:
+ active_model = model_override or self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
available_tools = self.format_handler.get_available_tools(self.tool_manager)
@@ -731,9 +802,7 @@ class AgentLoop:
tool_choice=tool_choice,
extra_headers=self._get_extra_headers(provider),
max_tokens=max_tokens,
- metadata=self.entrypoint_metadata.model_dump()
- if self.entrypoint_metadata
- else None,
+ metadata=self._build_metadata(),
)
end_time = time.perf_counter()
@@ -777,9 +846,7 @@ class AgentLoop:
tool_choice=tool_choice,
extra_headers=self._get_extra_headers(provider),
max_tokens=max_tokens,
- metadata=self.entrypoint_metadata.model_dump()
- if self.entrypoint_metadata
- else None,
+ metadata=self._build_metadata(),
):
processed_message = self.format_handler.process_api_response_message(
chunk.message
@@ -855,26 +922,17 @@ class AgentLoop:
approval_type=ToolPermission.ASK,
feedback="Tool execution not permitted.",
)
- if asyncio.iscoroutinefunction(self.approval_callback):
- async_callback = cast(AsyncApprovalCallback, self.approval_callback)
- response, feedback = await async_callback(tool_name, args, tool_call_id)
- else:
- sync_callback = cast(SyncApprovalCallback, self.approval_callback)
- response, feedback = sync_callback(tool_name, args, tool_call_id)
+ response, feedback = await self.approval_callback(tool_name, args, tool_call_id)
match response:
case ApprovalResponse.YES:
- return ToolDecision(
- verdict=ToolExecutionResponse.EXECUTE,
- approval_type=ToolPermission.ASK,
- feedback=feedback,
- )
- case ApprovalResponse.NO:
- return ToolDecision(
- verdict=ToolExecutionResponse.SKIP,
- approval_type=ToolPermission.ASK,
- feedback=feedback,
- )
+ verdict = ToolExecutionResponse.EXECUTE
+ case _:
+ verdict = ToolExecutionResponse.SKIP
+
+ return ToolDecision(
+ verdict=verdict, approval_type=ToolPermission.ASK, feedback=feedback
+ )
def _clean_message_history(self) -> None:
ACCEPTABLE_HISTORY_SIZE = 2
@@ -892,17 +950,19 @@ class AgentLoop:
expected_responses = len(msg.tool_calls)
if expected_responses > 0:
- actual_responses = 0
+ responded_ids: set[str] = set()
j = i + 1
while j < len(self.messages) and self.messages[j].role == "tool":
- actual_responses += 1
+ if self.messages[j].tool_call_id:
+ responded_ids.add(self.messages[j].tool_call_id)
j += 1
- if actual_responses < expected_responses:
- insertion_point = i + 1 + actual_responses
+ if len(responded_ids) < expected_responses:
+ insertion_point = j
- for call_idx in range(actual_responses, expected_responses):
- tool_call_data = msg.tool_calls[call_idx]
+ for tool_call_data in msg.tool_calls:
+ if (tool_call_data.id or "") in responded_ids:
+ continue
empty_response = LLMMessage(
role=Role.tool,
@@ -990,7 +1050,9 @@ class AgentLoop:
self.messages.append(
LLMMessage(role=Role.user, content=summary_request)
)
- summary_result = await self._chat()
+ summary_result = await self._chat(
+ model_override=self.config.get_compaction_model()
+ )
if summary_result.usage is None:
raise AgentLoopLLMResponseError(
@@ -1010,9 +1072,7 @@ class AgentLoop:
messages=self.messages,
tools=self.format_handler.get_available_tools(self.tool_manager),
extra_headers={"user-agent": get_user_agent(provider.backend)},
- metadata=self.entrypoint_metadata.model_dump()
- if self.entrypoint_metadata
- else None,
+ metadata=self._build_metadata(),
)
self.stats.context_tokens = actual_context_tokens
diff --git a/vibe/core/agents/manager.py b/vibe/core/agents/manager.py
index e6b9c23..36c4921 100644
--- a/vibe/core/agents/manager.py
+++ b/vibe/core/agents/manager.py
@@ -50,19 +50,25 @@ class AgentManager:
@property
def available_agents(self) -> dict[str, AgentProfile]:
+ installed = self._config.installed_agents
+ base = {
+ name: profile
+ for name, profile in self._available.items()
+ if not profile.install_required or name in installed
+ }
if self._config.enabled_agents:
return {
name: profile
- for name, profile in self._available.items()
+ for name, profile in base.items()
if name_matches(name, self._config.enabled_agents)
}
if self._config.disabled_agents:
return {
name: profile
- for name, profile in self._available.items()
+ for name, profile in base.items()
if not name_matches(name, self._config.disabled_agents)
}
- return dict(self._available)
+ return base
@property
def config(self) -> VibeConfig:
diff --git a/vibe/core/agents/models.py b/vibe/core/agents/models.py
index eb3ca71..b890e10 100644
--- a/vibe/core/agents/models.py
+++ b/vibe/core/agents/models.py
@@ -41,6 +41,7 @@ class BuiltinAgentName(StrEnum):
ACCEPT_EDITS = "accept-edits"
AUTO_APPROVE = "auto-approve"
EXPLORE = "explore"
+ LEAN = "lean"
@dataclass(frozen=True)
@@ -51,6 +52,7 @@ class AgentProfile:
safety: AgentSafety
agent_type: AgentType = AgentType.AGENT
overrides: dict[str, Any] = field(default_factory=dict)
+ install_required: bool = False
def apply_to_config(self, base: VibeConfig) -> VibeConfig:
from vibe.core.config import VibeConfig as VC
@@ -134,10 +136,51 @@ EXPLORE = AgentProfile(
overrides={"enabled_tools": ["grep", "read_file"], "system_prompt_id": "explore"},
)
+LEAN = AgentProfile(
+ name=BuiltinAgentName.LEAN,
+ display_name="Lean",
+ description="Specialized mode for Lean 4 code analysis, proof assistance, and theorem proving",
+ safety=AgentSafety.NEUTRAL,
+ agent_type=AgentType.AGENT,
+ install_required=True,
+ overrides={
+ "system_prompt_id": "lean",
+ "active_model": "leanstral",
+ "providers": [
+ {
+ "name": "mistral-testing",
+ "api_base": "https://api.mistral.ai/v1",
+ "api_key_env_var": "MISTRAL_API_KEY",
+ "api_style": "reasoning",
+ "backend": "generic",
+ }
+ ],
+ "models": [
+ {
+ "name": "labs-leanstral-2603",
+ "provider": "mistral-testing",
+ "alias": "leanstral",
+ "thinking": "high",
+ "temperature": 1.0,
+ "auto_compact_threshold": 168_000,
+ }
+ ],
+ "compaction_model": {
+ "name": "mistral-vibe-cli-latest",
+ "provider": "mistral-testing",
+ "alias": "devstral-compact",
+ "temperature": 0.2,
+ "thinking": "off",
+ },
+ "tools": {"bash": {"default_timeout": 1200}},
+ },
+)
+
BUILTIN_AGENTS: dict[str, AgentProfile] = {
BuiltinAgentName.DEFAULT: DEFAULT,
BuiltinAgentName.PLAN: PLAN,
BuiltinAgentName.ACCEPT_EDITS: ACCEPT_EDITS,
BuiltinAgentName.AUTO_APPROVE: AUTO_APPROVE,
BuiltinAgentName.EXPLORE: EXPLORE,
+ BuiltinAgentName.LEAN: LEAN,
}
diff --git a/vibe/core/audio_recorder/__init__.py b/vibe/core/audio_recorder/__init__.py
new file mode 100644
index 0000000..f8fa2f9
--- /dev/null
+++ b/vibe/core/audio_recorder/__init__.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from vibe.core.audio_recorder.audio_recorder import AudioRecorder
+from vibe.core.audio_recorder.audio_recorder_port import (
+ AlreadyRecordingError,
+ AudioBackendUnavailableError,
+ AudioRecorderPort,
+ AudioRecording,
+ IncompatibleSampleRateError,
+ NoAudioInputDeviceError,
+ RecordingMode,
+)
+
+__all__ = [
+ "AlreadyRecordingError",
+ "AudioBackendUnavailableError",
+ "AudioRecorder",
+ "AudioRecorderPort",
+ "AudioRecording",
+ "IncompatibleSampleRateError",
+ "NoAudioInputDeviceError",
+ "RecordingMode",
+]
diff --git a/vibe/core/audio_recorder/audio_recorder.py b/vibe/core/audio_recorder/audio_recorder.py
new file mode 100644
index 0000000..b3a10d5
--- /dev/null
+++ b/vibe/core/audio_recorder/audio_recorder.py
@@ -0,0 +1,286 @@
+from __future__ import annotations
+
+import asyncio
+from collections.abc import AsyncGenerator, Callable
+import io
+import struct
+import threading
+import time
+from typing import TYPE_CHECKING
+import wave
+
+from vibe.core.audio_recorder.audio_recorder_port import (
+ AlreadyRecordingError,
+ AudioBackendUnavailableError,
+ AudioRecording,
+ IncompatibleSampleRateError,
+ NoAudioInputDeviceError,
+ RecordingMode,
+)
+from vibe.core.logger import logger
+
+try:
+ import sounddevice as sd
+
+ if TYPE_CHECKING:
+ from sounddevice import CallbackFlags, RawInputStream
+except OSError:
+ sd = None # type: ignore[assignment]
+
+DEFAULT_SAMPLE_RATE = 48_000
+DEFAULT_CHANNELS = 1
+DTYPE = "int16"
+DEFAULT_BLOCKSIZE = 4096
+DEFAULT_SAMPLE_WIDTH = 2 # 16-bit = 2 bytes
+INT16_ABS_MAX = 2**15 - 1
+DRAIN_TIMEOUT = 5.0
+DEFAULT_MAX_DURATION = 300.0 # 5 min
+
+
+class AudioRecorder:
+ """Records audio from the default microphone using sounddevice.
+
+ Supports both buffer mode (stop returns WAV bytes) and streaming
+ mode (async generator yields chunks).
+ """
+
+ def __init__(self) -> None:
+ self._lock = threading.Lock()
+ self._mode: RecordingMode = RecordingMode.BUFFER
+ self._stream: RawInputStream | None = None
+ self._frames: list[bytes] = []
+ self._peak: float = 0.0
+ self._recording: bool = False
+ self._start_time: float = 0.0
+ self._loop: asyncio.AbstractEventLoop | None = None
+ self._audio_queue: asyncio.Queue[bytes | None] | None = None
+ self._audio_queue_drained: threading.Event | None = None
+ self._max_duration_timer: threading.Timer | None = None
+ self._on_expire: Callable[[AudioRecording], object] | None = None
+
+ @property
+ def is_recording(self) -> bool:
+ return self._recording
+
+ @property
+ def mode(self) -> RecordingMode:
+ return self._mode
+
+ @property
+ def peak(self) -> float:
+ """Current audio peak level normalized to [0.0, 1.0], updated per audio block."""
+ return self._peak
+
+ def start(
+ self,
+ mode: RecordingMode,
+ *,
+ sample_rate: int = DEFAULT_SAMPLE_RATE,
+ channels: int = DEFAULT_CHANNELS,
+ max_duration: float = DEFAULT_MAX_DURATION,
+ on_expire: Callable[[AudioRecording], object] | None = None,
+ ) -> None:
+ with self._lock:
+ if self._recording:
+ raise AlreadyRecordingError("Already recording")
+
+ if not sd:
+ error_message = "sounddevice is not available, audio recording disabled"
+ logger.error(error_message)
+ raise AudioBackendUnavailableError(error_message)
+
+ try:
+ sample_rate = self._guard_audio_input(sample_rate, channels)
+ except NoAudioInputDeviceError as exc:
+ logger.error("No audio input device available, recording disabled")
+ raise exc
+ except IncompatibleSampleRateError as exc:
+ logger.warning(
+ "Requested sample rate %d Hz not supported, falling back to %d Hz",
+ sample_rate,
+ exc.fallback_sample_rate,
+ )
+ sample_rate = exc.fallback_sample_rate
+
+ self._mode = mode
+ self._sample_rate = sample_rate
+ self._channels = channels
+ self._peak = 0.0
+ self._start_time = time.monotonic()
+ self._frames = []
+
+ if mode == RecordingMode.BUFFER:
+ self._audio_queue = None
+ self._loop = None
+ self._audio_queue_drained = None
+ else:
+ self._audio_queue_drained = threading.Event()
+ try:
+ self._loop = asyncio.get_running_loop()
+ self._audio_queue = asyncio.Queue()
+ except RuntimeError:
+ self._loop = None
+ self._audio_queue = None
+
+ self._stream = sd.RawInputStream(
+ samplerate=self._sample_rate,
+ channels=self._channels,
+ dtype=DTYPE,
+ blocksize=DEFAULT_BLOCKSIZE,
+ callback=self._audio_callback,
+ )
+ self._stream.start()
+ self._recording = True
+
+ self._on_expire = on_expire
+ self._start_max_duration_timer(max_duration)
+
+ def stop(self, *, wait_for_queue_drained: bool = True) -> AudioRecording:
+ with self._lock:
+ if not self._recording or self._stream is None:
+ return AudioRecording(data=b"", duration=0.0)
+
+ self._reset_max_duration_timer()
+
+ self._stop_stream()
+ self._recording = False
+ duration = time.monotonic() - self._start_time
+
+ if self._mode == RecordingMode.BUFFER:
+ wav_data = self._encode_wav()
+ self._frames = []
+ return AudioRecording(data=wav_data, duration=duration)
+
+ loop = self._loop
+ self._push_sentinel()
+ try:
+ on_event_loop = asyncio.get_running_loop() is loop
+ except RuntimeError:
+ on_event_loop = False
+ if (
+ wait_for_queue_drained
+ and self._audio_queue_drained is not None
+ and not on_event_loop
+ ):
+ self._audio_queue_drained.wait(timeout=DRAIN_TIMEOUT)
+ return AudioRecording(data=b"", duration=duration)
+
+ def cancel(self) -> None:
+ with self._lock:
+ if not self._recording or self._stream is None:
+ return
+
+ self._reset_max_duration_timer()
+ self._stop_stream()
+ self._recording = False
+
+ if self._mode == RecordingMode.BUFFER:
+ self._frames = []
+ else:
+ self._push_sentinel()
+
+ async def audio_stream(self) -> AsyncGenerator[bytes, None]:
+ queue = self._audio_queue
+ if queue is None:
+ return
+ audio_queue_drained = self._audio_queue_drained
+
+ try:
+ while True:
+ chunk = await queue.get()
+ if chunk is None:
+ break
+ yield chunk
+ finally:
+ if audio_queue_drained is not None:
+ audio_queue_drained.set()
+
+ def _audio_callback(
+ self, indata: bytes, frames: int, time_info: object, status: CallbackFlags
+ ) -> None:
+ if status:
+ logger.warning("Audio callback status: %s", status)
+
+ raw = bytes(indata)
+
+ n_samples = frames * self._channels
+ if n_samples > 0:
+ samples = struct.unpack(f"<{n_samples}h", raw)
+ self._peak = min(max(abs(s) for s in samples) / INT16_ABS_MAX, 1.0)
+
+ if self._mode == RecordingMode.BUFFER:
+ self._frames.append(raw)
+
+ if (
+ self._mode == RecordingMode.STREAM
+ and self._loop is not None
+ and self._audio_queue is not None
+ ):
+ self._loop.call_soon_threadsafe(self._audio_queue.put_nowait, raw)
+
+ def _stop_stream(self) -> None:
+ if self._stream is not None:
+ self._stream.stop()
+ self._stream.close()
+ self._stream = None
+
+ def _push_sentinel(self) -> None:
+ """Push None to the audio queue to signal end-of-stream to the consumer."""
+ if self._loop is not None and self._audio_queue is not None:
+ self._loop.call_soon_threadsafe(self._audio_queue.put_nowait, None)
+ self._audio_queue = None
+ self._loop = None
+
+ @staticmethod
+ def _guard_audio_input(sample_rate: int, channels: int) -> int:
+ if sd is None:
+ raise RuntimeError("sounddevice is not available")
+ try:
+ device_info = sd.query_devices(kind="input")
+ except Exception as exc:
+ raise NoAudioInputDeviceError("No audio input device available") from exc
+
+ try:
+ sd.check_input_settings(
+ samplerate=sample_rate, channels=channels, dtype=DTYPE
+ )
+ except sd.PortAudioError as exc:
+ fallback = int(device_info["default_samplerate"])
+ raise IncompatibleSampleRateError(
+ f"Requested sample rate {sample_rate} Hz is not supported by the default "
+ f"input device; device default is {fallback} Hz",
+ fallback_sample_rate=fallback,
+ ) from exc
+
+ return sample_rate
+
+ def _encode_wav(self) -> bytes:
+ buf = io.BytesIO()
+ with wave.open(buf, "wb") as wf:
+ wf.setnchannels(self._channels)
+ wf.setsampwidth(DEFAULT_SAMPLE_WIDTH)
+ wf.setframerate(self._sample_rate)
+ wf.writeframes(b"".join(self._frames))
+ return buf.getvalue()
+
+ def _on_max_duration_expired(self) -> None:
+ result = self.stop()
+ if self._on_expire is not None:
+ self._on_expire(result)
+
+ def _start_max_duration_timer(self, max_duration: float) -> None:
+ if max_duration <= 0:
+ return
+
+ self._max_duration_timer = threading.Timer(
+ max_duration, self._on_max_duration_expired
+ )
+ self._max_duration_timer.daemon = True
+ self._max_duration_timer.start()
+
+ def _reset_max_duration_timer(self) -> None:
+ if self._max_duration_timer is None:
+ return
+
+ self._max_duration_timer.cancel()
+ self._max_duration_timer = None
diff --git a/vibe/core/audio_recorder/audio_recorder_port.py b/vibe/core/audio_recorder/audio_recorder_port.py
new file mode 100644
index 0000000..a77d3dd
--- /dev/null
+++ b/vibe/core/audio_recorder/audio_recorder_port.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator, Callable
+from dataclasses import dataclass
+from enum import StrEnum, auto
+from typing import Protocol
+
+
+class RecordingMode(StrEnum):
+ BUFFER = auto()
+ STREAM = auto()
+
+
+@dataclass(frozen=True, slots=True)
+class AudioRecording:
+ """Result of a completed recording."""
+
+ data: bytes
+ duration: float
+
+
+class AlreadyRecordingError(Exception):
+ pass
+
+
+class AudioBackendUnavailableError(Exception):
+ pass
+
+
+class NoAudioInputDeviceError(Exception):
+ pass
+
+
+class IncompatibleSampleRateError(Exception):
+ def __init__(self, message: str, fallback_sample_rate: int) -> None:
+ super().__init__(message)
+ self.fallback_sample_rate = fallback_sample_rate
+
+
+class AudioRecorderPort(Protocol):
+ @property
+ def is_recording(self) -> bool: ...
+
+ @property
+ def peak(self) -> float: ...
+
+ @property
+ def mode(self) -> RecordingMode: ...
+
+ def start(
+ self,
+ mode: RecordingMode,
+ *,
+ sample_rate: int = ...,
+ channels: int = ...,
+ max_duration: float = ...,
+ on_expire: Callable[[AudioRecording], object] | None = ...,
+ ) -> None: ...
+
+ def stop(self, *, wait_for_queue_drained: bool = ...) -> AudioRecording: ...
+
+ def cancel(self) -> None: ...
+
+ def audio_stream(self) -> AsyncGenerator[bytes, None]: ...
diff --git a/vibe/core/autocompletion/completers.py b/vibe/core/autocompletion/completers.py
index 827b1b5..5b90c64 100644
--- a/vibe/core/autocompletion/completers.py
+++ b/vibe/core/autocompletion/completers.py
@@ -5,6 +5,10 @@ from pathlib import Path
from typing import NamedTuple
from vibe.core.autocompletion.file_indexer import FileIndexer, IndexEntry
+from vibe.core.autocompletion.file_indexer.store import (
+ ASCII_CODEPOINT_LIMIT,
+ build_ascii_mask,
+)
from vibe.core.autocompletion.fuzzy import fuzzy_match
DEFAULT_MAX_ENTRIES_TO_PROCESS = 32000
@@ -67,6 +71,18 @@ class CommandCompleter(Completer):
class PathCompleter(Completer):
+ class MatchRank(NamedTuple):
+ exact_directory: int
+ immediate_child_of_exact_path: int
+ exact_filename: int
+ preferred_stem_match: int
+ exact_stem: int
+ stem_prefix: int
+ name_prefix: int
+ extension_match: int
+ fuzzy_score: float
+ shallow_path: int
+
def __init__(
self,
max_entries_to_process: int = DEFAULT_MAX_ENTRIES_TO_PROCESS,
@@ -82,6 +98,7 @@ class PathCompleter(Completer):
search_pattern: str
path_prefix: str
immediate_only: bool
+ search_pattern_ascii_mask: int | None
def _extract_partial(self, before_cursor: str) -> str | None:
if "@" not in before_cursor:
@@ -97,11 +114,16 @@ class PathCompleter(Completer):
def _build_search_context(self, partial_path: str) -> _SearchContext:
suffix = partial_path.split("/")[-1]
+ search_pattern_ascii_mask = self._build_query_ascii_mask(partial_path)
if not partial_path:
# "@" => show top-level dir and files
return self._SearchContext(
- search_pattern="", path_prefix="", suffix=suffix, immediate_only=True
+ search_pattern="",
+ path_prefix="",
+ suffix=suffix,
+ immediate_only=True,
+ search_pattern_ascii_mask=search_pattern_ascii_mask,
)
if partial_path.endswith("/"):
@@ -111,6 +133,7 @@ class PathCompleter(Completer):
path_prefix=partial_path,
suffix=suffix,
immediate_only=True,
+ search_pattern_ascii_mask=search_pattern_ascii_mask,
)
return self._SearchContext(
@@ -119,29 +142,40 @@ class PathCompleter(Completer):
path_prefix="",
suffix=suffix,
immediate_only=False,
+ search_pattern_ascii_mask=search_pattern_ascii_mask,
)
+ def _build_query_ascii_mask(self, pattern: str) -> int | None:
+ if any(ord(char) >= ASCII_CODEPOINT_LIMIT for char in pattern):
+ return None
+ return build_ascii_mask(pattern.lower())
+
+ def _is_immediate_child_of_prefix(self, path_str: str, prefix: str) -> bool:
+ prefix_without_slash = prefix.rstrip("/")
+ prefix_with_slash = f"{prefix_without_slash}/"
+
+ if path_str.startswith(prefix_with_slash):
+ after_prefix = path_str[len(prefix_with_slash) :]
+ else:
+ idx = path_str.find(prefix_with_slash)
+ if idx == -1 or (idx > 0 and path_str[idx - 1] != "/"):
+ return False
+ after_prefix = path_str[idx + len(prefix_with_slash) :]
+
+ return bool(after_prefix) and "/" not in after_prefix
+
def _matches_prefix(self, entry: IndexEntry, context: _SearchContext) -> bool:
path_str = entry.rel
if context.path_prefix:
prefix_without_slash = context.path_prefix.rstrip("/")
- prefix_with_slash = f"{prefix_without_slash}/"
if path_str == prefix_without_slash and entry.is_dir:
# do not suggest the dir itself (e.g. "@src/" => don't suggest "@src/")
return False
- if path_str.startswith(prefix_with_slash):
- after_prefix = path_str[len(prefix_with_slash) :]
- else:
- idx = path_str.find(prefix_with_slash)
- if idx == -1 or (idx > 0 and path_str[idx - 1] != "/"):
- return False
- after_prefix = path_str[idx + len(prefix_with_slash) :]
-
# only suggest files/dirs that are immediate children of the prefix
- return bool(after_prefix) and "/" not in after_prefix
+ return self._is_immediate_child_of_prefix(path_str, context.path_prefix)
if context.immediate_only and "/" in path_str:
# when user just typed "@", only show top-level entries
@@ -153,15 +187,72 @@ class PathCompleter(Completer):
def _is_visible(self, entry: IndexEntry, context: _SearchContext) -> bool:
return not (entry.name.startswith(".") and not context.suffix.startswith("."))
+ def _can_possibly_fuzzy_match(
+ self, entry: IndexEntry, context: _SearchContext
+ ) -> bool:
+ if context.search_pattern_ascii_mask is None:
+ return True
+ return (
+ entry.ascii_mask & context.search_pattern_ascii_mask
+ ) == context.search_pattern_ascii_mask
+
def _format_label(self, entry: IndexEntry) -> str:
suffix = "/" if entry.is_dir else ""
return f"@{entry.rel}{suffix}"
+ def _build_match_rank(
+ self, entry: IndexEntry, context: _SearchContext, fuzzy_score: float
+ ) -> MatchRank:
+ query = context.suffix.lower()
+ if not query:
+ return self.MatchRank(
+ exact_directory=0,
+ immediate_child_of_exact_path=0,
+ exact_filename=0,
+ preferred_stem_match=0,
+ exact_stem=0,
+ stem_prefix=0,
+ name_prefix=0,
+ extension_match=0,
+ fuzzy_score=fuzzy_score,
+ shallow_path=-entry.rel.count("/"),
+ )
+
+ name = entry.name.lower()
+ rel = entry.rel.lower()
+ stem = Path(entry.name).stem.lower()
+ extension = Path(entry.name).suffix.lower()
+ query_extension = Path(query).suffix.lower()
+ query_stem = Path(query).stem.lower()
+ query_looks_like_filename = "." in query
+ query_looks_like_path = "/" in context.search_pattern
+ exact_directory = int(entry.is_dir and rel == context.search_pattern.lower())
+ immediate_child_of_exact_path = int(
+ query_looks_like_path
+ and self._is_immediate_child_of_prefix(rel, context.search_pattern.lower())
+ )
+
+ return self.MatchRank(
+ exact_directory=exact_directory,
+ immediate_child_of_exact_path=immediate_child_of_exact_path,
+ exact_filename=int(query_looks_like_filename and name == query),
+ preferred_stem_match=int(stem == query and extension != ".lock"),
+ exact_stem=int(
+ stem == query or (query_looks_like_filename and stem == query_stem)
+ ),
+ stem_prefix=int(
+ stem.startswith(query_stem if query_looks_like_filename else query)
+ ),
+ name_prefix=int(name.startswith(query)),
+ extension_match=int(bool(query_extension) and extension == query_extension),
+ fuzzy_score=fuzzy_score,
+ shallow_path=-entry.rel.count("/"),
+ )
+
def _score_matches(
self, entries: list[IndexEntry], context: _SearchContext
- ) -> list[tuple[str, float]]:
- scored_matches: list[tuple[str, float]] = []
- MAX_MATCHES = 50
+ ) -> list[tuple[str, PathCompleter.MatchRank]]:
+ scored_matches: list[tuple[str, PathCompleter.MatchRank]] = []
for i, entry in enumerate(entries):
if i >= self._max_entries_to_process:
@@ -176,24 +267,27 @@ class PathCompleter(Completer):
label = self._format_label(entry)
if not context.search_pattern:
- scored_matches.append((label, 0.0))
+ rank = self._build_match_rank(entry, context, 0.0)
+ scored_matches.append((label, rank))
if len(scored_matches) >= self._target_matches:
break
continue
+ if not self._can_possibly_fuzzy_match(entry, context):
+ continue
+
match_result = fuzzy_match(
context.search_pattern, entry.rel, entry.rel_lower
)
if match_result.matched:
- scored_matches.append((label, match_result.score))
- if (
- len(scored_matches) >= self._target_matches
- and match_result.score > MAX_MATCHES
- ):
- break
+ rank = self._build_match_rank(entry, context, match_result.score)
+ scored_matches.append((label, rank))
- scored_matches.sort(key=lambda x: (-x[1], x[0]))
- return scored_matches
+ # Sort alphabetically first, then by descending rank; Python's stable sort
+ # keeps the label order for entries with equal ranks.
+ scored_matches.sort(key=lambda x: x[0])
+ scored_matches.sort(key=lambda x: x[1], reverse=True)
+ return scored_matches[: self._target_matches]
def _collect_matches(self, text: str, cursor_pos: int) -> list[str]:
before_cursor = text[:cursor_pos]
diff --git a/vibe/core/autocompletion/file_indexer/store.py b/vibe/core/autocompletion/file_indexer/store.py
index 8efc47d..88c89e2 100644
--- a/vibe/core/autocompletion/file_indexer/store.py
+++ b/vibe/core/autocompletion/file_indexer/store.py
@@ -8,6 +8,8 @@ from pathlib import Path
from vibe.core.autocompletion.file_indexer.ignore_rules import IgnoreRules
from vibe.core.autocompletion.file_indexer.watcher import Change
+ASCII_CODEPOINT_LIMIT = 128
+
@dataclass(slots=True)
class FileIndexStats:
@@ -22,6 +24,17 @@ class IndexEntry:
name: str
path: Path
is_dir: bool
+ ascii_mask: int
+
+
+def build_ascii_mask(value: str) -> int:
+ mask = 0
+ for char in value:
+ codepoint = ord(char)
+ if codepoint >= ASCII_CODEPOINT_LIMIT:
+ continue
+ mask |= 1 << codepoint
+ return mask
class FileIndexStore:
@@ -118,8 +131,14 @@ class FileIndexStore:
) -> IndexEntry | None:
if self._ignore_rules.should_ignore(rel_str, name, is_dir):
return None
+ rel_lower = rel_str.lower()
return IndexEntry(
- rel=rel_str, rel_lower=rel_str.lower(), name=name, path=path, is_dir=is_dir
+ rel=rel_str,
+ rel_lower=rel_lower,
+ name=name,
+ path=path,
+ is_dir=is_dir,
+ ascii_mask=build_ascii_mask(rel_lower),
)
def _walk_directory(
diff --git a/vibe/core/config/__init__.py b/vibe/core/config/__init__.py
index 6bcfb9c..1a50747 100644
--- a/vibe/core/config/__init__.py
+++ b/vibe/core/config/__init__.py
@@ -4,6 +4,8 @@ from vibe.core.config._settings import (
DEFAULT_MISTRAL_API_ENV_KEY,
DEFAULT_MODELS,
DEFAULT_PROVIDERS,
+ DEFAULT_TRANSCRIBE_MODELS,
+ DEFAULT_TRANSCRIBE_PROVIDERS,
Backend,
MCPHttp,
MCPServer,
@@ -16,6 +18,9 @@ from vibe.core.config._settings import (
ProviderConfig,
SessionLoggingConfig,
TomlFileSettingsSource,
+ TranscribeClient,
+ TranscribeModelConfig,
+ TranscribeProviderConfig,
VibeConfig,
load_dotenv_values,
)
@@ -24,6 +29,8 @@ __all__ = [
"DEFAULT_MISTRAL_API_ENV_KEY",
"DEFAULT_MODELS",
"DEFAULT_PROVIDERS",
+ "DEFAULT_TRANSCRIBE_MODELS",
+ "DEFAULT_TRANSCRIBE_PROVIDERS",
"Backend",
"MCPHttp",
"MCPServer",
@@ -36,6 +43,9 @@ __all__ = [
"ProviderConfig",
"SessionLoggingConfig",
"TomlFileSettingsSource",
+ "TranscribeClient",
+ "TranscribeModelConfig",
+ "TranscribeProviderConfig",
"VibeConfig",
"load_dotenv_values",
]
diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py
index 7013fff..1ecbd5b 100644
--- a/vibe/core/config/_settings.py
+++ b/vibe/core/config/_settings.py
@@ -93,7 +93,6 @@ class ProjectContextConfig(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
default_commit_count: int = 5
- max_doc_bytes: int = 32 * 1024
timeout_seconds: float = 2.0
@@ -131,6 +130,17 @@ class ProviderConfig(BaseModel):
region: str = ""
+class TranscribeClient(StrEnum):
+ MISTRAL = auto()
+
+
+class TranscribeProviderConfig(BaseModel):
+ name: str
+ api_base: str = "wss://api.mistral.ai"
+ api_key_env_var: str = ""
+ client: TranscribeClient = TranscribeClient.MISTRAL
+
+
class _MCPBase(BaseModel):
name: str = Field(description="Short alias used to prefix tool names")
prompt: str | None = Field(
@@ -229,6 +239,13 @@ MCPServer = Annotated[
]
+def _default_alias_to_name(data: Any) -> Any:
+ if isinstance(data, dict):
+ if "alias" not in data or data["alias"] is None:
+ data["alias"] = data.get("name")
+ return data
+
+
class ModelConfig(BaseModel):
name: str
provider: str
@@ -239,13 +256,19 @@ class ModelConfig(BaseModel):
thinking: Literal["off", "low", "medium", "high"] = "off"
auto_compact_threshold: int = 200_000
- @model_validator(mode="before")
- @classmethod
- def _default_alias_to_name(cls, data: Any) -> Any:
- if isinstance(data, dict):
- if "alias" not in data or data["alias"] is None:
- data["alias"] = data.get("name")
- return data
+ _default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
+
+
+class TranscribeModelConfig(BaseModel):
+ name: str
+ provider: str
+ alias: str
+ sample_rate: int = 16000
+ encoding: Literal["pcm_s16le"] = "pcm_s16le"
+ language: str = "en"
+ target_streaming_delay_ms: int = 500
+
+ _default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
@@ -289,6 +312,22 @@ DEFAULT_MODELS = [
),
]
+DEFAULT_TRANSCRIBE_PROVIDERS = [
+ TranscribeProviderConfig(
+ name="mistral",
+ api_base="wss://api.mistral.ai",
+ api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
+ )
+]
+
+DEFAULT_TRANSCRIBE_MODELS = [
+ TranscribeModelConfig(
+ name="voxtral-mini-transcribe-realtime-2602",
+ provider="mistral",
+ alias="voxtral-realtime",
+ )
+]
+
class VibeConfig(BaseSettings):
active_model: str = "devstral-2"
@@ -298,6 +337,8 @@ class VibeConfig(BaseSettings):
file_watcher_for_autocomplete: bool = False
displayed_workdir: str = ""
context_warnings: bool = False
+ voice_mode_enabled: bool = False
+ active_transcribe_model: str = "voxtral-realtime"
auto_approve: bool = False
enable_telemetry: bool = True
system_prompt_id: str = "cli"
@@ -323,6 +364,14 @@ class VibeConfig(BaseSettings):
default_factory=lambda: list(DEFAULT_PROVIDERS)
)
models: list[ModelConfig] = Field(default_factory=lambda: list(DEFAULT_MODELS))
+ compaction_model: ModelConfig | None = None
+
+ transcribe_providers: list[TranscribeProviderConfig] = Field(
+ default_factory=lambda: list(DEFAULT_TRANSCRIBE_PROVIDERS)
+ )
+ transcribe_models: list[TranscribeModelConfig] = Field(
+ default_factory=lambda: list(DEFAULT_TRANSCRIBE_MODELS)
+ )
project_context: ProjectContextConfig = Field(default_factory=ProjectContextConfig)
session_logging: SessionLoggingConfig = Field(default_factory=SessionLoggingConfig)
@@ -378,6 +427,12 @@ class VibeConfig(BaseSettings):
" is set. Supports glob patterns and regex with 're:' prefix."
),
)
+ installed_agents: list[str] = Field(
+ default_factory=list,
+ description=(
+ "A list of opt-in builtin agent names that have been explicitly installed."
+ ),
+ )
skill_paths: list[Path] = Field(
default_factory=list,
description=(
@@ -405,6 +460,10 @@ class VibeConfig(BaseSettings):
env_prefix="VIBE_", case_sensitive=False, extra="ignore"
)
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
+ kwargs.setdefault("exclude_none", True)
+ return super().model_dump(**kwargs)
+
@property
def nuage_api_key(self) -> str:
return os.getenv(self.nuage_api_key_env_var, "")
@@ -437,6 +496,11 @@ class VibeConfig(BaseSettings):
f"Active model '{self.active_model}' not found in configuration."
)
+ def get_compaction_model(self) -> ModelConfig:
+ if self.compaction_model is not None:
+ return self.compaction_model
+ return self.get_active_model()
+
def get_provider_for_model(self, model: ModelConfig) -> ProviderConfig:
for provider in self.providers:
if provider.name == model.provider:
@@ -445,6 +509,24 @@ class VibeConfig(BaseSettings):
f"Provider '{model.provider}' for model '{model.name}' not found in configuration."
)
+ def get_active_transcribe_model(self) -> TranscribeModelConfig:
+ for model in self.transcribe_models:
+ if model.alias == self.active_transcribe_model:
+ return model
+ raise ValueError(
+ f"Active transcribe model '{self.active_transcribe_model}' not found in configuration."
+ )
+
+ def get_transcribe_provider_for_model(
+ self, model: TranscribeModelConfig
+ ) -> TranscribeProviderConfig:
+ for provider in self.transcribe_providers:
+ if provider.name == model.provider:
+ return provider
+ raise ValueError(
+ f"Transcribe provider '{model.provider}' for transcribe model '{model.name}' not found in configuration."
+ )
+
@classmethod
def settings_customise_sources(
cls,
@@ -480,6 +562,24 @@ class VibeConfig(BaseSettings):
]
return self
+ @model_validator(mode="after")
+ def _check_compaction_model_provider(self) -> VibeConfig:
+ if self.compaction_model is None:
+ return self
+
+ compaction_provider = self.get_provider_for_model(self.compaction_model)
+ try:
+ active_provider = self.get_provider_for_model(self.get_active_model())
+ except ValueError:
+ return self
+ if active_provider.name != compaction_provider.name:
+ raise ValueError(
+ f"Compaction model '{self.compaction_model.alias}' uses provider "
+ f"'{compaction_provider.name}' but active model uses provider "
+ f"'{active_provider.name}'. They must share the same provider."
+ )
+ return self
+
@model_validator(mode="after")
def _check_api_key(self) -> VibeConfig:
try:
@@ -534,6 +634,17 @@ class VibeConfig(BaseSettings):
seen_aliases.add(model.alias)
return self
+ @model_validator(mode="after")
+ def _validate_transcribe_model_uniqueness(self) -> VibeConfig:
+ seen_aliases: set[str] = set()
+ for model in self.transcribe_models:
+ if model.alias in seen_aliases:
+ raise ValueError(
+ f"Duplicate transcribe model alias found: '{model.alias}'. Aliases must be unique."
+ )
+ seen_aliases.add(model.alias)
+ return self
+
@model_validator(mode="after")
def _check_system_prompt(self) -> VibeConfig:
_ = self.system_prompt
@@ -558,7 +669,13 @@ class VibeConfig(BaseSettings):
and isinstance(target.get(key), list)
and isinstance(value, list)
):
- if key in {"providers", "models"}:
+ if key in {
+ "providers",
+ "models",
+ "transcribe_providers",
+ "transcribe_models",
+ "installed_agents",
+ }:
target[key] = value
else:
target[key] = list(set(value + target[key]))
@@ -566,9 +683,7 @@ class VibeConfig(BaseSettings):
target[key] = value
deep_merge(current_config, updates)
- cls.dump_config(
- to_jsonable_python(current_config, exclude_none=True, fallback=str)
- )
+ cls.dump_config(current_config)
@classmethod
def dump_config(cls, config: dict[str, Any]) -> None:
@@ -578,11 +693,29 @@ class VibeConfig(BaseSettings):
target = mgr.config_file or mgr.user_config_file
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("wb") as f:
- tomli_w.dump(config, f)
+ tomli_w.dump(to_jsonable_python(config, exclude_none=True, fallback=str), f)
@classmethod
def _migrate(cls) -> None:
- pass
+ mgr = get_harness_files_manager()
+ if not mgr.persist_allowed:
+ return
+ file = mgr.config_file
+ if file is None:
+ return
+ try:
+ with file.open("rb") as f:
+ data = tomllib.load(f)
+ except (FileNotFoundError, tomllib.TOMLDecodeError, OSError):
+ return
+
+ bash_tools = data.get("tools", {}).get("bash", {})
+ allowlist = bash_tools.get("allowlist")
+ if allowlist is None or "find" not in allowlist:
+ return
+
+ allowlist.remove("find")
+ cls.dump_config(data)
@classmethod
def load(cls, **overrides: Any) -> VibeConfig:
@@ -592,7 +725,7 @@ class VibeConfig(BaseSettings):
@classmethod
def create_default(cls) -> dict[str, Any]:
config = cls.model_construct()
- config_dict = config.model_dump(mode="json", exclude_none=True)
+ config_dict = config.model_dump(mode="json")
from vibe.core.tools.manager import ToolManager
diff --git a/vibe/core/config/harness_files/_harness_manager.py b/vibe/core/config/harness_files/_harness_manager.py
index 9fa7a7a..f3553c6 100644
--- a/vibe/core/config/harness_files/_harness_manager.py
+++ b/vibe/core/config/harness_files/_harness_manager.py
@@ -10,7 +10,7 @@ from vibe.core.config.harness_files._paths import (
GLOBAL_SKILLS_DIR,
GLOBAL_TOOLS_DIR,
)
-from vibe.core.paths import AGENTS_MD_FILENAMES, VIBE_HOME, walk_local_config_dirs_all
+from vibe.core.paths import AGENTS_MD_FILENAME, VIBE_HOME, walk_local_config_dirs_all
from vibe.core.trusted_folders import trusted_folders_manager
FileSource = Literal["user", "project"]
@@ -110,17 +110,85 @@ class HarnessFilesManager:
d = GLOBAL_PROMPTS_DIR.path
return [d] if d.is_dir() else []
- def load_project_doc(self, max_bytes: int) -> str:
+ def load_user_doc(self) -> str:
+ if "user" not in self.sources:
+ return ""
+ path = VIBE_HOME.path / AGENTS_MD_FILENAME
+ try:
+ content = path.read_text("utf-8", errors="ignore")
+ stripped = content.strip()
+ return stripped if stripped else ""
+ except (FileNotFoundError, OSError):
+ return ""
+
+ def _collect_agents_md(
+ self, start: Path, stop: Path, *, stop_inclusive: bool
+ ) -> list[tuple[Path, str]]:
+ """Walk up from start toward stop, collecting non-empty AGENTS.md files.
+
+ Returns ``(directory, content)`` pairs ordered outermost-first.
+ When ``stop_inclusive`` is True the stop directory is included in the
+ walk; when False the walk stops before reaching it.
+ """
+ if not start.is_relative_to(stop):
+ return []
+
+ docs: list[tuple[Path, str]] = []
+ current = start
+ while True:
+ if current == stop and not stop_inclusive:
+ break
+ path = current / AGENTS_MD_FILENAME
+ try:
+ content = path.read_text("utf-8", errors="ignore")
+ stripped = content.strip()
+ if stripped:
+ docs.append((current, stripped))
+ except (FileNotFoundError, OSError):
+ pass
+ if current == stop:
+ break
+ parent = current.parent
+ if parent == current: # fs-root safety
+ break
+ current = parent
+ docs.reverse() # outermost first
+ return docs
+
+ def find_subdirectory_agents_md(self, file_path: Path) -> list[tuple[Path, str]]:
+ """Find AGENTS.md files between file_path's parent and cwd (exclusive of cwd).
+
+ For lazy injection when reading files in subdirectories below cwd.
+ Returns (directory, content) pairs, outermost first.
+ Does not overlap with load_project_docs() which covers cwd and above.
+ """
workdir = self.trusted_workdir
if workdir is None:
- return ""
- for name in AGENTS_MD_FILENAMES:
- path = workdir / name
- try:
- return path.read_text("utf-8", errors="ignore")[:max_bytes]
- except (FileNotFoundError, OSError):
- continue
- return ""
+ return []
+ cwd = workdir.resolve()
+ try:
+ resolved = file_path.resolve()
+ except (ValueError, OSError):
+ return []
+ if not resolved.is_relative_to(cwd):
+ return []
+ start = resolved if resolved.is_dir() else resolved.parent
+ return self._collect_agents_md(start, cwd, stop_inclusive=False)
+
+ def load_project_docs(self) -> list[tuple[Path, str]]:
+ """Walk up from cwd to the trust root, collecting AGENTS.md files.
+
+ Returns ``(directory, content)`` pairs ordered outermost-first
+ (trust root first, cwd last). Later entries take priority.
+ """
+ workdir = self.trusted_workdir
+ if workdir is None:
+ return []
+ cwd = workdir.resolve()
+ trust_root = trusted_folders_manager.find_trust_root(cwd)
+ if trust_root is None:
+ return []
+ return self._collect_agents_md(cwd, trust_root, stop_inclusive=True)
_manager: HarnessFilesManager | None = None
diff --git a/vibe/core/llm/backend/mistral.py b/vibe/core/llm/backend/mistral.py
index 5714b98..a149e63 100644
--- a/vibe/core/llm/backend/mistral.py
+++ b/vibe/core/llm/backend/mistral.py
@@ -7,8 +7,28 @@ import types
from typing import TYPE_CHECKING, NamedTuple, cast
import httpx
-import mistralai
-from mistralai.utils.retries import BackoffStrategy, RetryConfig
+from mistralai.client import Mistral
+from mistralai.client.errors import SDKError
+from mistralai.client.models import (
+ AssistantMessage,
+ AssistantMessageContent,
+ ChatCompletionRequestMessage,
+ ChatCompletionStreamRequestToolChoice,
+ FileChunk,
+ Function,
+ FunctionCall as MistralFunctionCall,
+ FunctionName,
+ SystemMessage,
+ TextChunk,
+ ThinkChunk,
+ Tool,
+ ToolCall as MistralToolCall,
+ ToolChoice,
+ ToolChoiceEnum,
+ ToolMessage,
+ UserMessage,
+)
+from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
from vibe.core.llm.exceptions import BackendErrorBuilder
from vibe.core.llm.message_utils import merge_consecutive_user_messages
@@ -35,35 +55,33 @@ class ParsedContent(NamedTuple):
class MistralMapper:
- def prepare_message(self, msg: LLMMessage) -> mistralai.Messages:
+ def prepare_message(self, msg: LLMMessage) -> ChatCompletionRequestMessage:
match msg.role:
case Role.system:
- return mistralai.SystemMessage(role="system", content=msg.content or "")
+ return SystemMessage(role="system", content=msg.content or "")
case Role.user:
- return mistralai.UserMessage(role="user", content=msg.content)
+ return UserMessage(role="user", content=msg.content)
case Role.assistant:
- content: mistralai.AssistantMessageContent
+ content: AssistantMessageContent
if msg.reasoning_content:
content = [
- mistralai.ThinkChunk(
+ ThinkChunk(
type="thinking",
thinking=[
- mistralai.TextChunk(
- type="text", text=msg.reasoning_content
- )
+ TextChunk(type="text", text=msg.reasoning_content)
],
),
- mistralai.TextChunk(type="text", text=msg.content or ""),
+ TextChunk(type="text", text=msg.content or ""),
]
else:
content = msg.content or ""
- return mistralai.AssistantMessage(
+ return AssistantMessage(
role="assistant",
content=content,
tool_calls=[
- mistralai.ToolCall(
- function=mistralai.FunctionCall(
+ MistralToolCall(
+ function=MistralFunctionCall(
name=tc.function.name or "",
arguments=tc.function.arguments or "",
),
@@ -75,17 +93,17 @@ class MistralMapper:
],
)
case Role.tool:
- return mistralai.ToolMessage(
+ return ToolMessage(
role="tool",
content=msg.content,
tool_call_id=msg.tool_call_id,
name=msg.name,
)
- def prepare_tool(self, tool: AvailableTool) -> mistralai.Tool:
- return mistralai.Tool(
+ def prepare_tool(self, tool: AvailableTool) -> Tool:
+ return Tool(
type="function",
- function=mistralai.Function(
+ function=Function(
name=tool.function.name,
description=tool.function.description,
parameters=tool.function.parameters,
@@ -94,16 +112,15 @@ class MistralMapper:
def prepare_tool_choice(
self, tool_choice: StrToolChoice | AvailableTool
- ) -> mistralai.ChatCompletionStreamRequestToolChoice:
+ ) -> ChatCompletionStreamRequestToolChoice:
if isinstance(tool_choice, str):
- return cast(mistralai.ToolChoiceEnum, tool_choice)
+ return cast(ToolChoiceEnum, tool_choice)
- return mistralai.ToolChoice(
- type="function",
- function=mistralai.FunctionName(name=tool_choice.function.name),
+ return ToolChoice(
+ type="function", function=FunctionName(name=tool_choice.function.name)
)
- def _extract_thinking_text(self, chunk: mistralai.ThinkChunk) -> str:
+ def _extract_thinking_text(self, chunk: ThinkChunk) -> str:
thinking_content = getattr(chunk, "thinking", None)
if not thinking_content:
return ""
@@ -115,27 +132,25 @@ class MistralMapper:
parts.append(inner)
return "".join(parts)
- def parse_content(
- self, content: mistralai.AssistantMessageContent
- ) -> ParsedContent:
+ def parse_content(self, content: AssistantMessageContent) -> ParsedContent:
if isinstance(content, str):
return ParsedContent(content=content, reasoning_content=None)
concat_content = ""
concat_reasoning = ""
for chunk in content:
- if isinstance(chunk, mistralai.FileChunk):
+ if isinstance(chunk, FileChunk):
continue
- if isinstance(chunk, mistralai.TextChunk):
+ if isinstance(chunk, TextChunk):
concat_content += chunk.text
- elif isinstance(chunk, mistralai.ThinkChunk):
+ elif isinstance(chunk, ThinkChunk):
concat_reasoning += self._extract_thinking_text(chunk)
return ParsedContent(
content=concat_content,
reasoning_content=concat_reasoning if concat_reasoning else None,
)
- def parse_tool_calls(self, tool_calls: list[mistralai.ToolCall]) -> list[ToolCall]:
+ def parse_tool_calls(self, tool_calls: list[MistralToolCall]) -> list[ToolCall]:
return [
ToolCall(
id=tool_call.id,
@@ -153,7 +168,7 @@ class MistralMapper:
class MistralBackend:
def __init__(self, provider: ProviderConfig, timeout: float = 720.0) -> None:
- self._client: mistralai.Mistral | None = None
+ self._client: Mistral | None = None
self._provider = provider
self._mapper = MistralMapper()
self._api_key = (
@@ -208,15 +223,15 @@ class MistralBackend:
exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb
)
- def _create_mistral_client(self) -> mistralai.Mistral:
- return mistralai.Mistral(
+ def _create_mistral_client(self) -> Mistral:
+ return Mistral(
api_key=self._api_key,
server_url=self._server_url,
timeout_ms=int(self._timeout * 1000),
retry_config=self._retry_config,
)
- def _get_client(self) -> mistralai.Mistral:
+ def _get_client(self) -> Mistral:
if self._client is None:
self._client = self._create_mistral_client()
return self._client
@@ -273,7 +288,7 @@ class MistralBackend:
),
)
- except mistralai.SDKError as e:
+ except SDKError as e:
raise BackendErrorBuilder.build_http_error(
provider=self._provider.name,
endpoint=self._server_url,
@@ -351,7 +366,7 @@ class MistralBackend:
),
)
- except mistralai.SDKError as e:
+ except SDKError as e:
raise BackendErrorBuilder.build_http_error(
provider=self._provider.name,
endpoint=self._server_url,
diff --git a/vibe/core/llm/backend/reasoning_adapter.py b/vibe/core/llm/backend/reasoning_adapter.py
index 8e90558..8417b5f 100644
--- a/vibe/core/llm/backend/reasoning_adapter.py
+++ b/vibe/core/llm/backend/reasoning_adapter.py
@@ -106,6 +106,13 @@ class ReasoningAdapter(APIAdapter):
return payload
+ def _strip_reasoning(self, msg: LLMMessage) -> LLMMessage:
+ if msg.role != Role.assistant or not msg.reasoning_content:
+ return msg
+ return msg.model_copy(
+ update={"reasoning_content": None, "reasoning_signature": None}
+ )
+
def prepare_request( # noqa: PLR0913
self,
*,
@@ -121,6 +128,8 @@ class ReasoningAdapter(APIAdapter):
thinking: str = "off",
) -> PreparedRequest:
merged_messages = merge_consecutive_user_messages(messages)
+ if thinking == "off":
+ merged_messages = [self._strip_reasoning(msg) for msg in merged_messages]
converted_messages = [self._convert_message(msg) for msg in merged_messages]
payload = self._build_payload(
diff --git a/vibe/core/middleware.py b/vibe/core/middleware.py
index d15fda7..97b68ca 100644
--- a/vibe/core/middleware.py
+++ b/vibe/core/middleware.py
@@ -79,16 +79,14 @@ class PriceLimitMiddleware:
class AutoCompactMiddleware:
- def __init__(self, threshold: int) -> None:
- self.threshold = threshold
-
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
- if context.stats.context_tokens >= self.threshold:
+ threshold = context.config.get_active_model().auto_compact_threshold
+ if threshold > 0 and context.stats.context_tokens >= threshold:
return MiddlewareResult(
action=MiddlewareAction.COMPACT,
metadata={
"old_tokens": context.stats.context_tokens,
- "threshold": self.threshold,
+ "threshold": threshold,
},
)
return MiddlewareResult()
@@ -98,19 +96,16 @@ class AutoCompactMiddleware:
class ContextWarningMiddleware:
- def __init__(
- self, threshold_percent: float = 0.5, max_context: int | None = None
- ) -> None:
+ def __init__(self, threshold_percent: float = 0.5) -> None:
self.threshold_percent = threshold_percent
- self.max_context = max_context
self.has_warned = False
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
if self.has_warned:
return MiddlewareResult()
- max_context = self.max_context
- if max_context is None:
+ max_context = context.config.get_active_model().auto_compact_threshold
+ if max_context <= 0:
return MiddlewareResult()
if context.stats.context_tokens >= max_context * self.threshold_percent:
diff --git a/vibe/core/paths/__init__.py b/vibe/core/paths/__init__.py
index 4bc1593..5db117f 100644
--- a/vibe/core/paths/__init__.py
+++ b/vibe/core/paths/__init__.py
@@ -13,10 +13,10 @@ from vibe.core.paths._vibe_home import (
VIBE_HOME,
GlobalPath,
)
-from vibe.core.paths.conventions import AGENTS_MD_FILENAMES
+from vibe.core.paths.conventions import AGENTS_MD_FILENAME
__all__ = [
- "AGENTS_MD_FILENAMES",
+ "AGENTS_MD_FILENAME",
"DEFAULT_TOOL_DIR",
"GLOBAL_ENV_FILE",
"HISTORY_FILE",
diff --git a/vibe/core/paths/conventions.py b/vibe/core/paths/conventions.py
index 9c34598..cdbe277 100644
--- a/vibe/core/paths/conventions.py
+++ b/vibe/core/paths/conventions.py
@@ -1,3 +1,3 @@
from __future__ import annotations
-AGENTS_MD_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
+AGENTS_MD_FILENAME = "AGENTS.md"
diff --git a/vibe/core/prompts/__init__.py b/vibe/core/prompts/__init__.py
index f86bd7c..db4daba 100644
--- a/vibe/core/prompts/__init__.py
+++ b/vibe/core/prompts/__init__.py
@@ -14,16 +14,18 @@ class Prompt(StrEnum):
return (_PROMPTS_DIR / self.value).with_suffix(".md")
def read(self) -> str:
- return self.path.read_text(encoding="utf-8").strip()
+ return self.path.read_text(encoding="utf-8", errors="ignore").strip()
class SystemPrompt(Prompt):
CLI = auto()
EXPLORE = auto()
TESTS = auto()
+ LEAN = auto()
class UtilityPrompt(Prompt):
+ AGENTS_DOC = auto()
COMPACT = auto()
DANGEROUS_DIRECTORY = auto()
PROJECT_CONTEXT = auto()
diff --git a/vibe/core/prompts/agents_doc.md b/vibe/core/prompts/agents_doc.md
new file mode 100644
index 0000000..8361d47
--- /dev/null
+++ b/vibe/core/prompts/agents_doc.md
@@ -0,0 +1,5 @@
+Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written. When both user-level and project-level instructions are present, project instructions take priority over user instructions. When multiple project-level AGENTS.md files are present, instructions closer to the working directory take priority. Each AGENTS.md applies to its own directory and all of its descendants within the project.
+
+$sections
+
+IMPORTANT: this context may or may not be relevant to your tasks. You should act on these guidelines if they are relevant to your task.
diff --git a/vibe/core/prompts/dangerous_directory.md b/vibe/core/prompts/dangerous_directory.md
index 93632b5..a7387c1 100644
--- a/vibe/core/prompts/dangerous_directory.md
+++ b/vibe/core/prompts/dangerous_directory.md
@@ -1,5 +1,5 @@
-directoryStructure: Project context scanning has been disabled because {reason}. This prevents permission dialogs and potential system slowdowns. Use the LS tool and other file tools to explore the project structure as needed.
+directoryStructure: Project context scanning has been disabled because $reason. This prevents permission dialogs and potential system slowdowns. Use the LS tool and other file tools to explore the project structure as needed.
-Absolute path: {abs_path}
+Absolute path: $abs_path
gitStatus: Use git tools to check repository status if needed.
diff --git a/vibe/core/prompts/lean.md b/vibe/core/prompts/lean.md
new file mode 100644
index 0000000..9f6b5c8
--- /dev/null
+++ b/vibe/core/prompts/lean.md
@@ -0,0 +1,166 @@
+You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools.
+
+Use markdown when appropriate. Communicate clearly to the user.
+
+Skills are markdown files in your skill directories, NOT tools or agents. To use a skill:
+
+1. Find the matching file in your skill directories.
+2. Read it with `read_file`.
+3. Follow its instructions step by step. You are the executor.
+
+Do not try to invoke a skill as a tool or command. If the user references a skill by name (e.g., "iterate on this PR"), look for a file with that name and follow its contents.
+
+Phase 1 — Orient
+Before ANY action:
+Restate the goal in one line.
+Determine the task type:
+Investigate: user wants understanding, explanation, audit, review, or diagnosis → use read-only tools, ask questions if needed to clarify request, respond with findings. Do not edit files.
+Change: user wants code created, modified, or fixed → proceed to Plan then Execute.
+If unclear, default to investigate. It is better to explain what you would do than to make an unwanted change.
+
+Explore. Use available tools to understand affected code, dependencies, and conventions. Never edit a file you haven't read in this session.
+Identify constraints: language, framework, test setup, and any user restrictions on scope.
+When given a complex, multi-file architectural task: summarize your understanding and wait for user confirmation. For targeted tasks, including writing specific Lean proofs or single-file bug fixes, do not wait. Plan internally and execute immediately.
+
+Phase 2 — Plan
+State your plan before writing code:
+List files to edit and the specific modifications per file.
+Multi-file modifications: numbered checklist. Single-file fix: one-line plan.
+No time estimates. Concrete actions only.
+
+Phase 3 — Execute & Verify
+Apply modifications, then confirm they work:
+Edit one logical unit at a time.
+After each unit, verify: run tests, or read back the file to confirm the edit landed.
+Never claim completion without verification — a passing test, correct read-back, or successful build.
+
+Lean Rules
+
+Create a New Package or Project
+Usually, use the mathlib4 dependency. Run `lake +leanprover-community/mathlib4:lean-toolchain new math` to create a new project with mathlib4 as a dependency.
+Otherwise run `lake init `.
+
+Add External Dependencies
+You can add external dependencies by adding to lakefile.toml, for example:
+```
+[[require]]
+name = "mathlib"
+git = "https://github.com/leanprover-community/mathlib4.git"
+```
+
+Whenever you create a new package or add a new external dependency, run `lake exe cache get` to download cache for them. Do not build before downloading all the necessary dependencies. Never manually edit `lake-manifest.json`, use `lake` commands to update it.
+
+Work incrementally and in blocks. Make a plan before you take on a big project.
+
+Imports
+Put imports at the beginning of a file.
+
+Compile a Package or a File
+Before compiling or building for the first time, check if external dependencies are in the cache. If not, run `lake exe cache get`.
+Run `lake build` to check the entire repository's correctness or `lake build ` for one file. Check lakefile.toml for build targets. Prefer `lake build ` while developing, it is a lot faster. To check a standalone Lean file which not tracked by lake, such as a test file, use `lake env lean `.
+
+Tactics
+Make use of the `grind` tactic when possible if using Lean version >= 4.22.0. It is very powerful.
+
+Debug
+View the current goal and proof state by inserting the `trace_state` tactic before the line in question.
+
+Complete the Work
+When tasked with writing code or a Lean proof, do not stop until you find the complete working solution. Do not leave incomplete code, stubs, or use sorry in Lean unless the user explicitly instructs you to.
+
+Hard Rules
+
+Don't be Lazy
+When the user asks you to perform something, be laser-focused and do not settle for easier things.
+
+Never Commit
+Do not run `git commit`, `git push`, or `git add` unless the user explicitly asks you to. Saving files is sufficient — the user will review changes and commit themselves.
+
+Respect User Constraints
+"No writes", "just analyze", "plan only", "don't touch X" — these are hard constraints. Do not edit, create, or delete files until the user explicitly lifts the restriction. Violation of explicit user instructions is the worst failure mode.
+
+Don't Remove What Wasn't Asked
+If user asks to fix X, do not rewrite, delete, or restructure Y.
+
+Don't Assert — Verify
+If unsure about a file path, variable value, config state, or whether your edit worked — use a tool to check. Read the file. Run the command.
+
+Break Loops
+If approach isn't working after 2 attempts at the same region, STOP:
+Re-read the code and error output.
+Identify why it failed, not just what failed.
+Choose a fundamentally different strategy.
+If stuck, ask the user one specific question.
+
+Flip-flopping (add X → remove X → add X) is a critical failure. Commit to a direction or escalate.
+
+After creating test files that are not going to be used once the task is complete, remember to remove them.
+
+Response Format
+No Noise
+No greetings, outros, hedging, puffery, or tool narration.
+
+Never say: "Certainly", "Of course", "Let me help", "Happy to", "I hope this helps", "Let me search…", "I'll now read…", "Great question!", "In summary…"
+Never use: "robust", "seamless", "elegant", "powerful", "flexible"
+No unsolicited tutorials. Do not explain concepts the user clearly knows.
+
+Structure First
+Lead every response with the most useful structured element — code, diagram, table, or tree. Prose comes after, not before.
+For modification tasks:
+file_path:line_number
+langcode
+
+Prefer Brevity
+State only what's necessary to complete the task. Code + file reference > explanation.
+If your response exceeds 300 words, remove explanations the user didn't request.
+
+For investigate tasks:
+Start with a diagram, code reference, tree, or table — whichever conveys the answer fastest.
+request → auth.verify() → permissions.check() → handler
+See middleware/auth.py:45. Then 1-2 sentences of context if needed.
+BAD: "The authentication flow works by first checking the token…"
+GOOD: request → auth.verify() → permissions.check() → handler — see middleware/auth.py:45.
+Visual Formats
+
+Before responding with structural data, choose the right format:
+BAD: Bullet lists for hierarchy/tree
+GOOD: ASCII tree (├──/└──)
+BAD: Prose or bullet lists for comparisons/config/options
+GOOD: Markdown table
+BAD: Prose for Flows/pipelines
+GOOD: → A → B → C diagrams
+
+Interaction Design
+After completing a task, evaluate: does the user face a decision or tradeoff? If yes, end with ONE specific question or 2-3 options:
+
+Good: "Apply this fix to the other 3 endpoints?"
+Good: "Two approaches: (a) migration, (b) recreate table. Which?"
+Bad: "Does this look good?", "Anything else?", "Let me know"
+
+If unambiguous and complete, end with the result.
+
+Length
+Default to minimal prose. Your conversational text should be <100 words. However, this length restriction does NOT apply to code, scripts, or Lean proofs. Code and proofs must always be fully written out and functional, no matter how many lines they require.
+Elaborate only when: (1) user asks for explanation, (2) task involves architectural decisions, (3) multiple valid approaches exist.
+
+Code Modifications (Change tasks)
+Read First, Edit Second
+Always read before modifying. Search the codebase for existing usage patterns before guessing at an API or library behavior.
+
+Minimal, Focused Changes
+Only modify what was requested. No extra features, abstractions, or speculative error handling.
+Match existing style: indentation, naming, comment density, error handling.
+When removing code, delete completely. No _unused renames, // removed comments, shims, or wrappers. If an interface changes, update all call sites.
+
+Security
+Fix injection, XSS, SQLi vulnerabilities immediately if spotted.
+
+Code References
+Cite as file_path:line_number.
+
+Professional Conduct
+Prioritize technical accuracy over validating beliefs. Disagree when necessary.
+When uncertain, investigate before confirming.
+Your output must contain zero emoji. This includes smiley faces, icons, flags, symbols like ✅❌💡, and all other Unicode emoji.
+No over-the-top validation.
+Stay focused on solving the problem regardless of user tone. Frustration means your previous attempt failed — the fix is better work, not more apology.
diff --git a/vibe/core/prompts/project_context.md b/vibe/core/prompts/project_context.md
index cf39643..dc10bb4 100644
--- a/vibe/core/prompts/project_context.md
+++ b/vibe/core/prompts/project_context.md
@@ -1,4 +1,4 @@
-Absolute path: {abs_path}
+Absolute path: $abs_path
gitStatus: This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.
-{git_status}
+$git_status
diff --git a/vibe/core/system_prompt.py b/vibe/core/system_prompt.py
index e0e5324..4713072 100644
--- a/vibe/core/system_prompt.py
+++ b/vibe/core/system_prompt.py
@@ -3,11 +3,13 @@ from __future__ import annotations
import html
import os
from pathlib import Path
+from string import Template
import subprocess
import sys
from typing import TYPE_CHECKING
from vibe.core.config.harness_files import get_harness_files_manager
+from vibe.core.paths import VIBE_HOME
from vibe.core.prompts import UtilityPrompt
from vibe.core.utils import is_dangerous_directory, is_windows
@@ -138,7 +140,9 @@ class ProjectContextProvider:
git_status = self.get_git_status()
template = UtilityPrompt.PROJECT_CONTEXT.read()
- return template.format(abs_path=self.root_path, git_status=git_status)
+ return Template(template).safe_substitute(
+ abs_path=str(self.root_path), git_status=git_status
+ )
def _get_platform_name() -> str:
@@ -275,7 +279,7 @@ def get_universal_system_prompt(
is_dangerous, reason = is_dangerous_directory()
if is_dangerous:
template = UtilityPrompt.DANGEROUS_DIRECTORY.read()
- context = template.format(
+ context = Template(template).safe_substitute(
reason=reason.lower(), abs_path=Path(".").resolve()
)
else:
@@ -285,10 +289,25 @@ def get_universal_system_prompt(
sections.append(context)
- project_doc = get_harness_files_manager().load_project_doc(
- config.project_context.max_doc_bytes
- )
- if project_doc.strip():
- sections.append(project_doc)
+ mgr = get_harness_files_manager()
+ user_doc = mgr.load_user_doc()
+ project_docs = mgr.load_project_docs()
+
+ doc_sections: list[str] = []
+ if user_doc.strip():
+ doc_sections.append(
+ f"## User instructions\n\nContents of {VIBE_HOME.path}/AGENTS.md (user-level instructions):\n\n{user_doc.strip()}"
+ )
+ if project_docs:
+ doc_sections.append("## Project instructions (checked into the codebase)")
+ for doc_dir, doc_content in project_docs:
+ doc_sections.append(
+ f"Contents of {doc_dir}/AGENTS.md:\n\n{doc_content.strip()}"
+ )
+ if doc_sections:
+ template = UtilityPrompt.AGENTS_DOC.read()
+ sections.append(
+ Template(template).safe_substitute(sections="\n\n".join(doc_sections))
+ )
return "\n\n".join(sections)
diff --git a/vibe/core/tools/base.py b/vibe/core/tools/base.py
index ebc2ef9..72ef077 100644
--- a/vibe/core/tools/base.py
+++ b/vibe/core/tools/base.py
@@ -348,3 +348,12 @@ class BaseTool[
Override in subclasses for domain-specific rules (e.g. workdir checks).
"""
return None
+
+ def get_result_extra(self, result: ToolResult) -> str | None:
+ """Optional extra context appended to the result text sent to the LLM.
+
+ Override in subclasses to inject contextual information alongside
+ tool results (e.g. directory-level instructions discovered during
+ file reads). The default returns ``None`` (no annotation).
+ """
+ return None
diff --git a/vibe/core/tools/builtins/bash.py b/vibe/core/tools/builtins/bash.py
index c447bff..d86ada8 100644
--- a/vibe/core/tools/builtins/bash.py
+++ b/vibe/core/tools/builtins/bash.py
@@ -116,7 +116,7 @@ async def _kill_process_tree(proc: asyncio.subprocess.Process) -> None:
def _get_default_allowlist() -> list[str]:
- common = ["echo", "find", "git diff", "git log", "git status", "tree", "whoami"]
+ common = ["echo", "git diff", "git log", "git status", "tree", "whoami"]
if is_windows():
return common + ["dir", "findstr", "more", "type", "ver", "where"]
@@ -225,9 +225,6 @@ class Bash(
return "Running command"
def resolve_permission(self, args: BashArgs) -> ToolPermission | None:
- if is_windows():
- return None
-
command_parts = _extract_commands(args.command)
if not command_parts:
return None
diff --git a/vibe/core/tools/builtins/read_file.py b/vibe/core/tools/builtins/read_file.py
index 939b9ad..0acc9f8 100644
--- a/vibe/core/tools/builtins/read_file.py
+++ b/vibe/core/tools/builtins/read_file.py
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, ClassVar, NamedTuple, final
import anyio
from pydantic import BaseModel, Field
+from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
@@ -18,6 +19,7 @@ from vibe.core.tools.base import (
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolStreamEvent
+from vibe.core.utils import VIBE_WARNING_TAG
if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent
@@ -57,8 +59,12 @@ class ReadFileToolConfig(BaseToolConfig):
)
+class ReadFileState(BaseToolState):
+ injected_agents_md: set[str] = Field(default_factory=set)
+
+
class ReadFile(
- BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, BaseToolState],
+ BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, ReadFileState],
ToolUIData[ReadFileArgs, ReadFileResult],
):
description: ClassVar[str] = (
@@ -89,6 +95,27 @@ class ReadFile(
config_permission=self.config.permission,
)
+ def get_result_extra(self, result: ReadFileResult) -> str | None:
+ try:
+ mgr = get_harness_files_manager()
+ except RuntimeError:
+ return None
+ docs = mgr.find_subdirectory_agents_md(Path(result.path))
+ new_docs = [
+ (d, c)
+ for d, c in docs
+ if str(d.resolve()) not in self.state.injected_agents_md
+ ]
+ if not new_docs:
+ return None
+ for d, _ in new_docs:
+ self.state.injected_agents_md.add(str(d.resolve()))
+ sections = [
+ f"Contents of {d}/AGENTS.md (project instructions for this directory):\n\n{c.strip()}"
+ for d, c in new_docs
+ ]
+ return f"<{VIBE_WARNING_TAG}>\n{'\n\n'.join(sections)}\n{VIBE_WARNING_TAG}>"
+
def _prepare_and_validate_path(self, args: ReadFileArgs) -> Path:
self._validate_inputs(args)
diff --git a/vibe/core/tools/builtins/websearch.py b/vibe/core/tools/builtins/websearch.py
index 3b01912..d79c183 100644
--- a/vibe/core/tools/builtins/websearch.py
+++ b/vibe/core/tools/builtins/websearch.py
@@ -4,7 +4,14 @@ from collections.abc import AsyncGenerator
import os
from typing import TYPE_CHECKING, ClassVar, final
-import mistralai
+from mistralai.client import Mistral
+from mistralai.client.errors import SDKError
+from mistralai.client.models import (
+ ConversationResponse,
+ MessageOutputEntry,
+ TextChunk,
+ ToolReferenceChunk,
+)
from pydantic import BaseModel, Field
from vibe.core.config import Backend
@@ -67,7 +74,7 @@ class WebSearch(
if not api_key:
raise ToolError("MISTRAL_API_KEY environment variable not set.")
- client = mistralai.Mistral(
+ client = Mistral(
api_key=api_key,
server_url=self._resolve_server_url(ctx),
timeout_ms=self.config.timeout * 1000,
@@ -85,7 +92,7 @@ class WebSearch(
yield self._parse_response(response)
- except mistralai.SDKError as exc:
+ except SDKError as exc:
raise ToolError(f"Mistral API error: {exc}") from exc
def _resolve_server_url(self, ctx: InvokeContext | None) -> str | None:
@@ -96,19 +103,17 @@ class WebSearch(
return get_server_url_from_api_base(provider.api_base)
return None
- def _parse_response(
- self, response: mistralai.ConversationResponse
- ) -> WebSearchResult:
+ def _parse_response(self, response: ConversationResponse) -> WebSearchResult:
text_parts: list[str] = []
sources: dict[str, WebSearchSource] = {}
for entry in response.outputs:
- if not isinstance(entry, mistralai.MessageOutputEntry):
+ if not isinstance(entry, MessageOutputEntry):
continue
for chunk in entry.content:
- if isinstance(chunk, mistralai.TextChunk):
+ if isinstance(chunk, TextChunk):
text_parts.append(chunk.text)
- elif isinstance(chunk, mistralai.ToolReferenceChunk) and chunk.url:
+ elif isinstance(chunk, ToolReferenceChunk) and chunk.url:
if chunk.url not in sources:
sources[chunk.url] = WebSearchSource(
title=chunk.title, url=chunk.url
diff --git a/vibe/core/transcribe/__init__.py b/vibe/core/transcribe/__init__.py
new file mode 100644
index 0000000..4d2548e
--- /dev/null
+++ b/vibe/core/transcribe/__init__.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from vibe.core.transcribe.factory import make_transcribe_client
+from vibe.core.transcribe.mistral_transcribe_client import MistralTranscribeClient
+from vibe.core.transcribe.transcribe_client_port import (
+ TranscribeClientPort,
+ TranscribeDone,
+ TranscribeError,
+ TranscribeEvent,
+ TranscribeSessionCreated,
+ TranscribeTextDelta,
+)
+
+__all__ = [
+ "MistralTranscribeClient",
+ "TranscribeClientPort",
+ "TranscribeDone",
+ "TranscribeError",
+ "TranscribeEvent",
+ "TranscribeSessionCreated",
+ "TranscribeTextDelta",
+ "make_transcribe_client",
+]
diff --git a/vibe/core/transcribe/factory.py b/vibe/core/transcribe/factory.py
new file mode 100644
index 0000000..2fac37d
--- /dev/null
+++ b/vibe/core/transcribe/factory.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+from vibe.core.config import (
+ TranscribeClient,
+ TranscribeModelConfig,
+ TranscribeProviderConfig,
+)
+from vibe.core.transcribe.mistral_transcribe_client import MistralTranscribeClient
+from vibe.core.transcribe.transcribe_client_port import TranscribeClientPort
+
+TRANSCRIBE_CLIENT_MAP: dict[TranscribeClient, type[TranscribeClientPort]] = {
+ TranscribeClient.MISTRAL: MistralTranscribeClient
+}
+
+
+def make_transcribe_client(
+ provider: TranscribeProviderConfig, model: TranscribeModelConfig
+) -> TranscribeClientPort:
+ return TRANSCRIBE_CLIENT_MAP[provider.client](provider=provider, model=model)
diff --git a/vibe/core/transcribe/mistral_transcribe_client.py b/vibe/core/transcribe/mistral_transcribe_client.py
new file mode 100644
index 0000000..4b460ec
--- /dev/null
+++ b/vibe/core/transcribe/mistral_transcribe_client.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+import os
+
+from mistralai.client import Mistral
+from mistralai.client.models import (
+ AudioFormat,
+ RealtimeTranscriptionError,
+ RealtimeTranscriptionSessionCreated,
+ TranscriptionStreamDone,
+ TranscriptionStreamTextDelta,
+)
+from mistralai.extra.realtime import UnknownRealtimeEvent
+
+from vibe.core.config import TranscribeModelConfig, TranscribeProviderConfig
+from vibe.core.transcribe.transcribe_client_port import (
+ TranscribeDone,
+ TranscribeError,
+ TranscribeEvent,
+ TranscribeSessionCreated,
+ TranscribeTextDelta,
+)
+
+
+class MistralTranscribeClient:
+ def __init__(
+ self, provider: TranscribeProviderConfig, model: TranscribeModelConfig
+ ) -> None:
+ self._api_key = os.getenv(provider.api_key_env_var, "")
+ self._server_url = provider.api_base
+ self._model_name = model.name
+ self._audio_format = AudioFormat(
+ encoding=model.encoding, sample_rate=model.sample_rate
+ )
+ self._target_streaming_delay_ms = model.target_streaming_delay_ms
+ self._client: Mistral | None = None
+
+ def _get_client(self) -> Mistral:
+ if self._client is None:
+ self._client = Mistral(api_key=self._api_key, server_url=self._server_url)
+ return self._client
+
+ async def transcribe(
+ self, audio_stream: AsyncIterator[bytes]
+ ) -> AsyncIterator[TranscribeEvent]:
+ client = self._get_client()
+ async for event in client.audio.realtime.transcribe_stream(
+ audio_stream=audio_stream,
+ model=self._model_name,
+ audio_format=self._audio_format,
+ target_streaming_delay_ms=self._target_streaming_delay_ms,
+ ):
+ if isinstance(event, RealtimeTranscriptionSessionCreated):
+ yield TranscribeSessionCreated()
+ elif isinstance(event, TranscriptionStreamTextDelta):
+ yield TranscribeTextDelta(text=event.text)
+ elif isinstance(event, TranscriptionStreamDone):
+ yield TranscribeDone()
+ elif isinstance(event, RealtimeTranscriptionError):
+ yield TranscribeError(message=str(event.error.message))
+ elif isinstance(event, UnknownRealtimeEvent):
+ continue
diff --git a/vibe/core/transcribe/transcribe_client_port.py b/vibe/core/transcribe/transcribe_client_port.py
new file mode 100644
index 0000000..a482b45
--- /dev/null
+++ b/vibe/core/transcribe/transcribe_client_port.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+from dataclasses import dataclass
+from typing import Protocol
+
+from vibe.core.config import TranscribeModelConfig, TranscribeProviderConfig
+
+
+@dataclass(frozen=True, slots=True)
+class TranscribeSessionCreated:
+ pass
+
+
+@dataclass(frozen=True, slots=True)
+class TranscribeTextDelta:
+ text: str
+
+
+@dataclass(frozen=True, slots=True)
+class TranscribeDone:
+ pass
+
+
+@dataclass(frozen=True, slots=True)
+class TranscribeError:
+ message: str
+
+
+TranscribeEvent = (
+ TranscribeSessionCreated | TranscribeTextDelta | TranscribeDone | TranscribeError
+)
+
+
+class TranscribeClientPort(Protocol):
+ def __init__(
+ self, provider: TranscribeProviderConfig, model: TranscribeModelConfig
+ ) -> None: ...
+
+ def transcribe(
+ self, audio_stream: AsyncIterator[bytes]
+ ) -> AsyncIterator[TranscribeEvent]: ...
diff --git a/vibe/core/trusted_folders.py b/vibe/core/trusted_folders.py
index 06fac19..f6169c7 100644
--- a/vibe/core/trusted_folders.py
+++ b/vibe/core/trusted_folders.py
@@ -6,14 +6,14 @@ import tomllib
import tomli_w
from vibe.core.paths import (
- AGENTS_MD_FILENAMES,
+ AGENTS_MD_FILENAME,
TRUSTED_FOLDERS_FILE,
walk_local_config_dirs_all,
)
def has_agents_md_file(path: Path) -> bool:
- return any((path / name).exists() for name in AGENTS_MD_FILENAMES)
+ return (path / AGENTS_MD_FILENAME).exists()
def has_trustable_content(path: Path) -> bool:
@@ -60,11 +60,35 @@ class TrustedFoldersManager:
pass
def is_trusted(self, path: Path) -> bool | None:
- normalized = self._normalize_path(path)
- if normalized in self._trusted:
- return True
- if normalized in self._untrusted:
- return False
+ """Check trust walking up from *path* to filesystem root.
+
+ The first ancestor (or *path* itself) found in either the trusted
+ or untrusted list wins. Returns ``None`` when no decision exists.
+ """
+ current = Path(self._normalize_path(path))
+ while True:
+ s = str(current)
+ if s in self._trusted:
+ return True
+ if s in self._untrusted:
+ return False
+ parent = current.parent
+ if parent == current:
+ break
+ current = parent
+ return None
+
+ def find_trust_root(self, path: Path) -> Path | None:
+ """Return the closest ancestor (or *path* itself) explicitly in the trusted list."""
+ current = Path(self._normalize_path(path))
+ while True:
+ s = str(current)
+ if s in self._trusted:
+ return current
+ parent = current.parent
+ if parent == current:
+ break
+ current = parent
return None
def add_trusted(self, path: Path) -> None:
diff --git a/vibe/core/types.py b/vibe/core/types.py
index 3c97de3..0998178 100644
--- a/vibe/core/types.py
+++ b/vibe/core/types.py
@@ -366,6 +366,7 @@ class ToolResultEvent(BaseEvent):
error: str | None = None
skipped: bool = False
skip_reason: str | None = None
+ cancelled: bool = False
duration: float | None = None
tool_call_id: str
@@ -403,16 +404,10 @@ class OutputFormat(StrEnum):
STREAMING = auto()
-type AsyncApprovalCallback = Callable[
+type ApprovalCallback = Callable[
[str, BaseModel, str], Awaitable[tuple[ApprovalResponse, str | None]]
]
-type SyncApprovalCallback = Callable[
- [str, BaseModel, str], tuple[ApprovalResponse, str | None]
-]
-
-type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
-
type UserInputCallback = Callable[[BaseModel], Awaitable[BaseModel]]
type SwitchAgentCallback = Callable[[str], Awaitable[None]]
diff --git a/vibe/core/utils.py b/vibe/core/utils.py
index a6abb28..56c4e36 100644
--- a/vibe/core/utils.py
+++ b/vibe/core/utils.py
@@ -88,12 +88,9 @@ def get_user_cancellation_message(
def is_user_cancellation_event(event: BaseEvent) -> bool:
- return (
- isinstance(event, ToolResultEvent)
- and event.skipped
- and event.skip_reason is not None
- and f"<{CANCELLATION_TAG}>" in event.skip_reason
- )
+ if not isinstance(event, ToolResultEvent):
+ return False
+ return event.cancelled
def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:
diff --git a/vibe/setup/trusted_folders/trust_folder_dialog.py b/vibe/setup/trusted_folders/trust_folder_dialog.py
index 90ff844..6308975 100644
--- a/vibe/setup/trusted_folders/trust_folder_dialog.py
+++ b/vibe/setup/trusted_folders/trust_folder_dialog.py
@@ -46,14 +46,16 @@ class TrustFolderDialog(CenterMiddle):
def compose(self) -> ComposeResult:
with CenterMiddle(id="trust-dialog"):
- yield NoMarkupStatic("⚠ Trust this folder?", id="trust-dialog-title")
+ yield NoMarkupStatic(
+ "⚠ Trust this folder and all its subfolders?", id="trust-dialog-title"
+ )
yield NoMarkupStatic(
str(self.folder_path),
id="trust-dialog-path",
classes="trust-dialog-path",
)
yield NoMarkupStatic(
- "Files that can modify your Mistral Vibe setup were found here. Do you trust this folder?",
+ "Files that can modify your Mistral Vibe setup were found here. Do you trust this folder and all its subfolders?",
id="trust-dialog-message",
classes="trust-dialog-message",
)
diff --git a/vibe/whats_new.md b/vibe/whats_new.md
index c45e484..c9498de 100644
--- a/vibe/whats_new.md
+++ b/vibe/whats_new.md
@@ -1,4 +1,4 @@
-# What's new in v2.4.2
-
-- **Skill arguments**: Skills now extract arguments when invoked, allowing you to pass arguments
-- **Auto-compact fallback**: Auto-compact threshold falls back to the global setting when not defined at model level
+# What's new in v2.5.0
+- **Lean mode**: Setup a dedicated theorem proving agent powered by leanstral with /leanstall
+- **Parallel tool execution**: Parallel tool calls are sped up by being ran at the same time
+- **Voice mode**: Real-time transcription support with /voice