diff --git a/.vscode/launch.json b/.vscode/launch.json
index 369c564..029d20c 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -1,5 +1,5 @@
{
- "version": "2.2.1",
+ "version": "2.3.0",
"configurations": [
{
"name": "ACP Server",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9866ac0..5a6b422 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,44 @@ 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.3.0] - 2026-02-27
+
+### Added
+
+- /resume command to choose which session to resume
+- Web search and web fetch tools for retrieving and searching web content
+- MCP sampling support: MCP servers can request LLM completions via the sampling protocol
+- MCP server discovery cache (`MCPRegistry`): survives agent switches without re-discovering unchanged servers
+- Chat mode for ACP (`session/set_config_options` with `mode=chat`)
+- ACP `session/set_config_options` support for switching mode and model
+- Tool call streaming: tool call arguments are now streamed incrementally in the UI
+- Notification indicator in CLI: terminal bell and window title change on action required or completion
+- Subagent traces saved in `agents/` subfolder of parent session directory
+- IDE detection in `new_session` telemetry
+- Discover agents, tools, and skills in subfolders of trusted directories (monorepo support)
+- E2E test infrastructure for CLI TUI
+
+### Changed
+
+- System prompts rewritten for improved model behavior (3-phase Orient/Plan/Execute workflow, brevity rules)
+- Tool call display refactored with `ToolCallDisplay`/`ToolResultDisplay` models and per-tool UI customization
+- Middleware pipeline replaces observer pattern for system message injections
+- Improved permission handling for `write_file`, `read_file`, `search_replace` (allowlist/denylist globs, out-of-cwd detection)
+- Proxy setup UI updated with guided bottom-panel wizard
+- Smoother color transitions in CLI loader animation
+- Dead tool state classes removed (`Grep`, `ReadFile`, `WriteFile` state)
+
+### Fixed
+
+- Agent switch (Shift+Tab) no longer freezes the UI (moved to thread worker)
+- Empty assistant messages are no longer displayed
+- Tool results returned to LLM in correct order matching tool calls
+- Auto-scroll suspended when user has scrolled up; resumes at bottom
+- Retry and timeout handling in Mistral backend (backoff strategy, configurable timeout)
+
+### Removed
+
+
## [2.2.1] - 2026-02-18
### Added
@@ -33,7 +71,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Google Vertex AI support
-- Telemetry: user interaction and tool usage events sent to datalake (configurable via `disable_telemetry`)
+- Telemetry: user interaction and tool usage events sent to datalake (configurable via `enable_telemetry`)
- Skill discovery from `.agents/skills/` (Agent Skills standard) in addition to `.vibe/skills/`
- ACP: `session/load` and `session/list` for loading and listing sessions
- New model behavior prompts (CLI and explore)
diff --git a/README.md b/README.md
index 7d892c8..828a3fa 100644
--- a/README.md
+++ b/README.md
@@ -571,6 +571,16 @@ To disable auto-updates, add this to your `config.toml`:
enable_auto_update = false
```
+### Notification Settings
+
+Vibe can notify you when the agent needs your attention (awaiting approval, asking a question, or task complete). This is useful when you switch to another window while the agent works.
+
+To disable notifications:
+
+```toml
+enable_notifications = false
+```
+
### Custom Vibe Home Directory
By default, Vibe stores its configuration in `~/.vibe/`. You can override this by setting the `VIBE_HOME` environment variable:
diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml
index 8553bb8..c39a909 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.2.1"
+version = "2.3.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.2.1/vibe-acp-darwin-aarch64-2.2.1.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.3.0/vibe-acp-darwin-aarch64-2.3.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-darwin-x86_64-2.2.1.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.3.0/vibe-acp-darwin-x86_64-2.3.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-linux-aarch64-2.2.1.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.3.0/vibe-acp-linux-aarch64-2.3.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-linux-x86_64-2.2.1.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.3.0/vibe-acp-linux-x86_64-2.3.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-aarch64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-windows-aarch64-2.2.1.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.3.0/vibe-acp-windows-aarch64-2.3.0.zip"
cmd = "./vibe-acp.exe"
[agent_servers.mistral-vibe.targets.windows-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-windows-x86_64-2.2.1.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.3.0/vibe-acp-windows-x86_64-2.3.0.zip"
cmd = "./vibe-acp.exe"
diff --git a/pyproject.toml b/pyproject.toml
index 606a353..3972e03 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
-version = "2.2.1"
+version = "2.3.0"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
@@ -27,16 +27,18 @@ classifiers = [
"Topic :: Utilities",
]
dependencies = [
- "agent-client-protocol==0.8.0",
+ "agent-client-protocol==0.8.1",
"anyio>=4.12.0",
+ "cachetools>=5.5.0",
"cryptography>=44.0.0,<=46.0.3",
"gitpython>=3.1.46",
"giturlparse>=0.14.0",
"google-auth>=2.0.0",
"httpx>=0.28.1",
"keyring>=25.6.0",
+ "markdownify>=1.2.2",
"mcp>=1.14.0",
- "mistralai==1.9.11",
+ "mistralai==1.12.4",
"packaging>=24.1",
"pexpect>=4.9.0",
"pydantic>=2.12.4",
@@ -169,3 +171,7 @@ ignore_decorators = ["@*"]
[tool.pytest.ini_options]
addopts = "-vvvv -q -n auto --durations=10 --import-mode=importlib --maxschedchunk=1"
timeout = 10
+filterwarnings = [
+ # (e2e) streaming mock server is started in a thread; and the cli is spawn in a fork
+ "ignore:This process \\(pid=.*\\) is multi-threaded, use of forkpty\\(\\) may lead to deadlocks in the child\\.:DeprecationWarning",
+]
diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py
index 9137730..ba5a0bc 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.2.1"
+ name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.3.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.2.1"
+ name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.3.0"
)
assert response.auth_methods is not None
diff --git a/tests/acp/test_load_session.py b/tests/acp/test_load_session.py
index de0de0f..302b65c 100644
--- a/tests/acp/test_load_session.py
+++ b/tests/acp/test_load_session.py
@@ -34,7 +34,10 @@ def acp_agent_with_session_config(
models=[
ModelConfig(
name="devstral-latest", provider="mistral", alias="devstral-latest"
- )
+ ),
+ ModelConfig(
+ name="devstral-small", provider="mistral", alias="devstral-small"
+ ),
],
session_logging=session_config,
)
@@ -58,7 +61,7 @@ def acp_agent_with_session_config(
class TestLoadSession:
@pytest.mark.asyncio
- async def test_load_session_returns_response_with_models_and_modes(
+ async def test_load_session_response_structure(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
@@ -76,9 +79,49 @@ class TestLoadSession:
assert response is not None
assert response.models is not None
+ assert len(response.models.available_models) == 2
+
assert response.models.current_model_id == "devstral-latest"
+ assert response.models.available_models[0].model_id == "devstral-latest"
+ assert response.models.available_models[0].name == "devstral-latest"
+ assert response.models.available_models[1].model_id == "devstral-small"
+ assert response.models.available_models[1].name == "devstral-small"
+
assert response.modes is not None
assert response.modes.current_mode_id == BuiltinAgentName.DEFAULT
+ modes_ids = {m.id for m in response.modes.available_modes}
+ assert modes_ids == {
+ BuiltinAgentName.DEFAULT,
+ BuiltinAgentName.CHAT,
+ BuiltinAgentName.AUTO_APPROVE,
+ BuiltinAgentName.PLAN,
+ BuiltinAgentName.ACCEPT_EDITS,
+ }
+
+ assert response.config_options is not None
+ assert len(response.config_options) == 2
+ assert response.config_options[0].root.id == "mode"
+ assert response.config_options[0].root.category == "mode"
+ assert response.config_options[0].root.current_value == BuiltinAgentName.DEFAULT
+ assert len(response.config_options[0].root.options) == 5
+ mode_option_values = {
+ opt.value for opt in response.config_options[0].root.options
+ }
+ assert mode_option_values == {
+ BuiltinAgentName.DEFAULT,
+ BuiltinAgentName.CHAT,
+ BuiltinAgentName.AUTO_APPROVE,
+ BuiltinAgentName.PLAN,
+ BuiltinAgentName.ACCEPT_EDITS,
+ }
+ assert response.config_options[1].root.id == "model"
+ assert response.config_options[1].root.category == "model"
+ assert response.config_options[1].root.current_value == "devstral-latest"
+ assert len(response.config_options[1].root.options) == 2
+ model_option_values = {
+ opt.value for opt in response.config_options[1].root.options
+ }
+ assert model_option_values == {"devstral-latest", "devstral-small"}
@pytest.mark.asyncio
async def test_load_session_registers_session_with_original_id(
diff --git a/tests/acp/test_new_session.py b/tests/acp/test_new_session.py
index 6788c98..52942d8 100644
--- a/tests/acp/test_new_session.py
+++ b/tests/acp/test_new_session.py
@@ -48,7 +48,7 @@ class TestACPNewSession:
)
new_session_events = [
- e for e in telemetry_events if e.get("event_name") == "vibe/new_session"
+ e for e in telemetry_events if e.get("event_name") == "vibe.new_session"
]
assert len(new_session_events) == 1
assert new_session_events[0]["properties"]["entrypoint"] == "acp"
@@ -84,18 +84,47 @@ class TestACPNewSession:
assert session_response.modes is not None
assert session_response.modes.current_mode_id is not None
assert session_response.modes.available_modes is not None
- assert len(session_response.modes.available_modes) == 4
+ assert len(session_response.modes.available_modes) == 5
assert session_response.modes.current_mode_id == BuiltinAgentName.DEFAULT
# Check that all primary agents are available (order may vary)
mode_ids = {m.id for m in session_response.modes.available_modes}
assert mode_ids == {
BuiltinAgentName.DEFAULT,
+ BuiltinAgentName.CHAT,
BuiltinAgentName.AUTO_APPROVE,
BuiltinAgentName.PLAN,
BuiltinAgentName.ACCEPT_EDITS,
}
+ # Check config_options
+ assert session_response.config_options is not None
+ assert len(session_response.config_options) == 2
+
+ # Mode config option
+ mode_config = session_response.config_options[0]
+ assert mode_config.root.id == "mode"
+ assert mode_config.root.category == "mode"
+ assert mode_config.root.current_value == BuiltinAgentName.DEFAULT
+ assert len(mode_config.root.options) == 5
+ mode_option_values = {opt.value for opt in mode_config.root.options}
+ assert mode_option_values == {
+ BuiltinAgentName.DEFAULT,
+ BuiltinAgentName.CHAT,
+ BuiltinAgentName.AUTO_APPROVE,
+ BuiltinAgentName.PLAN,
+ BuiltinAgentName.ACCEPT_EDITS,
+ }
+
+ # Model config option
+ model_config = session_response.config_options[1]
+ assert model_config.root.id == "model"
+ assert model_config.root.category == "model"
+ assert model_config.root.current_value == "devstral-latest"
+ assert len(model_config.root.options) == 2
+ model_option_values = {opt.value for opt in model_config.root.options}
+ assert model_option_values == {"devstral-latest", "devstral-small"}
+
@pytest.mark.skip(reason="TODO: Fix this test")
@pytest.mark.asyncio
async def test_new_session_preserves_model_after_set_model(
diff --git a/tests/acp/test_set_config_option.py b/tests/acp/test_set_config_option.py
new file mode 100644
index 0000000..ffaf841
--- /dev/null
+++ b/tests/acp/test_set_config_option.py
@@ -0,0 +1,287 @@
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from tests.acp.conftest import _create_acp_agent
+from tests.conftest import build_test_vibe_config
+from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
+from vibe.core.agent_loop import AgentLoop
+from vibe.core.agents.models import BuiltinAgentName
+from vibe.core.config import ModelConfig, VibeConfig
+
+
+@pytest.fixture
+def acp_agent_loop(backend) -> VibeAcpAgentLoop:
+ config = build_test_vibe_config(
+ active_model="devstral-latest",
+ models=[
+ ModelConfig(
+ name="devstral-latest",
+ provider="mistral",
+ alias="devstral-latest",
+ input_price=0.4,
+ output_price=2.0,
+ ),
+ ModelConfig(
+ name="devstral-small",
+ provider="mistral",
+ alias="devstral-small",
+ input_price=0.1,
+ output_price=0.3,
+ ),
+ ],
+ )
+
+ VibeConfig.dump_config(config.model_dump())
+
+ class PatchedAgentLoop(AgentLoop):
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **{**kwargs, "backend": backend})
+ self._base_config = config
+ self.agent_manager.invalidate_config()
+ try:
+ active_model = config.get_active_model()
+ self.stats.input_price_per_million = active_model.input_price
+ self.stats.output_price_per_million = active_model.output_price
+ except ValueError:
+ pass
+
+ patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start()
+
+ return _create_acp_agent()
+
+
+class TestACPSetConfigOptionMode:
+ @pytest.mark.asyncio
+ async def test_set_config_option_mode_to_auto_approve(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+ acp_session = next(
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
+ )
+ assert acp_session is not None
+ assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
+
+ response = await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="mode", value=BuiltinAgentName.AUTO_APPROVE
+ )
+
+ assert response is not None
+ assert response.config_options is not None
+ assert len(response.config_options) == 2
+ assert (
+ acp_session.agent_loop.agent_profile.name == BuiltinAgentName.AUTO_APPROVE
+ )
+ assert acp_session.agent_loop.auto_approve is True
+
+ # Verify config_options reflect the new state
+ mode_config = response.config_options[0]
+ assert mode_config.root.id == "mode"
+ assert mode_config.root.current_value == BuiltinAgentName.AUTO_APPROVE
+
+ @pytest.mark.asyncio
+ async def test_set_config_option_mode_to_plan(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+ acp_session = next(
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
+ )
+ assert acp_session is not None
+
+ response = await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="mode", value=BuiltinAgentName.PLAN
+ )
+
+ assert response is not None
+ assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.PLAN
+
+ @pytest.mark.asyncio
+ async def test_set_config_option_mode_invalid_returns_none(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+ acp_session = next(
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
+ )
+ assert acp_session is not None
+ initial_mode = acp_session.agent_loop.agent_profile.name
+
+ response = await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="mode", value="invalid-mode"
+ )
+
+ assert response is None
+ assert acp_session.agent_loop.agent_profile.name == initial_mode
+
+ @pytest.mark.asyncio
+ async def test_set_config_option_mode_empty_string_returns_none(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+ acp_session = next(
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
+ )
+ assert acp_session is not None
+ initial_mode = acp_session.agent_loop.agent_profile.name
+
+ response = await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="mode", value=""
+ )
+
+ assert response is None
+ assert acp_session.agent_loop.agent_profile.name == initial_mode
+
+
+class TestACPSetConfigOptionModel:
+ @pytest.mark.asyncio
+ async def test_set_config_option_model_success(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+ acp_session = next(
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
+ )
+ assert acp_session is not None
+ assert acp_session.agent_loop.config.active_model == "devstral-latest"
+
+ response = await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="model", value="devstral-small"
+ )
+
+ assert response is not None
+ assert response.config_options is not None
+ assert len(response.config_options) == 2
+ assert acp_session.agent_loop.config.active_model == "devstral-small"
+
+ # Verify config_options reflect the new state
+ model_config = response.config_options[1]
+ assert model_config.root.id == "model"
+ assert model_config.root.current_value == "devstral-small"
+
+ @pytest.mark.asyncio
+ async def test_set_config_option_model_invalid_returns_none(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+ acp_session = next(
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
+ )
+ assert acp_session is not None
+ initial_model = acp_session.agent_loop.config.active_model
+
+ response = await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="model", value="non-existent-model"
+ )
+
+ assert response is None
+ assert acp_session.agent_loop.config.active_model == initial_model
+
+ @pytest.mark.asyncio
+ async def test_set_config_option_model_empty_string_returns_none(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+ acp_session = next(
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
+ )
+ assert acp_session is not None
+ initial_model = acp_session.agent_loop.config.active_model
+
+ response = await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="model", value=""
+ )
+
+ assert response is None
+ assert acp_session.agent_loop.config.active_model == initial_model
+
+ @pytest.mark.asyncio
+ async def test_set_config_option_model_saves_to_config(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+
+ with patch("vibe.acp.acp_agent_loop.VibeConfig.save_updates") as mock_save:
+ response = await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="model", value="devstral-small"
+ )
+
+ assert response is not None
+ mock_save.assert_called_once_with({"active_model": "devstral-small"})
+
+ @pytest.mark.asyncio
+ async def test_set_config_option_model_does_not_save_on_invalid(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+
+ with patch("vibe.acp.acp_agent_loop.VibeConfig.save_updates") as mock_save:
+ response = await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="model", value="non-existent-model"
+ )
+
+ assert response is None
+ mock_save.assert_not_called()
+
+
+class TestACPSetConfigOptionInvalidConfigId:
+ @pytest.mark.asyncio
+ async def test_set_config_option_invalid_config_id_returns_none(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+
+ response = await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="invalid_config", value="some_value"
+ )
+
+ assert response is None
+
+ @pytest.mark.asyncio
+ async def test_set_config_option_empty_config_id_returns_none(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+
+ response = await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="", value="some_value"
+ )
+
+ assert response is None
diff --git a/tests/acp/test_set_mode.py b/tests/acp/test_set_mode.py
index 3a146f4..a1e2499 100644
--- a/tests/acp/test_set_mode.py
+++ b/tests/acp/test_set_mode.py
@@ -106,6 +106,29 @@ class TestACPSetMode:
acp_session.agent_loop.auto_approve is False
) # Accept Edits mode doesn't auto-approve all
+ @pytest.mark.asyncio
+ async def test_set_mode_to_chat(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+ acp_session = next(
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
+ )
+ assert acp_session is not None
+
+ assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
+
+ response = await acp_agent_loop.set_session_mode(
+ session_id=session_id, mode_id=BuiltinAgentName.CHAT
+ )
+
+ assert response is not None
+ assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.CHAT
+ assert (
+ acp_session.agent_loop.auto_approve is True
+ ) # Chat mode auto-approves read-only tools
+
@pytest.mark.asyncio
async def test_set_mode_invalid_mode_returns_none(
self, acp_agent_loop: VibeAcpAgentLoop
diff --git a/tests/acp/test_tool_call_session_update.py b/tests/acp/test_tool_call_session_update.py
new file mode 100644
index 0000000..aaf2545
--- /dev/null
+++ b/tests/acp/test_tool_call_session_update.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from acp.schema import ToolCallStart
+
+from vibe.acp.tools.session_update import tool_call_session_update
+from vibe.core.tools.builtins.read_file import ReadFile, ReadFileArgs
+from vibe.core.types import ToolCallEvent
+
+
+class TestToolCallSessionUpdate:
+ def _create_event(self) -> ToolCallEvent:
+ return ToolCallEvent(
+ tool_name="read_file",
+ tool_call_id="test_call_123",
+ args=ReadFileArgs(path="/tmp/test.txt"),
+ tool_class=ReadFile,
+ )
+
+ def test_returns_tool_call_start(self) -> None:
+ event = self._create_event()
+
+ update = tool_call_session_update(event)
+
+ assert update is not None
+ assert isinstance(update, ToolCallStart)
+ assert update.session_update == "tool_call"
+ assert update.tool_call_id == "test_call_123"
+
+ def test_returns_tool_call_start_for_streaming_event(self) -> None:
+ event = ToolCallEvent(
+ tool_name="read_file",
+ tool_call_id="test_call_123",
+ tool_class=ReadFile,
+ args=None,
+ )
+
+ update = tool_call_session_update(event)
+
+ assert update is not None
+ assert isinstance(update, ToolCallStart)
+ assert update.session_update == "tool_call"
+ assert update.tool_call_id == "test_call_123"
+ assert update.kind == "read"
+ assert update.raw_input is None
diff --git a/tests/autocompletion/test_file_indexer.py b/tests/autocompletion/test_file_indexer.py
index 7ddb792..e2a82c6 100644
--- a/tests/autocompletion/test_file_indexer.py
+++ b/tests/autocompletion/test_file_indexer.py
@@ -89,11 +89,15 @@ def test_updates_index_on_file_rename(
old_file.rename(new_file)
assert _wait_for(
- lambda: all(
- entry.rel != old_file.name for entry in file_indexer.get_index(Path("."))
- )
- and any(
- entry.rel == new_file.name for entry in file_indexer.get_index(Path("."))
+ lambda: (
+ all(
+ entry.rel != old_file.name
+ for entry in file_indexer.get_index(Path("."))
+ )
+ and any(
+ entry.rel == new_file.name
+ for entry in file_indexer.get_index(Path("."))
+ )
)
)
diff --git a/tests/autocompletion/test_ui_chat_autocompletion.py b/tests/autocompletion/test_ui_chat_autocompletion.py
index adf12b9..e0a005d 100644
--- a/tests/autocompletion/test_ui_chat_autocompletion.py
+++ b/tests/autocompletion/test_ui_chat_autocompletion.py
@@ -118,7 +118,7 @@ async def test_pressing_enter_submits_selected_command_and_hides_popup(
slash_used = [
e
for e in telemetry_events
- if e.get("event_name") == "vibe/slash_command_used"
+ if e.get("event_name") == "vibe.slash_command_used"
]
assert any(
e.get("properties", {}).get("command") == "help"
diff --git a/tests/cli/test_bell_notifications.py b/tests/cli/test_bell_notifications.py
new file mode 100644
index 0000000..2c45d29
--- /dev/null
+++ b/tests/cli/test_bell_notifications.py
@@ -0,0 +1,151 @@
+from __future__ import annotations
+
+from unittest.mock import MagicMock, PropertyMock
+
+import pytest
+
+from vibe.cli.textual_ui.notifications import (
+ NotificationContext,
+ TextualNotificationAdapter,
+)
+
+
+def _make_fake_app(*, is_headless: bool = False) -> MagicMock:
+ app = MagicMock()
+ type(app).is_headless = PropertyMock(return_value=is_headless)
+ return app
+
+
+@pytest.fixture
+def fake_app() -> MagicMock:
+ return _make_fake_app()
+
+
+@pytest.fixture
+def adapter_enabled(fake_app: MagicMock) -> TextualNotificationAdapter:
+ return TextualNotificationAdapter(
+ fake_app, get_enabled=lambda: True, default_title="Vibe"
+ )
+
+
+@pytest.fixture
+def adapter_disabled(fake_app: MagicMock) -> TextualNotificationAdapter:
+ return TextualNotificationAdapter(
+ fake_app, get_enabled=lambda: False, default_title="Vibe"
+ )
+
+
+class TestTextualNotificationAdapter:
+ def test_no_notification_when_disabled(
+ self, adapter_disabled: TextualNotificationAdapter, fake_app: MagicMock
+ ) -> None:
+ adapter_disabled.on_blur()
+
+ adapter_disabled.notify(NotificationContext.ACTION_REQUIRED)
+
+ fake_app.bell.assert_not_called()
+
+ def test_no_notification_when_focused(
+ self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
+ ) -> None:
+ adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
+
+ fake_app.bell.assert_not_called()
+
+ def test_notification_sent_when_unfocused_and_enabled(
+ self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
+ ) -> None:
+ adapter_enabled.on_blur()
+
+ adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
+
+ fake_app.bell.assert_called_once()
+ fake_app._driver.write.assert_called_once_with(
+ "\x1b]0;Vibe - Action Required\x07"
+ )
+
+ def test_throttle_prevents_rapid_notifications(
+ self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
+ ) -> None:
+ adapter_enabled.on_blur()
+
+ adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
+ assert fake_app.bell.call_count == 1
+
+ adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
+ assert fake_app.bell.call_count == 1
+
+ def test_contextual_title_for_action_required(
+ self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
+ ) -> None:
+ adapter_enabled.on_blur()
+
+ adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
+
+ fake_app._driver.write.assert_called_once_with(
+ "\x1b]0;Vibe - Action Required\x07"
+ )
+
+ def test_contextual_title_for_complete(
+ self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
+ ) -> None:
+ adapter_enabled.on_blur()
+
+ adapter_enabled.notify(NotificationContext.COMPLETE)
+
+ fake_app._driver.write.assert_called_once_with(
+ "\x1b]0;Vibe - Task Complete\x07"
+ )
+
+ def test_restore_sets_default_title(
+ self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
+ ) -> None:
+ adapter_enabled.restore()
+
+ fake_app._driver.write.assert_called_once_with("\x1b]0;Vibe\x07")
+
+ def test_on_focus_restores_title(
+ self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
+ ) -> None:
+ adapter_enabled.on_blur()
+ adapter_enabled.on_focus()
+
+ fake_app._driver.write.assert_called_once_with("\x1b]0;Vibe\x07")
+
+ def test_on_focus_prevents_notifications(
+ self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
+ ) -> None:
+ adapter_enabled.on_blur()
+ adapter_enabled.on_focus()
+ fake_app.reset_mock()
+
+ adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
+
+ fake_app.bell.assert_not_called()
+
+ def test_no_title_write_when_headless(self) -> None:
+ app = _make_fake_app(is_headless=True)
+ adapter = TextualNotificationAdapter(
+ app, get_enabled=lambda: True, default_title="Vibe"
+ )
+ adapter.on_blur()
+
+ adapter.notify(NotificationContext.ACTION_REQUIRED)
+
+ app.bell.assert_called_once()
+ app._driver.write.assert_not_called()
+
+ def test_enabled_callback_reads_live_value(self, fake_app: MagicMock) -> None:
+ enabled = True
+ adapter = TextualNotificationAdapter(
+ fake_app, get_enabled=lambda: enabled, default_title="Vibe"
+ )
+ adapter.on_blur()
+
+ adapter.notify(NotificationContext.ACTION_REQUIRED)
+ assert fake_app.bell.call_count == 1
+
+ enabled = False
+ adapter._last_notification_time = 0.0
+ adapter.notify(NotificationContext.ACTION_REQUIRED)
+ assert fake_app.bell.call_count == 1
diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py
index 4c5fefb..495d5d9 100644
--- a/tests/cli/test_commands.py
+++ b/tests/cli/test_commands.py
@@ -52,3 +52,11 @@ class TestCommandRegistry:
assert registry.get_command_name("/exit") is None
assert registry.find_command("/exit") is None
assert registry.get_command_name("/help") == "help"
+
+ def test_resume_command_registration(self) -> None:
+ registry = CommandRegistry()
+ assert registry.get_command_name("/resume") == "resume"
+ assert registry.get_command_name("/continue") == "resume"
+ cmd = registry.find_command("/resume")
+ assert cmd is not None
+ assert cmd.handler == "_show_session_picker"
diff --git a/tests/cli/test_switching_mode.py b/tests/cli/test_switching_mode.py
new file mode 100644
index 0000000..835342c
--- /dev/null
+++ b/tests/cli/test_switching_mode.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+import pytest
+
+from tests.conftest import build_test_vibe_app
+from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
+from vibe.cli.textual_ui.widgets.chat_input.body import ChatInputBody, _PromptSpinner
+from vibe.cli.textual_ui.widgets.messages import UserMessage
+
+
+@pytest.mark.asyncio
+async def test_submit_ignored_while_switching_mode() -> None:
+ """Enter press during mode switch must not clear input or send a message."""
+ app = build_test_vibe_app()
+ async with app.run_test() as pilot:
+ await pilot.pause(0.1)
+
+ body = app.query_one(ChatInputBody)
+ body.switching_mode = True
+ await pilot.pause(0.1)
+
+ # Type some text and press enter
+ app.query_one(ChatInputContainer).value = "hello world"
+ await pilot.press("enter")
+ await pilot.pause(0.1)
+
+ # Text must remain in the input
+ assert app.query_one(ChatInputContainer).value == "hello world"
+ # No user message should have been posted
+ assert len(app.query(UserMessage)) == 0
+
+
+@pytest.mark.asyncio
+async def test_submit_works_after_switching_mode_ends() -> None:
+ """After switching_mode is set back to False, Enter should work normally."""
+ app = build_test_vibe_app()
+ async with app.run_test() as pilot:
+ await pilot.pause(0.1)
+
+ body = app.query_one(ChatInputBody)
+
+ # Enable then disable switching mode
+ body.switching_mode = True
+ await pilot.pause(0.1)
+ body.switching_mode = False
+ await pilot.pause(0.1)
+
+ # Now submit should work
+ app.query_one(ChatInputContainer).value = "hello"
+ await pilot.press("enter")
+ await pilot.pause(0.1)
+
+ assert app.query_one(ChatInputContainer).value == ""
+
+
+@pytest.mark.asyncio
+async def test_spinner_shown_while_switching_mode() -> None:
+ """Prompt widget is hidden and spinner is mounted when switching_mode is True."""
+ app = build_test_vibe_app()
+ async with app.run_test() as pilot:
+ await pilot.pause(0.1)
+
+ body = app.query_one(ChatInputBody)
+ prompt = body.prompt_widget
+ assert prompt is not None
+ assert prompt.display is True
+ assert len(body.query(_PromptSpinner)) == 0
+
+ body.switching_mode = True
+ await pilot.pause(0.1)
+
+ assert prompt.display is False
+ assert len(body.query(_PromptSpinner)) == 1
+
+
+@pytest.mark.asyncio
+async def test_spinner_removed_after_switching_mode_ends() -> None:
+ """Prompt is restored and spinner removed when switching_mode becomes False."""
+ app = build_test_vibe_app()
+ async with app.run_test() as pilot:
+ await pilot.pause(0.1)
+
+ body = app.query_one(ChatInputBody)
+ body.switching_mode = True
+ await pilot.pause(0.1)
+ body.switching_mode = False
+ await pilot.pause(0.1)
+
+ assert body.prompt_widget is not None
+ assert body.prompt_widget.display is True
+ assert len(body.query(_PromptSpinner)) == 0
+
+
+@pytest.mark.asyncio
+async def test_rapid_switching_mode_no_duplicate_spinners() -> None:
+ """Rapidly toggling switching_mode must never produce duplicate spinners."""
+ app = build_test_vibe_app()
+ async with app.run_test() as pilot:
+ await pilot.pause(0.1)
+
+ body = app.query_one(ChatInputBody)
+
+ # Rapidly toggle several times
+ for _ in range(5):
+ body.switching_mode = True
+ body.switching_mode = True # double set
+ await pilot.pause(0.1)
+
+ assert len(body.query(_PromptSpinner)) == 1
diff --git a/tests/cli/test_ui_session_incremental_renderer.py b/tests/cli/test_ui_session_incremental_renderer.py
index c396aef..5190c0f 100644
--- a/tests/cli/test_ui_session_incremental_renderer.py
+++ b/tests/cli/test_ui_session_incremental_renderer.py
@@ -144,8 +144,9 @@ async def test_ui_session_incremental_loader_load_more_batches_until_done(
app.post_message(HistoryLoadMoreRequested())
await _wait_until(
pilot.pause,
- lambda current_count=current_count: len(app.query(UserMessage))
- > current_count,
+ lambda current_count=current_count: (
+ len(app.query(UserMessage)) > current_count
+ ),
)
await _wait_until(
diff --git a/tests/cli/textual_ui/__init__.py b/tests/cli/textual_ui/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/cli/textual_ui/test_session_picker.py b/tests/cli/textual_ui/test_session_picker.py
new file mode 100644
index 0000000..3519d06
--- /dev/null
+++ b/tests/cli/textual_ui/test_session_picker.py
@@ -0,0 +1,131 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+
+import pytest
+
+from vibe.cli.textual_ui.widgets.session_picker import (
+ SessionPickerApp,
+ _format_relative_time,
+)
+from vibe.core.session.session_loader import SessionInfo
+
+
+@pytest.fixture
+def sample_sessions() -> list[SessionInfo]:
+ return [
+ SessionInfo(
+ session_id="session-a",
+ cwd="/test",
+ title="Session A",
+ end_time=(datetime.now(UTC) - timedelta(minutes=5)).isoformat(),
+ ),
+ SessionInfo(
+ session_id="session-b",
+ cwd="/test",
+ title="Session B",
+ end_time=(datetime.now(UTC) - timedelta(hours=1)).isoformat(),
+ ),
+ SessionInfo(
+ session_id="session-c",
+ cwd="/test",
+ title="Session C",
+ end_time=(datetime.now(UTC) - timedelta(days=1)).isoformat(),
+ ),
+ ]
+
+
+@pytest.fixture
+def sample_latest_messages() -> dict[str, str]:
+ return {
+ "session-a": "Help me fix this bug",
+ "session-b": "Refactor the authentication module",
+ "session-c": "Add unit tests for the API",
+ }
+
+
+class TestFormatRelativeTime:
+ def test_just_now(self) -> None:
+ now = datetime.now(UTC).isoformat()
+ assert _format_relative_time(now) == "just now"
+
+ def test_minutes_ago(self) -> None:
+ time_5m_ago = (datetime.now(UTC) - timedelta(minutes=5)).isoformat()
+ assert _format_relative_time(time_5m_ago) == "5m ago"
+
+ def test_hours_ago(self) -> None:
+ time_2h_ago = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
+ assert _format_relative_time(time_2h_ago) == "2h ago"
+
+ def test_days_ago(self) -> None:
+ time_3d_ago = (datetime.now(UTC) - timedelta(days=3)).isoformat()
+ assert _format_relative_time(time_3d_ago) == "3d ago"
+
+ def test_weeks_ago(self) -> None:
+ time_2w_ago = (datetime.now(UTC) - timedelta(weeks=2)).isoformat()
+ assert _format_relative_time(time_2w_ago) == "2w ago"
+
+ def test_none_returns_unknown(self) -> None:
+ assert _format_relative_time(None) == "unknown"
+
+ def test_invalid_format_returns_unknown(self) -> None:
+ assert _format_relative_time("not-a-date") == "unknown"
+
+ def test_handles_z_suffix(self) -> None:
+ time_str = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
+ assert _format_relative_time(time_str) == "just now"
+
+ def test_boundary_59_seconds(self) -> None:
+ time_59s_ago = (datetime.now(UTC) - timedelta(seconds=59)).isoformat()
+ assert _format_relative_time(time_59s_ago) == "just now"
+
+ def test_boundary_60_seconds(self) -> None:
+ time_60s_ago = (datetime.now(UTC) - timedelta(seconds=60)).isoformat()
+ assert _format_relative_time(time_60s_ago) == "1m ago"
+
+
+class TestSessionPickerAppInit:
+ def test_init_sets_properties(
+ self, sample_sessions: list[SessionInfo], sample_latest_messages: dict[str, str]
+ ) -> None:
+ picker = SessionPickerApp(
+ sessions=sample_sessions, latest_messages=sample_latest_messages
+ )
+ assert picker._sessions == sample_sessions
+ assert picker._latest_messages == sample_latest_messages
+
+ def test_id_is_sessionpicker_app(self) -> None:
+ picker = SessionPickerApp(sessions=[], latest_messages={})
+ assert picker.id == "sessionpicker-app"
+
+ def test_can_focus_children_is_true(self) -> None:
+ assert SessionPickerApp.can_focus_children is True
+
+
+class TestSessionPickerMessages:
+ def test_session_selected_stores_session_id(self) -> None:
+ msg = SessionPickerApp.SessionSelected("test-session-id")
+ assert msg.session_id == "test-session-id"
+
+ def test_session_selected_with_full_uuid(self) -> None:
+ session_id = "abc12345-6789-0123-4567-89abcdef0123"
+ msg = SessionPickerApp.SessionSelected(session_id)
+ assert msg.session_id == session_id
+
+ def test_cancelled_can_be_instantiated(self) -> None:
+ msg = SessionPickerApp.Cancelled()
+ assert isinstance(msg, SessionPickerApp.Cancelled)
+
+
+class TestSessionPickerAppBindings:
+ def _get_binding_keys(self) -> list[str]:
+ keys = []
+ for binding in SessionPickerApp.BINDINGS:
+ if isinstance(binding, tuple) and len(binding) >= 1:
+ keys.append(binding[0])
+ else:
+ keys.append(binding.key)
+ return keys
+
+ def test_has_escape_binding(self) -> None:
+ assert "escape" in self._get_binding_keys()
diff --git a/tests/core/test_config_paths.py b/tests/core/test_config_paths.py
index 3fc1111..31f5283 100644
--- a/tests/core/test_config_paths.py
+++ b/tests/core/test_config_paths.py
@@ -3,29 +3,33 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
-from vibe.core.paths.config_paths import resolve_local_skills_dirs
+from vibe.core.paths.config_paths import (
+ discover_local_agents_dirs,
+ discover_local_skills_dirs,
+ discover_local_tools_dirs,
+)
-class TestResolveLocalSkillsDirs:
+class TestDiscoverLocalSkillsDirs:
def test_returns_empty_list_when_dir_not_trusted(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = False
- assert resolve_local_skills_dirs(tmp_path) == []
+ assert discover_local_skills_dirs(tmp_path) == []
def test_returns_empty_list_when_trusted_but_no_skills_dirs(
self, tmp_path: Path
) -> None:
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = True
- assert resolve_local_skills_dirs(tmp_path) == []
+ assert discover_local_skills_dirs(tmp_path) == []
def test_returns_vibe_skills_only_when_only_it_exists(self, tmp_path: Path) -> None:
vibe_skills = tmp_path / ".vibe" / "skills"
vibe_skills.mkdir(parents=True)
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = True
- result = resolve_local_skills_dirs(tmp_path)
+ result = discover_local_skills_dirs(tmp_path)
assert result == [vibe_skills]
def test_returns_agents_skills_only_when_only_it_exists(
@@ -35,7 +39,7 @@ class TestResolveLocalSkillsDirs:
agents_skills.mkdir(parents=True)
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = True
- result = resolve_local_skills_dirs(tmp_path)
+ result = discover_local_skills_dirs(tmp_path)
assert result == [agents_skills]
def test_returns_both_in_order_when_both_exist(self, tmp_path: Path) -> None:
@@ -45,7 +49,7 @@ class TestResolveLocalSkillsDirs:
agents_skills.mkdir(parents=True)
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = True
- result = resolve_local_skills_dirs(tmp_path)
+ result = discover_local_skills_dirs(tmp_path)
assert result == [vibe_skills, agents_skills]
def test_ignores_vibe_skills_when_file_not_dir(self, tmp_path: Path) -> None:
@@ -53,5 +57,128 @@ class TestResolveLocalSkillsDirs:
(tmp_path / ".vibe" / "skills").write_text("", encoding="utf-8")
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = True
- result = resolve_local_skills_dirs(tmp_path)
+ result = discover_local_skills_dirs(tmp_path)
assert result == []
+
+ def test_finds_skills_dirs_recursively_in_trusted_folder(
+ self, tmp_path: Path
+ ) -> None:
+ (tmp_path / ".vibe" / "skills").mkdir(parents=True)
+ (tmp_path / "sub" / ".agents" / "skills").mkdir(parents=True)
+ (tmp_path / "sub" / "deep" / ".vibe" / "skills").mkdir(parents=True)
+ with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
+ mock_tfm.is_trusted.return_value = True
+ result = discover_local_skills_dirs(tmp_path)
+ assert result == [
+ tmp_path / ".vibe" / "skills",
+ tmp_path / "sub" / ".agents" / "skills",
+ tmp_path / "sub" / "deep" / ".vibe" / "skills",
+ ]
+
+ def test_does_not_descend_into_ignored_dirs(self, tmp_path: Path) -> None:
+ (tmp_path / ".vibe" / "skills").mkdir(parents=True)
+ (tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
+ with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
+ mock_tfm.is_trusted.return_value = True
+ result = discover_local_skills_dirs(tmp_path)
+ assert result == [tmp_path / ".vibe" / "skills"]
+
+
+class TestDiscoverLocalToolsDirs:
+ def test_returns_empty_list_when_dir_not_trusted(self, tmp_path: Path) -> None:
+ (tmp_path / ".vibe" / "tools").mkdir(parents=True)
+ with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
+ mock_tfm.is_trusted.return_value = False
+ assert discover_local_tools_dirs(tmp_path) == []
+
+ def test_returns_empty_list_when_trusted_but_no_tools_dir(
+ self, tmp_path: Path
+ ) -> None:
+ with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
+ mock_tfm.is_trusted.return_value = True
+ assert discover_local_tools_dirs(tmp_path) == []
+
+ def test_returns_tools_dir_when_exists(self, tmp_path: Path) -> None:
+ vibe_tools = tmp_path / ".vibe" / "tools"
+ vibe_tools.mkdir(parents=True)
+ with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
+ mock_tfm.is_trusted.return_value = True
+ result = discover_local_tools_dirs(tmp_path)
+ assert result == [vibe_tools]
+
+ def test_ignores_tools_when_file_not_dir(self, tmp_path: Path) -> None:
+ (tmp_path / ".vibe").mkdir()
+ (tmp_path / ".vibe" / "tools").write_text("", encoding="utf-8")
+ with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
+ mock_tfm.is_trusted.return_value = True
+ result = discover_local_tools_dirs(tmp_path)
+ assert result == []
+
+ def test_finds_tools_dirs_recursively(self, tmp_path: Path) -> None:
+ (tmp_path / ".vibe" / "tools").mkdir(parents=True)
+ (tmp_path / "sub" / ".vibe" / "tools").mkdir(parents=True)
+ with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
+ mock_tfm.is_trusted.return_value = True
+ result = discover_local_tools_dirs(tmp_path)
+ assert result == [
+ tmp_path / ".vibe" / "tools",
+ tmp_path / "sub" / ".vibe" / "tools",
+ ]
+
+ def test_does_not_descend_into_ignored_dirs(self, tmp_path: Path) -> None:
+ (tmp_path / ".vibe" / "tools").mkdir(parents=True)
+ (tmp_path / ".git" / ".vibe" / "tools").mkdir(parents=True)
+ with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
+ mock_tfm.is_trusted.return_value = True
+ result = discover_local_tools_dirs(tmp_path)
+ assert result == [tmp_path / ".vibe" / "tools"]
+
+
+class TestDiscoverLocalAgentsDirs:
+ def test_returns_empty_list_when_dir_not_trusted(self, tmp_path: Path) -> None:
+ (tmp_path / ".vibe" / "agents").mkdir(parents=True)
+ with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
+ mock_tfm.is_trusted.return_value = False
+ assert discover_local_agents_dirs(tmp_path) == []
+
+ def test_returns_empty_list_when_trusted_but_no_agents_dir(
+ self, tmp_path: Path
+ ) -> None:
+ with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
+ mock_tfm.is_trusted.return_value = True
+ assert discover_local_agents_dirs(tmp_path) == []
+
+ def test_returns_agents_dir_when_exists(self, tmp_path: Path) -> None:
+ vibe_agents = tmp_path / ".vibe" / "agents"
+ vibe_agents.mkdir(parents=True)
+ with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
+ mock_tfm.is_trusted.return_value = True
+ result = discover_local_agents_dirs(tmp_path)
+ assert result == [vibe_agents]
+
+ def test_ignores_agents_when_file_not_dir(self, tmp_path: Path) -> None:
+ (tmp_path / ".vibe").mkdir()
+ (tmp_path / ".vibe" / "agents").write_text("", encoding="utf-8")
+ with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
+ mock_tfm.is_trusted.return_value = True
+ result = discover_local_agents_dirs(tmp_path)
+ assert result == []
+
+ def test_finds_agents_dirs_recursively(self, tmp_path: Path) -> None:
+ (tmp_path / ".vibe" / "agents").mkdir(parents=True)
+ (tmp_path / "sub" / "deep" / ".vibe" / "agents").mkdir(parents=True)
+ with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
+ mock_tfm.is_trusted.return_value = True
+ result = discover_local_agents_dirs(tmp_path)
+ assert result == [
+ tmp_path / ".vibe" / "agents",
+ tmp_path / "sub" / "deep" / ".vibe" / "agents",
+ ]
+
+ def test_does_not_descend_into_ignored_dirs(self, tmp_path: Path) -> None:
+ (tmp_path / ".vibe" / "agents").mkdir(parents=True)
+ (tmp_path / "__pycache__" / ".vibe" / "agents").mkdir(parents=True)
+ with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
+ mock_tfm.is_trusted.return_value = True
+ result = discover_local_agents_dirs(tmp_path)
+ assert result == [tmp_path / ".vibe" / "agents"]
diff --git a/tests/core/test_file_logging.py b/tests/core/test_file_logging.py
index 966f22c..96b1145 100644
--- a/tests/core/test_file_logging.py
+++ b/tests/core/test_file_logging.py
@@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
import pytest
-from vibe.core.utils import StructuredLogFormatter, apply_logging_config
+from vibe.core.logger import StructuredLogFormatter, apply_logging_config
@pytest.fixture
@@ -19,8 +19,8 @@ def mock_log_dir(tmp_path: Path):
mock_file = MagicMock()
mock_file.path = tmp_path / "vibe.log"
with (
- patch("vibe.core.utils.LOG_DIR", mock_dir),
- patch("vibe.core.utils.LOG_FILE", mock_file),
+ patch("vibe.core.logger.LOG_DIR", mock_dir),
+ patch("vibe.core.logger.LOG_FILE", mock_file),
):
yield tmp_path
@@ -222,8 +222,8 @@ class TestApplyLoggingConfig:
mock_file = MagicMock()
mock_file.path = log_dir / "vibe.log"
with (
- patch("vibe.core.utils.LOG_DIR", mock_dir),
- patch("vibe.core.utils.LOG_FILE", mock_file),
+ patch("vibe.core.logger.LOG_DIR", mock_dir),
+ patch("vibe.core.logger.LOG_FILE", mock_file),
):
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
test_logger = logging.getLogger("test_mkdir")
diff --git a/tests/core/test_telemetry_send.py b/tests/core/test_telemetry_send.py
index fcb95c3..2e72ca5 100644
--- a/tests/core/test_telemetry_send.py
+++ b/tests/core/test_telemetry_send.py
@@ -47,7 +47,7 @@ class TestTelemetryClient:
def test_send_telemetry_event_does_nothing_when_api_key_is_none(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
- config = build_test_vibe_config(disable_telemetry=False)
+ config = build_test_vibe_config(enable_telemetry=True)
env_key = config.get_provider_for_model(
config.get_active_model()
).api_key_env_var
@@ -57,7 +57,7 @@ class TestTelemetryClient:
client._client = MagicMock()
client._client.post = AsyncMock()
- client.send_telemetry_event("vibe/test", {})
+ client.send_telemetry_event("vibe.test", {})
_run_telemetry_tasks()
client._client.post.assert_not_called()
@@ -65,7 +65,10 @@ class TestTelemetryClient:
def test_send_telemetry_event_does_nothing_when_disabled(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
- config = build_test_vibe_config(disable_telemetry=True)
+ monkeypatch.setattr(
+ TelemetryClient, "send_telemetry_event", _original_send_telemetry_event
+ )
+ config = build_test_vibe_config(enable_telemetry=False)
env_key = config.get_provider_for_model(
config.get_active_model()
).api_key_env_var
@@ -74,7 +77,7 @@ class TestTelemetryClient:
client._client = MagicMock()
client._client.post = AsyncMock()
- client.send_telemetry_event("vibe/test", {})
+ client.send_telemetry_event("vibe.test", {})
_run_telemetry_tasks()
client._client.post.assert_not_called()
@@ -86,7 +89,7 @@ class TestTelemetryClient:
monkeypatch.setattr(
TelemetryClient, "send_telemetry_event", _original_send_telemetry_event
)
- config = build_test_vibe_config(disable_telemetry=False)
+ config = build_test_vibe_config(enable_telemetry=True)
env_key = config.get_provider_for_model(
config.get_active_model()
).api_key_env_var
@@ -97,12 +100,12 @@ class TestTelemetryClient:
client._client.post = mock_post
client._client.aclose = AsyncMock()
- client.send_telemetry_event("vibe/test_event", {"key": "value"})
+ client.send_telemetry_event("vibe.test_event", {"key": "value"})
await client.aclose()
mock_post.assert_called_once_with(
DATALAKE_EVENTS_URL,
- json={"event": "vibe/test_event", "properties": {"key": "value"}},
+ json={"event": "vibe.test_event", "properties": {"key": "value"}},
headers={
"Content-Type": "application/json",
"Authorization": "Bearer sk-test",
@@ -113,7 +116,7 @@ class TestTelemetryClient:
def test_send_tool_call_finished_payload_shape(
self, telemetry_events: list[dict[str, Any]]
) -> None:
- config = build_test_vibe_config(disable_telemetry=False)
+ config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
tool_call = _make_resolved_tool_call("todo", {})
decision = ToolDecision(
@@ -129,7 +132,7 @@ class TestTelemetryClient:
assert len(telemetry_events) == 1
event_name = telemetry_events[0]["event_name"]
- assert event_name == "vibe/tool_call_finished"
+ assert event_name == "vibe.tool_call_finished"
properties = telemetry_events[0]["properties"]
assert properties["tool_name"] == "todo"
assert properties["status"] == "success"
@@ -142,7 +145,7 @@ class TestTelemetryClient:
def test_send_tool_call_finished_nb_files_created_write_file_new(
self, telemetry_events: list[dict[str, Any]]
) -> None:
- config = build_test_vibe_config(disable_telemetry=False)
+ config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
tool_call = _make_resolved_tool_call("write_file", {"overwrite": False})
@@ -160,7 +163,7 @@ class TestTelemetryClient:
def test_send_tool_call_finished_nb_files_modified_write_file_overwrite(
self, telemetry_events: list[dict[str, Any]]
) -> None:
- config = build_test_vibe_config(disable_telemetry=False)
+ config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
tool_call = _make_resolved_tool_call("write_file", {"overwrite": True})
@@ -178,7 +181,7 @@ class TestTelemetryClient:
def test_send_tool_call_finished_decision_none(
self, telemetry_events: list[dict[str, Any]]
) -> None:
- config = build_test_vibe_config(disable_telemetry=False)
+ config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
tool_call = _make_resolved_tool_call("todo", {})
@@ -195,49 +198,49 @@ class TestTelemetryClient:
def test_send_user_copied_text_payload(
self, telemetry_events: list[dict[str, Any]]
) -> None:
- config = build_test_vibe_config(disable_telemetry=False)
+ config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
client.send_user_copied_text("hello world")
assert len(telemetry_events) == 1
- assert telemetry_events[0]["event_name"] == "vibe/user_copied_text"
+ assert telemetry_events[0]["event_name"] == "vibe.user_copied_text"
assert telemetry_events[0]["properties"]["text_length"] == 11
def test_send_user_cancelled_action_payload(
self, telemetry_events: list[dict[str, Any]]
) -> None:
- config = build_test_vibe_config(disable_telemetry=False)
+ config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
client.send_user_cancelled_action("interrupt_agent")
assert len(telemetry_events) == 1
- assert telemetry_events[0]["event_name"] == "vibe/user_cancelled_action"
+ assert telemetry_events[0]["event_name"] == "vibe.user_cancelled_action"
assert telemetry_events[0]["properties"]["action"] == "interrupt_agent"
def test_send_auto_compact_triggered_payload(
self, telemetry_events: list[dict[str, Any]]
) -> None:
- config = build_test_vibe_config(disable_telemetry=False)
+ config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
client.send_auto_compact_triggered()
assert len(telemetry_events) == 1
- assert telemetry_events[0]["event_name"] == "vibe/auto_compact_triggered"
+ assert telemetry_events[0]["event_name"] == "vibe.auto_compact_triggered"
def test_send_slash_command_used_payload(
self, telemetry_events: list[dict[str, Any]]
) -> None:
- config = build_test_vibe_config(disable_telemetry=False)
+ config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
client.send_slash_command_used("help", "builtin")
client.send_slash_command_used("my_skill", "skill")
assert len(telemetry_events) == 2
- assert telemetry_events[0]["event_name"] == "vibe/slash_command_used"
+ assert telemetry_events[0]["event_name"] == "vibe.slash_command_used"
assert telemetry_events[0]["properties"]["command"] == "help"
assert telemetry_events[0]["properties"]["command_type"] == "builtin"
assert telemetry_events[1]["properties"]["command"] == "my_skill"
@@ -246,7 +249,7 @@ class TestTelemetryClient:
def test_send_new_session_payload(
self, telemetry_events: list[dict[str, Any]]
) -> None:
- config = build_test_vibe_config(disable_telemetry=False)
+ config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
client.send_new_session(
@@ -255,15 +258,17 @@ class TestTelemetryClient:
nb_mcp_servers=1,
nb_models=3,
entrypoint="cli",
+ terminal_emulator="vscode",
)
assert len(telemetry_events) == 1
event_name = telemetry_events[0]["event_name"]
- assert event_name == "vibe/new_session"
+ assert event_name == "vibe.new_session"
properties = telemetry_events[0]["properties"]
assert properties["has_agents_md"] is True
assert properties["nb_skills"] == 2
assert properties["nb_mcp_servers"] == 1
assert properties["nb_models"] == 3
assert properties["entrypoint"] == "cli"
+ assert properties["terminal_emulator"] == "vscode"
assert "version" in properties
diff --git a/tests/core/test_trusted_folders.py b/tests/core/test_trusted_folders.py
index be9447f..5e92d2a 100644
--- a/tests/core/test_trusted_folders.py
+++ b/tests/core/test_trusted_folders.py
@@ -237,11 +237,11 @@ class TestHasAgentsMdFile:
class TestHasTrustableContent:
def test_returns_true_when_vibe_dir_exists(self, tmp_path: Path) -> None:
- (tmp_path / ".vibe").mkdir()
+ (tmp_path / ".vibe" / "skills").mkdir(parents=True)
assert has_trustable_content(tmp_path) is True
def test_returns_true_when_agents_dir_exists(self, tmp_path: Path) -> None:
- (tmp_path / ".agents").mkdir()
+ (tmp_path / ".agents" / "skills").mkdir(parents=True)
assert has_trustable_content(tmp_path) is True
def test_returns_true_when_agents_md_filename_exists(self, tmp_path: Path) -> None:
@@ -253,3 +253,17 @@ class TestHasTrustableContent:
def test_returns_false_when_no_trustable_content(self, tmp_path: Path) -> None:
(tmp_path / "other.txt").write_text("", encoding="utf-8")
assert has_trustable_content(tmp_path) is False
+
+ def test_returns_true_when_vibe_config_in_subfolder(self, tmp_path: Path) -> None:
+ (tmp_path / "sub" / ".vibe" / "skills").mkdir(parents=True)
+ assert has_trustable_content(tmp_path) is True
+
+ def test_returns_true_when_agents_skills_in_subfolder(self, tmp_path: Path) -> None:
+ (tmp_path / "deep" / "nested" / ".agents" / "skills").mkdir(parents=True)
+ assert has_trustable_content(tmp_path) is True
+
+ def test_returns_false_when_config_only_inside_ignored_dir(
+ self, tmp_path: Path
+ ) -> None:
+ (tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
+ assert has_trustable_content(tmp_path) is False
diff --git a/tests/e2e/common.py b/tests/e2e/common.py
new file mode 100644
index 0000000..18d1d5c
--- /dev/null
+++ b/tests/e2e/common.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from contextlib import AbstractContextManager
+import io
+from pathlib import Path
+import re
+import time
+from typing import Protocol
+
+import pexpect
+
+
+class SpawnedVibeProcessFixture(Protocol):
+ def __call__(
+ self, workdir: Path
+ ) -> AbstractContextManager[tuple[pexpect.spawn, io.StringIO]]: ...
+
+
+def ansi_tolerant_pattern(text: str) -> re.Pattern[str]:
+ ansi = r"(?:\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07|\r|\n)*"
+ return re.compile(ansi.join(re.escape(char) for char in text))
+
+
+def write_e2e_config(vibe_home: Path, api_base: str) -> None:
+ vibe_home.mkdir(parents=True, exist_ok=True)
+ (vibe_home / "config.toml").write_text(
+ "\n".join([
+ 'active_model = "mock-model"',
+ "enable_update_checks = false",
+ "enable_auto_update = false",
+ "",
+ "[[providers]]",
+ 'name = "mock-provider"',
+ f'api_base = "{api_base}"',
+ 'api_key_env_var = "MISTRAL_API_KEY"',
+ 'backend = "generic"',
+ "",
+ "[[models]]",
+ 'name = "mock-model"',
+ 'provider = "mock-provider"',
+ 'alias = "mock-model"',
+ ]),
+ encoding="utf-8",
+ )
+
+
+def strip_ansi(text: str) -> str:
+ return re.sub(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07", "", text)
+
+
+def wait_for_request_count(
+ request_count_getter: Callable[[], int], expected_count: int, timeout: float
+) -> None:
+ start = time.monotonic()
+ while time.monotonic() - start < timeout:
+ if request_count_getter() >= expected_count:
+ return
+ time.sleep(0.05)
+ raise AssertionError(f"Timed out waiting for {expected_count} backend request(s).")
+
+
+def wait_for_main_screen(child: pexpect.spawn, timeout: float = 20.0) -> None:
+ child.expect(ansi_tolerant_pattern("Mistral Vibe v"), timeout=timeout)
+
+
+def wait_for_rendered_text(
+ child: pexpect.spawn, captured: io.StringIO, needle: str, timeout: float
+) -> None:
+ start = time.monotonic()
+ while time.monotonic() - start < timeout:
+ if needle in strip_ansi(captured.getvalue()):
+ return
+ try:
+ child.expect(r"\S", timeout=0.1)
+ except pexpect.TIMEOUT:
+ pass
+ except pexpect.EOF as exc:
+ rendered_tail = strip_ansi(captured.getvalue())[-1200:]
+ raise AssertionError(
+ f"Child exited while waiting for rendered text: {needle!r}\n\nRendered tail:\n{rendered_tail}"
+ ) from exc
+ rendered_tail = strip_ansi(captured.getvalue())[-1200:]
+ raise AssertionError(
+ f"Timed out waiting for rendered text: {needle!r}\n\nRendered tail:\n{rendered_tail}"
+ )
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
new file mode 100644
index 0000000..e57e99a
--- /dev/null
+++ b/tests/e2e/conftest.py
@@ -0,0 +1,82 @@
+from __future__ import annotations
+
+from collections.abc import Callable, Iterator
+from contextlib import AbstractContextManager, contextmanager
+import io
+import os
+from pathlib import Path
+from typing import cast
+
+import pexpect
+import pytest
+
+from tests import TESTS_ROOT
+from tests.e2e.common import write_e2e_config
+from tests.e2e.mock_server import ChunkFactory, StreamingMockServer
+
+
+@pytest.fixture
+def streaming_mock_server(
+ request: pytest.FixtureRequest,
+) -> Iterator[StreamingMockServer]:
+ chunk_factory = cast(ChunkFactory | None, getattr(request, "param", None))
+ server = StreamingMockServer(chunk_factory=chunk_factory)
+ server.start()
+ try:
+ yield server
+ finally:
+ server.stop()
+
+
+@pytest.fixture
+def setup_e2e_env(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ streaming_mock_server: StreamingMockServer,
+) -> None:
+ vibe_home = tmp_path / "vibe-home"
+ write_e2e_config(vibe_home, streaming_mock_server.api_base)
+ monkeypatch.setenv("MISTRAL_API_KEY", "fake-key")
+ monkeypatch.setenv("VIBE_HOME", str(vibe_home))
+ monkeypatch.setenv("TERM", "xterm-256color")
+
+
+@pytest.fixture
+def e2e_workdir(tmp_path: Path) -> Path:
+ workdir = tmp_path / "workdir"
+ workdir.mkdir()
+ return workdir
+
+
+type SpawnedVibeContext = Iterator[tuple[pexpect.spawn, io.StringIO]]
+type SpawnedVibeContextManager = AbstractContextManager[
+ tuple[pexpect.spawn, io.StringIO]
+]
+type SpawnedVibeFactory = Callable[[Path], SpawnedVibeContextManager]
+
+
+@pytest.fixture
+def spawned_vibe_process() -> SpawnedVibeFactory:
+ @contextmanager
+ def spawn(workdir: Path) -> SpawnedVibeContext:
+ captured = io.StringIO()
+ child = pexpect.spawn(
+ "uv",
+ ["run", "vibe", "--workdir", str(workdir)],
+ cwd=str(TESTS_ROOT.parent),
+ env=os.environ,
+ encoding="utf-8",
+ timeout=30,
+ dimensions=(36, 120),
+ )
+ child.logfile_read = captured
+
+ try:
+ yield child, captured
+ finally:
+ if child.isalive():
+ child.terminate(force=True)
+ if not child.closed:
+ child.close()
+
+ return spawn
diff --git a/tests/e2e/mock_server.py b/tests/e2e/mock_server.py
new file mode 100644
index 0000000..fea88e1
--- /dev/null
+++ b/tests/e2e/mock_server.py
@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+import json
+import threading
+import time
+from typing import TypedDict, cast
+
+
+class StreamOptionsPayload(TypedDict, total=False):
+ include_usage: bool
+ stream_tool_calls: bool
+
+
+class ChatMessagePayload(TypedDict, total=False):
+ role: str
+ content: str
+
+
+class ChatCompletionsRequestPayload(TypedDict, total=False):
+ model: str
+ messages: list[ChatMessagePayload]
+ stream: bool
+ stream_options: StreamOptionsPayload
+
+
+type StreamChunk = dict[str, object]
+type ChunkFactory = Callable[[int, ChatCompletionsRequestPayload], list[StreamChunk]]
+
+
+class StreamingMockServer:
+ @staticmethod
+ def build_chunk(
+ *,
+ created: int,
+ delta: dict[str, object],
+ finish_reason: str | None,
+ usage: dict[str, int] | None = None,
+ ) -> StreamChunk:
+ chunk: dict[str, object] = {
+ "id": "mock-id",
+ "object": "chat.completion.chunk",
+ "created": created,
+ "model": "mock-model",
+ "choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
+ }
+ if usage is not None:
+ chunk["usage"] = usage
+ return chunk
+
+ @staticmethod
+ def build_tool_call_delta(
+ *, call_id: str, tool_name: str, arguments: str, index: int = 0
+ ) -> dict[str, object]:
+ return {
+ "role": "assistant",
+ "tool_calls": [
+ {
+ "index": index,
+ "id": call_id,
+ "type": "function",
+ "function": {"name": tool_name, "arguments": arguments},
+ }
+ ],
+ }
+
+ @staticmethod
+ def _stream_chunks() -> list[StreamChunk]:
+ return [
+ StreamingMockServer.build_chunk(
+ created=123,
+ delta={"role": "assistant", "content": "Hello"},
+ finish_reason=None,
+ ),
+ StreamingMockServer.build_chunk(
+ created=124, delta={"content": " from mock server"}, finish_reason=None
+ ),
+ StreamingMockServer.build_chunk(
+ created=125,
+ delta={},
+ finish_reason="stop",
+ usage={"prompt_tokens": 3, "completion_tokens": 4},
+ ),
+ ]
+
+ def __init__(self, *, chunk_factory: ChunkFactory | None = None) -> None:
+ self.requests: list[ChatCompletionsRequestPayload] = []
+ self._lock = threading.Lock()
+ self._chunk_factory = chunk_factory
+ self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._build_handler())
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
+
+ def _build_handler(self) -> type[BaseHTTPRequestHandler]:
+ parent = self
+
+ class Handler(BaseHTTPRequestHandler):
+ def log_message(self, format, *args):
+ return
+
+ def do_POST(self) -> None:
+ if self.path != "/v1/chat/completions":
+ self.send_response(404)
+ self.end_headers()
+ return
+
+ length = int(self.headers.get("Content-Length", "0"))
+ body = self.rfile.read(length)
+ payload = cast(
+ ChatCompletionsRequestPayload, json.loads(body.decode("utf-8"))
+ )
+
+ with parent._lock:
+ parent.requests.append(payload)
+ request_index = len(parent.requests) - 1
+
+ self.send_response(200)
+ self.send_header("Content-Type", "text/event-stream")
+ self.send_header("Cache-Control", "no-cache")
+ self.end_headers()
+
+ chunks = (
+ parent._chunk_factory(request_index, payload)
+ if parent._chunk_factory is not None
+ else parent._stream_chunks()
+ )
+
+ for chunk in chunks:
+ data = json.dumps(chunk, ensure_ascii=False)
+ self.wfile.write(f"data: {data}\n\n".encode())
+ self.wfile.flush()
+ time.sleep(0.03)
+
+ self.wfile.write(b"data: [DONE]\n\n")
+ self.wfile.flush()
+
+ return Handler
+
+ @property
+ def api_base(self) -> str:
+ return f"http://127.0.0.1:{self._server.server_port}/v1"
+
+ def start(self) -> None:
+ self._thread.start()
+
+ def stop(self) -> None:
+ self._server.shutdown()
+ self._server.server_close()
+ self._thread.join(timeout=1)
diff --git a/tests/e2e/test_cli_tui_onboarding.py b/tests/e2e/test_cli_tui_onboarding.py
new file mode 100644
index 0000000..68b4032
--- /dev/null
+++ b/tests/e2e/test_cli_tui_onboarding.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pexpect
+import pytest
+
+from tests.e2e.common import SpawnedVibeProcessFixture, ansi_tolerant_pattern
+
+
+@pytest.mark.timeout(15)
+def test_spawn_cli_shows_onboarding_when_api_key_missing(
+ tmp_path: Path,
+ e2e_workdir: Path,
+ spawned_vibe_process: SpawnedVibeProcessFixture,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ vibe_home = tmp_path / "vibe-home-onboarding"
+ vibe_home.mkdir(parents=True, exist_ok=True)
+
+ monkeypatch.setenv("VIBE_HOME", str(vibe_home))
+ monkeypatch.setenv("TERM", "xterm-256color")
+ monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
+
+ with spawned_vibe_process(e2e_workdir) as (child, captured):
+ child.expect(ansi_tolerant_pattern("Welcome to Mistral Vibe"), timeout=15)
+ child.sendcontrol("c")
+ child.expect(pexpect.EOF, timeout=10)
+
+ output = captured.getvalue()
+ assert "Setup cancelled" in output
diff --git a/tests/e2e/test_cli_tui_streaming.py b/tests/e2e/test_cli_tui_streaming.py
new file mode 100644
index 0000000..0bca4d5
--- /dev/null
+++ b/tests/e2e/test_cli_tui_streaming.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pexpect
+import pytest
+
+from tests.e2e.common import (
+ SpawnedVibeProcessFixture,
+ ansi_tolerant_pattern,
+ wait_for_main_screen,
+ wait_for_request_count,
+)
+from tests.e2e.mock_server import StreamingMockServer
+
+
+@pytest.mark.timeout(15)
+def test_spawn_cli_to_send_and_receive_message(
+ streaming_mock_server: StreamingMockServer,
+ setup_e2e_env: None,
+ e2e_workdir: Path,
+ spawned_vibe_process: SpawnedVibeProcessFixture,
+) -> None:
+ with spawned_vibe_process(e2e_workdir) as (child, captured):
+ wait_for_main_screen(child, timeout=15)
+ child.send("Greet")
+ child.send("\r")
+
+ wait_for_request_count(
+ lambda: len(streaming_mock_server.requests), expected_count=1, timeout=10
+ )
+ child.expect(ansi_tolerant_pattern("Hello from mock server"), timeout=10)
+
+ child.sendcontrol("c")
+ child.expect(pexpect.EOF, timeout=10)
+
+ output = captured.getvalue()
+ assert "Welcome to Mistral Vibe" not in output
+
+ request_payload = streaming_mock_server.requests[-1]
+ assert request_payload.get("stream") is True
+ assert request_payload.get("model") == "mock-model"
+ stream_options = request_payload.get("stream_options")
+ assert stream_options is not None
+ assert stream_options.get("include_usage") is True
+ messages = request_payload.get("messages")
+ assert messages is not None
+ assert any(
+ message.get("role") == "user" and message.get("content") == "Greet"
+ for message in messages
+ )
diff --git a/tests/e2e/test_cli_tui_tool_approval.py b/tests/e2e/test_cli_tui_tool_approval.py
new file mode 100644
index 0000000..6305f9b
--- /dev/null
+++ b/tests/e2e/test_cli_tui_tool_approval.py
@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pexpect
+import pytest
+
+from tests.e2e.common import (
+ SpawnedVibeProcessFixture,
+ wait_for_main_screen,
+ wait_for_rendered_text,
+ wait_for_request_count,
+)
+from tests.e2e.mock_server import ChatCompletionsRequestPayload, StreamingMockServer
+
+PREDICTABLE_OUTPUT = "__E2E_BASH_OK__"
+TOOL_ARGUMENTS = f'{{"command":"printf \\"{PREDICTABLE_OUTPUT}\\\\n\\""}}'
+
+
+def _tool_call_factory(
+ request_index: int, _payload: ChatCompletionsRequestPayload
+) -> list[dict[str, object]]:
+ if request_index == 0:
+ return [
+ StreamingMockServer.build_chunk(
+ created=1,
+ delta=StreamingMockServer.build_tool_call_delta(
+ call_id="call_bash_1", tool_name="bash", arguments=TOOL_ARGUMENTS
+ ),
+ finish_reason=None,
+ ),
+ StreamingMockServer.build_chunk(
+ created=2,
+ delta={},
+ finish_reason="tool_calls",
+ usage={"prompt_tokens": 3, "completion_tokens": 4},
+ ),
+ ]
+
+ return [
+ StreamingMockServer.build_chunk(
+ created=3,
+ delta={
+ "role": "assistant",
+ "content": f"The string {PREDICTABLE_OUTPUT} has been printed successfully.",
+ },
+ finish_reason=None,
+ ),
+ StreamingMockServer.build_chunk(
+ created=4, delta={"content": PREDICTABLE_OUTPUT}, finish_reason=None
+ ),
+ StreamingMockServer.build_chunk(
+ created=5,
+ delta={},
+ finish_reason="stop",
+ usage={"prompt_tokens": 3, "completion_tokens": 4},
+ ),
+ ]
+
+
+@pytest.mark.timeout(25)
+@pytest.mark.parametrize(
+ "streaming_mock_server",
+ [pytest.param(_tool_call_factory, id="tool-call-stream")],
+ indirect=True,
+)
+def test_spawn_cli_asks_bash_permission_and_shows_tool_output_after_approval(
+ streaming_mock_server: StreamingMockServer,
+ setup_e2e_env: None,
+ e2e_workdir: Path,
+ spawned_vibe_process: SpawnedVibeProcessFixture,
+) -> None:
+ with spawned_vibe_process(e2e_workdir) as (child, captured):
+ wait_for_main_screen(child, timeout=15)
+ child.send("Run a shell command")
+ child.send("\r")
+
+ wait_for_request_count(
+ lambda: len(streaming_mock_server.requests), expected_count=1, timeout=10
+ )
+ wait_for_rendered_text(child, captured, needle="bash command", timeout=10)
+ child.send("y")
+ child.send("\r")
+ wait_for_rendered_text(child, captured, needle=PREDICTABLE_OUTPUT, timeout=10)
+
+ child.sendcontrol("c")
+ child.expect(pexpect.EOF, timeout=10)
diff --git a/tests/session/test_session_loader.py b/tests/session/test_session_loader.py
index f42754c..aed2d0a 100644
--- a/tests/session/test_session_loader.py
+++ b/tests/session/test_session_loader.py
@@ -822,3 +822,133 @@ class TestSessionLoaderListSessions:
assert len(result) == 1
assert result[0]["session_id"] == "notitle0"
assert result[0]["title"] is None
+
+
+class TestSessionLoaderGetFirstUserMessage:
+ """Tests for SessionLoader.get_first_user_message method."""
+
+ def test_returns_first_user_message(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test that get_first_user_message returns the first user message."""
+ session_dir = Path(session_config.save_dir)
+ messages = [
+ LLMMessage(role=Role.system, content="System prompt"),
+ LLMMessage(role=Role.user, content="First user message"),
+ LLMMessage(role=Role.assistant, content="First response"),
+ LLMMessage(role=Role.user, content="Second user message"),
+ LLMMessage(role=Role.assistant, content="Second response"),
+ ]
+ create_test_session(session_dir, "test-sess", messages=messages)
+
+ result = SessionLoader.get_first_user_message("test-sess", session_config)
+
+ assert result == "First user message"
+
+ def test_returns_fallback_for_missing_session(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that get_first_user_message returns fallback when session not found."""
+ result = SessionLoader.get_first_user_message("nonexistent", session_config)
+
+ assert result == "(session not found)"
+
+ def test_returns_no_user_messages_fallback(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test fallback when session has no user messages."""
+ session_dir = Path(session_config.save_dir)
+ messages = [
+ LLMMessage(role=Role.system, content="System prompt"),
+ LLMMessage(role=Role.assistant, content="Assistant only"),
+ ]
+ create_test_session(session_dir, "no-user0", messages=messages)
+
+ result = SessionLoader.get_first_user_message("no-user0", session_config)
+
+ assert result == "(no user messages)"
+
+ def test_replaces_newlines_with_spaces(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test that newlines in messages are replaced with spaces."""
+ session_dir = Path(session_config.save_dir)
+ messages = [
+ LLMMessage(role=Role.system, content="System prompt"),
+ LLMMessage(role=Role.user, content="Line one\nLine two\nLine three"),
+ ]
+ create_test_session(session_dir, "newline0", messages=messages)
+
+ result = SessionLoader.get_first_user_message("newline0", session_config)
+
+ assert "\n" not in result
+ assert "Line one Line two Line three" == result
+
+ def test_handles_empty_user_message(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test handling of empty user message content."""
+ session_dir = Path(session_config.save_dir)
+ messages = [
+ LLMMessage(role=Role.system, content="System prompt"),
+ LLMMessage(role=Role.user, content=""),
+ ]
+ create_test_session(session_dir, "empty-ms", messages=messages)
+
+ result = SessionLoader.get_first_user_message("empty-ms", session_config)
+
+ assert result == "(no user messages)"
+
+ def test_handles_whitespace_only_message(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test handling of whitespace-only user message."""
+ session_dir = Path(session_config.save_dir)
+ messages = [
+ LLMMessage(role=Role.system, content="System prompt"),
+ LLMMessage(role=Role.user, content=" \n\t "),
+ ]
+ create_test_session(session_dir, "whitespc", messages=messages)
+
+ result = SessionLoader.get_first_user_message("whitespc", session_config)
+
+ assert result == "(empty message)"
+
+ def test_handles_invalid_session_as_not_found(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ """Test that invalid sessions (bad JSON) are treated as not found.
+
+ Note: Sessions with invalid JSON are filtered out by _is_valid_session
+ during find_session_by_id, so they return 'not found' rather than
+ 'corrupted'. This is the expected behavior.
+ """
+ session_dir = Path(session_config.save_dir)
+
+ # Create a session with invalid JSON - will fail validation
+ session_folder = session_dir / "test_20230101_120000_corrupt0"
+ session_folder.mkdir()
+ (session_folder / "messages.jsonl").write_text("{invalid json}")
+ (session_folder / "meta.json").write_text('{"session_id": "corrupt0"}')
+
+ result = SessionLoader.get_first_user_message("corrupt0", session_config)
+
+ # Invalid sessions are filtered by _is_valid_session, so not found
+ assert result == "(session not found)"
+
+ def test_skips_non_user_messages(
+ self, session_config: SessionLoggingConfig, create_test_session
+ ) -> None:
+ """Test that only user messages are considered, not assistant/system."""
+ session_dir = Path(session_config.save_dir)
+ messages = [
+ LLMMessage(role=Role.system, content="System prompt"),
+ LLMMessage(role=Role.user, content="User question"),
+ LLMMessage(role=Role.assistant, content="Assistant response"),
+ ]
+ create_test_session(session_dir, "skip-non", messages=messages)
+
+ result = SessionLoader.get_first_user_message("skip-non", session_config)
+
+ # Should return "User question", not "Assistant response"
+ assert result == "User question"
diff --git a/tests/session/test_session_logger.py b/tests/session/test_session_logger.py
index 057493b..080caf5 100644
--- a/tests/session/test_session_logger.py
+++ b/tests/session/test_session_logger.py
@@ -1,10 +1,10 @@
from __future__ import annotations
-from datetime import datetime, timedelta
+from datetime import UTC, datetime, timedelta
import json
import os
from pathlib import Path
-from unittest.mock import MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -502,6 +502,61 @@ class TestSessionLoggerSaveInteraction:
with open(messages_file) as f:
assert len(f.readlines()) == 2
+ @pytest.mark.asyncio
+ async def test_save_interaction_throttles_tmp_cleanup(
+ self,
+ session_config: SessionLoggingConfig,
+ mock_vibe_config: VibeConfig,
+ mock_tool_manager: ToolManager,
+ mock_agent_profile: AgentProfile,
+ ) -> None:
+ logger = SessionLogger(session_config, "test-session-123")
+
+ messages = [
+ LLMMessage(role=Role.system, content="System prompt"),
+ LLMMessage(role=Role.user, content="Hello"),
+ LLMMessage(role=Role.assistant, content="Hi there!"),
+ ]
+
+ cleanup_spy = MagicMock()
+ with (
+ patch.object(
+ SessionLogger, "persist_messages", new_callable=AsyncMock
+ ) as persist_messages_mock,
+ patch.object(
+ SessionLogger, "persist_metadata", new_callable=AsyncMock
+ ) as persist_metadata_mock,
+ patch.object(logger, "cleanup_tmp_files", cleanup_spy),
+ patch(
+ "vibe.core.session.session_logger.utc_now",
+ # a bit brittle, but required for the call-count choregraphy...
+ side_effect=[
+ datetime(2026, 1, 1, 10, 0, 0, tzinfo=UTC),
+ datetime(2026, 1, 1, 10, 0, 0, tzinfo=UTC),
+ datetime(2026, 1, 1, 10, 0, 1, tzinfo=UTC),
+ datetime(2026, 1, 1, 10, 0, 1, tzinfo=UTC),
+ ],
+ ),
+ ):
+ await logger.save_interaction(
+ messages=messages,
+ stats=AgentStats(steps=1),
+ base_config=mock_vibe_config,
+ tool_manager=mock_tool_manager,
+ agent_profile=mock_agent_profile,
+ )
+ await logger.save_interaction(
+ messages=messages,
+ stats=AgentStats(steps=2),
+ base_config=mock_vibe_config,
+ tool_manager=mock_tool_manager,
+ agent_profile=mock_agent_profile,
+ )
+
+ assert persist_messages_mock.await_count == 2
+ assert persist_metadata_mock.await_count == 2
+ assert cleanup_spy.call_count == 1
+
class TestSessionLoggerResetSession:
def test_reset_session(self, session_config: SessionLoggingConfig) -> None:
@@ -701,3 +756,27 @@ class TestSessionLoggerCleanupTmpFiles:
assert old_tmp_file.exists()
assert not another_old_tmp_file.exists()
+
+ def test_maybe_cleanup_tmp_files_throttles_calls(
+ self, session_config: SessionLoggingConfig
+ ) -> None:
+ session_id = "test-session-123"
+ logger = SessionLogger(session_config, session_id)
+
+ cleanup_spy = MagicMock()
+ with (
+ patch.object(logger, "cleanup_tmp_files", cleanup_spy),
+ patch(
+ "vibe.core.session.session_logger.utc_now",
+ side_effect=[
+ datetime(2026, 1, 1, 10, 0, 0, tzinfo=UTC),
+ datetime(2026, 1, 1, 10, 0, 1, tzinfo=UTC),
+ datetime(2026, 1, 1, 10, 0, 6, tzinfo=UTC),
+ ],
+ ),
+ ):
+ logger.maybe_cleanup_tmp_files()
+ logger.maybe_cleanup_tmp_files()
+ logger.maybe_cleanup_tmp_files()
+
+ assert cleanup_spy.call_count == 2
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_empty_assistant_before_reasoning/test_snapshot_empty_assistant_removed_when_reasoning_starts.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_empty_assistant_before_reasoning/test_snapshot_empty_assistant_removed_when_reasoning_starts.svg
new file mode 100644
index 0000000..0637c8f
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_empty_assistant_before_reasoning/test_snapshot_empty_assistant_removed_when_reasoning_starts.svg
@@ -0,0 +1,203 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_parallel_tool_calls/test_snapshot_parallel_tool_calls_pending.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_parallel_tool_calls/test_snapshot_parallel_tool_calls_pending.svg
new file mode 100644
index 0000000..b84b444
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_parallel_tool_calls/test_snapshot_parallel_tool_calls_pending.svg
@@ -0,0 +1,114 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_parallel_tool_calls/test_snapshot_parallel_tool_calls_resolved.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_parallel_tool_calls/test_snapshot_parallel_tool_calls_resolved.svg
new file mode 100644
index 0000000..b465dda
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_parallel_tool_calls/test_snapshot_parallel_tool_calls_resolved.svg
@@ -0,0 +1,137 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_empty.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_empty.svg
index ed52ccb..83e065b 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_empty.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_empty.svg
@@ -33,13 +33,12 @@
}
.terminal-r1 { fill: #c5c8c6 }
-.terminal-r2 { fill: #4b4e55 }
+.terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 }
-.terminal-r4 { fill: #292929 }
-.terminal-r5 { fill: #9a9b99 }
-.terminal-r6 { fill: #608ab1;font-weight: bold }
+.terminal-r4 { fill: #9a9b99 }
+.terminal-r5 { fill: #608ab1;font-weight: bold }
+.terminal-r6 { fill: #949798 }
.terminal-r7 { fill: #868887 }
-.terminal-r8 { fill: #949798 }
@@ -161,44 +160,44 @@
-
+
- ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills▂
- ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
+
+
-⎣Proxy setup opened...
+
-┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│Proxy Configuration│
-││
-│HTTP_PROXYProxy URL for HTTP requests│
-││
-│▎NOT SET│
-││
-│HTTPS_PROXYProxy URL for HTTPS requests│
-││
-│▎NOT SET│
-││
-│ALL_PROXYProxy URL for all requests (fallback)│
-││
-│▎NOT SET│
-││
-│NO_PROXYComma-separated list of hosts to bypass proxy│
-││
-│▎NOT SET│
-││
-│SSL_CERT_FILEPath to custom SSL certificate file│
-││
-│▎NOT SET│
-││
-│SSL_CERT_DIRPath to directory containing SSL certificates│
-││
-│▎NOT SET│
-││
-│↑↓ navigate Enter save & exit ESC cancel│
-└──────────────────────────────────────────────────────────────────────────────────────────────────┘
-/test/workdir0% of 200k tokens
+
+
+
+
+
+
+ ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest
+ ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills
+ ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
+
+⎣Proxy setup opened...
+
+
+┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
+│Proxy Configuration│
+│HTTP_PROXY│
+│▎Proxy URL for HTTP requests│
+│HTTPS_PROXY│
+│▎Proxy URL for HTTPS requests│
+│ALL_PROXY│
+│▎Proxy URL for all requests (fallback)│
+│NO_PROXY│
+│▎Comma-separated list of hosts to bypass proxy│
+│SSL_CERT_FILE│
+│▎Path to custom SSL certificate file│
+│SSL_CERT_DIR│
+│▎Path to directory containing SSL certificates│
+│↑↓ navigate Enter save & exit ESC cancel│
+└──────────────────────────────────────────────────────────────────────────────────────────────────┘
+/test/workdir0% of 200k tokens
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_with_values.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_with_values.svg
index 5b21d8a..dba6a34 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_with_values.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_with_values.svg
@@ -33,12 +33,11 @@
}
.terminal-r1 { fill: #c5c8c6 }
-.terminal-r2 { fill: #4b4e55 }
+.terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 }
-.terminal-r4 { fill: #292929 }
-.terminal-r5 { fill: #9a9b99 }
-.terminal-r6 { fill: #608ab1;font-weight: bold }
-.terminal-r7 { fill: #868887 }
+.terminal-r4 { fill: #9a9b99 }
+.terminal-r5 { fill: #608ab1;font-weight: bold }
+.terminal-r6 { fill: #868887 }
@@ -160,44 +159,44 @@
-
+
- ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills▂
- ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
+
+
-⎣Proxy setup opened...
+
-┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│Proxy Configuration│
-││
-│HTTP_PROXYProxy URL for HTTP requests│
-││
-│▎http://old-proxy:8080│
-││
-│HTTPS_PROXYProxy URL for HTTPS requests│
-││
-│▎https://old-proxy:8443 │
-││
-│ALL_PROXYProxy URL for all requests (fallback)│
-││
-│▎NOT SET│
-││
-│NO_PROXYComma-separated list of hosts to bypass proxy│
-││
-│▎NOT SET│
-││
-│SSL_CERT_FILEPath to custom SSL certificate file│
-││
-│▎NOT SET│
-││
-│SSL_CERT_DIRPath to directory containing SSL certificates│
-││
-│▎NOT SET│
-││
-│↑↓ navigate Enter save & exit ESC cancel│
-└──────────────────────────────────────────────────────────────────────────────────────────────────┘
-/test/workdir0% of 200k tokens
+
+
+
+
+
+
+ ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest
+ ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills
+ ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
+
+⎣Proxy setup opened...
+
+
+┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
+│Proxy Configuration│
+│HTTP_PROXY│
+│▎http://old-proxy:8080│
+│HTTPS_PROXY│
+│▎https://old-proxy:8443 │
+│ALL_PROXY│
+│▎Proxy URL for all requests (fallback)│
+│NO_PROXY│
+│▎Comma-separated list of hosts to bypass proxy│
+│SSL_CERT_FILE│
+│▎Path to custom SSL certificate file│
+│SSL_CERT_DIR│
+│▎Path to directory containing SSL certificates│
+│↑↓ navigate Enter save & exit ESC cancel│
+└──────────────────────────────────────────────────────────────────────────────────────────────────┘
+/test/workdir0% of 200k tokens
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_tool_call/test_snapshot_tool_call_partial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_tool_call/test_snapshot_tool_call_partial.svg
new file mode 100644
index 0000000..9b74bc6
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_tool_call/test_snapshot_tool_call_partial.svg
@@ -0,0 +1,94 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_tool_call/test_snapshot_tool_call_updated.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_tool_call/test_snapshot_tool_call_updated.svg
new file mode 100644
index 0000000..1fc616e
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_tool_call/test_snapshot_tool_call_updated.svg
@@ -0,0 +1,94 @@
+
diff --git a/tests/snapshots/test_ui_snapshot_empty_assistant_before_reasoning.py b/tests/snapshots/test_ui_snapshot_empty_assistant_before_reasoning.py
new file mode 100644
index 0000000..09f6f6d
--- /dev/null
+++ b/tests/snapshots/test_ui_snapshot_empty_assistant_before_reasoning.py
@@ -0,0 +1,58 @@
+"""Snapshot tests for empty assistant message removed when reasoning starts (e.g. Opus)."""
+
+from __future__ import annotations
+
+from textual.pilot import Pilot
+
+from tests.conftest import build_test_agent_loop
+from tests.mock.utils import mock_llm_chunk
+from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
+from tests.snapshots.snap_compare import SnapCompare
+from tests.stubs.fake_backend import FakeBackend
+
+
+class SnapshotTestAppEmptyAssistantThenReasoning(BaseSnapshotTestApp):
+ """Backend stream: first chunk is assistant content only (empty), then reasoning.
+
+ Ensures the empty assistant bubble is removed when the first reasoning chunk
+ arrives, so the UI does not show a blank assistant message above the thinking block.
+ """
+
+ def __init__(self) -> None:
+ config = default_config()
+ fake_backend = FakeBackend(
+ chunks=[
+ mock_llm_chunk(content="\n\n"),
+ mock_llm_chunk(
+ content="", reasoning_content="Let me think about this..."
+ ),
+ mock_llm_chunk(
+ content="", reasoning_content=" Considering the options."
+ ),
+ mock_llm_chunk(content="The answer is 42."),
+ ]
+ )
+ super().__init__(config=config)
+ self.agent_loop = build_test_agent_loop(
+ config=config,
+ agent_name=self._current_agent_name,
+ enable_streaming=True,
+ backend=fake_backend,
+ )
+
+
+def test_snapshot_empty_assistant_removed_when_reasoning_starts(
+ snap_compare: SnapCompare,
+) -> None:
+ """Empty assistant message is removed when reasoning starts; no blank bubble above thinking."""
+
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.press(*"What is the answer?")
+ await pilot.press("enter")
+ await pilot.pause(0.5)
+
+ assert snap_compare(
+ "test_ui_snapshot_empty_assistant_before_reasoning.py:SnapshotTestAppEmptyAssistantThenReasoning",
+ terminal_size=(120, 36),
+ run_before=run_before,
+ )
diff --git a/tests/snapshots/test_ui_snapshot_modes.py b/tests/snapshots/test_ui_snapshot_modes.py
index 91c9bbb..b391a0d 100644
--- a/tests/snapshots/test_ui_snapshot_modes.py
+++ b/tests/snapshots/test_ui_snapshot_modes.py
@@ -24,6 +24,7 @@ def test_snapshot_cycle_to_plan_mode(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("shift+tab") # default -> plan
+ await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
assert snap_compare(
@@ -40,6 +41,7 @@ def test_snapshot_cycle_to_accept_edits_mode(snap_compare: SnapCompare) -> None:
await pilot.pause(0.1)
await pilot.press("shift+tab") # default -> plan
await pilot.press("shift+tab") # plan -> accept edits
+ await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
assert snap_compare(
@@ -57,6 +59,7 @@ def test_snapshot_cycle_to_auto_approve_mode(snap_compare: SnapCompare) -> None:
await pilot.press("shift+tab") # default -> plan
await pilot.press("shift+tab") # plan -> accept edits
await pilot.press("shift+tab") # accept edits -> auto approve
+ await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
assert snap_compare(
@@ -75,6 +78,7 @@ def test_snapshot_cycle_wraps_to_default(snap_compare: SnapCompare) -> None:
await pilot.press("shift+tab") # plan -> accept edits
await pilot.press("shift+tab") # accept edits -> auto approve
await pilot.press("shift+tab") # auto approve -> default (wrap)
+ await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
assert snap_compare(
diff --git a/tests/snapshots/test_ui_snapshot_parallel_tool_calls.py b/tests/snapshots/test_ui_snapshot_parallel_tool_calls.py
new file mode 100644
index 0000000..1244cb5
--- /dev/null
+++ b/tests/snapshots/test_ui_snapshot_parallel_tool_calls.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+from typing import cast
+
+from textual.app import App, ComposeResult
+from textual.containers import VerticalScroll
+from textual.pilot import Pilot
+from textual.widget import Widget
+
+from tests.snapshots.snap_compare import SnapCompare
+from vibe.cli.textual_ui.handlers.event_handler import EventHandler
+from vibe.cli.textual_ui.widgets.tools import ToolCallMessage
+from vibe.core.tools.builtins.read_file import ReadFile, ReadFileArgs, ReadFileResult
+from vibe.core.types import ToolCallEvent, ToolResultEvent
+
+
+class ParallelToolCallsApp(App):
+ CSS_PATH = "../../vibe/cli/textual_ui/app.tcss"
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._scroll: VerticalScroll | None = None
+ self._handler: EventHandler | None = None
+
+ def compose(self) -> ComposeResult:
+ self._scroll = VerticalScroll(id="messages")
+ yield self._scroll
+
+ def on_mount(self) -> None:
+ async def mount_callback(
+ widget: Widget, *, after: Widget | None = None
+ ) -> None:
+ if self._scroll is None:
+ return
+ if after is not None and after.parent is self._scroll:
+ await self._scroll.mount(widget, after=after)
+ else:
+ await self._scroll.mount(widget)
+
+ self._handler = EventHandler(
+ mount_callback=mount_callback, get_tools_collapsed=lambda: False
+ )
+
+ async def emit_all_tool_calls(self) -> None:
+ if self._handler is None:
+ return
+ for i in range(3):
+ await self._handler.handle_event(
+ ToolCallEvent(
+ tool_call_id=f"tc_{i}",
+ tool_call_index=i,
+ tool_name="read_file",
+ tool_class=ReadFile,
+ args=ReadFileArgs(path=f"/src/file_{i}.py"),
+ )
+ )
+
+ def freeze_spinners(self) -> None:
+ for widget in self.query(ToolCallMessage):
+ widget._is_spinning = False
+ if widget._spinner_timer:
+ widget._spinner_timer.stop()
+ widget._spinner_timer = None
+ widget._spinner.reset()
+ if widget._indicator_widget:
+ widget._indicator_widget.update(widget._spinner.current_frame())
+
+ async def resolve_all_results(self) -> None:
+ if self._handler is None:
+ return
+ for i in range(3):
+ await self._handler.handle_event(
+ ToolResultEvent(
+ tool_name="read_file",
+ tool_class=ReadFile,
+ result=ReadFileResult(
+ path=f"/src/file_{i}.py",
+ content=f"# content of file_{i}.py",
+ lines_read=1,
+ was_truncated=False,
+ ),
+ tool_call_id=f"tc_{i}",
+ )
+ )
+
+
+def test_snapshot_parallel_tool_calls_pending(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ app = cast(ParallelToolCallsApp, pilot.app)
+ await app.emit_all_tool_calls()
+ await pilot.pause(0.3)
+ app.freeze_spinners()
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_parallel_tool_calls.py:ParallelToolCallsApp",
+ terminal_size=(80, 15),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_parallel_tool_calls_resolved(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ app = cast(ParallelToolCallsApp, pilot.app)
+ await app.emit_all_tool_calls()
+ await pilot.pause(0.3)
+ await app.resolve_all_results()
+ await pilot.pause(0.3)
+
+ assert snap_compare(
+ "test_ui_snapshot_parallel_tool_calls.py:ParallelToolCallsApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
diff --git a/tests/snapshots/test_ui_snapshot_streaming_tool_call.py b/tests/snapshots/test_ui_snapshot_streaming_tool_call.py
new file mode 100644
index 0000000..9d94c22
--- /dev/null
+++ b/tests/snapshots/test_ui_snapshot_streaming_tool_call.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+from typing import cast
+from unittest.mock import patch
+
+from textual.app import App, ComposeResult
+from textual.containers import VerticalScroll
+from textual.pilot import Pilot
+
+from tests.snapshots.snap_compare import SnapCompare
+from vibe.cli.textual_ui.widgets.status_message import StatusMessage
+from vibe.cli.textual_ui.widgets.tools import ToolCallMessage
+from vibe.core.tools.builtins.read_file import ReadFile, ReadFileArgs
+from vibe.core.types import ToolCallEvent
+
+
+class ToolCallStreamingUpdateTest(App):
+ CSS_PATH = "../../vibe/cli/textual_ui/app.tcss"
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._widget: ToolCallMessage | None = None
+
+ def compose(self) -> ComposeResult:
+ partial_event = ToolCallEvent(
+ tool_call_id="tc_streaming",
+ tool_call_index=0,
+ tool_name="read_file",
+ tool_class=ReadFile,
+ args=None,
+ )
+ self._widget = ToolCallMessage(partial_event)
+
+ with VerticalScroll():
+ yield self._widget
+
+ def update_with_full_event(self) -> None:
+ if self._widget is None:
+ return
+
+ full_event = ToolCallEvent(
+ tool_call_id="tc_streaming",
+ tool_call_index=0,
+ tool_name="read_file",
+ tool_class=ReadFile,
+ args=ReadFileArgs(path="/test/example.py"),
+ )
+ self._widget.update_event(full_event)
+
+
+def test_snapshot_tool_call_partial(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+
+ with patch.object(StatusMessage, "start_spinner_timer"):
+ assert snap_compare(
+ "test_ui_snapshot_streaming_tool_call.py:ToolCallStreamingUpdateTest",
+ terminal_size=(80, 10),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_tool_call_updated(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.1)
+ app = cast(ToolCallStreamingUpdateTest, pilot.app)
+ app.update_with_full_event()
+ await pilot.pause(0.1)
+
+ with patch.object(StatusMessage, "start_spinner_timer"):
+ assert snap_compare(
+ "test_ui_snapshot_streaming_tool_call.py:ToolCallStreamingUpdateTest",
+ terminal_size=(80, 10),
+ run_before=run_before,
+ )
diff --git a/tests/stubs/fake_backend.py b/tests/stubs/fake_backend.py
index 26b47dc..9e3ce07 100644
--- a/tests/stubs/fake_backend.py
+++ b/tests/stubs/fake_backend.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from collections.abc import AsyncGenerator, Callable, Iterable
+from collections.abc import AsyncGenerator, Callable, Iterable, Sequence
from typing import cast
from tests.mock.utils import mock_llm_chunk
@@ -21,7 +21,7 @@ class FakeBackend:
| Iterable[Iterable[LLMChunk]]
| None = None,
*,
- token_counter: Callable[[list[LLMMessage]], int] | None = None,
+ token_counter: Callable[[Sequence[LLMMessage]], int] | None = None,
exception_to_raise: Exception | None = None,
) -> None:
"""Fake backend that will output the given chunks in the order they are given.
@@ -35,6 +35,7 @@ class FakeBackend:
"""
self._requests_messages: list[list[LLMMessage]] = []
self._requests_extra_headers: list[dict[str, str] | None] = []
+ self._requests_metadata: list[dict[str, str] | None] = []
self._count_tokens_calls: list[list[LLMMessage]] = []
self._token_counter = token_counter or self._default_token_counter
self._exception_to_raise = exception_to_raise
@@ -65,8 +66,12 @@ class FakeBackend:
def requests_extra_headers(self) -> list[dict[str, str] | None]:
return self._requests_extra_headers
+ @property
+ def requests_metadata(self) -> list[dict[str, str] | None]:
+ return self._requests_metadata
+
@staticmethod
- def _default_token_counter(messages: list[LLMMessage]) -> int:
+ def _default_token_counter(messages: Sequence[LLMMessage]) -> int:
return 1
async def __aenter__(self):
@@ -85,12 +90,14 @@ class FakeBackend:
tool_choice,
extra_headers,
max_tokens,
+ metadata=None,
) -> LLMChunk:
if self._exception_to_raise:
raise self._exception_to_raise
self._requests_messages.append(messages)
self._requests_extra_headers.append(extra_headers)
+ self._requests_metadata.append(metadata)
if self._streams:
stream = self._streams.pop(0)
@@ -111,12 +118,14 @@ class FakeBackend:
tool_choice,
extra_headers,
max_tokens,
+ metadata=None,
) -> AsyncGenerator[LLMChunk]:
if self._exception_to_raise:
raise self._exception_to_raise
self._requests_messages.append(messages)
self._requests_extra_headers.append(extra_headers)
+ self._requests_metadata.append(metadata)
if self._streams:
stream = list(self._streams.pop(0))
@@ -134,6 +143,7 @@ class FakeBackend:
tools,
tool_choice=None,
extra_headers,
+ metadata=None,
) -> int:
self._count_tokens_calls.append(list(messages))
return self._token_counter(messages)
diff --git a/tests/test_agent_auto_compact.py b/tests/test_agent_auto_compact.py
index dfa7c04..44922a8 100644
--- a/tests/test_agent_auto_compact.py
+++ b/tests/test_agent_auto_compact.py
@@ -16,22 +16,13 @@ from vibe.core.types import (
@pytest.mark.asyncio
-async def test_auto_compact_triggers_and_batches_observer(
- telemetry_events: list[dict],
-) -> None:
- observed: list[tuple[Role, str | None]] = []
-
- def observer(msg: LLMMessage) -> None:
- observed.append((msg.role, msg.content))
-
+async def test_auto_compact_emits_correct_events(telemetry_events: list[dict]) -> None:
backend = FakeBackend([
[mock_llm_chunk(content="")],
[mock_llm_chunk(content="")],
])
cfg = build_test_vibe_config(auto_compact_threshold=1)
- agent = build_test_agent_loop(
- config=cfg, message_observer=observer, backend=backend
- )
+ agent = build_test_agent_loop(config=cfg, backend=backend)
agent.stats.context_tokens = 2
events = [ev async for ev in agent.act("Hello")]
@@ -50,14 +41,83 @@ async def test_auto_compact_triggers_and_batches_observer(
assert end.new_context_tokens >= 1
assert final.content == ""
- roles = [r for r, _ in observed]
- assert roles == [Role.system, Role.user, Role.assistant]
- assert observed[1][1] is not None and "" in observed[1][1]
- assert observed[2][1] == ""
-
auto_compact = [
e
for e in telemetry_events
- if e.get("event_name") == "vibe/auto_compact_triggered"
+ if e.get("event_name") == "vibe.auto_compact_triggered"
]
assert len(auto_compact) == 1
+
+
+@pytest.mark.asyncio
+async def test_auto_compact_observer_sees_user_msg_not_summary() -> None:
+ """Observer sees the original user message and final response.
+
+ Compact internals (summary request, LLM summary) are invisible
+ to the observer because they happen inside silent() / reset().
+ """
+ observed: list[tuple[Role, str | None]] = []
+
+ def observer(msg: LLMMessage) -> None:
+ observed.append((msg.role, msg.content))
+
+ backend = FakeBackend([
+ [mock_llm_chunk(content="")],
+ [mock_llm_chunk(content="")],
+ ])
+ cfg = build_test_vibe_config(auto_compact_threshold=1)
+ agent = build_test_agent_loop(
+ config=cfg, message_observer=observer, backend=backend
+ )
+ agent.stats.context_tokens = 2
+
+ [_ async for _ in agent.act("Hello")]
+
+ roles = [r for r, _ in observed]
+ assert roles == [Role.system, Role.user, Role.assistant]
+ assert observed[1][1] == "Hello"
+ assert observed[2][1] == ""
+
+
+@pytest.mark.asyncio
+async def test_auto_compact_observer_does_not_see_summary_request() -> None:
+ """The compact summary request and LLM response must not leak to observer."""
+ observed: list[tuple[Role, str | None]] = []
+
+ def observer(msg: LLMMessage) -> None:
+ observed.append((msg.role, msg.content))
+
+ backend = FakeBackend([
+ [mock_llm_chunk(content="")],
+ [mock_llm_chunk(content="")],
+ ])
+ cfg = build_test_vibe_config(auto_compact_threshold=1)
+ agent = build_test_agent_loop(
+ config=cfg, message_observer=observer, backend=backend
+ )
+ agent.stats.context_tokens = 2
+
+ [_ async for _ in agent.act("Hello")]
+
+ contents = [c for _, c in observed]
+ assert "" not in contents
+ assert all("compact" not in (c or "").lower() for c in contents)
+
+
+@pytest.mark.asyncio
+async def test_compact_replaces_messages_with_summary() -> None:
+ """After compact, messages list contains only system + summary."""
+ backend = FakeBackend([
+ [mock_llm_chunk(content="")],
+ [mock_llm_chunk(content="")],
+ ])
+ cfg = build_test_vibe_config(auto_compact_threshold=1)
+ agent = build_test_agent_loop(config=cfg, backend=backend)
+ agent.stats.context_tokens = 2
+
+ [_ async for _ in agent.act("Hello")]
+
+ # After compact + final response: system, summary, final
+ assert agent.messages[0].role == Role.system
+ assert agent.messages[-1].role == Role.assistant
+ assert agent.messages[-1].content == ""
diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py
index a41bf36..bf93ea4 100644
--- a/tests/test_agent_backend.py
+++ b/tests/test_agent_backend.py
@@ -1,11 +1,54 @@
from __future__ import annotations
+from unittest.mock import MagicMock
+
+from mcp.types import (
+ CreateMessageRequestParams,
+ CreateMessageResult,
+ SamplingMessage,
+ TextContent,
+)
import pytest
-from tests.conftest import build_test_agent_loop
+from tests.conftest import build_test_agent_loop, build_test_vibe_config
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
-from vibe.core.config import VibeConfig
+from vibe.core.config import Backend, ModelConfig, ProviderConfig, VibeConfig
+from vibe.core.types import EntrypointMetadata
+
+
+def _two_model_vibe_config(active_model: str) -> VibeConfig:
+ """VibeConfig with two models so we can switch active_model."""
+ models = [
+ ModelConfig(
+ name="mistral-vibe-cli-latest", provider="mistral", alias="devstral-latest"
+ ),
+ ModelConfig(
+ name="devstral-small-latest", provider="mistral", alias="devstral-small"
+ ),
+ ]
+ providers = [
+ ProviderConfig(
+ name="mistral",
+ api_base="https://api.mistral.ai/v1",
+ api_key_env_var="MISTRAL_API_KEY",
+ backend=Backend.MISTRAL,
+ )
+ ]
+ return build_test_vibe_config(
+ active_model=active_model, models=models, providers=providers
+ )
+
+
+def _make_sampling_params() -> CreateMessageRequestParams:
+ return CreateMessageRequestParams(
+ messages=[
+ SamplingMessage(role="user", content=TextContent(type="text", text="Hi"))
+ ],
+ systemPrompt=None,
+ temperature=None,
+ maxTokens=100,
+ )
@pytest.mark.asyncio
@@ -66,3 +109,79 @@ async def test_updates_tokens_stats_based_on_backend_response_streaming(
[_ async for _ in agent.act("Hello")]
assert agent.stats.context_tokens == 275
+
+
+@pytest.mark.asyncio
+async def test_passes_entrypoint_metadata_to_backend(vibe_config: VibeConfig):
+ metadata = EntrypointMetadata(
+ agent_entrypoint="acp",
+ agent_version="2.0.0",
+ client_name="vibe_ide",
+ client_version="0.5.0",
+ )
+ backend = FakeBackend([mock_llm_chunk(content="Response")])
+ agent = build_test_agent_loop(
+ config=vibe_config,
+ backend=backend,
+ enable_streaming=True,
+ entrypoint_metadata=metadata,
+ )
+
+ [_ async for _ in agent.act("Hello")]
+
+ assert len(backend.requests_metadata) > 0
+ assert backend.requests_metadata[0] == {
+ "agent_entrypoint": "acp",
+ "agent_version": "2.0.0",
+ "client_name": "vibe_ide",
+ "client_version": "0.5.0",
+ }
+
+
+@pytest.mark.asyncio
+async def test_mcp_sampling_handler_uses_updated_backend_when_agent_backend_changes():
+ """AgentLoop's MCP sampling handler uses current backend when backend is reassigned."""
+ backend1 = FakeBackend([mock_llm_chunk(content="from-backend-1")])
+ backend2 = FakeBackend([mock_llm_chunk(content="from-backend-2")])
+ config = _two_model_vibe_config("devstral-latest")
+ agent = build_test_agent_loop(config=config, backend=backend1)
+ handler = agent._sampling_handler
+ params = _make_sampling_params()
+ context = MagicMock()
+
+ result1 = await handler(context, params)
+ assert isinstance(result1, CreateMessageResult)
+ assert result1.content.type == "text"
+ assert result1.content.text == "from-backend-1"
+ assert len(backend1.requests_messages) == 1
+ assert len(backend2.requests_messages) == 0
+
+ agent.backend = backend2
+ result2 = await handler(context, params)
+ assert isinstance(result2, CreateMessageResult)
+ assert result2.content.type == "text"
+ assert result2.content.text == "from-backend-2"
+ assert len(backend1.requests_messages) == 1
+ assert len(backend2.requests_messages) == 1
+
+
+@pytest.mark.asyncio
+async def test_mcp_sampling_handler_uses_updated_config_when_agent_config_changes():
+ chunk = mock_llm_chunk(content="ok")
+ backend = FakeBackend([chunk])
+ config1 = _two_model_vibe_config("devstral-latest")
+ config2 = _two_model_vibe_config("devstral-small")
+ agent = build_test_agent_loop(config=config1, backend=backend)
+ handler = agent._sampling_handler
+ params = _make_sampling_params()
+ context = MagicMock()
+
+ result1 = await handler(context, params)
+ assert isinstance(result1, CreateMessageResult)
+ assert result1.model == "mistral-vibe-cli-latest"
+
+ agent._base_config = config2
+ agent.agent_manager.invalidate_config()
+ result2 = await handler(context, params)
+ assert isinstance(result2, CreateMessageResult)
+ assert result2.model == "devstral-small-latest"
diff --git a/tests/test_agent_observer_streaming.py b/tests/test_agent_observer_streaming.py
index b08f2d1..ea84055 100644
--- a/tests/test_agent_observer_streaming.py
+++ b/tests/test_agent_observer_streaming.py
@@ -1,9 +1,10 @@
from __future__ import annotations
+from collections import Counter
from collections.abc import Callable
from http import HTTPStatus
from typing import cast
-from unittest.mock import AsyncMock
+from unittest.mock import AsyncMock, Mock
import httpx
import pytest
@@ -180,6 +181,27 @@ async def test_act_streams_chunks_in_order() -> None:
assert agent.messages[-1].content == "Hello from Vibe! More and end"
+@pytest.mark.asyncio
+async def test_act_streaming_does_not_cleanup_tmp_files_directly() -> None:
+ backend = FakeBackend([
+ mock_llm_chunk(content="Hello"),
+ mock_llm_chunk(content=" from"),
+ mock_llm_chunk(content=" Vibe"),
+ ])
+ agent = build_test_agent_loop(
+ config=make_config(), backend=backend, enable_streaming=True
+ )
+ agent.session_logger.save_interaction = AsyncMock(return_value=None)
+ cleanup_spy = Mock()
+ agent.session_logger.maybe_cleanup_tmp_files = cleanup_spy
+
+ events = [event async for event in agent.act("Stream, please.")]
+
+ assistant_events = [event for event in events if isinstance(event, AssistantEvent)]
+ assert len(assistant_events) == 3
+ assert cleanup_spy.call_count == 0
+
+
@pytest.mark.asyncio
async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None:
todo_tool_call = ToolCall(
@@ -210,6 +232,7 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None
UserMessageEvent,
AssistantEvent,
ToolCallEvent,
+ ToolCallEvent,
ToolResultEvent,
AssistantEvent,
]
@@ -217,12 +240,17 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None
assert isinstance(events[1], AssistantEvent)
assert events[1].content == "Checking your todos."
assert isinstance(events[2], ToolCallEvent)
+ assert events[2].args is None # streaming event
+ assert events[2].tool_call_id == "tc_stream"
assert events[2].tool_name == "todo"
- assert isinstance(events[3], ToolResultEvent)
- assert events[3].error is None
- assert events[3].skipped is False
- assert isinstance(events[4], AssistantEvent)
- assert events[4].content == "Done reviewing todos."
+ assert isinstance(events[3], ToolCallEvent)
+ assert events[3].args is not None
+ assert events[3].tool_name == "todo"
+ assert isinstance(events[4], ToolResultEvent)
+ assert events[4].error is None
+ assert events[4].skipped is False
+ assert isinstance(events[5], AssistantEvent)
+ assert events[5].content == "Done reviewing todos."
assert agent.messages[-1].content == "Done reviewing todos."
@@ -250,21 +278,27 @@ async def test_act_handles_tool_call_chunk_with_content() -> None:
events = [event async for event in agent.act("Check todos with content.")]
- assert [type(event) for event in events] == [
- UserMessageEvent,
- AssistantEvent,
- AssistantEvent,
- AssistantEvent,
- ToolCallEvent,
- ToolResultEvent,
- ]
- assert isinstance(events[0], UserMessageEvent)
- assert isinstance(events[1], AssistantEvent)
- assert isinstance(events[2], AssistantEvent)
- assert isinstance(events[3], AssistantEvent)
- assert events[1].content == "Preparing "
- assert events[2].content == "todo request"
- assert events[3].content == " complete"
+ event_types = [type(e) for e in events]
+ assert Counter(event_types) == Counter({
+ UserMessageEvent: 1,
+ AssistantEvent: 3,
+ ToolCallEvent: 2,
+ ToolResultEvent: 1,
+ })
+
+ tool_call_events = [e for e in events if isinstance(e, ToolCallEvent)]
+ assert len(tool_call_events) == 2
+ assert any(
+ tc.args is None and tc.tool_call_id == "tc_content" for tc in tool_call_events
+ )
+ assert any(tc.args is not None for tc in tool_call_events)
+
+ assistant_events = [e for e in events if isinstance(e, AssistantEvent)]
+ assistant_contents = {e.content for e in assistant_events}
+ assert "Preparing " in assistant_contents
+ assert "todo request" in assistant_contents
+ assert " complete" in assistant_contents
+
assert any(
m.role == Role.assistant and m.content == "Preparing todo request complete"
for m in agent.messages
@@ -304,17 +338,21 @@ async def test_act_merges_streamed_tool_call_arguments() -> None:
UserMessageEvent,
AssistantEvent,
ToolCallEvent,
+ ToolCallEvent,
ToolResultEvent,
]
assert isinstance(events[0], UserMessageEvent)
- call_event = events[2]
+ assert isinstance(events[2], ToolCallEvent)
+ assert events[2].args is None # streaming event
+ assert events[2].tool_call_id == "tc_merge"
+ call_event = events[3]
assert isinstance(call_event, ToolCallEvent)
assert call_event.tool_call_id == "tc_merge"
call_args = cast(TodoArgs, call_event.args)
assert call_args.action == "read"
- assert isinstance(events[3], ToolResultEvent)
- assert events[3].error is None
- assert events[3].skipped is False
+ assert isinstance(events[4], ToolResultEvent)
+ assert events[4].error is None
+ assert events[4].skipped is False
assistant_with_calls = next(
m for m in agent.messages if m.role == Role.assistant and m.tool_calls
)
@@ -371,6 +409,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
assert [type(event) for event in events] == [
UserMessageEvent,
AssistantEvent,
+ ToolCallEvent,
AssistantEvent,
ToolCallEvent,
ToolResultEvent,
diff --git a/tests/test_agent_stats.py b/tests/test_agent_stats.py
index d77388d..b228eaa 100644
--- a/tests/test_agent_stats.py
+++ b/tests/test_agent_stats.py
@@ -531,7 +531,7 @@ class TestAutoCompactIntegration:
roles = [r for r, _ in observed]
assert roles == [Role.system, Role.user, Role.assistant]
- assert observed[1][1] is not None and "" in observed[1][1]
+ assert observed[1][1] == "Hello"
class TestClearHistoryFullReset:
@@ -623,6 +623,37 @@ class TestClearHistoryFullReset:
assert agent.session_id == agent.session_logger.session_id
+class TestClearHistoryObserverBugfix:
+ @pytest.mark.asyncio
+ async def test_clear_history_observer_sees_new_messages(
+ self, observer_capture
+ ) -> None:
+ """Bug fix: clear_history previously left a stale index, so new messages
+ appended after clearing were never observed.
+ """
+ observed, observer = observer_capture
+ backend = FakeBackend([
+ [mock_llm_chunk(content="First")],
+ [mock_llm_chunk(content="Second")],
+ ])
+ agent = build_test_agent_loop(
+ config=make_config(), message_observer=observer, backend=backend
+ )
+
+ async for _ in agent.act("Hello"):
+ pass
+
+ await agent.clear_history()
+ observed.clear()
+
+ async for _ in agent.act("After clear"):
+ pass
+
+ roles = [msg.role for msg in observed]
+ assert Role.user in roles
+ assert Role.assistant in roles
+
+
class TestStatsEdgeCases:
@pytest.mark.asyncio
async def test_session_cost_approximation_on_model_change(
diff --git a/tests/test_agent_tool_call.py b/tests/test_agent_tool_call.py
index ae44e23..95e740b 100644
--- a/tests/test_agent_tool_call.py
+++ b/tests/test_agent_tool_call.py
@@ -113,7 +113,7 @@ async def test_single_tool_call_executes_under_auto_approve(
assert "total_count" in (tool_msgs[-1].content or "")
tool_finished = [
- e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
+ e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
]
assert len(tool_finished) == 1
assert tool_finished[0]["properties"]["tool_name"] == "todo"
@@ -158,7 +158,7 @@ async def test_tool_call_requires_approval_if_not_auto_approved(
assert agent_loop.stats.tool_calls_succeeded == 0
tool_finished = [
- e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
+ e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
]
assert len(tool_finished) == 1
assert tool_finished[0]["properties"]["approval_type"] == "ask"
@@ -198,7 +198,7 @@ async def test_tool_call_approved_by_callback(telemetry_events: list[dict]) -> N
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"
+ e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
]
assert len(tool_finished) == 1
assert tool_finished[0]["properties"]["approval_type"] == "ask"
@@ -243,7 +243,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
assert agent_loop.stats.tool_calls_succeeded == 0
tool_finished = [
- e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
+ e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
]
assert len(tool_finished) == 1
assert tool_finished[0]["properties"]["approval_type"] == "ask"
@@ -287,7 +287,7 @@ async def test_tool_call_skipped_when_permission_is_never(
assert agent_loop.stats.tool_calls_succeeded == 0
tool_finished = [
- e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
+ e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
]
assert len(tool_finished) == 1
assert tool_finished[0]["properties"]["approval_type"] == "never"
@@ -492,14 +492,14 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
assistant_msg = LLMMessage(
role=Role.assistant, content="Calling tools...", tool_calls=tool_calls_messages
)
- agent_loop.messages = [
+ agent_loop.messages.reset([
agent_loop.messages[0],
assistant_msg,
# only one tool responded: the second is missing
LLMMessage(
role=Role.tool, tool_call_id="tc1", name="todo", content="Retrieved 0 todos"
),
- ]
+ ])
await act_and_collect_events(agent_loop, "Proceed")
@@ -524,7 +524,7 @@ async def test_ensure_assistant_after_tool_appends_understood() -> None:
tool_msg = LLMMessage(
role=Role.tool, tool_call_id="tc_z", name="todo", content="Done"
)
- agent_loop.messages = [agent_loop.messages[0], tool_msg]
+ agent_loop.messages.reset([agent_loop.messages[0], tool_msg])
await act_and_collect_events(agent_loop, "Next")
diff --git a/tests/test_agents.py b/tests/test_agents.py
index d007dd1..1d6a6a6 100644
--- a/tests/test_agents.py
+++ b/tests/test_agents.py
@@ -126,8 +126,9 @@ class TestAgentSafety:
class TestAgentProfile:
- def test_all_builtin_agents_exist(self) -> None:
- assert set(BUILTIN_AGENTS.keys()) == set(BuiltinAgentName)
+ def test_all_builtin_agents_have_valid_names(self) -> None:
+ acp_only = {BuiltinAgentName.CHAT}
+ assert set(BUILTIN_AGENTS.keys()) == set(BuiltinAgentName) - acp_only
def test_display_name_property(self) -> None:
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].display_name == "Default"
diff --git a/tests/test_cli_programmatic_preload.py b/tests/test_cli_programmatic_preload.py
index 34d4568..58c0d24 100644
--- a/tests/test_cli_programmatic_preload.py
+++ b/tests/test_cli_programmatic_preload.py
@@ -79,7 +79,7 @@ def test_run_programmatic_preload_streaming_is_batched(
]
new_session = [
- e for e in telemetry_events if e.get("event_name") == "vibe/new_session"
+ e for e in telemetry_events if e.get("event_name") == "vibe.new_session"
]
assert len(new_session) == 1
assert new_session[0]["properties"]["entrypoint"] == "programmatic"
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
index 8085395..bb44e0a 100644
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -3,44 +3,67 @@ from __future__ import annotations
import pytest
from tests.conftest import build_test_agent_loop, build_test_vibe_config
-from vibe.core.agents.models import BUILTIN_AGENTS, AgentProfile, BuiltinAgentName
+from vibe.core.agents.models import BUILTIN_AGENTS, CHAT, AgentProfile, BuiltinAgentName
from vibe.core.config import VibeConfig
from vibe.core.middleware import (
+ CHAT_AGENT_EXIT,
+ CHAT_AGENT_REMINDER,
PLAN_AGENT_EXIT,
PLAN_AGENT_REMINDER,
ConversationContext,
MiddlewareAction,
MiddlewarePipeline,
- PlanAgentMiddleware,
+ ReadOnlyAgentMiddleware,
ResetReason,
)
-from vibe.core.types import AgentStats
+from vibe.core.types import AgentStats, MessageList
+
+REMINDER = "test reminder"
+EXIT_MSG = "test exit"
+TARGET_AGENT = BuiltinAgentName.PLAN
+
+
+def _build_middleware(
+ profile_getter,
+ agent_name: str = TARGET_AGENT,
+ reminder: str = REMINDER,
+ exit_message: str = EXIT_MSG,
+) -> ReadOnlyAgentMiddleware:
+ return ReadOnlyAgentMiddleware(profile_getter, agent_name, reminder, exit_message)
@pytest.fixture
def ctx(vibe_config: VibeConfig) -> ConversationContext:
- return ConversationContext(messages=[], stats=AgentStats(), config=vibe_config)
+ return ConversationContext(
+ messages=MessageList(), stats=AgentStats(), config=vibe_config
+ )
-class TestPlanAgentMiddleware:
+class TestReadOnlyAgentMiddleware:
@pytest.mark.asyncio
- async def test_injects_reminder_when_plan_agent_active(
+ async def test_injects_reminder_when_target_agent_active(
self, ctx: ConversationContext
) -> None:
- middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
+ middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
- assert result.message == PLAN_AGENT_REMINDER
+ assert result.message == REMINDER
@pytest.mark.asyncio
- async def test_does_not_inject_when_default_agent(
- self, ctx: ConversationContext
+ @pytest.mark.parametrize(
+ "agent_name",
+ [
+ BuiltinAgentName.DEFAULT,
+ BuiltinAgentName.AUTO_APPROVE,
+ BuiltinAgentName.ACCEPT_EDITS,
+ ],
+ )
+ async def test_does_not_inject_when_non_target_agent(
+ self, ctx: ConversationContext, agent_name: str
) -> None:
- middleware = PlanAgentMiddleware(
- lambda: BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
- )
+ middleware = _build_middleware(lambda: BUILTIN_AGENTS[agent_name])
result = await middleware.before_turn(ctx)
@@ -48,89 +71,56 @@ class TestPlanAgentMiddleware:
assert result.message is None
@pytest.mark.asyncio
- async def test_does_not_inject_when_auto_approve_agent(
- self, ctx: ConversationContext
- ) -> None:
- middleware = PlanAgentMiddleware(
- lambda: BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
- )
-
- result = await middleware.before_turn(ctx)
-
- assert result.action == MiddlewareAction.CONTINUE
- assert result.message is None
-
- @pytest.mark.asyncio
- async def test_does_not_inject_when_accept_edits_agent(
- self, ctx: ConversationContext
- ) -> None:
- middleware = PlanAgentMiddleware(
- lambda: BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS]
- )
-
- result = await middleware.before_turn(ctx)
-
- assert result.action == MiddlewareAction.CONTINUE
- assert result.message is None
-
- @pytest.mark.asyncio
- async def test_injects_reminder_only_once_while_in_plan_mode(
- self, ctx: ConversationContext
- ) -> None:
- middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
+ async def test_injects_reminder_only_once(self, ctx: ConversationContext) -> None:
+ middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
result1 = await middleware.before_turn(ctx)
assert result1.action == MiddlewareAction.INJECT_MESSAGE
- assert result1.message == PLAN_AGENT_REMINDER
+ assert result1.message == REMINDER
result2 = await middleware.before_turn(ctx)
assert result2.action == MiddlewareAction.CONTINUE
assert result2.message is None
@pytest.mark.asyncio
- async def test_injects_exit_message_when_leaving_plan_mode(
+ async def test_injects_exit_message_when_leaving(
self, ctx: ConversationContext
) -> None:
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
- middleware = PlanAgentMiddleware(lambda: current_profile)
+ middleware = _build_middleware(lambda: current_profile)
- # Enter plan mode
await middleware.before_turn(ctx)
- # Leave plan mode
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
- assert result.message == PLAN_AGENT_EXIT
+ assert result.message == EXIT_MSG
@pytest.mark.asyncio
- async def test_reinjects_reminder_when_reentering_plan_mode(
+ async def test_reinjects_reminder_on_reentry(
self, ctx: ConversationContext
) -> None:
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
- middleware = PlanAgentMiddleware(lambda: current_profile)
+ middleware = _build_middleware(lambda: current_profile)
- # Enter plan mode - should inject reminder
result1 = await middleware.before_turn(ctx)
assert result1.action == MiddlewareAction.INJECT_MESSAGE
- assert result1.message == PLAN_AGENT_REMINDER
+ assert result1.message == REMINDER
- # Leave plan mode - should inject exit message
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
result2 = await middleware.before_turn(ctx)
assert result2.action == MiddlewareAction.INJECT_MESSAGE
- assert result2.message == PLAN_AGENT_EXIT
+ assert result2.message == EXIT_MSG
- # Re-enter plan mode - should inject reminder again
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
result3 = await middleware.before_turn(ctx)
assert result3.action == MiddlewareAction.INJECT_MESSAGE
- assert result3.message == PLAN_AGENT_REMINDER
+ assert result3.message == REMINDER
@pytest.mark.asyncio
async def test_custom_reminder(self, ctx: ConversationContext) -> None:
- custom_reminder = "Custom plan agent reminder"
- middleware = PlanAgentMiddleware(
+ custom_reminder = "Custom reminder"
+ middleware = _build_middleware(
lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN], reminder=custom_reminder
)
@@ -138,246 +128,216 @@ class TestPlanAgentMiddleware:
assert result.message == custom_reminder
- @pytest.mark.asyncio
- async def test_reset_clears_state(self, ctx: ConversationContext) -> None:
- middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
- await middleware.before_turn(ctx) # Enter and inject
-
- middleware.reset()
-
- # Should inject again after reset
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.INJECT_MESSAGE
-
- @pytest.mark.asyncio
- async def test_exit_message_fires_only_once(self, ctx: ConversationContext) -> None:
- current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
- middleware = PlanAgentMiddleware(lambda: current_profile)
-
- # Enter plan mode
- await middleware.before_turn(ctx)
-
- # Leave plan mode - first call should inject exit
- current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.INJECT_MESSAGE
- assert result.message == PLAN_AGENT_EXIT
-
- # Subsequent calls in default mode should be CONTINUE
- result2 = await middleware.before_turn(ctx)
- assert result2.action == MiddlewareAction.CONTINUE
- assert result2.message is None
-
- @pytest.mark.asyncio
- async def test_multiple_turns_in_plan_mode_after_entry(
- self, ctx: ConversationContext
- ) -> None:
- middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
-
- # First turn: inject reminder
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.INJECT_MESSAGE
-
- # Several more turns in plan mode: all should be CONTINUE
- for _ in range(5):
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.CONTINUE
- assert result.message is None
-
- @pytest.mark.asyncio
- async def test_multiple_turns_in_default_mode_after_exit(
- self, ctx: ConversationContext
- ) -> None:
- current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
- middleware = PlanAgentMiddleware(lambda: current_profile)
-
- await middleware.before_turn(ctx) # enter plan
-
- current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
- await middleware.before_turn(ctx) # exit plan (fires exit message)
-
- # Several more turns in default mode: all should be CONTINUE
- for _ in range(5):
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.CONTINUE
- assert result.message is None
-
- @pytest.mark.asyncio
- async def test_rapid_toggling_plan_default_multiple_cycles(
- self, ctx: ConversationContext
- ) -> None:
- current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
- middleware = PlanAgentMiddleware(lambda: current_profile)
-
- for _ in range(3):
- # Enter plan
- current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.INJECT_MESSAGE
- assert result.message == PLAN_AGENT_REMINDER
-
- # Leave plan
- current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.INJECT_MESSAGE
- assert result.message == PLAN_AGENT_EXIT
-
- @pytest.mark.asyncio
- async def test_exit_to_non_default_agent(self, ctx: ConversationContext) -> None:
- current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
- middleware = PlanAgentMiddleware(lambda: current_profile)
-
- await middleware.before_turn(ctx) # enter plan
-
- # Switch to auto_approve (not default)
- current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.INJECT_MESSAGE
- assert result.message == PLAN_AGENT_EXIT
-
- @pytest.mark.asyncio
- async def test_exit_to_accept_edits_agent(self, ctx: ConversationContext) -> None:
- current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
- middleware = PlanAgentMiddleware(lambda: current_profile)
-
- await middleware.before_turn(ctx) # enter plan
-
- # Switch to accept_edits
- current_profile = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS]
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.INJECT_MESSAGE
- assert result.message == PLAN_AGENT_EXIT
-
- @pytest.mark.asyncio
- async def test_switching_between_non_plan_agents(
- self, ctx: ConversationContext
- ) -> None:
- current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
- middleware = PlanAgentMiddleware(lambda: current_profile)
-
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.CONTINUE
-
- current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.CONTINUE
-
- current_profile = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS]
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.CONTINUE
-
- @pytest.mark.asyncio
- async def test_non_plan_to_plan_entry(self, ctx: ConversationContext) -> None:
- """Starting in a non-plan agent then entering plan should inject reminder."""
- current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
- middleware = PlanAgentMiddleware(lambda: current_profile)
-
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.CONTINUE
-
- # Now switch to plan
- current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.INJECT_MESSAGE
- assert result.message == PLAN_AGENT_REMINDER
-
- @pytest.mark.asyncio
- async def test_reset_while_in_default_after_exiting_plan(
- self, ctx: ConversationContext
- ) -> None:
- current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
- middleware = PlanAgentMiddleware(lambda: current_profile)
-
- await middleware.before_turn(ctx) # enter plan
- current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
- await middleware.before_turn(ctx) # exit plan
-
- middleware.reset()
-
- # Still in default mode - should CONTINUE (no phantom exit message)
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.CONTINUE
-
- @pytest.mark.asyncio
- async def test_reset_while_in_default_then_reenter_plan(
- self, ctx: ConversationContext
- ) -> None:
- current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
- middleware = PlanAgentMiddleware(lambda: current_profile)
-
- await middleware.before_turn(ctx) # enter plan
- current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
- await middleware.before_turn(ctx) # exit plan
-
- middleware.reset()
-
- # Re-enter plan after reset
- current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.INJECT_MESSAGE
- assert result.message == PLAN_AGENT_REMINDER
-
- @pytest.mark.asyncio
- async def test_reset_with_compact_reason(self, ctx: ConversationContext) -> None:
- middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
- await middleware.before_turn(ctx) # enter and inject
-
- middleware.reset(ResetReason.COMPACT)
-
- # Should reinject reminder after compact reset
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.INJECT_MESSAGE
- assert result.message == PLAN_AGENT_REMINDER
-
@pytest.mark.asyncio
async def test_custom_exit_message(self, ctx: ConversationContext) -> None:
custom_exit = "Custom exit message"
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
- middleware = PlanAgentMiddleware(
+ middleware = _build_middleware(
lambda: current_profile, exit_message=custom_exit
)
- await middleware.before_turn(ctx) # enter plan
+ await middleware.before_turn(ctx)
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
result = await middleware.before_turn(ctx)
assert result.message == custom_exit
@pytest.mark.asyncio
- async def test_plan_entry_then_immediate_exit_same_not_possible(
- self, ctx: ConversationContext
- ) -> None:
- """Even if profile changes between two calls, each call sees one transition."""
- current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
- middleware = PlanAgentMiddleware(lambda: current_profile)
+ async def test_reset_clears_state(self, ctx: ConversationContext) -> None:
+ middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
+ await middleware.before_turn(ctx)
+
+ middleware.reset()
- # First call: entry
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
- assert result.message == PLAN_AGENT_REMINDER
- # Second call (still plan): no injection
- result = await middleware.before_turn(ctx)
- assert result.action == MiddlewareAction.CONTINUE
+ @pytest.mark.asyncio
+ async def test_exit_message_fires_only_once(self, ctx: ConversationContext) -> None:
+ current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
+ middleware = _build_middleware(lambda: current_profile)
+
+ await middleware.before_turn(ctx)
- # Third call (switched to default): exit
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
- assert result.message == PLAN_AGENT_EXIT
+ assert result.message == EXIT_MSG
+
+ result2 = await middleware.before_turn(ctx)
+ assert result2.action == MiddlewareAction.CONTINUE
+ assert result2.message is None
+
+ @pytest.mark.asyncio
+ async def test_multiple_turns_after_entry(self, ctx: ConversationContext) -> None:
+ middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
+
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.INJECT_MESSAGE
+
+ for _ in range(5):
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.CONTINUE
+ assert result.message is None
+
+ @pytest.mark.asyncio
+ async def test_multiple_turns_after_exit(self, ctx: ConversationContext) -> None:
+ current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
+ middleware = _build_middleware(lambda: current_profile)
+
+ await middleware.before_turn(ctx)
+
+ current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
+ await middleware.before_turn(ctx)
+
+ for _ in range(5):
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.CONTINUE
+ assert result.message is None
+
+ @pytest.mark.asyncio
+ async def test_rapid_toggling_multiple_cycles(
+ self, ctx: ConversationContext
+ ) -> None:
+ current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
+ middleware = _build_middleware(lambda: current_profile)
+
+ for _ in range(3):
+ current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.INJECT_MESSAGE
+ assert result.message == REMINDER
+
+ current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.INJECT_MESSAGE
+ assert result.message == EXIT_MSG
+
+ @pytest.mark.asyncio
+ async def test_exit_to_non_default_agent(self, ctx: ConversationContext) -> None:
+ current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
+ middleware = _build_middleware(lambda: current_profile)
+
+ await middleware.before_turn(ctx)
+
+ current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.INJECT_MESSAGE
+ assert result.message == EXIT_MSG
+
+ @pytest.mark.asyncio
+ async def test_switching_between_non_target_agents(
+ self, ctx: ConversationContext
+ ) -> None:
+ current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
+ middleware = _build_middleware(lambda: current_profile)
+
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.CONTINUE
+
+ current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.CONTINUE
+
+ current_profile = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS]
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.CONTINUE
+
+ @pytest.mark.asyncio
+ async def test_non_target_to_target_entry(self, ctx: ConversationContext) -> None:
+ """Starting in a non-target agent then entering target should inject reminder."""
+ current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
+ middleware = _build_middleware(lambda: current_profile)
+
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.CONTINUE
+
+ current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.INJECT_MESSAGE
+ assert result.message == REMINDER
+
+ @pytest.mark.asyncio
+ async def test_reset_while_inactive_after_exit(
+ self, ctx: ConversationContext
+ ) -> None:
+ current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
+ middleware = _build_middleware(lambda: current_profile)
+
+ await middleware.before_turn(ctx)
+ current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
+ await middleware.before_turn(ctx)
+
+ middleware.reset()
+
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.CONTINUE
+
+ @pytest.mark.asyncio
+ async def test_reset_while_inactive_then_reenter(
+ self, ctx: ConversationContext
+ ) -> None:
+ current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
+ middleware = _build_middleware(lambda: current_profile)
+
+ await middleware.before_turn(ctx)
+ current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
+ await middleware.before_turn(ctx)
+
+ middleware.reset()
+
+ current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.INJECT_MESSAGE
+ assert result.message == REMINDER
+
+ @pytest.mark.asyncio
+ async def test_reset_with_compact_reason(self, ctx: ConversationContext) -> None:
+ middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
+ await middleware.before_turn(ctx)
+
+ middleware.reset(ResetReason.COMPACT)
+
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.INJECT_MESSAGE
+ assert result.message == REMINDER
+
+ @pytest.mark.asyncio
+ async def test_entry_then_continuation_then_exit_then_continuation(
+ self, ctx: ConversationContext
+ ) -> None:
+ """Each call sees one transition at a time."""
+ current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
+ middleware = _build_middleware(lambda: current_profile)
+
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.INJECT_MESSAGE
+ assert result.message == REMINDER
+
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.CONTINUE
+
+ current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
+ result = await middleware.before_turn(ctx)
+ assert result.action == MiddlewareAction.INJECT_MESSAGE
+ assert result.message == EXIT_MSG
- # Fourth call (still default): no injection
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
-class TestMiddlewarePipelineWithPlanAgent:
+class TestMiddlewarePipelineWithReadOnlyAgent:
@pytest.mark.asyncio
- async def test_pipeline_includes_plan_agent_injection(
- self, ctx: ConversationContext
- ) -> None:
+ async def test_pipeline_includes_injection(self, ctx: ConversationContext) -> None:
pipeline = MiddlewarePipeline()
- pipeline.add(PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN]))
+ pipeline.add(
+ ReadOnlyAgentMiddleware(
+ lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN],
+ BuiltinAgentName.PLAN,
+ PLAN_AGENT_REMINDER,
+ PLAN_AGENT_EXIT,
+ )
+ )
result = await pipeline.run_before_turn(ctx)
@@ -385,20 +345,73 @@ class TestMiddlewarePipelineWithPlanAgent:
assert PLAN_AGENT_REMINDER in (result.message or "")
@pytest.mark.asyncio
- async def test_pipeline_skips_injection_when_not_plan_agent(
+ async def test_pipeline_skips_injection_when_not_target_agent(
self, ctx: ConversationContext
) -> None:
pipeline = MiddlewarePipeline()
pipeline.add(
- PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.DEFAULT])
+ ReadOnlyAgentMiddleware(
+ lambda: BUILTIN_AGENTS[BuiltinAgentName.DEFAULT],
+ BuiltinAgentName.PLAN,
+ PLAN_AGENT_REMINDER,
+ PLAN_AGENT_EXIT,
+ )
)
result = await pipeline.run_before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
+ @pytest.mark.asyncio
+ async def test_direct_plan_to_chat_transition_delivers_both_messages(
+ self, ctx: ConversationContext
+ ) -> None:
+ current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
+ pipeline = MiddlewarePipeline()
+ pipeline.add(
+ ReadOnlyAgentMiddleware(
+ lambda: current_profile,
+ BuiltinAgentName.PLAN,
+ PLAN_AGENT_REMINDER,
+ PLAN_AGENT_EXIT,
+ )
+ )
+ pipeline.add(
+ ReadOnlyAgentMiddleware(
+ lambda: current_profile,
+ BuiltinAgentName.CHAT,
+ CHAT_AGENT_REMINDER,
+ CHAT_AGENT_EXIT,
+ )
+ )
-class TestPlanAgentMiddlewareIntegration:
+ result = await pipeline.run_before_turn(ctx)
+ assert result.action == MiddlewareAction.INJECT_MESSAGE
+ assert PLAN_AGENT_REMINDER in (result.message or "")
+
+ current_profile = CHAT
+ result = await pipeline.run_before_turn(ctx)
+ assert result.action == MiddlewareAction.INJECT_MESSAGE
+ assert PLAN_AGENT_EXIT in (result.message or "")
+ assert CHAT_AGENT_REMINDER in (result.message or "")
+
+ current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
+ result = await pipeline.run_before_turn(ctx)
+ assert result.action == MiddlewareAction.INJECT_MESSAGE
+ assert CHAT_AGENT_EXIT in (result.message or "")
+ assert PLAN_AGENT_REMINDER in (result.message or "")
+
+
+def _find_plan_middleware(agent) -> ReadOnlyAgentMiddleware:
+ return next(
+ mw
+ for mw in agent.middleware_pipeline.middlewares
+ if isinstance(mw, ReadOnlyAgentMiddleware)
+ and mw._agent_name == BuiltinAgentName.PLAN
+ )
+
+
+class TestReadOnlyAgentMiddlewareIntegration:
@pytest.mark.asyncio
async def test_switch_agent_preserves_middleware_state_for_exit_message(
self,
@@ -414,11 +427,7 @@ class TestPlanAgentMiddlewareIntegration:
)
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
- plan_middleware = next(
- mw
- for mw in agent.middleware_pipeline.middlewares
- if isinstance(mw, PlanAgentMiddleware)
- )
+ plan_middleware = _find_plan_middleware(agent)
ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config
@@ -429,11 +438,7 @@ class TestPlanAgentMiddlewareIntegration:
await agent.switch_agent(BuiltinAgentName.DEFAULT)
- plan_middleware_after = next(
- mw
- for mw in agent.middleware_pipeline.middlewares
- if isinstance(mw, PlanAgentMiddleware)
- )
+ plan_middleware_after = _find_plan_middleware(agent)
assert plan_middleware is plan_middleware_after
ctx = ConversationContext(
@@ -456,11 +461,7 @@ class TestPlanAgentMiddlewareIntegration:
)
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
- plan_middleware = next(
- mw
- for mw in agent.middleware_pipeline.middlewares
- if isinstance(mw, PlanAgentMiddleware)
- )
+ plan_middleware = _find_plan_middleware(agent)
ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config
@@ -497,11 +498,7 @@ class TestPlanAgentMiddlewareIntegration:
)
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
- plan_middleware = next(
- mw
- for mw in agent.middleware_pipeline.middlewares
- if isinstance(mw, PlanAgentMiddleware)
- )
+ plan_middleware = _find_plan_middleware(agent)
ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config
@@ -532,11 +529,7 @@ class TestPlanAgentMiddlewareIntegration:
config=config, agent_name=BuiltinAgentName.DEFAULT
)
- plan_middleware = next(
- mw
- for mw in agent.middleware_pipeline.middlewares
- if isinstance(mw, PlanAgentMiddleware)
- )
+ plan_middleware = _find_plan_middleware(agent)
ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config
@@ -566,11 +559,7 @@ class TestPlanAgentMiddlewareIntegration:
)
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
- plan_middleware = next(
- mw
- for mw in agent.middleware_pipeline.middlewares
- if isinstance(mw, PlanAgentMiddleware)
- )
+ plan_middleware = _find_plan_middleware(agent)
def _ctx():
return ConversationContext(
diff --git a/tests/tools/test_bash.py b/tests/tools/test_bash.py
index b3a2b7b..438edf2 100644
--- a/tests/tools/test_bash.py
+++ b/tests/tools/test_bash.py
@@ -76,14 +76,14 @@ async def test_decodes_non_utf8_bytes(bash):
assert result.stderr == ""
-def test_check_allowlist_denylist():
+def test_resolve_permission():
config = BashToolConfig(allowlist=["echo", "pwd"], denylist=["rm"])
bash_tool = Bash(config=config, state=BaseToolState())
- allowlisted = bash_tool.check_allowlist_denylist(BashArgs(command="echo hi"))
- denylisted = bash_tool.check_allowlist_denylist(BashArgs(command="rm -rf /tmp"))
- mixed = bash_tool.check_allowlist_denylist(BashArgs(command="pwd && whoami"))
- empty = bash_tool.check_allowlist_denylist(BashArgs(command=""))
+ allowlisted = bash_tool.resolve_permission(BashArgs(command="echo hi"))
+ denylisted = bash_tool.resolve_permission(BashArgs(command="rm -rf /tmp"))
+ mixed = bash_tool.resolve_permission(BashArgs(command="pwd && whoami"))
+ empty = bash_tool.resolve_permission(BashArgs(command=""))
assert allowlisted is ToolPermission.ALWAYS
assert denylisted is ToolPermission.NEVER
diff --git a/tests/tools/test_grep.py b/tests/tools/test_grep.py
index 668210f..66ae935 100644
--- a/tests/tools/test_grep.py
+++ b/tests/tools/test_grep.py
@@ -5,21 +5,15 @@ import shutil
import pytest
from tests.mock.utils import collect_result
-from vibe.core.tools.base import ToolError
-from vibe.core.tools.builtins.grep import (
- Grep,
- GrepArgs,
- GrepBackend,
- GrepState,
- GrepToolConfig,
-)
+from vibe.core.tools.base import BaseToolState, ToolError
+from vibe.core.tools.builtins.grep import Grep, GrepArgs, GrepBackend, GrepToolConfig
@pytest.fixture
def grep(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = GrepToolConfig()
- return Grep(config=config, state=GrepState())
+ return Grep(config=config, state=BaseToolState())
@pytest.fixture
@@ -34,7 +28,7 @@ def grep_gnu_only(tmp_path, monkeypatch):
monkeypatch.setattr("shutil.which", mock_which)
config = GrepToolConfig()
- return Grep(config=config, state=GrepState())
+ return Grep(config=config, state=BaseToolState())
def test_detects_ripgrep_when_available(grep):
@@ -143,7 +137,7 @@ async def test_truncates_to_max_matches(grep, tmp_path):
async def test_truncates_to_max_output_bytes(grep, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = GrepToolConfig(max_output_bytes=100)
- grep_tool = Grep(config=config, state=GrepState())
+ grep_tool = Grep(config=config, state=BaseToolState())
(tmp_path / "test.py").write_text("\n".join("x" * 100 for _ in range(10)))
result = await collect_result(grep_tool.run(GrepArgs(pattern="x")))
@@ -191,22 +185,11 @@ async def test_ignores_comments_in_vibeignore(grep, tmp_path):
assert result.match_count >= 1
-@pytest.mark.asyncio
-async def test_tracks_search_history(grep, tmp_path):
- (tmp_path / "test.py").write_text("content\n")
-
- await collect_result(grep.run(GrepArgs(pattern="first")))
- await collect_result(grep.run(GrepArgs(pattern="second")))
- await collect_result(grep.run(GrepArgs(pattern="third")))
-
- assert grep.state.search_history == ["first", "second", "third"]
-
-
@pytest.mark.asyncio
async def test_uses_effective_workdir(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = GrepToolConfig()
- grep_tool = Grep(config=config, state=GrepState())
+ grep_tool = Grep(config=config, state=BaseToolState())
(tmp_path / "test.py").write_text("match\n")
result = await collect_result(grep_tool.run(GrepArgs(pattern="match", path=".")))
diff --git a/tests/tools/test_manager_get_tool_config.py b/tests/tools/test_manager_get_tool_config.py
index 172eabd..ae7f66b 100644
--- a/tests/tools/test_manager_get_tool_config.py
+++ b/tests/tools/test_manager_get_tool_config.py
@@ -227,6 +227,70 @@ class FileTool(BaseTool[FileToolArgs, FileToolResult, BaseToolConfig, BaseToolSt
assert "file_tool" in tools
+class TestToolRuntimeAvailability:
+ """Tests for is_available() filtering in ToolManager."""
+
+ def test_unavailable_tool_excluded_from_available_tools(
+ self, tmp_path: Path, monkeypatch
+ ):
+ """Tools where is_available() returns False should be excluded."""
+ import sys
+
+ tool_dir = tmp_path / "tools"
+ tool_dir.mkdir()
+ (tool_dir / "conditional_tool.py").write_text("""
+import os
+from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
+from pydantic import BaseModel
+
+class ConditionalToolArgs(BaseModel):
+ pass
+
+class ConditionalToolResult(BaseModel):
+ pass
+
+class ConditionalTool(BaseTool[ConditionalToolArgs, ConditionalToolResult, BaseToolConfig, BaseToolState]):
+ description = "Tool that requires TEST_VAR"
+
+ @classmethod
+ def is_available(cls) -> bool:
+ return bool(os.getenv("TEST_VAR"))
+
+ async def run(self, args, ctx=None):
+ yield ConditionalToolResult()
+""")
+
+ to_remove = [k for k in sys.modules if "conditional_tool" in k]
+ for k in to_remove:
+ del sys.modules[k]
+
+ monkeypatch.delenv("TEST_VAR", raising=False)
+ vibe_config = build_test_vibe_config(
+ system_prompt_id="tests",
+ include_project_context=False,
+ tool_paths=[tool_dir],
+ )
+ manager = ToolManager(lambda: vibe_config)
+ assert "conditional_tool" not in manager.available_tools
+
+ to_remove = [k for k in sys.modules if "conditional_tool" in k]
+ for k in to_remove:
+ del sys.modules[k]
+
+ monkeypatch.setenv("TEST_VAR", "1")
+ manager2 = ToolManager(lambda: vibe_config)
+ assert "conditional_tool" in manager2.available_tools
+
+ def test_default_is_available_returns_true(self):
+ """Tools without is_available() override should be available."""
+ vibe_config = build_test_vibe_config(
+ system_prompt_id="tests", include_project_context=False
+ )
+ manager = ToolManager(lambda: vibe_config)
+
+ assert "bash" in manager.available_tools
+
+
class TestToolManagerModuleReuse:
"""Tests for module reuse across ToolManager instances.
diff --git a/tests/tools/test_mcp.py b/tests/tools/test_mcp.py
index 56f24ba..451796d 100644
--- a/tests/tools/test_mcp.py
+++ b/tests/tools/test_mcp.py
@@ -11,6 +11,7 @@ import pytest
from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp
from vibe.core.tools.mcp import (
+ MCPRegistry,
MCPToolResult,
RemoteTool,
_mcp_stderr_capture,
@@ -372,3 +373,181 @@ class TestMCPConfigModels:
# Trailing special chars become underscores which are then stripped
assert config.name == "my_server"
+
+
+class TestMCPRegistry:
+ def _make_http_server(
+ self, name: str, url: str = "http://localhost:8080"
+ ) -> MCPHttp:
+ return MCPHttp(name=name, transport="http", url=url)
+
+ def _make_stdio_server(self, name: str, command: str = "python -m srv") -> MCPStdio:
+ return MCPStdio(name=name, transport="stdio", command=command)
+
+ def test_server_key_is_stable(self):
+ srv = self._make_http_server("s1")
+ registry = MCPRegistry()
+
+ assert registry._server_key(srv) == registry._server_key(srv)
+
+ def test_different_configs_produce_different_keys(self):
+ registry = MCPRegistry()
+ s1 = self._make_http_server("s1", url="http://a:1")
+ s2 = self._make_http_server("s2", url="http://b:2")
+
+ assert registry._server_key(s1) != registry._server_key(s2)
+
+ def test_get_tools_caches_discovery(self):
+ registry = MCPRegistry()
+ srv = self._make_http_server("cached")
+ remote = RemoteTool(name="tool_a", description="A tool")
+ proxy = create_mcp_http_proxy_tool_class(
+ url="http://localhost:8080", remote=remote, alias="cached"
+ )
+
+ key = registry._server_key(srv)
+ registry._cache[key] = {proxy.get_name(): proxy}
+
+ tools = registry.get_tools([srv])
+ assert "cached_tool_a" in tools
+ assert tools["cached_tool_a"] is proxy
+
+ def test_get_tools_returns_empty_for_no_servers(self):
+ registry = MCPRegistry()
+
+ assert registry.get_tools([]) == {}
+
+ def test_clear_drops_cache(self):
+ registry = MCPRegistry()
+ srv = self._make_http_server("s")
+ proxy = create_mcp_http_proxy_tool_class(
+ url="http://localhost:8080", remote=RemoteTool(name="t"), alias="s"
+ )
+ key = registry._server_key(srv)
+ registry._cache[key] = {proxy.get_name(): proxy}
+
+ registry.clear()
+
+ assert len(registry._cache) == 0
+
+ def test_cache_survives_multiple_get_tools_calls(self):
+ registry = MCPRegistry()
+ srv = self._make_http_server("stable")
+ remote = RemoteTool(name="t1")
+ proxy = create_mcp_http_proxy_tool_class(
+ url="http://localhost:8080", remote=remote, alias="stable"
+ )
+
+ key = registry._server_key(srv)
+ registry._cache[key] = {proxy.get_name(): proxy}
+
+ first = registry.get_tools([srv])
+ second = registry.get_tools([srv])
+
+ assert first == second
+ assert first["stable_t1"] is second["stable_t1"]
+
+ def test_disjoint_server_lists_across_agents(self):
+ registry = MCPRegistry()
+
+ srv_x = self._make_http_server("x", url="http://x:1")
+ srv_y = self._make_http_server("y", url="http://y:2")
+
+ proxy_x = create_mcp_http_proxy_tool_class(
+ url="http://x:1", remote=RemoteTool(name="tx"), alias="x"
+ )
+ proxy_y = create_mcp_http_proxy_tool_class(
+ url="http://y:2", remote=RemoteTool(name="ty"), alias="y"
+ )
+
+ registry._cache[registry._server_key(srv_x)] = {proxy_x.get_name(): proxy_x}
+ registry._cache[registry._server_key(srv_y)] = {proxy_y.get_name(): proxy_y}
+
+ agent_a_tools = registry.get_tools([srv_x])
+ agent_b_tools = registry.get_tools([srv_y])
+
+ assert "x_tx" in agent_a_tools
+ assert "y_ty" not in agent_a_tools
+ assert "y_ty" in agent_b_tools
+ assert "x_tx" not in agent_b_tools
+
+ @pytest.mark.asyncio
+ async def test_discover_http_success(self):
+ registry = MCPRegistry()
+ srv = self._make_http_server("demo", url="http://demo:9090")
+ remote = RemoteTool(name="hello", description="Hi")
+
+ with patch(
+ "vibe.core.tools.mcp.registry.list_tools_http", return_value=[remote]
+ ):
+ tools = await registry._discover_http(srv)
+
+ assert tools is not None
+ assert len(tools) == 1
+ name = next(iter(tools))
+ assert name == "demo_hello"
+
+ @pytest.mark.asyncio
+ async def test_discover_http_failure_returns_none(self):
+ registry = MCPRegistry()
+ srv = self._make_http_server("fail", url="http://fail:1")
+
+ with patch(
+ "vibe.core.tools.mcp.registry.list_tools_http",
+ side_effect=ConnectionError("down"),
+ ):
+ tools = await registry._discover_http(srv)
+
+ assert tools is None
+
+ @pytest.mark.asyncio
+ async def test_discover_stdio_success(self):
+ registry = MCPRegistry()
+ srv = self._make_stdio_server("local", command="python -m local_srv")
+ remote = RemoteTool(name="run", description="Run it")
+
+ with patch(
+ "vibe.core.tools.mcp.registry.list_tools_stdio", return_value=[remote]
+ ):
+ tools = await registry._discover_stdio(srv)
+
+ assert tools is not None
+ assert len(tools) == 1
+ name = next(iter(tools))
+ assert name == "local_run"
+
+ @pytest.mark.asyncio
+ async def test_discover_stdio_failure_returns_none(self):
+ registry = MCPRegistry()
+ srv = self._make_stdio_server("broken")
+
+ with patch(
+ "vibe.core.tools.mcp.registry.list_tools_stdio",
+ side_effect=OSError("no binary"),
+ ):
+ tools = await registry._discover_stdio(srv)
+
+ assert tools is None
+
+ def test_get_tools_discovers_only_uncached(self):
+ registry = MCPRegistry()
+
+ cached_srv = self._make_http_server("cached", url="http://c:1")
+ new_srv = self._make_http_server("new", url="http://n:2")
+
+ cached_proxy = create_mcp_http_proxy_tool_class(
+ url="http://c:1", remote=RemoteTool(name="ct"), alias="cached"
+ )
+ registry._cache[registry._server_key(cached_srv)] = {
+ cached_proxy.get_name(): cached_proxy
+ }
+
+ new_remote = RemoteTool(name="nt")
+ with patch(
+ "vibe.core.tools.mcp.registry.list_tools_http", return_value=[new_remote]
+ ):
+ tools = registry.get_tools([cached_srv, new_srv])
+
+ assert "cached_ct" in tools
+ assert "new_nt" in tools
+ assert len(registry._cache) == 2
diff --git a/tests/tools/test_mcp_sampling.py b/tests/tools/test_mcp_sampling.py
new file mode 100644
index 0000000..ae37710
--- /dev/null
+++ b/tests/tools/test_mcp_sampling.py
@@ -0,0 +1,171 @@
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+from mcp.types import (
+ CreateMessageRequestParams,
+ CreateMessageResult,
+ ErrorData,
+ SamplingMessage,
+ TextContent,
+)
+import pytest
+
+from tests.mock.utils import mock_llm_chunk
+from tests.stubs.fake_backend import FakeBackend
+from vibe.core.tools.mcp_sampling import (
+ MCPSamplingHandler,
+ _extract_text_content,
+ _map_sampling_messages,
+)
+from vibe.core.types import LLMMessage, Role
+
+
+def _make_config(model_name: str = "test-model") -> MagicMock:
+ config = MagicMock()
+ model = MagicMock()
+ model.name = model_name
+ model.temperature = 0.7
+ config.get_active_model.return_value = model
+ return config
+
+
+def _make_params(
+ messages: list[SamplingMessage] | None = None,
+ system_prompt: str | None = None,
+ temperature: float | None = None,
+ max_tokens: int = 100,
+) -> CreateMessageRequestParams:
+ if messages is None:
+ messages = [
+ SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))
+ ]
+ return CreateMessageRequestParams(
+ messages=messages,
+ systemPrompt=system_prompt,
+ temperature=temperature,
+ maxTokens=max_tokens,
+ )
+
+
+class TestExtractTextContent:
+ def test_single_text_block(self) -> None:
+ block = TextContent(type="text", text="hello")
+ assert _extract_text_content(block) == "hello"
+
+ def test_list_of_text_blocks(self) -> None:
+ blocks = [
+ TextContent(type="text", text="a"),
+ TextContent(type="text", text="b"),
+ ]
+ assert _extract_text_content(blocks) == "a\nb"
+
+ def test_unsupported_single_block(self) -> None:
+ block = MagicMock(type="image", text=None)
+ assert _extract_text_content(block) == ""
+
+ def test_mixed_blocks_skips_non_text(self) -> None:
+ blocks = [TextContent(type="text", text="keep"), MagicMock(type="image")]
+ assert _extract_text_content(blocks) == "keep"
+
+
+class TestMapSamplingMessages:
+ def test_maps_user_message(self) -> None:
+ msgs = [
+ SamplingMessage(role="user", content=TextContent(type="text", text="hi"))
+ ]
+ result = _map_sampling_messages(msgs)
+ assert len(result) == 1
+ assert result[0].role == Role.user
+ assert result[0].content == "hi"
+
+ def test_maps_assistant_message(self) -> None:
+ msgs = [
+ SamplingMessage(
+ role="assistant", content=TextContent(type="text", text="hello")
+ )
+ ]
+ result = _map_sampling_messages(msgs)
+ assert result[0].role == Role.assistant
+ assert result[0].content == "hello"
+
+ def test_maps_multiple_messages(self) -> None:
+ msgs = [
+ SamplingMessage(role="user", content=TextContent(type="text", text="q")),
+ SamplingMessage(
+ role="assistant", content=TextContent(type="text", text="a")
+ ),
+ ]
+ result = _map_sampling_messages(msgs)
+ assert len(result) == 2
+ assert result[0].role == Role.user
+ assert result[1].role == Role.assistant
+
+
+class TestMCPSamplingHandler:
+ @pytest.mark.asyncio
+ async def test_basic_text_response(self) -> None:
+ chunk = mock_llm_chunk(content="LLM says hi")
+ backend = FakeBackend(chunk)
+ config = _make_config("my-model")
+ handler = MCPSamplingHandler(
+ backend_getter=lambda: backend, config_getter=lambda: config
+ )
+
+ result = await handler(MagicMock(), _make_params())
+
+ assert not isinstance(result, Exception)
+ assert isinstance(result, CreateMessageResult)
+ assert result.role == "assistant"
+ assert result.content.type == "text"
+ assert result.content.text == "LLM says hi"
+ assert result.model == "my-model"
+ assert result.stopReason == "endTurn"
+
+ @pytest.mark.asyncio
+ async def test_system_prompt_prepended(self) -> None:
+ chunk = mock_llm_chunk(content="ok")
+ backend = FakeBackend(chunk)
+ config = _make_config()
+ handler = MCPSamplingHandler(
+ backend_getter=lambda: backend, config_getter=lambda: config
+ )
+
+ await handler(MagicMock(), _make_params(system_prompt="Be helpful"))
+
+ sent_messages: list[LLMMessage] = backend.requests_messages[0]
+ assert sent_messages[0].role == Role.system
+ assert sent_messages[0].content == "Be helpful"
+ assert sent_messages[1].role == Role.user
+
+ @pytest.mark.asyncio
+ async def test_calls_backend_with_messages(self) -> None:
+ """Verify the handler forwards messages to the backend."""
+ chunk = mock_llm_chunk(content="ok")
+ backend = FakeBackend(chunk)
+ config = _make_config()
+ handler = MCPSamplingHandler(
+ backend_getter=lambda: backend, config_getter=lambda: config
+ )
+
+ await handler(MagicMock(), _make_params())
+
+ assert len(backend.requests_messages) == 1
+ sent = backend.requests_messages[0]
+ assert len(sent) == 1
+ assert sent[0].role == Role.user
+ assert sent[0].content == "Hello"
+
+ @pytest.mark.asyncio
+ async def test_returns_error_on_backend_failure(self) -> None:
+ backend = FakeBackend(exception_to_raise=RuntimeError("boom"))
+ config = _make_config()
+ handler = MCPSamplingHandler(
+ backend_getter=lambda: backend, config_getter=lambda: config
+ )
+
+ result = await handler(MagicMock(), _make_params())
+
+ assert isinstance(result, ErrorData)
+ assert result.code == -1
+ assert "boom" in result.message
diff --git a/tests/tools/test_task.py b/tests/tools/test_task.py
index 580f338..c042c13 100644
--- a/tests/tools/test_task.py
+++ b/tests/tools/test_task.py
@@ -8,7 +8,7 @@ from tests.conftest import build_test_vibe_config
from tests.mock.utils import collect_result
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import BUILTIN_AGENTS, AgentType
-from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
+from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError, ToolPermission
from vibe.core.tools.builtins.task import Task, TaskArgs, TaskResult, TaskToolConfig
from vibe.core.types import AssistantEvent, LLMMessage, Role
@@ -76,6 +76,50 @@ class TestTaskToolValidation:
assert agent.agent_type == AgentType.SUBAGENT
+class TestTaskToolResolvePermission:
+ def test_explore_allowed_by_default(self, task_tool: Task) -> None:
+ args = TaskArgs(task="do something", agent="explore")
+ result = task_tool.resolve_permission(args)
+ assert result == ToolPermission.ALWAYS
+
+ def test_unknown_agent_returns_none(self, task_tool: Task) -> None:
+ args = TaskArgs(task="do something", agent="custom_agent")
+ result = task_tool.resolve_permission(args)
+ assert result is None
+
+ def test_denylist_takes_precedence(self) -> None:
+ config = TaskToolConfig(allowlist=["explore"], denylist=["explore"])
+ tool = Task(config=config, state=BaseToolState())
+ args = TaskArgs(task="do something", agent="explore")
+ result = tool.resolve_permission(args)
+ assert result == ToolPermission.NEVER
+
+ def test_glob_pattern_in_allowlist(self) -> None:
+ config = TaskToolConfig(allowlist=["exp*"])
+ tool = Task(config=config, state=BaseToolState())
+ args = TaskArgs(task="do something", agent="explore")
+ result = tool.resolve_permission(args)
+ assert result == ToolPermission.ALWAYS
+
+ def test_glob_pattern_in_denylist(self) -> None:
+ config = TaskToolConfig(denylist=["danger*"])
+ tool = Task(config=config, state=BaseToolState())
+ args = TaskArgs(task="do something", agent="dangerous_agent")
+ result = tool.resolve_permission(args)
+ assert result == ToolPermission.NEVER
+
+ def test_empty_lists_returns_none(self) -> None:
+ config = TaskToolConfig(allowlist=[], denylist=[])
+ tool = Task(config=config, state=BaseToolState())
+ args = TaskArgs(task="do something", agent="explore")
+ result = tool.resolve_permission(args)
+ assert result is None
+
+ def test_default_config_has_explore_in_allowlist(self) -> None:
+ config = TaskToolConfig()
+ assert "explore" in config.allowlist
+
+
class TestTaskToolExecution:
@pytest.fixture
def ctx(self) -> InvokeContext:
diff --git a/tests/tools/test_webfetch.py b/tests/tools/test_webfetch.py
new file mode 100644
index 0000000..244b4bd
--- /dev/null
+++ b/tests/tools/test_webfetch.py
@@ -0,0 +1,253 @@
+from __future__ import annotations
+
+import httpx
+import pytest
+import respx
+
+from tests.mock.utils import collect_result
+from vibe.core.tools.base import BaseToolState, ToolError
+from vibe.core.tools.builtins.webfetch import WebFetch, WebFetchArgs, WebFetchConfig
+
+
+@pytest.fixture
+def webfetch():
+ config = WebFetchConfig()
+ return WebFetch(config=config, state=BaseToolState())
+
+
+@pytest.fixture
+def webfetch_small():
+ config = WebFetchConfig(max_content_bytes=100)
+ return WebFetch(config=config, state=BaseToolState())
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_bare_domain_gets_https(webfetch):
+ respx.get("https://example.com").mock(
+ return_value=httpx.Response(
+ 200, text="ok", headers={"Content-Type": "text/plain"}
+ )
+ )
+ result = await collect_result(webfetch.run(WebFetchArgs(url="example.com")))
+ assert result.url == "https://example.com"
+ assert result.content == "ok"
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_http_url_stays_http(webfetch):
+ respx.get("http://example.com").mock(
+ return_value=httpx.Response(
+ 200, text="ok", headers={"Content-Type": "text/plain"}
+ )
+ )
+ result = await collect_result(webfetch.run(WebFetchArgs(url="http://example.com")))
+ assert result.url == "http://example.com"
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_https_url_stays_https(webfetch):
+ respx.get("https://example.com").mock(
+ return_value=httpx.Response(
+ 200, text="ok", headers={"Content-Type": "text/plain"}
+ )
+ )
+ result = await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
+ assert result.url == "https://example.com"
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_protocol_relative_url_normalized(webfetch):
+ respx.get("https://example.com").mock(
+ return_value=httpx.Response(
+ 200, text="ok", headers={"Content-Type": "text/plain"}
+ )
+ )
+ result = await collect_result(webfetch.run(WebFetchArgs(url="//example.com")))
+ assert result.url == "https://example.com"
+ assert result.content == "ok"
+
+
+@pytest.mark.asyncio
+async def test_ftp_scheme_rejected(webfetch):
+ with pytest.raises(ToolError, match="Invalid URL scheme: ftp"):
+ await collect_result(webfetch.run(WebFetchArgs(url="ftp://example.com")))
+
+
+@pytest.mark.asyncio
+async def test_empty_url_rejected(webfetch):
+ with pytest.raises(ToolError, match="URL cannot be empty"):
+ await collect_result(webfetch.run(WebFetchArgs(url=" ")))
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_html_converted_to_markdown(webfetch):
+ html = "Title
Hello world
"
+ respx.get("https://example.com").mock(
+ return_value=httpx.Response(
+ 200, text=html, headers={"Content-Type": "text/html; charset=utf-8"}
+ )
+ )
+ result = await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
+ assert "# Title" in result.content
+ assert "Hello world" in result.content
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_plain_text_unchanged(webfetch):
+ respx.get("https://example.com/file.txt").mock(
+ return_value=httpx.Response(
+ 200, text="just text", headers={"Content-Type": "text/plain"}
+ )
+ )
+ result = await collect_result(
+ webfetch.run(WebFetchArgs(url="https://example.com/file.txt"))
+ )
+ assert result.content == "just text"
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_scripts_stripped_from_markdown(webfetch):
+ html = "Clean
"
+ respx.get("https://example.com").mock(
+ return_value=httpx.Response(
+ 200, text=html, headers={"Content-Type": "text/html"}
+ )
+ )
+ result = await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
+ assert "alert" not in result.content
+ assert ".x{}" not in result.content
+ assert "Clean" in result.content
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_cloudflare_retry_on_challenge(webfetch):
+ route = respx.get("https://example.com")
+ route.side_effect = [
+ httpx.Response(403, headers={"cf-mitigated": "challenge"}),
+ httpx.Response(200, text="success", headers={"Content-Type": "text/plain"}),
+ ]
+ result = await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
+ assert result.content == "success"
+ assert route.call_count == 2
+
+ second_request = route.calls[1].request
+ assert second_request.headers["User-Agent"] == "vibe-cli"
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_regular_403_not_retried(webfetch):
+ route = respx.get("https://example.com").mock(
+ return_value=httpx.Response(403, headers={"Content-Type": "text/plain"})
+ )
+ with pytest.raises(ToolError, match="HTTP error 403"):
+ await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
+ assert route.call_count == 1
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_truncates_to_max_bytes_with_disclaimer(webfetch_small):
+ body = "a" * 200
+ respx.get("https://example.com").mock(
+ return_value=httpx.Response(
+ 200, text=body, headers={"Content-Type": "text/plain"}
+ )
+ )
+ result = await collect_result(
+ webfetch_small.run(WebFetchArgs(url="https://example.com"))
+ )
+ assert result.content.startswith("a" * 100)
+ assert "[Content truncated due to size limit]" in result.content
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_truncates_html_with_disclaimer(webfetch_small):
+ html = (
+ "first title
"
+ + "x" * 200
+ + "second title
"
+ )
+ respx.get("https://example.com").mock(
+ return_value=httpx.Response(
+ 200, text=html, headers={"Content-Type": "text/html"}
+ )
+ )
+ result = await collect_result(
+ webfetch_small.run(WebFetchArgs(url="https://example.com"))
+ )
+
+ assert "## first title" in result.content
+ assert "## second title" not in result.content
+ assert "[Content truncated due to size limit]" in result.content
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_http_404_raises_tool_error(webfetch):
+ respx.get("https://example.com").mock(return_value=httpx.Response(404))
+ with pytest.raises(ToolError, match="HTTP error 404"):
+ await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_http_500_raises_tool_error(webfetch):
+ respx.get("https://example.com").mock(return_value=httpx.Response(500))
+ with pytest.raises(ToolError, match="HTTP error 500"):
+ await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_timeout_raises_tool_error(webfetch):
+ respx.get("https://example.com").mock(side_effect=httpx.ReadTimeout("timed out"))
+ with pytest.raises(ToolError, match="Request timed out"):
+ await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_network_error_raises_tool_error(webfetch):
+ respx.get("https://example.com").mock(
+ side_effect=httpx.ConnectError("connection refused")
+ )
+ with pytest.raises(ToolError, match="Failed to fetch URL"):
+ await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
+
+
+@pytest.mark.asyncio
+async def test_negative_timeout_rejected(webfetch):
+ with pytest.raises(ToolError, match="Timeout must be a positive number"):
+ await collect_result(
+ webfetch.run(WebFetchArgs(url="https://example.com", timeout=-1))
+ )
+
+
+@pytest.mark.asyncio
+async def test_zero_timeout_rejected(webfetch):
+ with pytest.raises(ToolError, match="Timeout must be a positive number"):
+ await collect_result(
+ webfetch.run(WebFetchArgs(url="https://example.com", timeout=0))
+ )
+
+
+@pytest.mark.asyncio
+async def test_over_max_timeout_rejected(webfetch):
+ with pytest.raises(ToolError, match="Timeout cannot exceed"):
+ await collect_result(
+ webfetch.run(WebFetchArgs(url="https://example.com", timeout=999))
+ )
+
+
+def test_get_status_text():
+ assert WebFetch.get_status_text() == "Fetching URL"
diff --git a/tests/tools/test_websearch.py b/tests/tools/test_websearch.py
new file mode 100644
index 0000000..66becce
--- /dev/null
+++ b/tests/tools/test_websearch.py
@@ -0,0 +1,165 @@
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import mistralai
+import pytest
+
+from tests.mock.utils import collect_result
+from vibe.core.tools.base import BaseToolState, ToolError
+from vibe.core.tools.builtins.websearch import WebSearch, WebSearchArgs, WebSearchConfig
+
+
+def _make_response(
+ content: list | None = None, outputs: list | None = None
+) -> mistralai.ConversationResponse:
+ if outputs is None:
+ outputs = [mistralai.MessageOutputEntry(content=content or [])]
+ return mistralai.ConversationResponse(
+ conversation_id="test",
+ outputs=outputs,
+ usage=mistralai.ConversationUsageInfo(
+ prompt_tokens=10, completion_tokens=20, total_tokens=30
+ ),
+ )
+
+
+@pytest.fixture
+def websearch(monkeypatch):
+ monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
+ config = WebSearchConfig()
+ return WebSearch(config=config, state=BaseToolState())
+
+
+def test_parse_text_chunks(websearch):
+ response = _make_response(
+ content=[mistralai.TextChunk(text="Hello "), mistralai.TextChunk(text="world")]
+ )
+ result = websearch._parse_response(response)
+ assert result.answer == "Hello world"
+ assert result.sources == []
+
+
+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(
+ tool="web_search", title="Site A duplicate", url="https://a.com"
+ ),
+ mistralai.ToolReferenceChunk(
+ tool="web_search", title="Site B", url="https://b.com"
+ ),
+ ]
+ )
+ result = websearch._parse_response(response)
+ assert result.answer == "Answer"
+ assert len(result.sources) == 2
+ assert result.sources[0].url == "https://a.com"
+ assert result.sources[0].title == "Site A"
+ assert result.sources[1].url == "https://b.com"
+
+
+def test_parse_skips_source_without_url(websearch):
+ response = _make_response(
+ content=[
+ mistralai.TextChunk(text="Answer"),
+ mistralai.ToolReferenceChunk(tool="web_search", title="No URL"),
+ ]
+ )
+ result = websearch._parse_response(response)
+ assert result.sources == []
+
+
+def test_parse_empty_text_raises(websearch):
+ response = _make_response(content=[])
+ with pytest.raises(ToolError, match="No text in agent response"):
+ websearch._parse_response(response)
+
+
+def test_parse_whitespace_only_raises(websearch):
+ response = _make_response(content=[mistralai.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")])
+ ]
+ )
+ result = websearch._parse_response(response)
+ assert result.answer == "Answer"
+
+
+@pytest.mark.asyncio
+async def test_run_missing_api_key(monkeypatch):
+ monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
+ config = WebSearchConfig()
+ ws = WebSearch(config=config, state=BaseToolState())
+ with pytest.raises(ToolError, match="MISTRAL_API_KEY"):
+ await collect_result(ws.run(WebSearchArgs(query="test")))
+
+
+@pytest.mark.asyncio
+async def test_run_returns_parsed_result(websearch):
+ response = _make_response(
+ content=[
+ mistralai.TextChunk(text="The answer"),
+ mistralai.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:
+ 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):
+ result = await collect_result(
+ websearch.run(WebSearchArgs(query="test query"))
+ )
+
+ assert result.answer == "The answer"
+ assert len(result.sources) == 1
+ assert result.sources[0].url == "https://example.com"
+
+
+@pytest.mark.asyncio
+async def test_run_sdk_error_wrapped(websearch):
+ from unittest.mock import Mock
+
+ import httpx
+
+ mock_response = Mock(spec=httpx.Response)
+ mock_response.status_code = 500
+ mock_response.text = "error"
+ mock_response.headers = httpx.Headers({"content-type": "application/json"})
+
+ with patch.object(mistralai.Mistral, "beta", create=True) as mock_beta:
+ mock_beta.conversations.start_async = AsyncMock(
+ side_effect=mistralai.SDKError("API failed", mock_response)
+ )
+ with patch.object(mistralai.Mistral, "__aenter__", return_value=None):
+ with patch.object(mistralai.Mistral, "__aexit__", return_value=None):
+ with pytest.raises(ToolError, match="Mistral API error"):
+ await collect_result(websearch.run(WebSearchArgs(query="test")))
+
+
+def test_is_available_with_key(monkeypatch):
+ monkeypatch.setenv("MISTRAL_API_KEY", "key")
+ assert WebSearch.is_available() is True
+
+
+def test_is_available_without_key(monkeypatch):
+ monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
+ assert WebSearch.is_available() is False
+
+
+def test_get_status_text():
+ assert WebSearch.get_status_text() == "Searching the web"
diff --git a/uv.lock b/uv.lock
index c27ffe9..89bbc45 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,21 +1,17 @@
version = 1
revision = 3
requires-python = ">=3.12"
-resolution-markers = [
- "python_full_version >= '3.13'",
- "python_full_version < '3.13'",
-]
[[package]]
name = "agent-client-protocol"
-version = "0.8.0"
+version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a8/a4/26698e0186933b4ab6e814626c99ee52b5522d039b5c94c983ecb3a66eed/agent_client_protocol-0.8.0.tar.gz", hash = "sha256:f9eade29167ff72a10fae7a0a0c1f27436909a790e159fb10265c2874e58d922", size = 68577, upload-time = "2026-02-07T17:08:46.513Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7b/7cdac86db388809d9e3bc58cac88cc7dfa49b7615b98fab304a828cd7f8a/agent_client_protocol-0.8.1.tar.gz", hash = "sha256:1bbf15663bf51f64942597f638e32a6284c5da918055d9672d3510e965143dbd", size = 68866, upload-time = "2026-02-13T15:34:54.567Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4b/04/e55a3c549c09c0023cb92c696c7b98d97bb657088f940e34f4bc47d1a49a/agent_client_protocol-0.8.0-py3-none-any.whl", hash = "sha256:2d5712b88b3249dbd6148b24d32c6eb8992e5663f224db6291524ac80cca8037", size = 54362, upload-time = "2026-02-07T17:08:45.575Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/f3/219eeca0ad4a20843d4b9eaac5532f87018b9d25730a62a16f54f6c52d1a/agent_client_protocol-0.8.1-py3-none-any.whl", hash = "sha256:9421a11fd435b4831660272d169c3812d553bb7247049c138c3ca127e4b8af8e", size = 54529, upload-time = "2026-02-13T15:34:53.344Z" },
]
[[package]]
@@ -38,15 +34,15 @@ wheels = [
[[package]]
name = "anyio"
-version = "4.12.0"
+version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" },
+ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
@@ -59,12 +55,34 @@ wheels = [
]
[[package]]
-name = "certifi"
-version = "2025.11.12"
+name = "beautifulsoup4"
+version = "4.14.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
+]
+
+[[package]]
+name = "cachetools"
+version = "7.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d4/07/56595285564e90777d758ebd383d6b0b971b87729bbe2184a849932a3736/cachetools-7.0.1.tar.gz", hash = "sha256:e31e579d2c5b6e2944177a0397150d312888ddf4e16e12f1016068f0c03b8341", size = 36126, upload-time = "2026-02-10T22:24:05.03Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484, upload-time = "2026-02-10T22:24:03.741Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
@@ -269,23 +287,23 @@ wheels = [
[[package]]
name = "debugpy"
-version = "1.8.19"
+version = "1.8.20"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/73/75/9e12d4d42349b817cd545b89247696c67917aab907012ae5b64bbfea3199/debugpy-1.8.19.tar.gz", hash = "sha256:eea7e5987445ab0b5ed258093722d5ecb8bb72217c5c9b1e21f64efe23ddebdb", size = 1644590, upload-time = "2025-12-15T21:53:28.044Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4a/15/d762e5263d9e25b763b78be72dc084c7a32113a0bac119e2f7acae7700ed/debugpy-1.8.19-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:bccb1540a49cde77edc7ce7d9d075c1dbeb2414751bc0048c7a11e1b597a4c2e", size = 2549995, upload-time = "2025-12-15T21:53:43.773Z" },
- { url = "https://files.pythonhosted.org/packages/a7/88/f7d25c68b18873b7c53d7c156ca7a7ffd8e77073aa0eac170a9b679cf786/debugpy-1.8.19-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:e9c68d9a382ec754dc05ed1d1b4ed5bd824b9f7c1a8cd1083adb84b3c93501de", size = 4309891, upload-time = "2025-12-15T21:53:45.26Z" },
- { url = "https://files.pythonhosted.org/packages/c5/4f/a65e973aba3865794da65f71971dca01ae66666132c7b2647182d5be0c5f/debugpy-1.8.19-cp312-cp312-win32.whl", hash = "sha256:6599cab8a783d1496ae9984c52cb13b7c4a3bd06a8e6c33446832a5d97ce0bee", size = 5286355, upload-time = "2025-12-15T21:53:46.763Z" },
- { url = "https://files.pythonhosted.org/packages/d8/3a/d3d8b48fec96e3d824e404bf428276fb8419dfa766f78f10b08da1cb2986/debugpy-1.8.19-cp312-cp312-win_amd64.whl", hash = "sha256:66e3d2fd8f2035a8f111eb127fa508469dfa40928a89b460b41fd988684dc83d", size = 5328239, upload-time = "2025-12-15T21:53:48.868Z" },
- { url = "https://files.pythonhosted.org/packages/71/3d/388035a31a59c26f1ecc8d86af607d0c42e20ef80074147cd07b180c4349/debugpy-1.8.19-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:91e35db2672a0abaf325f4868fcac9c1674a0d9ad9bb8a8c849c03a5ebba3e6d", size = 2538859, upload-time = "2025-12-15T21:53:50.478Z" },
- { url = "https://files.pythonhosted.org/packages/4a/19/c93a0772d0962294f083dbdb113af1a7427bb632d36e5314297068f55db7/debugpy-1.8.19-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:85016a73ab84dea1c1f1dcd88ec692993bcbe4532d1b49ecb5f3c688ae50c606", size = 4292575, upload-time = "2025-12-15T21:53:51.821Z" },
- { url = "https://files.pythonhosted.org/packages/5c/56/09e48ab796b0a77e3d7dc250f95251832b8bf6838c9632f6100c98bdf426/debugpy-1.8.19-cp313-cp313-win32.whl", hash = "sha256:b605f17e89ba0ecee994391194285fada89cee111cfcd29d6f2ee11cbdc40976", size = 5286209, upload-time = "2025-12-15T21:53:53.602Z" },
- { url = "https://files.pythonhosted.org/packages/fb/4e/931480b9552c7d0feebe40c73725dd7703dcc578ba9efc14fe0e6d31cfd1/debugpy-1.8.19-cp313-cp313-win_amd64.whl", hash = "sha256:c30639998a9f9cd9699b4b621942c0179a6527f083c72351f95c6ab1728d5b73", size = 5328206, upload-time = "2025-12-15T21:53:55.433Z" },
- { url = "https://files.pythonhosted.org/packages/f6/b9/cbec520c3a00508327476c7fce26fbafef98f412707e511eb9d19a2ef467/debugpy-1.8.19-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:1e8c4d1bd230067bf1bbcdbd6032e5a57068638eb28b9153d008ecde288152af", size = 2537372, upload-time = "2025-12-15T21:53:57.318Z" },
- { url = "https://files.pythonhosted.org/packages/88/5e/cf4e4dc712a141e10d58405c58c8268554aec3c35c09cdcda7535ff13f76/debugpy-1.8.19-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d40c016c1f538dbf1762936e3aeb43a89b965069d9f60f9e39d35d9d25e6b809", size = 4268729, upload-time = "2025-12-15T21:53:58.712Z" },
- { url = "https://files.pythonhosted.org/packages/82/a3/c91a087ab21f1047db328c1d3eb5d1ff0e52de9e74f9f6f6fa14cdd93d58/debugpy-1.8.19-cp314-cp314-win32.whl", hash = "sha256:0601708223fe1cd0e27c6cce67a899d92c7d68e73690211e6788a4b0e1903f5b", size = 5286388, upload-time = "2025-12-15T21:54:00.687Z" },
- { url = "https://files.pythonhosted.org/packages/17/b8/bfdc30b6e94f1eff09f2dc9cc1f9cd1c6cde3d996bcbd36ce2d9a4956e99/debugpy-1.8.19-cp314-cp314-win_amd64.whl", hash = "sha256:8e19a725f5d486f20e53a1dde2ab8bb2c9607c40c00a42ab646def962b41125f", size = 5327741, upload-time = "2025-12-15T21:54:02.148Z" },
- { url = "https://files.pythonhosted.org/packages/25/3e/e27078370414ef35fafad2c06d182110073daaeb5d3bf734b0b1eeefe452/debugpy-1.8.19-py2.py3-none-any.whl", hash = "sha256:360ffd231a780abbc414ba0f005dad409e71c78637efe8f2bd75837132a41d38", size = 5292321, upload-time = "2025-12-15T21:54:16.024Z" },
+ { url = "https://files.pythonhosted.org/packages/14/57/7f34f4736bfb6e00f2e4c96351b07805d83c9a7b33d28580ae01374430f7/debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d", size = 2550686, upload-time = "2026-01-29T23:03:42.023Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/78/b193a3975ca34458f6f0e24aaf5c3e3da72f5401f6054c0dfd004b41726f/debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b", size = 4310588, upload-time = "2026-01-29T23:03:43.314Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/55/f14deb95eaf4f30f07ef4b90a8590fc05d9e04df85ee379712f6fb6736d7/debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390", size = 5331372, upload-time = "2026-01-29T23:03:45.526Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/39/2bef246368bd42f9bd7cba99844542b74b84dacbdbea0833e610f384fee8/debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3", size = 5372835, upload-time = "2026-01-29T23:03:47.245Z" },
+ { url = "https://files.pythonhosted.org/packages/15/e2/fc500524cc6f104a9d049abc85a0a8b3f0d14c0a39b9c140511c61e5b40b/debugpy-1.8.20-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:5dff4bb27027821fdfcc9e8f87309a28988231165147c31730128b1c983e282a", size = 2539560, upload-time = "2026-01-29T23:03:48.738Z" },
+ { url = "https://files.pythonhosted.org/packages/90/83/fb33dcea789ed6018f8da20c5a9bc9d82adc65c0c990faed43f7c955da46/debugpy-1.8.20-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:84562982dd7cf5ebebfdea667ca20a064e096099997b175fe204e86817f64eaf", size = 4293272, upload-time = "2026-01-29T23:03:50.169Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/25/b1e4a01bfb824d79a6af24b99ef291e24189080c93576dfd9b1a2815cd0f/debugpy-1.8.20-cp313-cp313-win32.whl", hash = "sha256:da11dea6447b2cadbf8ce2bec59ecea87cc18d2c574980f643f2d2dfe4862393", size = 5331208, upload-time = "2026-01-29T23:03:51.547Z" },
+ { url = "https://files.pythonhosted.org/packages/13/f7/a0b368ce54ffff9e9028c098bd2d28cfc5b54f9f6c186929083d4c60ba58/debugpy-1.8.20-cp313-cp313-win_amd64.whl", hash = "sha256:eb506e45943cab2efb7c6eafdd65b842f3ae779f020c82221f55aca9de135ed7", size = 5372930, upload-time = "2026-01-29T23:03:53.585Z" },
+ { url = "https://files.pythonhosted.org/packages/33/2e/f6cb9a8a13f5058f0a20fe09711a7b726232cd5a78c6a7c05b2ec726cff9/debugpy-1.8.20-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9c74df62fc064cd5e5eaca1353a3ef5a5d50da5eb8058fcef63106f7bebe6173", size = 2538066, upload-time = "2026-01-29T23:03:54.999Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/56/6ddca50b53624e1ca3ce1d1e49ff22db46c47ea5fb4c0cc5c9b90a616364/debugpy-1.8.20-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:077a7447589ee9bc1ff0cdf443566d0ecf540ac8aa7333b775ebcb8ce9f4ecad", size = 4269425, upload-time = "2026-01-29T23:03:56.518Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/d9/d64199c14a0d4c476df46c82470a3ce45c8d183a6796cfb5e66533b3663c/debugpy-1.8.20-cp314-cp314-win32.whl", hash = "sha256:352036a99dd35053b37b7803f748efc456076f929c6a895556932eaf2d23b07f", size = 5331407, upload-time = "2026-01-29T23:03:58.481Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/d9/1f07395b54413432624d61524dfd98c1a7c7827d2abfdb8829ac92638205/debugpy-1.8.20-cp314-cp314-win_amd64.whl", hash = "sha256:a98eec61135465b062846112e5ecf2eebb855305acc1dfbae43b72903b8ab5be", size = 5372521, upload-time = "2026-01-29T23:03:59.864Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" },
]
[[package]]
@@ -299,11 +317,11 @@ wheels = [
[[package]]
name = "docutils"
-version = "0.22.3"
+version = "0.22.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d9/02/111134bfeb6e6c7ac4c74594e39a59f6c0195dc4846afbeac3cba60f1927/docutils-0.22.3.tar.gz", hash = "sha256:21486ae730e4ca9f622677b1412b879af1791efcfba517e4c6f60be543fc8cdd", size = 2290153, upload-time = "2025-11-06T02:35:55.655Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/11/a8/c6a4b901d17399c77cd81fb001ce8961e9f5e04d3daf27e8925cb012e163/docutils-0.22.3-py3-none-any.whl", hash = "sha256:bd772e4aca73aff037958d44f2be5229ded4c09927fcf8690c577b66234d6ceb", size = 633032, upload-time = "2025-11-06T02:35:52.391Z" },
+ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" },
]
[[package]]
@@ -326,11 +344,11 @@ wheels = [
[[package]]
name = "filelock"
-version = "3.20.0"
+version = "3.24.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" },
]
[[package]]
@@ -380,6 +398,18 @@ 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"
@@ -428,23 +458,23 @@ wheels = [
[[package]]
name = "id"
-version = "1.5.0"
+version = "1.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "requests" },
+ { name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237, upload-time = "2024-12-04T19:53:05.575Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088, upload-time = "2026-02-04T16:19:41.26Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611, upload-time = "2024-12-04T19:53:03.02Z" },
+ { url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689, upload-time = "2026-02-04T16:19:40.051Z" },
]
[[package]]
name = "identify"
-version = "2.6.15"
+version = "2.6.16"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" },
]
[[package]]
@@ -456,6 +486,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
+[[package]]
+name = "importlib-metadata"
+version = "8.7.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "zipp" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
+]
+
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -488,23 +530,23 @@ wheels = [
[[package]]
name = "jaraco-context"
-version = "6.0.1"
+version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" },
]
[[package]]
name = "jaraco-functools"
-version = "4.3.0"
+version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294", size = 19755, upload-time = "2025-08-18T20:05:09.91Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", size = 10408, upload-time = "2025-08-18T20:05:08.69Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" },
]
[[package]]
@@ -530,7 +572,7 @@ wheels = [
[[package]]
name = "jsonschema"
-version = "4.25.1"
+version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
@@ -538,9 +580,9 @@ dependencies = [
{ name = "referencing" },
{ name = "rpds-py" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" },
+ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
[[package]]
@@ -613,6 +655,19 @@ linkify = [
{ name = "linkify-it-py" },
]
+[[package]]
+name = "markdownify"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beautifulsoup4" },
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" },
+]
+
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -678,7 +733,7 @@ wheels = [
[[package]]
name = "mcp"
-version = "1.23.0"
+version = "1.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -696,9 +751,9 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/25/1a/9c8a5362e3448d585081d6c7aa95898a64e0ac59d3e26169ae6c3ca5feaf/mcp-1.23.0.tar.gz", hash = "sha256:84e0c29316d0a8cf0affd196fd000487ac512aa3f771b63b2ea864e22961772b", size = 596506, upload-time = "2025-12-02T13:40:02.558Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7b/b2/28739ce409f98159c0121eab56e69ad71546c4f34ac8b42e58c03f57dccc/mcp-1.23.0-py3-none-any.whl", hash = "sha256:5a645cf111ed329f4619f2629a3f15d9aabd7adc2ea09d600d31467b51ecb64f", size = 231427, upload-time = "2025-12-02T13:40:00.738Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
]
[[package]]
@@ -724,17 +779,19 @@ wheels = [
[[package]]
name = "mistral-vibe"
-version = "2.2.1"
+version = "2.3.0"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
{ name = "anyio" },
+ { name = "cachetools" },
{ name = "cryptography" },
{ name = "gitpython" },
{ name = "giturlparse" },
{ name = "google-auth" },
{ name = "httpx" },
{ name = "keyring" },
+ { name = "markdownify" },
{ name = "mcp" },
{ name = "mistralai" },
{ name = "packaging" },
@@ -777,16 +834,18 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "agent-client-protocol", specifier = "==0.8.0" },
+ { name = "agent-client-protocol", specifier = "==0.8.1" },
{ name = "anyio", specifier = ">=4.12.0" },
+ { name = "cachetools", specifier = ">=5.5.0" },
{ name = "cryptography", specifier = ">=44.0.0,<=46.0.3" },
{ name = "gitpython", specifier = ">=3.1.46" },
{ name = "giturlparse", specifier = ">=0.14.0" },
{ name = "google-auth", specifier = ">=2.0.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "keyring", specifier = ">=25.6.0" },
+ { name = "markdownify", specifier = ">=1.2.2" },
{ name = "mcp", specifier = ">=1.14.0" },
- { name = "mistralai", specifier = "==1.9.11" },
+ { name = "mistralai", specifier = "==1.12.4" },
{ name = "packaging", specifier = ">=24.1" },
{ name = "pexpect", specifier = ">=4.9.0" },
{ name = "pydantic", specifier = ">=2.12.4" },
@@ -825,20 +884,23 @@ dev = [
[[package]]
name = "mistralai"
-version = "1.9.11"
+version = "1.12.4"
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 = "pydantic" },
{ name = "python-dateutil" },
{ name = "pyyaml" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5a/8d/d8b7af67a966b6f227024e1cb7287fc19901a434f87a5a391dcfe635d338/mistralai-1.9.11.tar.gz", hash = "sha256:3df9e403c31a756ec79e78df25ee73cea3eb15f86693773e16b16adaf59c9b8a", size = 208051, upload-time = "2025-10-02T15:53:40.473Z" }
+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" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fe/76/4ce12563aea5a76016f8643eff30ab731e6656c845e9e4d090ef10c7b925/mistralai-1.9.11-py3-none-any.whl", hash = "sha256:7a3dc2b8ef3fceaa3582220234261b5c4e3e03a972563b07afa150e44a25a6d3", size = 442796, upload-time = "2025-10-02T15:53:39.134Z" },
+ { 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" },
]
[[package]]
@@ -852,53 +914,136 @@ wheels = [
[[package]]
name = "nh3"
-version = "0.3.2"
+version = "0.3.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ca/a5/34c26015d3a434409f4d2a1cd8821a06c05238703f49283ffeb937bef093/nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376", size = 19288, upload-time = "2025-10-30T11:17:45.948Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/37/ab55eb2b05e334ff9a1ad52c556ace1f9c20a3f63613a165d384d5387657/nh3-0.3.3.tar.gz", hash = "sha256:185ed41b88c910b9ca8edc89ca3b4be688a12cb9de129d84befa2f74a0039fee", size = 18968, upload-time = "2026-02-14T09:35:15.664Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5b/01/a1eda067c0ba823e5e2bb033864ae4854549e49fb6f3407d2da949106bfb/nh3-0.3.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d18957a90806d943d141cc5e4a0fefa1d77cf0d7a156878bf9a66eed52c9cc7d", size = 1419839, upload-time = "2025-10-30T11:17:09.956Z" },
- { url = "https://files.pythonhosted.org/packages/30/57/07826ff65d59e7e9cc789ef1dc405f660cabd7458a1864ab58aefa17411b/nh3-0.3.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45c953e57028c31d473d6b648552d9cab1efe20a42ad139d78e11d8f42a36130", size = 791183, upload-time = "2025-10-30T11:17:11.99Z" },
- { url = "https://files.pythonhosted.org/packages/af/2f/e8a86f861ad83f3bb5455f596d5c802e34fcdb8c53a489083a70fd301333/nh3-0.3.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c9850041b77a9147d6bbd6dbbf13eeec7009eb60b44e83f07fcb2910075bf9b", size = 829127, upload-time = "2025-10-30T11:17:13.192Z" },
- { url = "https://files.pythonhosted.org/packages/d8/97/77aef4daf0479754e8e90c7f8f48f3b7b8725a3b8c0df45f2258017a6895/nh3-0.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:403c11563e50b915d0efdb622866d1d9e4506bce590ef7da57789bf71dd148b5", size = 997131, upload-time = "2025-10-30T11:17:14.677Z" },
- { url = "https://files.pythonhosted.org/packages/41/ee/fd8140e4df9d52143e89951dd0d797f5546004c6043285289fbbe3112293/nh3-0.3.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0dca4365db62b2d71ff1620ee4f800c4729849906c5dd504ee1a7b2389558e31", size = 1068783, upload-time = "2025-10-30T11:17:15.861Z" },
- { url = "https://files.pythonhosted.org/packages/87/64/bdd9631779e2d588b08391f7555828f352e7f6427889daf2fa424bfc90c9/nh3-0.3.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0fe7ee035dd7b2290715baf29cb27167dddd2ff70ea7d052c958dbd80d323c99", size = 994732, upload-time = "2025-10-30T11:17:17.155Z" },
- { url = "https://files.pythonhosted.org/packages/79/66/90190033654f1f28ca98e3d76b8be1194505583f9426b0dcde782a3970a2/nh3-0.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a40202fd58e49129764f025bbaae77028e420f1d5b3c8e6f6fd3a6490d513868", size = 975997, upload-time = "2025-10-30T11:17:18.77Z" },
- { url = "https://files.pythonhosted.org/packages/34/30/ebf8e2e8d71fdb5a5d5d8836207177aed1682df819cbde7f42f16898946c/nh3-0.3.2-cp314-cp314t-win32.whl", hash = "sha256:1f9ba555a797dbdcd844b89523f29cdc90973d8bd2e836ea6b962cf567cadd93", size = 583364, upload-time = "2025-10-30T11:17:20.286Z" },
- { url = "https://files.pythonhosted.org/packages/94/ae/95c52b5a75da429f11ca8902c2128f64daafdc77758d370e4cc310ecda55/nh3-0.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:dce4248edc427c9b79261f3e6e2b3ecbdd9b88c267012168b4a7b3fc6fd41d13", size = 589982, upload-time = "2025-10-30T11:17:21.384Z" },
- { url = "https://files.pythonhosted.org/packages/b4/bd/c7d862a4381b95f2469704de32c0ad419def0f4a84b7a138a79532238114/nh3-0.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:019ecbd007536b67fdf76fab411b648fb64e2257ca3262ec80c3425c24028c80", size = 577126, upload-time = "2025-10-30T11:17:22.755Z" },
- { url = "https://files.pythonhosted.org/packages/b6/3e/f5a5cc2885c24be13e9b937441bd16a012ac34a657fe05e58927e8af8b7a/nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e", size = 1431980, upload-time = "2025-10-30T11:17:25.457Z" },
- { url = "https://files.pythonhosted.org/packages/7f/f7/529a99324d7ef055de88b690858f4189379708abae92ace799365a797b7f/nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8", size = 820805, upload-time = "2025-10-30T11:17:26.98Z" },
- { url = "https://files.pythonhosted.org/packages/3d/62/19b7c50ccd1fa7d0764822d2cea8f2a320f2fd77474c7a1805cb22cf69b0/nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866", size = 803527, upload-time = "2025-10-30T11:17:28.284Z" },
- { url = "https://files.pythonhosted.org/packages/4a/ca/f022273bab5440abff6302731a49410c5ef66b1a9502ba3fbb2df998d9ff/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131", size = 1051674, upload-time = "2025-10-30T11:17:29.909Z" },
- { url = "https://files.pythonhosted.org/packages/fa/f7/5728e3b32a11daf5bd21cf71d91c463f74305938bc3eb9e0ac1ce141646e/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5", size = 1004737, upload-time = "2025-10-30T11:17:31.205Z" },
- { url = "https://files.pythonhosted.org/packages/53/7f/f17e0dba0a99cee29e6cee6d4d52340ef9cb1f8a06946d3a01eb7ec2fb01/nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07", size = 911745, upload-time = "2025-10-30T11:17:32.945Z" },
- { url = "https://files.pythonhosted.org/packages/42/0f/c76bf3dba22c73c38e9b1113b017cf163f7696f50e003404ec5ecdb1e8a6/nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7", size = 797184, upload-time = "2025-10-30T11:17:34.226Z" },
- { url = "https://files.pythonhosted.org/packages/08/a1/73d8250f888fb0ddf1b119b139c382f8903d8bb0c5bd1f64afc7e38dad1d/nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87", size = 838556, upload-time = "2025-10-30T11:17:35.875Z" },
- { url = "https://files.pythonhosted.org/packages/d1/09/deb57f1fb656a7a5192497f4a287b0ade5a2ff6b5d5de4736d13ef6d2c1f/nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a", size = 1006695, upload-time = "2025-10-30T11:17:37.071Z" },
- { url = "https://files.pythonhosted.org/packages/b6/61/8f4d41c4ccdac30e4b1a4fa7be4b0f9914d8314a5058472f84c8e101a418/nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", size = 1075471, upload-time = "2025-10-30T11:17:38.225Z" },
- { url = "https://files.pythonhosted.org/packages/b0/c6/966aec0cb4705e69f6c3580422c239205d5d4d0e50fac380b21e87b6cf1b/nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", size = 1002439, upload-time = "2025-10-30T11:17:39.553Z" },
- { url = "https://files.pythonhosted.org/packages/e2/c8/97a2d5f7a314cce2c5c49f30c6f161b7f3617960ade4bfc2fd1ee092cb20/nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", size = 987439, upload-time = "2025-10-30T11:17:40.81Z" },
- { url = "https://files.pythonhosted.org/packages/0d/95/2d6fc6461687d7a171f087995247dec33e8749a562bfadd85fb5dbf37a11/nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", size = 589826, upload-time = "2025-10-30T11:17:42.239Z" },
- { url = "https://files.pythonhosted.org/packages/64/9a/1a1c154f10a575d20dd634e5697805e589bbdb7673a0ad00e8da90044ba7/nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", size = 596406, upload-time = "2025-10-30T11:17:43.773Z" },
- { url = "https://files.pythonhosted.org/packages/9e/7e/a96255f63b7aef032cbee8fc4d6e37def72e3aaedc1f72759235e8f13cb1/nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", size = 584162, upload-time = "2025-10-30T11:17:44.96Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/a4/834f0ebd80844ce67e1bdb011d6f844f61cdb4c1d7cdc56a982bc054cc00/nh3-0.3.3-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:21b058cd20d9f0919421a820a2843fdb5e1749c0bf57a6247ab8f4ba6723c9fc", size = 1428680, upload-time = "2026-02-14T09:34:33.015Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/1a/a7d72e750f74c6b71befbeebc4489579fe783466889d41f32e34acde0b6b/nh3-0.3.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4400a73c2a62859e769f9d36d1b5a7a5c65c4179d1dddd2f6f3095b2db0cbfc", size = 799003, upload-time = "2026-02-14T09:34:35.108Z" },
+ { url = "https://files.pythonhosted.org/packages/58/d5/089eb6d65da139dc2223b83b2627e00872eccb5e1afdf5b1d76eb6ad3fcc/nh3-0.3.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ef87f8e916321a88b45f2d597f29bd56e560ed4568a50f0f1305afab86b7189", size = 846818, upload-time = "2026-02-14T09:34:37Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/c6/44a0b65fc7b213a3a725f041ef986534b100e58cd1a2e00f0fd3c9603893/nh3-0.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a446eae598987f49ee97ac2f18eafcce4e62e7574bd1eb23782e4702e54e217d", size = 1012537, upload-time = "2026-02-14T09:34:38.515Z" },
+ { url = "https://files.pythonhosted.org/packages/94/3a/91bcfcc0a61b286b8b25d39e288b9c0ba91c3290d402867d1cd705169844/nh3-0.3.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0d5eb734a78ac364af1797fef718340a373f626a9ff6b4fb0b4badf7927e7b81", size = 1095435, upload-time = "2026-02-14T09:34:40.022Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/fd/4617a19d80cf9f958e65724ff5e97bc2f76f2f4c5194c740016606c87bd1/nh3-0.3.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:92a958e6f6d0100e025a5686aafd67e3c98eac67495728f8bb64fbeb3e474493", size = 1056344, upload-time = "2026-02-14T09:34:41.469Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/7d/5bcbbc56e71b7dda7ef1d6008098da9c5426d6334137ef32bb2b9c496984/nh3-0.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9ed40cf8449a59a03aa465114fedce1ff7ac52561688811d047917cc878b19ca", size = 1034533, upload-time = "2026-02-14T09:34:43.313Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/9c/054eff8a59a8b23b37f0f4ac84cdd688ee84cf5251664c0e14e5d30a8a67/nh3-0.3.3-cp314-cp314t-win32.whl", hash = "sha256:b50c3770299fb2a7c1113751501e8878d525d15160a4c05194d7fe62b758aad8", size = 608305, upload-time = "2026-02-14T09:34:44.622Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/b0/64667b8d522c7b859717a02b1a66ba03b529ca1df623964e598af8db1ed5/nh3-0.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:21a63ccb18ddad3f784bb775955839b8b80e347e597726f01e43ca1abcc5c808", size = 620633, upload-time = "2026-02-14T09:34:46.069Z" },
+ { url = "https://files.pythonhosted.org/packages/91/b5/ae9909e4ddfd86ee076c4d6d62ba69e9b31061da9d2f722936c52df8d556/nh3-0.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f508ddd4e2433fdcb78c790fc2d24e3a349ba775e5fa904af89891321d4844a3", size = 607027, upload-time = "2026-02-14T09:34:47.91Z" },
+ { url = "https://files.pythonhosted.org/packages/13/3e/aef8cf8e0419b530c95e96ae93a5078e9b36c1e6613eeb1df03a80d5194e/nh3-0.3.3-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e8ee96156f7dfc6e30ecda650e480c5ae0a7d38f0c6fafc3c1c655e2500421d9", size = 1448640, upload-time = "2026-02-14T09:34:49.316Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/43/d2011a4f6c0272cb122eeff40062ee06bb2b6e57eabc3a5e057df0d582df/nh3-0.3.3-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45fe0d6a607264910daec30360c8a3b5b1500fd832d21b2da608256287bcb92d", size = 839405, upload-time = "2026-02-14T09:34:50.779Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/f3/965048510c1caf2a34ed04411a46a04a06eb05563cd06f1aa57b71eb2bc8/nh3-0.3.3-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bc1d4b30ba1ba896669d944b6003630592665974bd11a3dc2f661bde92798a7", size = 825849, upload-time = "2026-02-14T09:34:52.622Z" },
+ { url = "https://files.pythonhosted.org/packages/78/99/b4bbc6ad16329d8db2c2c320423f00b549ca3b129c2b2f9136be2606dbb0/nh3-0.3.3-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f433a2dd66545aad4a720ad1b2150edcdca75bfff6f4e6f378ade1ec138d5e77", size = 1068303, upload-time = "2026-02-14T09:34:54.179Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/34/3420d97065aab1b35f3e93ce9c96c8ebd423ce86fe84dee3126790421a2a/nh3-0.3.3-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52e973cb742e95b9ae1b35822ce23992428750f4b46b619fe86eba4205255b30", size = 1029316, upload-time = "2026-02-14T09:34:56.186Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/9a/99eda757b14e596fdb2ca5f599a849d9554181aa899274d0d183faef4493/nh3-0.3.3-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c730617bdc15d7092dcc0469dc2826b914c8f874996d105b4bc3842a41c1cd9", size = 919944, upload-time = "2026-02-14T09:34:57.886Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/84/c0dc75c7fb596135f999e59a410d9f45bdabb989f1cb911f0016d22b747b/nh3-0.3.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e98fa3dbfd54e25487e36ba500bc29bca3a4cab4ffba18cfb1a35a2d02624297", size = 811461, upload-time = "2026-02-14T09:34:59.65Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/ec/b1bf57cab6230eec910e4863528dc51dcf21b57aaf7c88ee9190d62c9185/nh3-0.3.3-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:3a62b8ae7c235481715055222e54c682422d0495a5c73326807d4e44c5d14691", size = 840360, upload-time = "2026-02-14T09:35:01.444Z" },
+ { url = "https://files.pythonhosted.org/packages/37/5e/326ae34e904dde09af1de51219a611ae914111f0970f2f111f4f0188f57e/nh3-0.3.3-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc305a2264868ec8fa16548296f803d8fd9c1fa66cd28b88b605b1bd06667c0b", size = 859872, upload-time = "2026-02-14T09:35:03.348Z" },
+ { url = "https://files.pythonhosted.org/packages/09/38/7eba529ce17ab4d3790205da37deabb4cb6edcba15f27b8562e467f2fc97/nh3-0.3.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:90126a834c18af03bfd6ff9a027bfa6bbf0e238527bc780a24de6bd7cc1041e2", size = 1023550, upload-time = "2026-02-14T09:35:04.829Z" },
+ { url = "https://files.pythonhosted.org/packages/05/a2/556fdecd37c3681b1edee2cf795a6799c6ed0a5551b2822636960d7e7651/nh3-0.3.3-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:24769a428e9e971e4ccfb24628f83aaa7dc3c8b41b130c8ddc1835fa1c924489", size = 1105212, upload-time = "2026-02-14T09:35:06.821Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/e3/5db0b0ad663234967d83702277094687baf7c498831a2d3ad3451c11770f/nh3-0.3.3-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:b7a18ee057761e455d58b9d31445c3e4b2594cff4ddb84d2e331c011ef46f462", size = 1069970, upload-time = "2026-02-14T09:35:08.504Z" },
+ { url = "https://files.pythonhosted.org/packages/79/b2/2ea21b79c6e869581ce5f51549b6e185c4762233591455bf2a326fb07f3b/nh3-0.3.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a4b2c1f3e6f3cbe7048e17f4fefad3f8d3e14cc0fd08fb8599e0d5653f6b181", size = 1047588, upload-time = "2026-02-14T09:35:09.911Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/92/2e434619e658c806d9c096eed2cdff9a883084299b7b19a3f0824eb8e63d/nh3-0.3.3-cp38-abi3-win32.whl", hash = "sha256:e974850b131fdffa75e7ad8e0d9c7a855b96227b093417fdf1bd61656e530f37", size = 616179, upload-time = "2026-02-14T09:35:11.366Z" },
+ { url = "https://files.pythonhosted.org/packages/73/88/1ce287ef8649dc51365b5094bd3713b76454838140a32ab4f8349973883c/nh3-0.3.3-cp38-abi3-win_amd64.whl", hash = "sha256:2efd17c0355d04d39e6d79122b42662277ac10a17ea48831d90b46e5ef7e4fc0", size = 631159, upload-time = "2026-02-14T09:35:12.77Z" },
+ { url = "https://files.pythonhosted.org/packages/31/f1/b4835dbde4fb06f29db89db027576d6014081cd278d9b6751facc3e69e43/nh3-0.3.3-cp38-abi3-win_arm64.whl", hash = "sha256:b838e619f483531483d26d889438e53a880510e832d2aafe73f93b7b1ac2bce2", size = 616645, upload-time = "2026-02-14T09:35:14.062Z" },
]
[[package]]
name = "nodeenv"
-version = "1.9.1"
+version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
+ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
+]
+
+[[package]]
+name = "opentelemetry-api"
+version = "1.39.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "importlib-metadata" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" }
+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"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "opentelemetry-api" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" },
]
[[package]]
name = "packaging"
-version = "25.0"
+version = "26.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
@@ -924,11 +1069,11 @@ wheels = [
[[package]]
name = "platformdirs"
-version = "4.5.0"
+version = "4.9.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
+ { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
]
[[package]]
@@ -942,7 +1087,7 @@ wheels = [
[[package]]
name = "pre-commit"
-version = "4.5.0"
+version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
@@ -951,9 +1096,24 @@ dependencies = [
{ name = "pyyaml" },
{ name = "virtualenv" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f4/9b/6a4ffb4ed980519da959e1cf3122fc6cb41211daa58dbae1c73c0e519a37/pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b", size = 198428, upload-time = "2025-11-22T21:02:42.304Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429, upload-time = "2025-11-22T21:02:40.836Z" },
+ { 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]]
@@ -988,11 +1148,11 @@ wheels = [
[[package]]
name = "pycparser"
-version = "2.23"
+version = "3.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
@@ -1083,16 +1243,16 @@ wheels = [
[[package]]
name = "pydantic-settings"
-version = "2.12.0"
+version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
+ { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
@@ -1106,7 +1266,7 @@ wheels = [
[[package]]
name = "pyinstaller"
-version = "6.17.0"
+version = "6.19.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
@@ -1117,41 +1277,41 @@ dependencies = [
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "setuptools" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/01/80/9e0dad9c69a7cfd4b5aaede8c6225d762bab7247a2a6b7651e1995522001/pyinstaller-6.17.0.tar.gz", hash = "sha256:be372bd911392b88277e510940ac32a5c2a6ce4b8d00a311c78fa443f4f27313", size = 4014147, upload-time = "2025-11-24T19:43:32.109Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c8/63/fd62472b6371d89dc138d40c36d87a50dc2de18a035803bbdc376b4ffac4/pyinstaller-6.19.0.tar.gz", hash = "sha256:ec73aeb8bd9b7f2f1240d328a4542e90b3c6e6fbc106014778431c616592a865", size = 4036072, upload-time = "2026-02-14T18:06:28.718Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/35/f5/37e419d84d5284ecab11ef8b61306a3b978fe6f0fd69a9541e16bfd72e65/pyinstaller-6.17.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4e446b8030c6e5a2f712e3f82011ecf6c7ead86008357b0d23a0ec4bcde31dac", size = 1031880, upload-time = "2025-11-24T19:42:30.862Z" },
- { url = "https://files.pythonhosted.org/packages/9e/b6/2e184879ab9cf90a1d2867fdd34d507c4d246b3cc52ca05aad00bfc70ee7/pyinstaller-6.17.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:aa9fd87aaa28239c6f0d0210114029bd03f8cac316a90bab071a5092d7c85ad7", size = 731968, upload-time = "2025-11-24T19:42:35.421Z" },
- { url = "https://files.pythonhosted.org/packages/40/76/f529de98f7e5cce7904c19b224990003fc2267eda2ee5fdd8452acb420a9/pyinstaller-6.17.0-py3-none-manylinux2014_i686.whl", hash = "sha256:060b122e43e7c0b23e759a4153be34bd70914135ab955bb18a67181e0dca85a2", size = 743217, upload-time = "2025-11-24T19:42:39.286Z" },
- { url = "https://files.pythonhosted.org/packages/a3/10/c02bfbb050cafc4c353cf69baf95407e211e1372bd286ab5ce5cbc13a30a/pyinstaller-6.17.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:cd213d1a545c97dfe4a3c40e8213ff7c5127fc115c49229f27a3fa541503444b", size = 741119, upload-time = "2025-11-24T19:42:43.12Z" },
- { url = "https://files.pythonhosted.org/packages/11/9d/69fdacfd9335695f5900a376cfe3e4aed28f0720ffc15fee81fdb9d920bc/pyinstaller-6.17.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:89c0d18ba8b62c6607abd8cf2299ae5ffa5c36d8c47f39608ce8c3f357f6099f", size = 738111, upload-time = "2025-11-24T19:42:46.97Z" },
- { url = "https://files.pythonhosted.org/packages/5e/1e/e8e36e1568f6865ac706c6e1f875c1a346ddaa9f9a8f923d66545d2240ed/pyinstaller-6.17.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:2a147b83cdebb07855bd5a663600891550062373a2ca375c58eacead33741a27", size = 737795, upload-time = "2025-11-24T19:42:50.675Z" },
- { url = "https://files.pythonhosted.org/packages/8d/15/9dc0f81ccb746c27bfa6ee53164422fe47ee079c7a717d9c4791aba78797/pyinstaller-6.17.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:f8cfbbfa6708e54fb936df6dd6eafaf133e84efb0d2fe25b91cfeefa793c4ca4", size = 736891, upload-time = "2025-11-24T19:42:54.458Z" },
- { url = "https://files.pythonhosted.org/packages/97/e6/bed54821c1ebe1275c559661d3e7bfa23c406673b515252dfbf89db56c65/pyinstaller-6.17.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:97f4c1942f7b4cd73f9e38b49cc8f5f8a6fbb44922cb60dd3073a189b77ee1ae", size = 736752, upload-time = "2025-11-24T19:42:58.144Z" },
- { url = "https://files.pythonhosted.org/packages/c7/84/897d759198676b910d69d42640b6d25d50b449f2209e18127a974cf59dbe/pyinstaller-6.17.0-py3-none-win32.whl", hash = "sha256:ce0be227a037fd4be672226db709088565484f597d6b230bceec19850fdd4c85", size = 1317851, upload-time = "2025-11-24T19:43:04.361Z" },
- { url = "https://files.pythonhosted.org/packages/2d/f5/6a122efe024433ecc34aab6f499e0bd2bbe059c639b77b0045aa2421b0bf/pyinstaller-6.17.0-py3-none-win_amd64.whl", hash = "sha256:b019940dbf7a01489d6b26f9fb97db74b504e0a757010f7ad078675befc85a82", size = 1378685, upload-time = "2025-11-24T19:43:10.395Z" },
- { url = "https://files.pythonhosted.org/packages/c4/96/14991773c9e599707a53594429ccf372f9ee638df3b7d26b65fd1a7433f0/pyinstaller-6.17.0-py3-none-win_arm64.whl", hash = "sha256:3c92a335e338170df7e615f75279cfeea97ade89e6dd7694943c8c185460f7b7", size = 1320032, upload-time = "2025-11-24T19:43:16.388Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/eb/23374721fecfa72677e79800921cb6aceefa6ba48574dc404f3f6c6c3be7/pyinstaller-6.19.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4190e76b74f0c4b5c5f11ac360928cd2e36ec8e3194d437bf6b8648c7bc0c134", size = 1040563, upload-time = "2026-02-14T18:05:22.436Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/7e/dfd724b0b533f5aaec0ee5df406fe2319987ed6964480a706f85478b12ea/pyinstaller-6.19.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8bd68abd812d8a6ba33b9f1810e91fee0f325969733721b78151f0065319ca11", size = 735477, upload-time = "2026-02-14T18:05:27.143Z" },
+ { url = "https://files.pythonhosted.org/packages/88/c9/ee3a4101c31f26344e66896c73c1fd6ed8282bf871473365b7f8674af406/pyinstaller-6.19.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1ec54ef967996ca61dacba676227e2b23219878ccce5ee9d6f3aada7b8ed8abf", size = 747143, upload-time = "2026-02-14T18:05:31.488Z" },
+ { url = "https://files.pythonhosted.org/packages/da/0a/fc77e9f861be8cf300ac37155f59cc92aff99b29f2ddd78546f563a5b5a6/pyinstaller-6.19.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4ab2bb52e58448e14ddf9450601bdedd66800465043501c1d8f1cab87b60b122", size = 744849, upload-time = "2026-02-14T18:05:35.492Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/e3/6872e020ee758afe0b821663858492c10745608b07150e5e2c824a5b3e1c/pyinstaller-6.19.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:da6d5c6391ccefe73554b9fa29b86001c8e378e0f20c2a4004f836ba537eff63", size = 741590, upload-time = "2026-02-14T18:05:39.59Z" },
+ { url = "https://files.pythonhosted.org/packages/53/60/b8db5f1a4b0fb228175f2ea0aa33f949adcc097fbe981cc524f9faf85777/pyinstaller-6.19.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a0fc5f6b3c55aa54353f0c74ffa59b1115433c1850c6f655d62b461a2ed6cbbe", size = 741448, upload-time = "2026-02-14T18:05:45.636Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/4d/63b0600f2694e9141b83129fbc1c488ec84d5a0770b1448ec154dcd0fee9/pyinstaller-6.19.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:e649ba6bd1b0b89b210ad92adb5fbdc8a42dd2c5ca4f72ef3a0bfec83a424b83", size = 740613, upload-time = "2026-02-14T18:05:49.726Z" },
+ { url = "https://files.pythonhosted.org/packages/01/d4/e812ad36178093a0e9fd4b8127577748dd85b0cb71de912229dca21fd741/pyinstaller-6.19.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:481a909c8e60c8692fc60fcb1344d984b44b943f8bc9682f2fcdae305ad297e6", size = 740350, upload-time = "2026-02-14T18:05:54.093Z" },
+ { url = "https://files.pythonhosted.org/packages/52/03/b2c2ee41fb8e10fd2a45d21f5ec2ef25852cfb978dbf762972eed59e3d63/pyinstaller-6.19.0-py3-none-win32.whl", hash = "sha256:3c5c251054fe4cfaa04c34a363dcfbf811545438cb7198304cd444756bc2edd2", size = 1324317, upload-time = "2026-02-14T18:06:00.085Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d3/6d5e62b8270e2b53a6065e281b3a7785079b00e9019c8019952828dd1669/pyinstaller-6.19.0-py3-none-win_amd64.whl", hash = "sha256:b5bb6536c6560330d364d91522250f254b107cf69129d9cbcd0e6727c570be33", size = 1384894, upload-time = "2026-02-14T18:06:06.425Z" },
+ { url = "https://files.pythonhosted.org/packages/81/65/458cd523308a101a22fd2742893405030cc24994cc74b1b767cecf137160/pyinstaller-6.19.0-py3-none-win_arm64.whl", hash = "sha256:c2d5a539b0bfe6159d5522c8c70e1c0e487f22c2badae0f97d45246223b798ea", size = 1325374, upload-time = "2026-02-14T18:06:12.804Z" },
]
[[package]]
name = "pyinstaller-hooks-contrib"
-version = "2025.10"
+version = "2026.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "setuptools" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/26/4f/e33132acdb8f732978e577b8a0130a412cbfe7a3414605e3fd380a975522/pyinstaller_hooks_contrib-2025.10.tar.gz", hash = "sha256:a1a737e5c0dccf1cf6f19a25e2efd109b9fec9ddd625f97f553dac16ee884881", size = 168155, upload-time = "2025-11-22T09:34:36.138Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/95/eb/e1dd9a5348e4cf348471c0e5fd617d948779bc3199cf4edb134d8fceca91/pyinstaller_hooks_contrib-2026.1.tar.gz", hash = "sha256:a5f0891a1e81e92406ab917d9e76adfd7a2b68415ee2e35c950a7b3910bc361b", size = 171504, upload-time = "2026-02-18T13:01:15.711Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/86/de/a7688eed49a1d3df337cdaa4c0d64e231309a52f269850a72051975e3c4a/pyinstaller_hooks_contrib-2025.10-py3-none-any.whl", hash = "sha256:aa7a378518772846221f63a84d6306d9827299323243db890851474dfd1231a9", size = 447760, upload-time = "2025-11-22T09:34:34.753Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/69/12bafee3cc485d977f596e0d803d7c6fb147430fc35dfe505730aa3a28dd/pyinstaller_hooks_contrib-2026.1-py3-none-any.whl", hash = "sha256:66ad4888ba67de6f3cfd7ef554f9dd1a4389e2eb19f84d7129a5a6818e3f2180", size = 452841, upload-time = "2026-02-18T13:01:14.471Z" },
]
[[package]]
name = "pyjwt"
-version = "2.10.1"
+version = "2.11.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
]
[package.optional-dependencies]
@@ -1170,15 +1330,15 @@ wheels = [
[[package]]
name = "pyright"
-version = "1.1.407"
+version = "1.1.408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
]
[[package]]
@@ -1274,11 +1434,11 @@ wheels = [
[[package]]
name = "python-multipart"
-version = "0.0.20"
+version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
]
[[package]]
@@ -1430,15 +1590,15 @@ wheels = [
[[package]]
name = "rich"
-version = "14.2.0"
+version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
+ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
@@ -1536,28 +1696,27 @@ wheels = [
[[package]]
name = "ruff"
-version = "0.14.7"
+version = "0.15.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b7/5b/dd7406afa6c95e3d8fa9d652b6d6dd17dd4a6bf63cb477014e8ccd3dcd46/ruff-0.14.7.tar.gz", hash = "sha256:3417deb75d23bd14a722b57b0a1435561db65f0ad97435b4cf9f85ffcef34ae5", size = 5727324, upload-time = "2025-11-28T20:55:10.525Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8c/b1/7ea5647aaf90106f6d102230e5df874613da43d1089864da1553b899ba5e/ruff-0.14.7-py3-none-linux_armv6l.whl", hash = "sha256:b9d5cb5a176c7236892ad7224bc1e63902e4842c460a0b5210701b13e3de4fca", size = 13414475, upload-time = "2025-11-28T20:54:54.569Z" },
- { url = "https://files.pythonhosted.org/packages/af/19/fddb4cd532299db9cdaf0efdc20f5c573ce9952a11cb532d3b859d6d9871/ruff-0.14.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3f64fe375aefaf36ca7d7250292141e39b4cea8250427482ae779a2aa5d90015", size = 13634613, upload-time = "2025-11-28T20:55:17.54Z" },
- { url = "https://files.pythonhosted.org/packages/40/2b/469a66e821d4f3de0440676ed3e04b8e2a1dc7575cf6fa3ba6d55e3c8557/ruff-0.14.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93e83bd3a9e1a3bda64cb771c0d47cda0e0d148165013ae2d3554d718632d554", size = 12765458, upload-time = "2025-11-28T20:55:26.128Z" },
- { url = "https://files.pythonhosted.org/packages/f1/05/0b001f734fe550bcfde4ce845948ac620ff908ab7241a39a1b39bb3c5f49/ruff-0.14.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3838948e3facc59a6070795de2ae16e5786861850f78d5914a03f12659e88f94", size = 13236412, upload-time = "2025-11-28T20:55:28.602Z" },
- { url = "https://files.pythonhosted.org/packages/11/36/8ed15d243f011b4e5da75cd56d6131c6766f55334d14ba31cce5461f28aa/ruff-0.14.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24c8487194d38b6d71cd0fd17a5b6715cda29f59baca1defe1e3a03240f851d1", size = 13182949, upload-time = "2025-11-28T20:55:33.265Z" },
- { url = "https://files.pythonhosted.org/packages/3b/cf/fcb0b5a195455729834f2a6eadfe2e4519d8ca08c74f6d2b564a4f18f553/ruff-0.14.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79c73db6833f058a4be8ffe4a0913b6d4ad41f6324745179bd2aa09275b01d0b", size = 13816470, upload-time = "2025-11-28T20:55:08.203Z" },
- { url = "https://files.pythonhosted.org/packages/7f/5d/34a4748577ff7a5ed2f2471456740f02e86d1568a18c9faccfc73bd9ca3f/ruff-0.14.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:12eb7014fccff10fc62d15c79d8a6be4d0c2d60fe3f8e4d169a0d2def75f5dad", size = 15289621, upload-time = "2025-11-28T20:55:30.837Z" },
- { url = "https://files.pythonhosted.org/packages/53/53/0a9385f047a858ba133d96f3f8e3c9c66a31cc7c4b445368ef88ebeac209/ruff-0.14.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c623bbdc902de7ff715a93fa3bb377a4e42dd696937bf95669118773dbf0c50", size = 14975817, upload-time = "2025-11-28T20:55:24.107Z" },
- { url = "https://files.pythonhosted.org/packages/a8/d7/2f1c32af54c3b46e7fadbf8006d8b9bcfbea535c316b0bd8813d6fb25e5d/ruff-0.14.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f53accc02ed2d200fa621593cdb3c1ae06aa9b2c3cae70bc96f72f0000ae97a9", size = 14284549, upload-time = "2025-11-28T20:55:06.08Z" },
- { url = "https://files.pythonhosted.org/packages/92/05/434ddd86becd64629c25fb6b4ce7637dd52a45cc4a4415a3008fe61c27b9/ruff-0.14.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:281f0e61a23fcdcffca210591f0f53aafaa15f9025b5b3f9706879aaa8683bc4", size = 14071389, upload-time = "2025-11-28T20:55:35.617Z" },
- { url = "https://files.pythonhosted.org/packages/ff/50/fdf89d4d80f7f9d4f420d26089a79b3bb1538fe44586b148451bc2ba8d9c/ruff-0.14.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dbbaa5e14148965b91cb090236931182ee522a5fac9bc5575bafc5c07b9f9682", size = 14202679, upload-time = "2025-11-28T20:55:01.472Z" },
- { url = "https://files.pythonhosted.org/packages/77/54/87b34988984555425ce967f08a36df0ebd339bb5d9d0e92a47e41151eafc/ruff-0.14.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1464b6e54880c0fe2f2d6eaefb6db15373331414eddf89d6b903767ae2458143", size = 13147677, upload-time = "2025-11-28T20:55:19.933Z" },
- { url = "https://files.pythonhosted.org/packages/67/29/f55e4d44edfe053918a16a3299e758e1c18eef216b7a7092550d7a9ec51c/ruff-0.14.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f217ed871e4621ea6128460df57b19ce0580606c23aeab50f5de425d05226784", size = 13151392, upload-time = "2025-11-28T20:55:21.967Z" },
- { url = "https://files.pythonhosted.org/packages/36/69/47aae6dbd4f1d9b4f7085f4d9dcc84e04561ee7ad067bf52e0f9b02e3209/ruff-0.14.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6be02e849440ed3602d2eb478ff7ff07d53e3758f7948a2a598829660988619e", size = 13412230, upload-time = "2025-11-28T20:55:12.749Z" },
- { url = "https://files.pythonhosted.org/packages/b7/4b/6e96cb6ba297f2ba502a231cd732ed7c3de98b1a896671b932a5eefa3804/ruff-0.14.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19a0f116ee5e2b468dfe80c41c84e2bbd6b74f7b719bee86c2ecde0a34563bcc", size = 14195397, upload-time = "2025-11-28T20:54:56.896Z" },
- { url = "https://files.pythonhosted.org/packages/69/82/251d5f1aa4dcad30aed491b4657cecd9fb4274214da6960ffec144c260f7/ruff-0.14.7-py3-none-win32.whl", hash = "sha256:e33052c9199b347c8937937163b9b149ef6ab2e4bb37b042e593da2e6f6cccfa", size = 13126751, upload-time = "2025-11-28T20:55:03.47Z" },
- { url = "https://files.pythonhosted.org/packages/a8/b5/d0b7d145963136b564806f6584647af45ab98946660d399ec4da79cae036/ruff-0.14.7-py3-none-win_amd64.whl", hash = "sha256:e17a20ad0d3fad47a326d773a042b924d3ac31c6ca6deb6c72e9e6b5f661a7c6", size = 14531726, upload-time = "2025-11-28T20:54:59.121Z" },
- { url = "https://files.pythonhosted.org/packages/1d/d2/1637f4360ada6a368d3265bf39f2cf737a0aaab15ab520fc005903e883f8/ruff-0.14.7-py3-none-win_arm64.whl", hash = "sha256:be4d653d3bea1b19742fcc6502354e32f65cd61ff2fbdb365803ef2c2aec6228", size = 13609215, upload-time = "2025-11-28T20:55:15.375Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" },
+ { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" },
+ { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" },
+ { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" },
+ { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" },
+ { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" },
+ { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" },
+ { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
]
[[package]]
@@ -1575,11 +1734,11 @@ wheels = [
[[package]]
name = "setuptools"
-version = "80.9.0"
+version = "82.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" },
]
[[package]]
@@ -1600,28 +1759,39 @@ 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 = "soupsieve"
+version = "2.8.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" },
+]
+
[[package]]
name = "sse-starlette"
-version = "3.0.3"
+version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
+ { name = "starlette" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943, upload-time = "2025-10-30T18:44:20.117Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253, upload-time = "2026-01-17T13:11:05.62Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765, upload-time = "2025-10-30T18:44:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763, upload-time = "2026-01-17T13:11:03.775Z" },
]
[[package]]
name = "starlette"
-version = "0.46.2"
+version = "0.52.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" },
+ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
]
[[package]]
@@ -1638,7 +1808,7 @@ wheels = [
[[package]]
name = "textual"
-version = "7.4.0"
+version = "8.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
@@ -1648,9 +1818,9 @@ dependencies = [
{ name = "rich" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/64/8d/2fbd6b8652f4cabf9cb0852d7af1aa45b6cad32d0f50735856e8f9e41719/textual-7.4.0.tar.gz", hash = "sha256:1a9598e485492f9a8f033c7ec5e59528df3ab0742fda925681acf78b0fb210de", size = 1592252, upload-time = "2026-01-25T19:57:04.624Z" }
+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" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ab/9c/4169ccffed6d53f78e3175eae0cd649990071c6e24b6ad8830812ebab726/textual-7.4.0-py3-none-any.whl", hash = "sha256:41a066cae649654d4ecfe53b8316f5737c0042d1693ce50690b769a7840780ac", size = 717985, upload-time = "2026-01-25T19:57:02.966Z" },
+ { 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" },
]
[[package]]
@@ -1813,19 +1983,19 @@ wheels = [
[[package]]
name = "typos"
-version = "1.40.0"
+version = "1.43.5"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/f0/8d988732b10ef72ed82900b055590a210a5ae423b4088d17fa961305ed6b/typos-1.40.0.tar.gz", hash = "sha256:5cb1a04a6291fa1fa358ce6d8cd5b50e396d0a306466b792ac6c246066b1780f", size = 1765534, upload-time = "2025-11-26T20:54:53.792Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/10/f3e13aece5b053745242aecbcdfa05110d65d5fb8d3ea25b05540ea97540/typos-1.43.5.tar.gz", hash = "sha256:aa445c5eaf0e32095c3183dda96e0fa8ffcabbfd796721c75d7a8a68e9cd0d75", size = 1806801, upload-time = "2026-02-16T15:34:17.41Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/24/dd/64ceee60d4d1d7d0c90dac8d3bb5ebfcc2e1d1e5b5166f3284abc4052e45/typos-1.40.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:71441cb06044baba29911e4b6500a85b2e915736d1fc0a54d5f575addb12a307", size = 3507274, upload-time = "2025-11-26T20:54:39.564Z" },
- { url = "https://files.pythonhosted.org/packages/18/db/64f7146b86e912041aafe275f627081e4bd005f71932f5280cf0c3944f2b/typos-1.40.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:269e411f342126b06f38936eba9d391a41442c17425e57068797c9e6997e3fca", size = 3391108, upload-time = "2025-11-26T20:54:41.462Z" },
- { url = "https://files.pythonhosted.org/packages/9d/f1/1eead106cc0c025319d23ccff78aa7b9c86a8a918f62359180f119deb96b/typos-1.40.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78d4d7be7e6f61c1bbec01abd9ee2e08254f633b845a9d2c5786051832c3e0c1", size = 8215390, upload-time = "2025-11-26T20:54:43.01Z" },
- { url = "https://files.pythonhosted.org/packages/82/c9/dc027ec8819d1c652d80ac2c3b6216dcc4c6d198907e2c2ed29cd4710685/typos-1.40.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4dbc419aed7cd4b9e8ec71a28045a3b6262fa5a41170734a3fc4dfdf1e7d7a51", size = 7192543, upload-time = "2025-11-26T20:54:44.616Z" },
- { url = "https://files.pythonhosted.org/packages/1a/db/f6fef0f4d173f501b469a90ed3d462bf7e4301a28507b7914cefa1d78ca1/typos-1.40.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0701400559effc6806a043dac55e1b77fc09e540661bf4315eaf55a628138214", size = 7729297, upload-time = "2025-11-26T20:54:46.122Z" },
- { url = "https://files.pythonhosted.org/packages/fe/a4/bb5b415cd352168550170ba5bb7c6b1c53fe457084df5ff07488c525dca6/typos-1.40.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:41ed67ad7cba724841f72d5c7c69de20f79dbee52917fb1fb5f3efa327d44cd3", size = 7107127, upload-time = "2025-11-26T20:54:47.974Z" },
- { url = "https://files.pythonhosted.org/packages/1d/92/1a39cea9ba7369555ed3f540b48ed5fd6f059ec89e24fb87dd21df69bf2a/typos-1.40.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:47764e89fca194b77ff65741b1527210096e39984b2c460ba5bc4868ea05ea88", size = 8141765, upload-time = "2025-11-26T20:54:49.461Z" },
- { url = "https://files.pythonhosted.org/packages/09/64/7d28b539b6d09b59ed3ea13f54e74e0cb8409fff3174928ee2f98ca349fb/typos-1.40.0-py3-none-win32.whl", hash = "sha256:9cd19efd5a3abcc788770ffb9a070f39da0d97c4aadd7eaf471e744a02002464", size = 3065525, upload-time = "2025-11-26T20:54:51.112Z" },
- { url = "https://files.pythonhosted.org/packages/49/0a/e324e17a0407dfe2459ecd8c467b0b3953ec5c553bd552949fdc238bec91/typos-1.40.0-py3-none-win_amd64.whl", hash = "sha256:69c47f0b899bc62d87d6fc431824348782e76dca1867115976915a197b0a1fd2", size = 3254935, upload-time = "2025-11-26T20:54:52.458Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3d/698e7b760f68558048407718d4abb0bcc9baf9cfb7d1b9d68bcf60417031/typos-1.43.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:733221c09d6914c001d2ac16f630773dc36ff384df9ab128c9e93b019eeedf2f", size = 3474242, upload-time = "2026-02-16T15:34:03.26Z" },
+ { url = "https://files.pythonhosted.org/packages/92/08/3b09c26a9e476399f9b2c4ab42df4d079b900d3818d5f47a7e9c04d81192/typos-1.43.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1d2f86b458917fdc91b89f47101498adc7338361e1ed0bed6e4325d0e674aca", size = 3370863, upload-time = "2026-02-16T15:34:04.839Z" },
+ { url = "https://files.pythonhosted.org/packages/67/c6/64e75469439f78c75ed23a1c3712ce68e10293402da912c453f5b432ee9f/typos-1.43.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d75091c9519224f2f74964b6ca5133abfba9be44ce49f12a981e27c29bcef97", size = 8176174, upload-time = "2026-02-16T15:34:06.627Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/0f/60a3cc327cd5d5ab49f615c451ac24e59dedea2e33d69b3d54a6e9749c76/typos-1.43.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:839dfa2bc802b02460097b21f7d93d0fa87d5f76adb83049072211a7279f5ebe", size = 7326066, upload-time = "2026-02-16T15:34:08.002Z" },
+ { url = "https://files.pythonhosted.org/packages/75/6e/54ee65eb9a00f398d0767a10f73ea430ec072ff3a1fea40a3f3573ce7a61/typos-1.43.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0825a4d77d66726e86fac03c6cf2efc11b0a5c112b2156dcaeb61a24a807166", size = 7701673, upload-time = "2026-02-16T15:34:09.324Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/9b/b5a6dc3fadece24dbf6e9a528ed0b9d19d22e55681d5330c1c693cb8bcfe/typos-1.43.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7ee0a71ac21820868686ad52cb26a3e08197b09ceedf04d1736cb8472061665b", size = 7053904, upload-time = "2026-02-16T15:34:11.065Z" },
+ { url = "https://files.pythonhosted.org/packages/45/e1/9369b16d0797d4601a874e454825a3742b9df43941aca90d89448750ce2f/typos-1.43.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:09615c8f64e656940533aa025a9e8dc5c93e5cf6bfbedb059cdcafde75cb6d72", size = 8126680, upload-time = "2026-02-16T15:34:12.549Z" },
+ { url = "https://files.pythonhosted.org/packages/18/6f/dd5c786e860954a31fb5d2831a4d95e5f5b019cc6c558225fb6d38edc6c1/typos-1.43.5-py3-none-win32.whl", hash = "sha256:4c865b1b6149acbdaed9d548282ccfad526e1380de19d189e7ac675cd4869156", size = 3129847, upload-time = "2026-02-16T15:34:14.309Z" },
+ { url = "https://files.pythonhosted.org/packages/07/35/d496189b38ec92c0946e13e63fa630699fd785f6d765132dad0c3efe3b55/typos-1.43.5-py3-none-win_amd64.whl", hash = "sha256:10009e9a3702da037803b075efc94fa5328bd93af357fb6a2f284c0dbcc77f30", size = 3310650, upload-time = "2026-02-16T15:34:15.617Z" },
]
[[package]]
@@ -1839,38 +2009,38 @@ wheels = [
[[package]]
name = "urllib3"
-version = "2.5.0"
+version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "uvicorn"
-version = "0.38.0"
+version = "0.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
+ { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
]
[[package]]
name = "virtualenv"
-version = "20.35.4"
+version = "20.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d2/03/a94d404ca09a89a7301a7008467aed525d4cdeb9186d262154dd23208709/virtualenv-20.38.0.tar.gz", hash = "sha256:94f39b1abaea5185bf7ea5a46702b56f1d0c9aa2f41a6c2b8b0af4ddc74c10a7", size = 5864558, upload-time = "2026-02-19T07:48:02.385Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
+ { url = "https://files.pythonhosted.org/packages/42/d7/394801755d4c8684b655d35c665aea7836ec68320304f62ab3c94395b442/virtualenv-20.38.0-py3-none-any.whl", hash = "sha256:d6e78e5889de3a4742df2d3d44e779366325a90cf356f15621fddace82431794", size = 5837778, upload-time = "2026-02-19T07:47:59.778Z" },
]
[[package]]
@@ -1952,6 +2122,15 @@ 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 = "zipp"
+version = "3.23.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
+]
+
[[package]]
name = "zstandard"
version = "0.25.0"
diff --git a/vibe/__init__.py b/vibe/__init__.py
index c32a834..0c4be8f 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.2.1"
+__version__ = "2.3.0"
diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py
index 75baab5..db8171b 100644
--- a/vibe/acp/acp_agent_loop.py
+++ b/vibe/acp/acp_agent_loop.py
@@ -37,14 +37,12 @@ from acp.schema import (
Implementation,
ListSessionsResponse,
McpServerStdio,
- ModelInfo,
PromptCapabilities,
ResumeSessionResponse,
SessionCapabilities,
SessionInfo,
SessionListCapabilities,
- SessionModelState,
- SessionModeState,
+ SetSessionConfigOptionResponse,
SseMcpServer,
TextContentBlock,
TextResourceContents,
@@ -56,6 +54,7 @@ from acp.schema import (
from pydantic import BaseModel, ConfigDict
from vibe import VIBE_ROOT, __version__
+from vibe.acp.acp_logger import acp_message_observer
from vibe.acp.tools.base import BaseAcpTool
from vibe.acp.tools.session_update import (
tool_call_session_update,
@@ -71,12 +70,13 @@ from vibe.acp.utils import (
create_tool_call_replay,
create_tool_result_replay,
create_user_message_replay,
- get_all_acp_session_modes,
get_proxy_help_text,
- is_valid_acp_agent,
+ is_valid_acp_mode,
+ make_mode_response,
+ make_model_response,
)
from vibe.core.agent_loop import AgentLoop
-from vibe.core.agents.models import BuiltinAgentName
+from vibe.core.agents.models import CHAT as CHAT_AGENT, BuiltinAgentName
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import (
MissingAPIKeyError,
@@ -98,6 +98,7 @@ from vibe.core.types import (
AsyncApprovalCallback,
CompactEndEvent,
CompactStartEvent,
+ EntrypointMetadata,
LLMMessage,
ReasoningEvent,
Role,
@@ -121,7 +122,8 @@ class VibeAcpAgentLoop(AcpAgent):
def __init__(self) -> None:
self.sessions: dict[str, AcpSessionLoop] = {}
- self.client_capabilities = None
+ self.client_capabilities: ClientCapabilities | None = None
+ self.client_info: Implementation | None = None
@override
async def initialize(
@@ -132,6 +134,7 @@ class VibeAcpAgentLoop(AcpAgent):
**kwargs: Any,
) -> InitializeResponse:
self.client_capabilities = client_capabilities
+ self.client_info = client_info
# The ACP Agent process can be launched in 3 different ways, depending on installation
# - dev mode: `uv run vibe-acp`, ran from the project root
@@ -200,6 +203,14 @@ class VibeAcpAgentLoop(AcpAgent):
) -> AuthenticateResponse | None:
raise NotImplementedError("Not implemented yet")
+ def _build_entrypoint_metadata(self) -> EntrypointMetadata:
+ return EntrypointMetadata(
+ agent_entrypoint="acp",
+ agent_version=__version__,
+ client_name=self.client_info.name if self.client_info else "",
+ client_version=self.client_info.version if self.client_info else "",
+ )
+
def _load_config(self) -> VibeConfig:
try:
config = VibeConfig.load(disabled_tools=["ask_user_question"])
@@ -223,21 +234,6 @@ class VibeAcpAgentLoop(AcpAgent):
return session
- def _build_session_model_state(self, agent_loop: AgentLoop) -> SessionModelState:
- return SessionModelState(
- current_model_id=agent_loop.config.active_model,
- available_models=[
- ModelInfo(model_id=model.alias, name=model.alias)
- for model in agent_loop.config.models
- ],
- )
-
- def _build_session_mode_state(self, session: AcpSessionLoop) -> SessionModeState:
- return SessionModeState(
- current_mode_id=session.agent_loop.agent_profile.name,
- available_modes=get_all_acp_session_modes(session.agent_loop.agent_manager),
- )
-
@override
async def new_session(
self,
@@ -251,19 +247,32 @@ class VibeAcpAgentLoop(AcpAgent):
config = self._load_config()
agent_loop = AgentLoop(
- config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
+ config=config,
+ agent_name=BuiltinAgentName.DEFAULT,
+ enable_streaming=True,
+ entrypoint_metadata=self._build_entrypoint_metadata(),
)
+ agent_loop.agent_manager.register_agent(CHAT_AGENT)
# NOTE: For now, we pin session.id to agent_loop.session_id right after init time.
# We should just use agent_loop.session_id everywhere, but it can still change during
# session lifetime (e.g. agent_loop.compact is called).
# We should refactor agent_loop.session_id to make it immutable in ACP context.
session = await self._create_acp_session(agent_loop.session_id, agent_loop)
- agent_loop.emit_new_session_telemetry("acp")
+ agent_loop.emit_new_session_telemetry()
+
+ modes_state, modes_config = make_mode_response(
+ list(agent_loop.agent_manager.available_agents.values()),
+ session.agent_loop.agent_profile.name,
+ )
+ models_state, models_config = make_model_response(
+ agent_loop.config.models, agent_loop.config.active_model
+ )
return NewSessionResponse(
session_id=session.id,
- models=self._build_session_model_state(agent_loop),
- modes=self._build_session_mode_state(session),
+ models=models_state,
+ modes=modes_state,
+ config_options=[modes_config, models_config],
)
def _get_acp_tool_overrides(self) -> list[Path]:
@@ -447,8 +456,12 @@ class VibeAcpAgentLoop(AcpAgent):
}) from e
agent_loop = AgentLoop(
- config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
+ config=config,
+ agent_name=BuiltinAgentName.DEFAULT,
+ enable_streaming=True,
+ entrypoint_metadata=self._build_entrypoint_metadata(),
)
+ agent_loop.agent_manager.register_agent(CHAT_AGENT)
non_system_messages = [
msg for msg in loaded_messages if msg.role != Role.system
@@ -460,19 +473,24 @@ class VibeAcpAgentLoop(AcpAgent):
await self._replay_conversation_history(session_id, non_system_messages)
- return LoadSessionResponse(
- models=self._build_session_model_state(agent_loop),
- modes=self._build_session_mode_state(session),
+ modes_state, modes_config = make_mode_response(
+ list(agent_loop.agent_manager.available_agents.values()),
+ session.agent_loop.agent_profile.name,
+ )
+ models_state, models_config = make_model_response(
+ agent_loop.config.models, agent_loop.config.active_model
)
- @override
- async def set_session_mode(
- self, mode_id: str, session_id: str, **kwargs: Any
- ) -> SetSessionModeResponse | None:
- session = self._get_session(session_id)
+ return LoadSessionResponse(
+ models=models_state,
+ modes=modes_state,
+ config_options=[modes_config, models_config],
+ )
- if not is_valid_acp_agent(session.agent_loop.agent_manager, mode_id):
- return None
+ async def _apply_mode_change(self, session: AcpSessionLoop, mode_id: str) -> bool:
+ profiles = list(session.agent_loop.agent_manager.available_agents.values())
+ if not is_valid_acp_mode(profiles, mode_id):
+ return False
await session.agent_loop.switch_agent(mode_id)
@@ -483,17 +501,12 @@ class VibeAcpAgentLoop(AcpAgent):
self._create_approval_callback(session.id)
)
- return SetSessionModeResponse()
-
- @override
- async def set_session_model(
- self, model_id: str, session_id: str, **kwargs: Any
- ) -> SetSessionModelResponse | None:
- session = self._get_session(session_id)
+ return True
+ async def _apply_model_change(self, session: AcpSessionLoop, model_id: str) -> bool:
model_aliases = [model.alias for model in session.agent_loop.config.models]
if model_id not in model_aliases:
- return None
+ return False
VibeConfig.save_updates({"active_model": model_id})
@@ -504,8 +517,59 @@ class VibeAcpAgentLoop(AcpAgent):
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
+ return True
+
+ @override
+ async def set_session_mode(
+ self, mode_id: str, session_id: str, **kwargs: Any
+ ) -> SetSessionModeResponse | None:
+ session = self._get_session(session_id)
+
+ if not await self._apply_mode_change(session, mode_id):
+ return None
+
+ return SetSessionModeResponse()
+
+ @override
+ async def set_session_model(
+ self, model_id: str, session_id: str, **kwargs: Any
+ ) -> SetSessionModelResponse | None:
+ session = self._get_session(session_id)
+
+ if not await self._apply_model_change(session, model_id):
+ return None
+
return SetSessionModelResponse()
+ @override
+ async def set_config_option(
+ self, config_id: str, session_id: str, value: str, **kwargs: Any
+ ) -> SetSessionConfigOptionResponse | None:
+ session = self._get_session(session_id)
+
+ match config_id:
+ case "mode":
+ success = await self._apply_mode_change(session, value)
+ case "model":
+ success = await self._apply_model_change(session, value)
+ case _:
+ success = False
+
+ if not success:
+ return None
+
+ profiles = list(session.agent_loop.agent_manager.available_agents.values())
+ _, modes_config = make_mode_response(
+ profiles, session.agent_loop.agent_profile.name
+ )
+ _, models_config = make_model_response(
+ session.agent_loop.config.models, session.agent_loop.config.active_model
+ )
+
+ return SetSessionConfigOptionResponse(
+ config_options=[modes_config, models_config]
+ )
+
@override
async def list_sessions(
self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any
@@ -736,7 +800,13 @@ class VibeAcpAgentLoop(AcpAgent):
def run_acp_server() -> None:
try:
- asyncio.run(run_agent(agent=VibeAcpAgentLoop(), use_unstable_protocol=True))
+ asyncio.run(
+ run_agent(
+ agent=VibeAcpAgentLoop(),
+ use_unstable_protocol=True,
+ observers=[acp_message_observer],
+ )
+ )
except KeyboardInterrupt:
# This is expected when the server is terminated
pass
diff --git a/vibe/acp/acp_logger.py b/vibe/acp/acp_logger.py
new file mode 100644
index 0000000..2ccf21a
--- /dev/null
+++ b/vibe/acp/acp_logger.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+import json
+import logging
+from logging.handlers import RotatingFileHandler
+import os
+from pathlib import Path
+import re
+from typing import TYPE_CHECKING
+
+from cachetools import TTLCache
+
+if TYPE_CHECKING:
+ from acp.connection import StreamEvent
+
+ACP_LOG_DIR = Path.home() / ".vibe" / "logs" / "acp"
+ACP_LOG_FILE = ACP_LOG_DIR / "messages.jsonl"
+MAX_LOG_SIZE_BYTES = 1_000_000
+BACKUP_COUNT = 3
+
+ACP_LOGGING_ENABLED_KEY = "VIBE_ACP_LOGGING_ENABLED"
+
+_session_cache: TTLCache[int | str, str] = TTLCache(maxsize=1000, ttl=3600)
+_current_session: str | None = None
+_logger: logging.Logger | None = None
+
+
+def is_acp_logging_enabled() -> bool:
+ return os.getenv(ACP_LOGGING_ENABLED_KEY, "").lower() in {"1", "true", "yes"}
+
+
+class JsonLineFormatter(logging.Formatter):
+ def format(self, record: logging.LogRecord) -> str:
+ return json.dumps(record.msg, separators=(",", ":"))
+
+
+def _get_logger() -> logging.Logger:
+ global _logger
+ if _logger is not None:
+ return _logger
+
+ ACP_LOG_DIR.mkdir(parents=True, exist_ok=True)
+
+ logger = logging.getLogger("acp_messages")
+ logger.setLevel(logging.INFO)
+ logger.propagate = False
+
+ handler = RotatingFileHandler(
+ ACP_LOG_FILE,
+ maxBytes=MAX_LOG_SIZE_BYTES,
+ backupCount=BACKUP_COUNT,
+ encoding="utf-8",
+ )
+ handler.setFormatter(JsonLineFormatter())
+ logger.addHandler(handler)
+
+ _logger = logger
+ return _logger
+
+
+def _extract_session_id(message: dict) -> str | None:
+ json_str = json.dumps(message)
+ match = re.search(r'"(?:session_id|sessionId)":\s*"([^"]+)"', json_str)
+ return match.group(1) if match else None
+
+
+def acp_message_observer(event: StreamEvent) -> None:
+ if not is_acp_logging_enabled():
+ return
+
+ try:
+ global _current_session
+
+ message = event.message
+ msg_id = message.get("id", "")
+
+ if msg_id in _session_cache:
+ session_id = _session_cache[msg_id]
+ else:
+ session_id = _extract_session_id(message) or _current_session
+
+ if session_id is not None:
+ _current_session = session_id
+ if msg_id:
+ _session_cache[msg_id] = session_id
+
+ log_entry: dict = {
+ "ts": datetime.now(UTC).isoformat(),
+ "dir": "in" if event.direction.value == "incoming" else "out",
+ "msg": message,
+ **({"session": session_id} if session_id else {}),
+ }
+
+ _get_logger().info(log_entry)
+ except Exception:
+ pass
diff --git a/vibe/acp/entrypoint.py b/vibe/acp/entrypoint.py
index 9095baf..60a1c90 100644
--- a/vibe/acp/entrypoint.py
+++ b/vibe/acp/entrypoint.py
@@ -7,8 +7,8 @@ import sys
from vibe import __version__
from vibe.core.config import VibeConfig
+from vibe.core.logger import logger
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE, unlock_config_paths
-from vibe.core.utils import logger
# Configure line buffering for subprocess communication
sys.stdout.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue]
diff --git a/vibe/acp/tools/base.py b/vibe/acp/tools/base.py
index 3fd817d..da4ed42 100644
--- a/vibe/acp/tools/base.py
+++ b/vibe/acp/tools/base.py
@@ -8,10 +8,10 @@ from acp.helpers import SessionUpdate, ToolCallContentVariant
from acp.schema import ToolCallProgress
from pydantic import BaseModel, ConfigDict, Field, SkipValidation
+from vibe.core.logger import logger
from vibe.core.tools.base import BaseTool, ToolError
from vibe.core.tools.manager import ToolManager
from vibe.core.types import ToolCallEvent, ToolResultEvent
-from vibe.core.utils import logger
@runtime_checkable
diff --git a/vibe/acp/tools/builtins/bash.py b/vibe/acp/tools/builtins/bash.py
index 90b71cc..576db16 100644
--- a/vibe/acp/tools/builtins/bash.py
+++ b/vibe/acp/tools/builtins/bash.py
@@ -13,10 +13,10 @@ from acp.schema import (
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
+from vibe.core.logger import logger
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
from vibe.core.tools.builtins.bash import Bash as CoreBashTool, BashArgs, BashResult
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
-from vibe.core.utils import logger
class AcpBashState(BaseToolState, AcpToolState):
@@ -110,7 +110,16 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
raise self._build_timeout_error(command, timeout)
@classmethod
- def tool_call_session_update(cls, event: ToolCallEvent) -> ToolCallStart:
+ def tool_call_session_update(cls, event: ToolCallEvent) -> ToolCallStart | None:
+ if event.args is None:
+ return ToolCallStart(
+ session_update="tool_call",
+ title="bash",
+ tool_call_id=event.tool_call_id,
+ kind="execute",
+ content=None,
+ raw_input=None,
+ )
if not isinstance(event.args, BashArgs):
raise ValueError(f"Unexpected tool args: {event.args}")
diff --git a/vibe/acp/tools/builtins/read_file.py b/vibe/acp/tools/builtins/read_file.py
index af20697..920dc3b 100644
--- a/vibe/acp/tools/builtins/read_file.py
+++ b/vibe/acp/tools/builtins/read_file.py
@@ -4,19 +4,18 @@ from pathlib import Path
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
-from vibe.core.tools.base import ToolError
+from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.read_file import (
ReadFile as CoreReadFileTool,
ReadFileArgs,
ReadFileResult,
- ReadFileState,
_ReadResult,
)
ReadFileResult = ReadFileResult
-class AcpReadFileState(ReadFileState, AcpToolState):
+class AcpReadFileState(BaseToolState, AcpToolState):
pass
diff --git a/vibe/acp/tools/builtins/search_replace.py b/vibe/acp/tools/builtins/search_replace.py
index f2dde1e..b0883a3 100644
--- a/vibe/acp/tools/builtins/search_replace.py
+++ b/vibe/acp/tools/builtins/search_replace.py
@@ -12,17 +12,16 @@ from acp.schema import (
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
-from vibe.core.tools.base import ToolError
+from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.search_replace import (
SearchReplace as CoreSearchReplaceTool,
SearchReplaceArgs,
SearchReplaceResult,
- SearchReplaceState,
)
from vibe.core.types import ToolCallEvent, ToolResultEvent
-class AcpSearchReplaceState(SearchReplaceState, AcpToolState):
+class AcpSearchReplaceState(BaseToolState, AcpToolState):
file_backup_content: str | None = None
@@ -73,6 +72,15 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
args = event.args
+ if args is None:
+ return ToolCallStart(
+ session_update="tool_call",
+ title="search_replace",
+ tool_call_id=event.tool_call_id,
+ kind="edit",
+ content=None,
+ raw_input=None,
+ )
if not isinstance(args, SearchReplaceArgs):
return None
diff --git a/vibe/acp/tools/builtins/write_file.py b/vibe/acp/tools/builtins/write_file.py
index 39dc0fb..97fcbcf 100644
--- a/vibe/acp/tools/builtins/write_file.py
+++ b/vibe/acp/tools/builtins/write_file.py
@@ -12,17 +12,16 @@ from acp.schema import (
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
-from vibe.core.tools.base import ToolError
+from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.write_file import (
WriteFile as CoreWriteFileTool,
WriteFileArgs,
WriteFileResult,
- WriteFileState,
)
from vibe.core.types import ToolCallEvent, ToolResultEvent
-class AcpWriteFileState(WriteFileState, AcpToolState):
+class AcpWriteFileState(BaseToolState, AcpToolState):
pass
@@ -51,6 +50,15 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
args = event.args
+ if args is None:
+ return ToolCallStart(
+ session_update="tool_call",
+ title="write_file",
+ tool_call_id=event.tool_call_id,
+ kind="edit",
+ content=None,
+ raw_input=None,
+ )
if not isinstance(args, WriteFileArgs):
return None
diff --git a/vibe/acp/tools/session_update.py b/vibe/acp/tools/session_update.py
index e8c836f..415e0ae 100644
--- a/vibe/acp/tools/session_update.py
+++ b/vibe/acp/tools/session_update.py
@@ -50,7 +50,7 @@ def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
content=content,
tool_call_id=event.tool_call_id,
kind=TOOL_KIND.get(event.tool_name, "other"),
- raw_input=event.args.model_dump_json(),
+ raw_input=event.args.model_dump_json() if event.args else None,
)
diff --git a/vibe/acp/utils.py b/vibe/acp/utils.py
index bac082d..64cf16d 100644
--- a/vibe/acp/utils.py
+++ b/vibe/acp/utils.py
@@ -7,8 +7,14 @@ from acp.schema import (
AgentMessageChunk,
AgentThoughtChunk,
ContentToolCallContent,
+ ModelInfo,
PermissionOption,
+ SessionConfigOption,
+ SessionConfigOptionSelect,
+ SessionConfigSelectOption,
SessionMode,
+ SessionModelState,
+ SessionModeState,
TextContentBlock,
ToolCallProgress,
ToolCallStart,
@@ -21,7 +27,7 @@ from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
from vibe.core.utils import compact_reduction_display
if TYPE_CHECKING:
- from vibe.core.agents.manager import AgentManager
+ from vibe.core.config import ModelConfig
class ToolOption(StrEnum):
@@ -50,22 +56,80 @@ TOOL_OPTIONS = [
]
-def agent_profile_to_acp(profile: AgentProfile) -> SessionMode:
- return SessionMode(
- id=profile.name, name=profile.display_name, description=profile.description
+def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
+ return any(
+ p.name == mode_name and p.agent_type == AgentType.AGENT for p in profiles
)
-def is_valid_acp_agent(agent_manager: AgentManager, agent_name: str) -> bool:
- return agent_name in agent_manager.available_agents
+def make_mode_response(
+ profiles: list[AgentProfile], current_mode_id: str
+) -> tuple[SessionModeState, SessionConfigOption]:
+ session_modes: list[SessionMode] = []
+ config_options: list[SessionConfigSelectOption] = []
+
+ for profile in profiles:
+ if profile.agent_type != AgentType.AGENT:
+ continue
+ session_modes.append(
+ SessionMode(
+ id=profile.name,
+ name=profile.display_name,
+ description=profile.description,
+ )
+ )
+ config_options.append(
+ SessionConfigSelectOption(
+ value=profile.name,
+ name=profile.display_name,
+ description=profile.description,
+ )
+ )
+
+ state = SessionModeState(
+ current_mode_id=current_mode_id, available_modes=session_modes
+ )
+ config = SessionConfigOption(
+ root=SessionConfigOptionSelect(
+ id="mode",
+ name="Session Mode",
+ current_value=current_mode_id,
+ category="mode",
+ type="select",
+ options=config_options,
+ )
+ )
+ return state, config
-def get_all_acp_session_modes(agent_manager: AgentManager) -> list[SessionMode]:
- return [
- agent_profile_to_acp(profile)
- for profile in agent_manager.available_agents.values()
- if profile.agent_type == AgentType.AGENT
- ]
+def make_model_response(
+ models: list[ModelConfig], current_model_id: str
+) -> tuple[SessionModelState, SessionConfigOption]:
+ model_infos: list[ModelInfo] = []
+ config_options: list[SessionConfigSelectOption] = []
+
+ for model in models:
+ model_infos.append(ModelInfo(model_id=model.alias, name=model.alias))
+ config_options.append(
+ SessionConfigSelectOption(
+ value=model.alias, name=model.alias, description=model.name
+ )
+ )
+
+ state = SessionModelState(
+ current_model_id=current_model_id, available_models=model_infos
+ )
+ config_option = SessionConfigOption(
+ root=SessionConfigOptionSelect(
+ id="model",
+ name="Model",
+ current_value=current_model_id,
+ category="model",
+ type="select",
+ options=config_options,
+ )
+ )
+ return state, config_option
def create_compact_start_session_update(event: CompactStartEvent) -> ToolCallStart:
diff --git a/vibe/cli/cli.py b/vibe/cli/cli.py
index 23749b8..5aa52ed 100644
--- a/vibe/cli/cli.py
+++ b/vibe/cli/cli.py
@@ -1,10 +1,12 @@
from __future__ import annotations
import argparse
+from pathlib import Path
import sys
from rich import print as rprint
+from vibe import __version__
from vibe.cli.textual_ui.app import run_textual_ui
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
@@ -14,11 +16,12 @@ from vibe.core.config import (
VibeConfig,
load_dotenv_values,
)
+from vibe.core.logger import logger
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE
from vibe.core.programmatic import run_programmatic
from vibe.core.session.session_loader import SessionLoader
-from vibe.core.types import LLMMessage, OutputFormat, Role
-from vibe.core.utils import ConversationLimitException, logger
+from vibe.core.types import EntrypointMetadata, LLMMessage, OutputFormat, Role
+from vibe.core.utils import ConversationLimitException
from vibe.setup.onboarding import run_onboarding
@@ -74,7 +77,7 @@ def bootstrap_config_files() -> None:
def load_session(
args: argparse.Namespace, config: VibeConfig
-) -> list[LLMMessage] | None:
+) -> tuple[list[LLMMessage], Path] | None:
if not args.continue_session and not args.resume:
return None
@@ -107,18 +110,26 @@ def load_session(
try:
loaded_messages, _ = SessionLoader.load_session(session_to_load)
- return loaded_messages
+ return loaded_messages, session_to_load
except Exception as e:
rprint(f"[red]Failed to load session: {e}[/]")
sys.exit(1)
-def _load_messages_from_previous_session(
- agent_loop: AgentLoop, loaded_messages: list[LLMMessage]
+def _resume_previous_session(
+ agent_loop: AgentLoop, loaded_messages: list[LLMMessage], session_path: Path
) -> None:
non_system_messages = [msg for msg in loaded_messages if msg.role != Role.system]
agent_loop.messages.extend(non_system_messages)
- logger.info("Loaded %d messages from previous session", len(non_system_messages))
+
+ _, metadata = SessionLoader.load_session(session_path)
+ session_id = metadata.get("session_id", agent_loop.session_id)
+ agent_loop.session_id = session_id
+ agent_loop.session_logger.resume_existing_session(session_id, session_path)
+
+ logger.info(
+ "Resumed session %s with %d messages", session_id, len(non_system_messages)
+ )
def run_cli(args: argparse.Namespace) -> None:
@@ -136,7 +147,7 @@ def run_cli(args: argparse.Namespace) -> None:
if args.enabled_tools:
config.enabled_tools = args.enabled_tools
- loaded_messages = load_session(args, config)
+ loaded_session = load_session(args, config)
stdin_prompt = get_prompt_from_stdin()
if args.prompt is not None:
@@ -157,7 +168,7 @@ def run_cli(args: argparse.Namespace) -> None:
max_turns=args.max_turns,
max_price=args.max_price,
output_format=output_format,
- previous_messages=loaded_messages,
+ previous_messages=loaded_session[0] if loaded_session else None,
agent_name=initial_agent_name,
)
if final_response:
@@ -171,11 +182,19 @@ def run_cli(args: argparse.Namespace) -> None:
sys.exit(1)
else:
agent_loop = AgentLoop(
- config, agent_name=initial_agent_name, enable_streaming=True
+ config,
+ agent_name=initial_agent_name,
+ enable_streaming=True,
+ entrypoint_metadata=EntrypointMetadata(
+ agent_entrypoint="cli",
+ agent_version=__version__,
+ client_name="vibe_cli",
+ client_version=__version__,
+ ),
)
- if loaded_messages:
- _load_messages_from_previous_session(agent_loop, loaded_messages)
+ if loaded_session:
+ _resume_previous_session(agent_loop, *loaded_session)
run_textual_ui(
agent_loop=agent_loop,
diff --git a/vibe/cli/commands.py b/vibe/cli/commands.py
index c99f8fc..3e7deac 100644
--- a/vibe/cli/commands.py
+++ b/vibe/cli/commands.py
@@ -72,6 +72,11 @@ class CommandRegistry:
description="Configure proxy and SSL certificate settings",
handler="_show_proxy_setup",
),
+ "resume": Command(
+ aliases=frozenset(["/resume", "/continue"]),
+ description="Browse and resume past sessions",
+ handler="_show_session_picker",
+ ),
}
for command in excluded_commands:
diff --git a/vibe/cli/terminal_setup.py b/vibe/cli/terminal_setup.py
index 832fcd4..b928652 100644
--- a/vibe/cli/terminal_setup.py
+++ b/vibe/cli/terminal_setup.py
@@ -14,6 +14,7 @@ class Terminal(Enum):
VSCODE = "vscode"
VSCODE_INSIDERS = "vscode_insiders"
CURSOR = "cursor"
+ JETBRAINS = "jetbrains"
ITERM2 = "iterm2"
WEZTERM = "wezterm"
GHOSTTY = "ghostty"
@@ -50,6 +51,16 @@ def _detect_vscode_terminal() -> Literal[Terminal.VSCODE, Terminal.VSCODE_INSIDE
return Terminal.VSCODE
+def _detect_terminal_from_env() -> Terminal | None:
+ if os.environ.get("WEZTERM_PANE"):
+ return Terminal.WEZTERM
+ if os.environ.get("GHOSTTY_RESOURCES_DIR"):
+ return Terminal.GHOSTTY
+ if "jetbrains" in os.environ.get("TERMINAL_EMULATOR", "").lower():
+ return Terminal.JETBRAINS
+ return None
+
+
def detect_terminal() -> Terminal:
term_program = os.environ.get("TERM_PROGRAM", "").lower()
@@ -66,12 +77,7 @@ def detect_terminal() -> Terminal:
if term_program in term_map:
return term_map[term_program]
- if os.environ.get("WEZTERM_PANE"):
- return Terminal.WEZTERM
- if os.environ.get("GHOSTTY_RESOURCES_DIR"):
- return Terminal.GHOSTTY
-
- return Terminal.UNKNOWN
+ return _detect_terminal_from_env() or Terminal.UNKNOWN
def _get_vscode_keybindings_path(is_stable: bool) -> Path | None:
@@ -303,6 +309,13 @@ def setup_terminal() -> SetupResult:
match terminal:
case Terminal.VSCODE | Terminal.VSCODE_INSIDERS | Terminal.CURSOR:
return _setup_vscode_like_terminal(terminal)
+ case Terminal.JETBRAINS:
+ return SetupResult(
+ success=False,
+ terminal=Terminal.JETBRAINS,
+ message="Jetbrains terminal is not supported.\n"
+ "You can manually configure Shift+Enter to send: \\x1b[13;2u",
+ )
case Terminal.ITERM2:
return _setup_iterm2()
case Terminal.WEZTERM:
diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py
index c1f21f3..ffe7d00 100644
--- a/vibe/cli/textual_ui/app.py
+++ b/vibe/cli/textual_ui/app.py
@@ -33,6 +33,11 @@ from vibe.cli.plan_offer.decide_plan_offer import (
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway
from vibe.cli.terminal_setup import setup_terminal
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
+from vibe.cli.textual_ui.notifications import (
+ NotificationContext,
+ NotificationPort,
+ TextualNotificationAdapter,
+)
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
from vibe.cli.textual_ui.widgets.banner.banner import Banner
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
@@ -55,6 +60,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
+from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
from vibe.cli.textual_ui.windowing import (
@@ -84,6 +90,7 @@ from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents import AgentProfile
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig
+from vibe.core.logger import logger
from vibe.core.paths.config_paths import HISTORY_FILE
from vibe.core.session.session_loader import SessionLoader
from vibe.core.teleport.types import (
@@ -115,7 +122,6 @@ from vibe.core.utils import (
CancellationReason,
get_user_cancellation_message,
is_dangerous_directory,
- logger,
)
@@ -132,11 +138,16 @@ class BottomApp(StrEnum):
Input = auto()
ProxySetup = auto()
Question = auto()
+ SessionPicker = auto()
class ChatScroll(VerticalScroll):
"""Optimized scroll container that skips cascading style recalculations."""
+ @property
+ def is_at_bottom(self) -> bool:
+ return self.scroll_offset.y >= (self.max_scroll_y - 3)
+
def update_node_styles(self, animate: bool = True) -> None:
pass
@@ -145,34 +156,40 @@ PRUNE_LOW_MARK = 1000
PRUNE_HIGH_MARK = 1500
-async def prune_by_height(messages_area: Widget, low_mark: int, high_mark: int) -> bool:
- """Remove older children to keep virtual height within bounds.
- Implementation from https://github.com/batrachianai/toad/blob/a335b56c9015514d5f38654e3909aaa78850c510/src/toad/widgets/conversation.py#L1495
+async def prune_oldest_children(
+ messages_area: Widget, low_mark: int, high_mark: int
+) -> bool:
+ """Remove the oldest children so the virtual height stays within bounds.
+
+ Walks children back-to-front to find how much to keep (up to *low_mark*
+ of visible height), then removes everything before that point.
"""
- height = messages_area.virtual_size.height
- if height <= high_mark:
+ total_height = messages_area.virtual_size.height
+ if total_height <= high_mark:
return False
- prune_children: list[Widget] = []
- bottom_margin = 0
- prune_height = 0
- for child in messages_area.children:
+
+ children = messages_area.children
+ if not children:
+ return False
+
+ accumulated = 0
+ cut = len(children)
+
+ for child in reversed(children):
if not child.display:
- prune_children.append(child)
+ cut -= 1
continue
- top, _, bottom, _ = child.styles.margin
- child_height = child.outer_size.height
- prune_height = (
- (prune_height - bottom_margin + max(bottom_margin, top))
- + bottom
- + child_height
- )
- bottom_margin = bottom
- if height - prune_height <= low_mark:
+ accumulated += child.outer_size.height
+ cut -= 1
+ if accumulated >= low_mark:
break
- prune_children.append(child)
- if prune_children:
- await messages_area.remove_children(prune_children)
- return bool(prune_children)
+
+ to_remove = list(children[:cut])
+ if not to_remove:
+ return False
+
+ await messages_area.remove_children(to_remove)
+ return True
class VibeApp(App): # noqa: PLR0904
@@ -203,10 +220,16 @@ class VibeApp(App): # noqa: PLR0904
update_cache_repository: UpdateCacheRepository | None = None,
current_version: str = CORE_VERSION,
plan_offer_gateway: WhoAmIGateway | None = None,
+ terminal_notifier: NotificationPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.agent_loop = agent_loop
+ self._terminal_notifier = terminal_notifier or TextualNotificationAdapter(
+ self,
+ get_enabled=lambda: self.config.enable_notifications,
+ default_title="Vibe",
+ )
self._agent_running = False
self._interrupt_requested = False
self._agent_task: asyncio.Task | None = None
@@ -246,6 +269,7 @@ class VibeApp(App): # noqa: PLR0904
self._cached_messages_area: Widget | None = None
self._cached_chat: ChatScroll | None = None
self._cached_loading_area: Widget | None = None
+ self._switch_agent_generation = 0
@property
def config(self) -> VibeConfig:
@@ -279,6 +303,7 @@ class VibeApp(App): # noqa: PLR0904
async def on_mount(self) -> None:
self.theme = "textual-ansi"
+ self._terminal_notifier.restore()
self._cached_messages_area = self.query_one("#messages")
self._cached_chat = self.query_one("#chat", ChatScroll)
@@ -311,7 +336,7 @@ class VibeApp(App): # noqa: PLR0904
await self._resume_history_from_messages()
await self._check_and_show_whats_new()
self._schedule_update_notification()
- self.agent_loop.emit_new_session_telemetry("cli")
+ self.agent_loop.emit_new_session_telemetry()
self.call_after_refresh(self._refresh_banner)
@@ -575,12 +600,16 @@ class VibeApp(App): # noqa: PLR0904
self._handle_agent_loop_turn(message)
)
+ def _reset_ui_state(self) -> None:
+ self._windowing.reset()
+ self._tool_call_map = None
+ self._history_widget_indices = WeakKeyDictionary()
+
async def _resume_history_from_messages(self) -> None:
messages_area = self._cached_messages_area or self.query_one("#messages")
if not should_resume_history(list(messages_area.children)):
return
- self._windowing.reset()
history_messages = non_system_history_messages(self.agent_loop.messages)
if (
plan := create_resume_plan(history_messages, HISTORY_RESUME_TAIL_MESSAGES)
@@ -592,7 +621,8 @@ class VibeApp(App): # noqa: PLR0904
plan.tool_call_map,
start_index=plan.tail_start_index,
)
- self.call_after_refresh(self._scroll_chat_to_end)
+ chat = self._cached_chat or self.query_one("#chat", ChatScroll)
+ self.call_after_refresh(chat.anchor)
self._tool_call_map = plan.tool_call_map
self._windowing.set_backfill(plan.backfill_messages)
await self._load_more.set_visible(
@@ -643,6 +673,7 @@ class VibeApp(App): # noqa: PLR0904
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
@@ -654,6 +685,7 @@ class VibeApp(App): # noqa: PLR0904
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
@@ -714,6 +746,7 @@ class VibeApp(App): # noqa: PLR0904
if self.event_handler:
await self.event_handler.finalize_streaming()
await self._refresh_windowing_from_history()
+ self._terminal_notifier.notify(NotificationContext.COMPLETE)
async def _teleport_command(self) -> None:
await self._handle_teleport_command(show_message=False)
@@ -864,11 +897,108 @@ class VibeApp(App): # noqa: PLR0904
return
await self._switch_to_proxy_setup_app()
+ async def _show_session_picker(self) -> None:
+ session_config = self.config.session_logging
+
+ if not session_config.enabled:
+ await self._mount_and_scroll(
+ ErrorMessage(
+ "Session logging is disabled in configuration.",
+ collapsed=self._tools_collapsed,
+ )
+ )
+ return
+
+ cwd = str(Path.cwd())
+ raw_sessions = SessionLoader.list_sessions(session_config, cwd=cwd)
+
+ if not raw_sessions:
+ await self._mount_and_scroll(
+ UserCommandMessage("No sessions found for this directory.")
+ )
+ return
+
+ sessions = sorted(
+ raw_sessions, key=lambda s: s.get("end_time") or "", reverse=True
+ )
+
+ latest_messages = {
+ s["session_id"]: SessionLoader.get_first_user_message(
+ s["session_id"], session_config
+ )
+ for s in sessions
+ }
+
+ picker = SessionPickerApp(sessions=sessions, latest_messages=latest_messages)
+ await self._switch_from_input(picker)
+
+ async def on_session_picker_app_session_selected(
+ self, event: SessionPickerApp.SessionSelected
+ ) -> None:
+ await self._switch_to_input_app()
+
+ session_config = self.config.session_logging
+ session_path = SessionLoader.find_session_by_id(
+ event.session_id, session_config
+ )
+
+ if not session_path:
+ await self._mount_and_scroll(
+ ErrorMessage(
+ f"Session `{event.session_id[:8]}` not found.",
+ collapsed=self._tools_collapsed,
+ )
+ )
+ return
+
+ try:
+ loaded_messages, _ = SessionLoader.load_session(session_path)
+
+ current_system_messages = [
+ msg for msg in self.agent_loop.messages if msg.role == Role.system
+ ]
+ non_system_messages = [
+ msg for msg in loaded_messages if msg.role != Role.system
+ ]
+
+ self.agent_loop.session_id = event.session_id
+ self.agent_loop.session_logger.resume_existing_session(
+ event.session_id, session_path
+ )
+
+ self.agent_loop.messages.reset(
+ current_system_messages + non_system_messages
+ )
+
+ self._reset_ui_state()
+ await self._load_more.hide()
+
+ messages_area = self._cached_messages_area or self.query_one("#messages")
+ await messages_area.remove_children()
+
+ await self._resume_history_from_messages()
+
+ await self._mount_and_scroll(
+ UserCommandMessage(f"Resumed session `{event.session_id[:8]}`")
+ )
+
+ except ValueError as e:
+ await self._mount_and_scroll(
+ ErrorMessage(
+ f"Failed to load session: {e}", collapsed=self._tools_collapsed
+ )
+ )
+
+ async def on_session_picker_app_cancelled(
+ self, event: SessionPickerApp.Cancelled
+ ) -> None:
+ await self._switch_to_input_app()
+
+ await self._mount_and_scroll(UserCommandMessage("Resume cancelled."))
+
async def _reload_config(self) -> None:
try:
- self._windowing.reset()
- self._tool_call_map = None
- self._history_widget_indices = WeakKeyDictionary()
+ self._reset_ui_state()
await self._load_more.hide()
base_config = VibeConfig.load()
@@ -886,9 +1016,7 @@ class VibeApp(App): # noqa: PLR0904
async def _clear_history(self) -> None:
try:
- self._windowing.reset()
- self._tool_call_map = None
- self._history_widget_indices = WeakKeyDictionary()
+ self._reset_ui_state()
await self.agent_loop.clear_history()
if self.event_handler:
await self.event_handler.finalize_streaming()
@@ -1019,6 +1147,8 @@ class VibeApp(App): # noqa: PLR0904
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)
+ should_scroll = scroll and chat.is_at_bottom
if self._chat_input_container:
self._chat_input_container.display = False
@@ -1028,8 +1158,8 @@ class VibeApp(App): # noqa: PLR0904
await bottom_container.mount(widget)
self.call_after_refresh(widget.focus)
- if scroll:
- self.call_after_refresh(self._scroll_chat_to_end)
+ if should_scroll:
+ self.call_after_refresh(chat.anchor)
async def _switch_to_config_app(self) -> None:
if self._current_bottom_app == BottomApp.Config:
@@ -1069,7 +1199,9 @@ class VibeApp(App): # noqa: PLR0904
self._chat_input_container.display = True
self._current_bottom_app = BottomApp.Input
self.call_after_refresh(self._chat_input_container.focus_input)
- self.call_after_refresh(self._scroll_chat_to_end)
+ chat = self._cached_chat or self.query_one("#chat", ChatScroll)
+ if chat.is_at_bottom:
+ self.call_after_refresh(chat.anchor)
def _focus_current_bottom_app(self) -> None:
try:
@@ -1084,6 +1216,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(ApprovalApp).focus()
case BottomApp.Question:
self.query_one(QuestionApp).focus()
+ case BottomApp.SessionPicker:
+ self.query_one(SessionPickerApp).focus()
case app:
assert_never(app)
except Exception:
@@ -1115,6 +1249,14 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
self._last_escape_time = None
+ def _handle_session_picker_app_escape(self) -> None:
+ try:
+ session_picker = self.query_one(SessionPickerApp)
+ session_picker.post_message(SessionPickerApp.Cancelled())
+ except Exception:
+ pass
+ self._last_escape_time = None
+
def _handle_input_app_escape(self) -> None:
try:
input_widget = self.query_one(ChatInputContainer)
@@ -1151,6 +1293,10 @@ class VibeApp(App): # noqa: PLR0904
self._handle_question_app_escape()
return
+ if self._current_bottom_app == BottomApp.SessionPicker:
+ self._handle_session_picker_app_escape()
+ return
+
if (
self._current_bottom_app == BottomApp.Input
and self._last_escape_time is not None
@@ -1163,7 +1309,9 @@ class VibeApp(App): # noqa: PLR0904
self._handle_agent_running_escape()
self._last_escape_time = current_time
- self.call_after_refresh(self._scroll_chat_to_end)
+ chat = self._cached_chat or self.query_one("#chat", ChatScroll)
+ if chat.is_at_bottom:
+ self.call_after_refresh(chat.anchor)
self._focus_current_bottom_app()
async def on_history_load_more_requested(self, _: HistoryLoadMoreRequested) -> None:
@@ -1235,9 +1383,32 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.agent_profile
)
self._update_profile_widgets(new_profile)
- await self.agent_loop.switch_agent(new_profile.name)
- self.agent_loop.set_approval_callback(self._approval_callback)
- self.agent_loop.set_user_input_callback(self._user_input_callback)
+ if self._chat_input_container:
+ self._chat_input_container.switching_mode = True
+
+ def schedule_switch() -> None:
+ self._switch_agent_generation += 1
+ my_gen = self._switch_agent_generation
+
+ def switch_agent_sync() -> None:
+ try:
+ asyncio.run(self.agent_loop.switch_agent(new_profile.name))
+ self.agent_loop.set_approval_callback(self._approval_callback)
+ self.agent_loop.set_user_input_callback(self._user_input_callback)
+ finally:
+ if (
+ self._chat_input_container
+ and self._switch_agent_generation == my_gen
+ ):
+ self.call_from_thread(
+ setattr, self._chat_input_container, "switching_mode", False
+ )
+
+ self.run_worker(
+ switch_agent_sync, group="switch_agent", exclusive=True, thread=True
+ )
+
+ self.call_after_refresh(schedule_switch)
def action_clear_quit(self) -> None:
input_widgets = self.query(ChatInputContainer)
@@ -1296,9 +1467,11 @@ class VibeApp(App): # noqa: PLR0904
whats_new_message.add_class("after-history")
messages_area = self._cached_messages_area or self.query_one("#messages")
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
+ should_anchor = chat.is_at_bottom
await chat.mount(whats_new_message, after=messages_area)
self._whats_new_message = whats_new_message
- chat.anchor()
+ if should_anchor:
+ chat.anchor()
await mark_version_as_seen(self._current_version, self._update_cache_repository)
async def _plan_offer_cta(self) -> str | None:
@@ -1324,29 +1497,37 @@ class VibeApp(App): # noqa: PLR0904
)
return
- def _scroll_chat_to_end(self) -> None:
- chat = self._cached_chat or self.query_one("#chat", ChatScroll)
- chat.scroll_end(animate=False)
-
- async def _mount_and_scroll(self, widget: Widget) -> None:
+ async def _mount_and_scroll(
+ self, widget: Widget, after: Widget | None = None
+ ) -> None:
messages_area = self._cached_messages_area or self.query_one("#messages")
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
- await messages_area.mount(widget)
+ is_user_initiated = isinstance(widget, (UserMessage, UserCommandMessage))
+ should_anchor = is_user_initiated or chat.is_at_bottom
+
+ if after is not None and after.parent is messages_area:
+ await messages_area.mount(widget, after=after)
+ else:
+ await messages_area.mount(widget)
if isinstance(widget, StreamingMessageBase):
await widget.write_initial_content()
self.call_after_refresh(self._try_prune)
- chat.anchor()
+ if should_anchor:
+ chat.anchor()
async def _try_prune(self) -> None:
messages_area = self._cached_messages_area or self.query_one("#messages")
- pruned = await prune_by_height(messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK)
+ pruned = await prune_oldest_children(
+ messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK
+ )
if self._load_more.widget and not self._load_more.widget.parent:
self._load_more.widget = None
if pruned:
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
- self.call_later(chat.anchor)
+ if chat.is_at_bottom:
+ self.call_later(chat.anchor)
async def _refresh_windowing_from_history(self) -> None:
if self._load_more.widget is None:
@@ -1425,10 +1606,12 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
def on_app_blur(self, event: AppBlur) -> None:
+ self._terminal_notifier.on_blur()
if self._chat_input_container and self._chat_input_container.input_widget:
self._chat_input_container.input_widget.set_app_focus(False)
def on_app_focus(self, event: AppFocus) -> None:
+ self._terminal_notifier.on_focus()
if self._chat_input_container and self._chat_input_container.input_widget:
self._chat_input_container.input_widget.set_app_focus(True)
diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss
index c688b89..92000e8 100644
--- a/vibe/cli/textual_ui/app.tcss
+++ b/vibe/cli/textual_ui/app.tcss
@@ -118,7 +118,8 @@ TextArea > .text-area--cursor {
height: auto;
}
-#prompt {
+#prompt,
+#prompt-spinner {
width: auto;
background: transparent;
color: $mistral_orange;
@@ -719,17 +720,6 @@ StatusMessage {
height: auto;
}
-.proxy-label {
- height: auto;
- color: ansi_blue;
- text-style: bold;
-}
-
-.proxy-description {
- height: auto;
- color: ansi_bright_black;
-}
-
.proxy-label-line {
height: auto;
}
@@ -739,7 +729,6 @@ StatusMessage {
height: auto;
border: none;
border-left: wide ansi_bright_black;
- margin-top: 1;
padding: 0 0 0 1;
}
@@ -1020,3 +1009,36 @@ ContextProgress {
.whats-new-message.after-history {
margin-top: 1;
}
+
+#sessionpicker-app {
+ width: 100%;
+ height: auto;
+ background: transparent;
+ border: solid ansi_bright_black;
+ padding: 0 1;
+ margin: 0;
+}
+
+#sessionpicker-content {
+ width: 100%;
+ height: auto;
+}
+
+#sessionpicker-options {
+ width: 100%;
+ max-height: 50vh;
+ text-wrap: nowrap;
+ text-overflow: ellipsis;
+ border: none;
+}
+
+#sessionpicker-options:focus {
+ border: none;
+}
+
+.sessionpicker-help {
+ width: 100%;
+ height: auto;
+ color: ansi_bright_black;
+ margin-top: 1;
+}
diff --git a/vibe/cli/textual_ui/handlers/event_handler.py b/vibe/cli/textual_ui/handlers/event_handler.py
index 45eba69..9e0e47e 100644
--- a/vibe/cli/textual_ui/handlers/event_handler.py
+++ b/vibe/cli/textual_ui/handlers/event_handler.py
@@ -31,7 +31,7 @@ class EventHandler:
) -> None:
self.mount_callback = mount_callback
self.get_tools_collapsed = get_tools_collapsed
- self.current_tool_call: ToolCallMessage | None = None
+ self.tool_calls: dict[str, ToolCallMessage] = {}
self.current_compact: CompactMessage | None = None
self.current_streaming_message: AssistantMessage | None = None
self.current_streaming_reasoning: ReasoningMessage | None = None
@@ -90,30 +90,40 @@ class EventHandler:
async def _handle_tool_call(
self, event: ToolCallEvent, loading_widget: LoadingWidget | None = None
) -> ToolCallMessage | None:
- tool_call = ToolCallMessage(event)
+ tool_call_id = event.tool_call_id
+ existing_tool_call = self.tool_calls.get(tool_call_id) if tool_call_id else None
+ if existing_tool_call:
+ existing_tool_call.update_event(event)
+ tool_call = existing_tool_call
+ else:
+ tool_call = ToolCallMessage(event)
+ if tool_call_id:
+ self.tool_calls[tool_call_id] = tool_call
+ await self.mount_callback(tool_call)
if loading_widget and event.tool_class:
adapter = ToolUIDataAdapter(event.tool_class)
- status_text = adapter.get_status_text()
- loading_widget.set_status(status_text)
-
- self.current_tool_call = tool_call
- await self.mount_callback(tool_call)
+ loading_widget.set_status(adapter.get_status_text())
return tool_call
async def _handle_tool_result(self, event: ToolResultEvent) -> None:
tools_collapsed = self.get_tools_collapsed()
- tool_result = ToolResultMessage(
- event, self.current_tool_call, collapsed=tools_collapsed
- )
- await self.mount_callback(tool_result)
- self.current_tool_call = None
+ call_widget = (
+ self.tool_calls.get(event.tool_call_id) if event.tool_call_id else None
+ )
+
+ tool_result = ToolResultMessage(event, call_widget, collapsed=tools_collapsed)
+ await self.mount_callback(tool_result, after=call_widget)
+
+ if event.tool_call_id and event.tool_call_id in self.tool_calls:
+ del self.tool_calls[event.tool_call_id]
async def _handle_tool_stream(self, event: ToolStreamEvent) -> None:
- if self.current_tool_call:
- self.current_tool_call.set_stream_message(event.message)
+ tool_call = self.tool_calls.get(event.tool_call_id)
+ if tool_call:
+ tool_call.set_stream_message(event.message)
async def _handle_assistant_message(self, event: AssistantEvent) -> None:
if self.current_streaming_reasoning is not None:
@@ -131,6 +141,8 @@ class EventHandler:
async def _handle_reasoning_message(self, event: ReasoningEvent) -> None:
if self.current_streaming_message is not None:
await self.current_streaming_message.stop_stream()
+ if self.current_streaming_message.is_stripped_content_empty():
+ await self.current_streaming_message.remove()
self.current_streaming_message = None
if self.current_streaming_reasoning is None:
@@ -166,9 +178,9 @@ class EventHandler:
self.current_streaming_message = None
def stop_current_tool_call(self, success: bool = True) -> None:
- if self.current_tool_call:
- self.current_tool_call.stop_spinning(success=success)
- self.current_tool_call = None
+ for tool_call in self.tool_calls.values():
+ tool_call.stop_spinning(success=success)
+ self.tool_calls.clear()
def stop_current_compact(self) -> None:
if self.current_compact:
diff --git a/vibe/cli/textual_ui/notifications/__init__.py b/vibe/cli/textual_ui/notifications/__init__.py
new file mode 100644
index 0000000..10c0250
--- /dev/null
+++ b/vibe/cli/textual_ui/notifications/__init__.py
@@ -0,0 +1,11 @@
+from __future__ import annotations
+
+from vibe.cli.textual_ui.notifications.adapters.textual_notification_adapter import (
+ TextualNotificationAdapter,
+)
+from vibe.cli.textual_ui.notifications.ports.notification_port import (
+ NotificationContext,
+ NotificationPort,
+)
+
+__all__ = ["NotificationContext", "NotificationPort", "TextualNotificationAdapter"]
diff --git a/vibe/cli/textual_ui/notifications/adapters/__init__.py b/vibe/cli/textual_ui/notifications/adapters/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/vibe/cli/textual_ui/notifications/adapters/textual_notification_adapter.py b/vibe/cli/textual_ui/notifications/adapters/textual_notification_adapter.py
new file mode 100644
index 0000000..8b85052
--- /dev/null
+++ b/vibe/cli/textual_ui/notifications/adapters/textual_notification_adapter.py
@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+import time
+
+from textual.app import App
+
+from vibe.cli.textual_ui.notifications.ports.notification_port import (
+ NotificationContext,
+)
+
+NOTIFICATION_TITLE_SUFFIXES: dict[NotificationContext, str] = {
+ NotificationContext.ACTION_REQUIRED: "Action Required",
+ NotificationContext.COMPLETE: "Task Complete",
+}
+
+NOTIFICATION_THROTTLE_SECONDS: float = 1.0
+
+
+class TextualNotificationAdapter:
+ def __init__(
+ self, app: App, *, get_enabled: Callable[[], bool], default_title: str = "App"
+ ) -> None:
+ self._app = app
+ self._get_enabled = get_enabled
+ self._default_title = default_title
+ self._has_focus: bool = True
+ self._last_notification_time: float = 0.0
+
+ def notify(self, context: NotificationContext) -> None:
+ if not self._get_enabled() or self._has_focus:
+ return
+
+ current_time = time.monotonic()
+ if current_time - self._last_notification_time < NOTIFICATION_THROTTLE_SECONDS:
+ return
+
+ self._last_notification_time = current_time
+ self._app.bell()
+ self._set_title(self._get_notification_title(context))
+
+ def on_focus(self) -> None:
+ self._has_focus = True
+ self.restore()
+
+ def on_blur(self) -> None:
+ self._has_focus = False
+
+ def restore(self) -> None:
+ self._set_title(self._default_title)
+
+ def _get_notification_title(self, context: NotificationContext) -> str:
+ suffix = NOTIFICATION_TITLE_SUFFIXES.get(context)
+ if suffix is None:
+ return self._default_title
+ return f"{self._default_title} - {suffix}"
+
+ def _set_title(self, title: str) -> None:
+ if not self._app.is_headless and self._app._driver is not None:
+ self._app._driver.write(f"\x1b]0;{title}\x07")
diff --git a/vibe/cli/textual_ui/notifications/ports/__init__.py b/vibe/cli/textual_ui/notifications/ports/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/vibe/cli/textual_ui/notifications/ports/notification_port.py b/vibe/cli/textual_ui/notifications/ports/notification_port.py
new file mode 100644
index 0000000..4d18b39
--- /dev/null
+++ b/vibe/cli/textual_ui/notifications/ports/notification_port.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from enum import StrEnum, auto
+from typing import Protocol
+
+
+class NotificationContext(StrEnum):
+ ACTION_REQUIRED = auto()
+ COMPLETE = auto()
+
+
+class NotificationPort(Protocol):
+ def notify(self, context: NotificationContext) -> None: ...
+ def on_focus(self) -> None: ...
+ def on_blur(self) -> None: ...
+ def restore(self) -> None: ...
diff --git a/vibe/cli/textual_ui/widgets/chat_input/body.py b/vibe/cli/textual_ui/widgets/chat_input/body.py
index 407b2ef..636bcfd 100644
--- a/vibe/cli/textual_ui/widgets/chat_input/body.py
+++ b/vibe/cli/textual_ui/widgets/chat_input/body.py
@@ -2,16 +2,31 @@ from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
-from typing import Any
+from typing import Any, ClassVar
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.message import Message
from textual.widget import Widget
+from textual.widgets import Static
from vibe.cli.history_manager import HistoryManager
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
+
+
+class _PromptSpinner(SpinnerMixin, Static):
+ SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.BRAILLE
+
+ def __init__(self) -> None:
+ self._indicator_widget: Static | None = None
+ self.init_spinner()
+ super().__init__(self._spinner.current_frame(), id="prompt-spinner")
+
+ def on_mount(self) -> None:
+ self._indicator_widget = self
+ self.start_spinner_timer()
class ChatInputBody(Widget):
@@ -30,6 +45,7 @@ class ChatInputBody(Widget):
self.input_widget: ChatTextArea | None = None
self.prompt_widget: NoMarkupStatic | None = None
self._nuage_enabled = nuage_enabled
+ self._switching_mode = False
if history_file:
self.history = HistoryManager(history_file)
@@ -159,6 +175,9 @@ class ChatInputBody(Widget):
def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None:
event.stop()
+ if self._switching_mode:
+ return
+
if not self.input_widget:
return
@@ -175,6 +194,25 @@ class ChatInputBody(Widget):
self.post_message(self.Submitted(value))
+ @property
+ def switching_mode(self) -> bool:
+ return self._switching_mode
+
+ @switching_mode.setter
+ def switching_mode(self, value: bool) -> None:
+ self._switching_mode = value
+ if value:
+ if self.prompt_widget:
+ self.prompt_widget.display = False
+ if not self.query(_PromptSpinner):
+ self.query_one(Horizontal).mount(_PromptSpinner(), before=0)
+ else:
+ for spinner in self.query(_PromptSpinner):
+ spinner.remove()
+ if self.prompt_widget:
+ self.prompt_widget.display = True
+ self._update_prompt()
+
@property
def value(self) -> str:
if not self.input_widget:
diff --git a/vibe/cli/textual_ui/widgets/chat_input/container.py b/vibe/cli/textual_ui/widgets/chat_input/container.py
index 43857b3..db20463 100644
--- a/vibe/cli/textual_ui/widgets/chat_input/container.py
+++ b/vibe/cli/textual_ui/widgets/chat_input/container.py
@@ -180,6 +180,15 @@ class ChatInputContainer(Vertical):
event.stop()
self.post_message(self.Submitted(event.value))
+ @property
+ def switching_mode(self) -> bool:
+ return self._body.switching_mode if self._body else False
+
+ @switching_mode.setter
+ def switching_mode(self, value: bool) -> None:
+ if self._body:
+ self._body.switching_mode = value
+
def set_safety(self, safety: AgentSafety) -> None:
self._safety = safety
diff --git a/vibe/cli/textual_ui/widgets/loading.py b/vibe/cli/textual_ui/widgets/loading.py
index f497257..3e1aad0 100644
--- a/vibe/cli/textual_ui/widgets/loading.py
+++ b/vibe/cli/textual_ui/widgets/loading.py
@@ -69,6 +69,7 @@ class LoadingWidget(SpinnerMixin, Static):
self.init_spinner()
self.status = status or self._get_default_status()
self.current_color_index = 0
+ self._color_direction = 1
self.transition_progress = 0
self._status_widget: Static | None = None
self.hint_widget: Static | None = None
@@ -141,11 +142,12 @@ class LoadingWidget(SpinnerMixin, Static):
return
self._update_animation()
+ def _next_color_index(self) -> int:
+ return self.current_color_index + self._color_direction
+
def _get_color_for_position(self, position: int) -> str:
current_color = self.TARGET_COLORS[self.current_color_index]
- next_color = self.TARGET_COLORS[
- (self.current_color_index + 1) % len(self.TARGET_COLORS)
- ]
+ next_color = self.TARGET_COLORS[self._next_color_index()]
if position < self.transition_progress:
return next_color
return current_color
@@ -173,9 +175,9 @@ class LoadingWidget(SpinnerMixin, Static):
self.transition_progress += 1
if self.transition_progress > total_elements:
- self.current_color_index = (self.current_color_index + 1) % len(
- self.TARGET_COLORS
- )
+ self.current_color_index = self._next_color_index()
+ if not 0 < self.current_color_index < len(self.TARGET_COLORS) - 1:
+ self._color_direction *= -1
self.transition_progress = 0
if self.hint_widget and self.start_time is not None:
diff --git a/vibe/cli/textual_ui/widgets/messages.py b/vibe/cli/textual_ui/widgets/messages.py
index d0b0d54..207efa6 100644
--- a/vibe/cli/textual_ui/widgets/messages.py
+++ b/vibe/cli/textual_ui/widgets/messages.py
@@ -106,6 +106,9 @@ class StreamingMessageBase(Static):
def _should_write_content(self) -> bool:
return True
+ def is_stripped_content_empty(self) -> bool:
+ return self._content.strip() == ""
+
class AssistantMessage(StreamingMessageBase):
def __init__(self, content: str) -> None:
diff --git a/vibe/cli/textual_ui/widgets/proxy_setup_app.py b/vibe/cli/textual_ui/widgets/proxy_setup_app.py
index baafba7..c4076fb 100644
--- a/vibe/cli/textual_ui/widgets/proxy_setup_app.py
+++ b/vibe/cli/textual_ui/widgets/proxy_setup_app.py
@@ -43,26 +43,20 @@ class ProxySetupApp(Container):
with Vertical(id="proxysetup-content"):
yield NoMarkupStatic("Proxy Configuration", classes="settings-title")
- yield NoMarkupStatic("")
for key, description in SUPPORTED_PROXY_VARS.items():
- yield Static(
- f"[bold ansi_blue]{key}[/] [dim]{description}[/dim]",
- classes="proxy-label-line",
- )
+ yield Static(f"[bold ansi_blue]{key}[/]", classes="proxy-label-line")
initial_value = self.initial_values.get(key) or ""
input_widget = Input(
value=initial_value,
- placeholder="NOT SET",
+ placeholder=description,
id=f"proxy-input-{key}",
classes="proxy-input",
)
self.inputs[key] = input_widget
yield input_widget
- yield NoMarkupStatic("")
-
yield NoMarkupStatic(
"↑↓ navigate Enter save & exit ESC cancel", classes="settings-help"
)
@@ -92,7 +86,7 @@ class ProxySetupApp(Container):
prev_idx = (idx - 1) % len(inputs)
inputs[prev_idx].focus()
- def on_input_submitted(self, event: Input.Submitted) -> None:
+ def on_input_submitted(self, _event: Input.Submitted) -> None:
self._save_and_close()
def on_blur(self, _event: events.Blur) -> None:
diff --git a/vibe/cli/textual_ui/widgets/session_picker.py b/vibe/cli/textual_ui/widgets/session_picker.py
new file mode 100644
index 0000000..11404b5
--- /dev/null
+++ b/vibe/cli/textual_ui/widgets/session_picker.py
@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import TYPE_CHECKING, Any, ClassVar
+
+from rich.text import Text
+from textual.app import ComposeResult
+from textual.binding import Binding, BindingType
+from textual.containers import Container, Vertical
+from textual.message import Message
+from textual.widgets import OptionList
+from textual.widgets.option_list import Option
+
+from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
+
+if TYPE_CHECKING:
+ from vibe.core.session.session_loader import SessionInfo
+
+_SECONDS_PER_MINUTE = 60
+_SECONDS_PER_HOUR = 3600
+_SECONDS_PER_DAY = 86400
+_SECONDS_PER_WEEK = 604800
+
+
+def _format_relative_time(iso_time: str | None) -> str:
+ if not iso_time:
+ return "unknown"
+ try:
+ dt = datetime.fromisoformat(iso_time.replace("Z", "+00:00"))
+ now = datetime.now(UTC)
+ delta = now - dt
+ seconds = int(delta.total_seconds())
+
+ if seconds < _SECONDS_PER_MINUTE:
+ return "just now"
+ for threshold, divisor, unit in [
+ (_SECONDS_PER_HOUR, _SECONDS_PER_MINUTE, "m"),
+ (_SECONDS_PER_DAY, _SECONDS_PER_HOUR, "h"),
+ (_SECONDS_PER_WEEK, _SECONDS_PER_DAY, "d"),
+ (float("inf"), _SECONDS_PER_WEEK, "w"),
+ ]:
+ if seconds < threshold:
+ return f"{seconds // divisor}{unit} ago"
+ except (ValueError, OSError):
+ pass
+ return "unknown"
+
+
+def _build_option_text(session: SessionInfo, message: str) -> Text:
+ text = Text(no_wrap=True)
+ time_str = _format_relative_time(session.get("end_time"))
+ session_id = session["session_id"][:8]
+ text.append(f"{time_str:10}", style="dim")
+ text.append(" ")
+ text.append(f"{session_id} ", style="dim")
+ text.append(message)
+ return text
+
+
+class SessionPickerApp(Container):
+ """Session picker for /resume command."""
+
+ can_focus_children = True
+
+ BINDINGS: ClassVar[list[BindingType]] = [
+ Binding("escape", "cancel", "Cancel", show=False)
+ ]
+
+ class SessionSelected(Message):
+ def __init__(self, session_id: str) -> None:
+ self.session_id = session_id
+ super().__init__()
+
+ class Cancelled(Message):
+ pass
+
+ def __init__(
+ self,
+ sessions: list[SessionInfo],
+ latest_messages: dict[str, str],
+ **kwargs: Any,
+ ) -> None:
+ super().__init__(id="sessionpicker-app", **kwargs)
+ self._sessions = sessions
+ self._latest_messages = latest_messages
+
+ def compose(self) -> ComposeResult:
+ options = [
+ Option(
+ _build_option_text(
+ session,
+ self._latest_messages.get(session["session_id"], "(empty session)"),
+ ),
+ id=session["session_id"],
+ )
+ for session in self._sessions
+ ]
+ with Vertical(id="sessionpicker-content"):
+ yield OptionList(*options, id="sessionpicker-options")
+ yield NoMarkupStatic(
+ "Up/Down Navigate Enter Select Esc Cancel",
+ classes="sessionpicker-help",
+ )
+
+ def on_mount(self) -> None:
+ self.query_one(OptionList).focus()
+
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+ if event.option.id:
+ self.post_message(self.SessionSelected(event.option.id))
+
+ def action_cancel(self) -> None:
+ self.post_message(self.Cancelled())
diff --git a/vibe/cli/textual_ui/widgets/tool_widgets.py b/vibe/cli/textual_ui/widgets/tool_widgets.py
index d7cd34a..00e2de6 100644
--- a/vibe/cli/textual_ui/widgets/tool_widgets.py
+++ b/vibe/cli/textual_ui/widgets/tool_widgets.py
@@ -42,8 +42,8 @@ def parse_search_replace_to_diff(content: str) -> list[str]:
for i, (search_text, replace_text) in enumerate(matches):
if i > 0:
all_diff_lines.append("") # Separator between blocks
- search_lines = search_text.strip().split("\n")
- replace_lines = replace_text.strip().split("\n")
+ search_lines = search_text.strip("\n").split("\n")
+ replace_lines = replace_text.strip("\n").split("\n")
diff = difflib.unified_diff(search_lines, replace_lines, lineterm="", n=2)
all_diff_lines.extend(list(diff)[2:]) # Skip file headers
@@ -74,8 +74,10 @@ class ToolApprovalWidget[TArgs: BaseModel](Vertical):
def compose(self) -> ComposeResult:
MAX_MSG_SIZE = 150
- for field_name in type(self.args).model_fields:
- value = getattr(self.args, field_name)
+ model_cls = type(self.args)
+ field_names = model_cls.model_fields or self.args.model_extra or {}
+ for field_name in field_names:
+ value = getattr(self.args, field_name, None)
if value is None or value in ("", []):
continue
value_str = str(value)
diff --git a/vibe/cli/textual_ui/widgets/tools.py b/vibe/cli/textual_ui/widgets/tools.py
index 964a7f1..8bf0193 100644
--- a/vibe/cli/textual_ui/widgets/tools.py
+++ b/vibe/cli/textual_ui/widgets/tools.py
@@ -20,7 +20,7 @@ class ToolCallMessage(StatusMessage):
raise ValueError("Either event or tool_name must be provided")
self._event = event
- self._tool_name = tool_name or (event.tool_name if event else "unknown")
+ self._tool_name = tool_name or (event.tool_name if event else None) or "unknown"
self._is_history = event is None
self._stream_widget: NoMarkupStatic | None = None
@@ -44,6 +44,7 @@ class ToolCallMessage(StatusMessage):
yield self._stream_widget
def on_mount(self) -> None:
+ super().on_mount()
siblings = list(self.parent.children) if self.parent else []
idx = siblings.index(self) if self in siblings else -1
if idx > 0 and isinstance(
@@ -51,13 +52,23 @@ class ToolCallMessage(StatusMessage):
):
self.add_class("no-gap")
+ @property
+ def tool_call_id(self) -> str | None:
+ return self._event.tool_call_id if self._event else None
+
def get_content(self) -> str:
- if self._event and self._event.tool_class:
+ if self._event:
adapter = ToolUIDataAdapter(self._event.tool_class)
display = adapter.get_call_display(self._event)
return display.summary
return self._tool_name
+ def update_event(self, event: ToolCallEvent) -> None:
+ self._event = event
+ self._tool_name = event.tool_name
+ if self._text_widget:
+ self._text_widget.update(self.get_content())
+
def set_stream_message(self, message: str) -> None:
"""Update the stream message displayed below the tool call indicator."""
if self._stream_widget:
@@ -151,7 +162,13 @@ class ToolResultMessage(Static):
await self._content_container.remove_children()
if self._event is None:
- self.display = False
+ if self._content:
+ await self._content_container.mount(
+ NoMarkupStatic(self._content, classes="tool-result-detail")
+ )
+ self.display = not self.collapsed
+ else:
+ self.display = False
return
if self._event.error:
diff --git a/vibe/cli/textual_ui/windowing/history.py b/vibe/cli/textual_ui/windowing/history.py
index 4b0c8c7..2a70404 100644
--- a/vibe/cli/textual_ui/windowing/history.py
+++ b/vibe/cli/textual_ui/windowing/history.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from collections.abc import Sequence
from weakref import WeakKeyDictionary
from textual.widget import Widget
@@ -13,11 +14,11 @@ from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.core.types import LLMMessage, Role
-def non_system_history_messages(messages: list[LLMMessage]) -> list[LLMMessage]:
+def non_system_history_messages(messages: Sequence[LLMMessage]) -> list[LLMMessage]:
return [msg for msg in messages if msg.role != Role.system]
-def build_tool_call_map(messages: list[LLMMessage]) -> dict[str, str]:
+def build_tool_call_map(messages: Sequence[LLMMessage]) -> dict[str, str]:
tool_call_map: dict[str, str] = {}
for msg in messages:
if msg.role != Role.assistant or not msg.tool_calls:
@@ -29,7 +30,7 @@ def build_tool_call_map(messages: list[LLMMessage]) -> dict[str, str]:
def build_history_widgets(
- batch: list[LLMMessage],
+ batch: Sequence[LLMMessage],
tool_call_map: dict[str, str],
*,
start_index: int,
diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py
index f2cb1eb..a2cdd32 100644
--- a/vibe/core/agent_loop.py
+++ b/vibe/core/agent_loop.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import asyncio
-from collections.abc import AsyncGenerator, Callable
+from collections.abc import AsyncGenerator, Callable, Generator
from enum import StrEnum, auto
from http import HTTPStatus
import json
@@ -13,6 +13,7 @@ from uuid import uuid4
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
@@ -26,14 +27,18 @@ from vibe.core.llm.format import (
)
from vibe.core.llm.types import BackendLike
from vibe.core.middleware import (
+ CHAT_AGENT_EXIT,
+ CHAT_AGENT_REMINDER,
+ PLAN_AGENT_EXIT,
+ PLAN_AGENT_REMINDER,
AutoCompactMiddleware,
ContextWarningMiddleware,
ConversationContext,
MiddlewareAction,
MiddlewarePipeline,
MiddlewareResult,
- PlanAgentMiddleware,
PriceLimitMiddleware,
+ ReadOnlyAgentMiddleware,
ResetReason,
TurnLimitMiddleware,
)
@@ -52,6 +57,8 @@ from vibe.core.tools.base import (
ToolPermissionError,
)
from vibe.core.tools.manager import ToolManager
+from vibe.core.tools.mcp import MCPRegistry
+from vibe.core.tools.mcp_sampling import MCPSamplingHandler
from vibe.core.trusted_folders import has_agents_md_file
from vibe.core.types import (
AgentStats,
@@ -62,13 +69,16 @@ from vibe.core.types import (
BaseEvent,
CompactEndEvent,
CompactStartEvent,
+ EntrypointMetadata,
LLMChunk,
LLMMessage,
LLMUsage,
+ MessageList,
RateLimitError,
ReasoningEvent,
Role,
SyncApprovalCallback,
+ ToolCall,
ToolCallEvent,
ToolResultEvent,
ToolStreamEvent,
@@ -139,6 +149,7 @@ class AgentLoop:
max_price: float | None = None,
backend: BackendLike | None = None,
enable_streaming: bool = False,
+ entrypoint_metadata: EntrypointMetadata | None = None,
) -> None:
self._base_config = config
self._max_turns = max_turns
@@ -147,15 +158,20 @@ class AgentLoop:
self.agent_manager = AgentManager(
lambda: self._base_config, initial_agent=agent_name
)
- self.tool_manager = ToolManager(lambda: self.config)
+ self._mcp_registry = MCPRegistry()
+ self.tool_manager = ToolManager(
+ lambda: self.config, mcp_registry=self._mcp_registry
+ )
self.skill_manager = SkillManager(lambda: self.config)
self.format_handler = APIToolFormatHandler()
self.backend_factory = lambda: backend or self._select_backend()
self.backend = self.backend_factory()
+ self._sampling_handler = MCPSamplingHandler(
+ backend_getter=lambda: self.backend, config_getter=lambda: self.config
+ )
self.message_observer = message_observer
- self._last_observed_message_index: int = 0
self.enable_streaming = enable_streaming
self.middleware_pipeline = MiddlewarePipeline()
self._setup_middleware()
@@ -163,11 +179,8 @@ class AgentLoop:
system_prompt = get_universal_system_prompt(
self.tool_manager, self.config, self.skill_manager, self.agent_manager
)
- self.messages = [LLMMessage(role=Role.system, content=system_prompt)]
-
- if self.message_observer:
- self.message_observer(self.messages[0])
- self._last_observed_message_index = 1
+ system_message = LLMMessage(role=Role.system, content=system_prompt)
+ self.messages = MessageList(initial=[system_message], observer=message_observer)
self.stats = AgentStats()
try:
@@ -180,6 +193,7 @@ class AgentLoop:
self.approval_callback: ApprovalCallback | None = None
self.user_input_callback: UserInputCallback | None = None
+ self.entrypoint_metadata = entrypoint_metadata
self.session_id = str(uuid4())
self._current_user_message_id: str | None = None
@@ -221,19 +235,28 @@ class AgentLoop:
self.config.tools[tool_name].permission = permission
self.tool_manager.invalidate_tool(tool_name)
- def emit_new_session_telemetry(
- self, entrypoint: Literal["cli", "acp", "programmatic"]
- ) -> None:
+ def emit_new_session_telemetry(self) -> None:
+ entrypoint = (
+ self.entrypoint_metadata.agent_entrypoint
+ if self.entrypoint_metadata
+ else "unknown"
+ )
has_agents_md = has_agents_md_file(Path.cwd())
nb_skills = len(self.skill_manager.available_skills)
nb_mcp_servers = len(self.config.mcp_servers)
nb_models = len(self.config.models)
+
+ terminal_emulator = None
+ if entrypoint == "cli":
+ terminal_emulator = detect_terminal().value
+
self.telemetry_client.send_new_session(
has_agents_md=has_agents_md,
nb_skills=nb_skills,
nb_mcp_servers=nb_mcp_servers,
nb_models=nb_models,
entrypoint=entrypoint,
+ terminal_emulator=terminal_emulator,
)
def _select_backend(self) -> BackendLike:
@@ -242,9 +265,6 @@ class AgentLoop:
timeout = self.config.api_timeout
return BACKEND_FACTORY[provider.backend](provider=provider, timeout=timeout)
- def add_message(self, message: LLMMessage) -> None:
- self.messages.append(message)
-
async def _save_messages(self) -> None:
await self.session_logger.save_interaction(
self.messages,
@@ -254,19 +274,6 @@ class AgentLoop:
self.agent_profile,
)
- async def _flush_new_messages(self) -> None:
- await self._save_messages()
-
- if not self.message_observer:
- return
-
- if self._last_observed_message_index >= len(self.messages):
- return
-
- for msg in self.messages[self._last_observed_message_index :]:
- self.message_observer(msg)
- self._last_observed_message_index = len(self.messages)
-
async def act(self, msg: str) -> AsyncGenerator[BaseEvent]:
self._clean_message_history()
async for event in self._conversation_loop(msg):
@@ -345,7 +352,22 @@ class AgentLoop:
ContextWarningMiddleware(0.5, self.config.auto_compact_threshold)
)
- self.middleware_pipeline.add(PlanAgentMiddleware(lambda: self.agent_profile))
+ self.middleware_pipeline.add(
+ ReadOnlyAgentMiddleware(
+ lambda: self.agent_profile,
+ BuiltinAgentName.PLAN,
+ PLAN_AGENT_REMINDER,
+ PLAN_AGENT_EXIT,
+ )
+ )
+ self.middleware_pipeline.add(
+ ReadOnlyAgentMiddleware(
+ lambda: self.agent_profile,
+ BuiltinAgentName.CHAT,
+ CHAT_AGENT_REMINDER,
+ CHAT_AGENT_EXIT,
+ )
+ )
async def _handle_middleware_result(
self, result: MiddlewareResult
@@ -440,7 +462,7 @@ class AgentLoop:
if is_user_cancellation_event(event):
user_cancelled = True
yield event
- await self._flush_new_messages()
+ await self._save_messages()
last_message = self.messages[-1]
should_break_loop = last_message.role != Role.tool
@@ -449,7 +471,7 @@ class AgentLoop:
return
finally:
- await self._flush_new_messages()
+ await self._save_messages()
async def _perform_llm_turn(self) -> AsyncGenerator[BaseEvent, None]:
if self.enable_streaming:
@@ -471,15 +493,42 @@ class AgentLoop:
async for event in self._handle_tool_calls(resolved):
yield event
+ def _build_tool_call_events(
+ self, tool_calls: list[ToolCall] | None, emitted_ids: set[str]
+ ) -> Generator[ToolCallEvent, None, None]:
+ for tc in tool_calls or []:
+ if tc.id is None or not tc.function.name:
+ continue
+ if tc.id in emitted_ids:
+ continue
+
+ tool_class = self.tool_manager.available_tools.get(tc.function.name)
+ if tool_class is None:
+ continue
+
+ yield ToolCallEvent(
+ tool_call_id=tc.id,
+ tool_call_index=tc.index,
+ tool_name=tc.function.name,
+ tool_class=tool_class,
+ )
+
async def _stream_assistant_events(
self,
- ) -> AsyncGenerator[AssistantEvent | ReasoningEvent]:
+ ) -> AsyncGenerator[AssistantEvent | ReasoningEvent | ToolCallEvent]:
message_id: str | None = None
+ emitted_tool_call_ids = set[str]()
async for chunk in self._chat_streaming():
if message_id is None:
message_id = chunk.message.message_id
+ for event in self._build_tool_call_events(
+ chunk.message.tool_calls, emitted_tool_call_ids
+ ):
+ emitted_tool_call_ids.add(event.tool_call_id)
+ yield event
+
if chunk.message.reasoning_content:
yield ReasoningEvent(
content=chunk.message.reasoning_content, message_id=message_id
@@ -560,9 +609,12 @@ class AgentLoop:
async for item in tool_instance.invoke(
ctx=InvokeContext(
tool_call_id=tool_call.call_id,
- approval_callback=self.approval_callback,
agent_manager=self.agent_manager,
+ session_dir=self.session_logger.session_dir,
+ entrypoint_metadata=self.entrypoint_metadata,
+ approval_callback=self.approval_callback,
user_input_callback=self.user_input_callback,
+ sampling_callback=self._sampling_handler,
),
**tool_call.args_dict,
):
@@ -671,6 +723,9 @@ 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,
)
end_time = time.perf_counter()
@@ -714,6 +769,9 @@ 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,
):
processed_message = self.format_handler.process_api_response_message(
chunk.message
@@ -759,37 +817,26 @@ class AgentLoop:
approval_type=ToolPermission.ALWAYS,
)
- allowlist_denylist_result = tool.check_allowlist_denylist(args)
- if allowlist_denylist_result == ToolPermission.ALWAYS:
- return ToolDecision(
- verdict=ToolExecutionResponse.EXECUTE,
- approval_type=ToolPermission.ALWAYS,
- )
- elif allowlist_denylist_result == ToolPermission.NEVER:
- denylist_patterns = tool.config.denylist
- denylist_str = ", ".join(repr(pattern) for pattern in denylist_patterns)
- return ToolDecision(
- verdict=ToolExecutionResponse.SKIP,
- approval_type=ToolPermission.NEVER,
- feedback=f"Tool '{tool.get_name()}' blocked by denylist: [{denylist_str}]",
- )
-
tool_name = tool.get_name()
- perm = self.tool_manager.get_tool_config(tool_name).permission
+ effective = (
+ tool.resolve_permission(args)
+ or self.tool_manager.get_tool_config(tool_name).permission
+ )
- if perm is ToolPermission.ALWAYS:
- return ToolDecision(
- verdict=ToolExecutionResponse.EXECUTE,
- approval_type=ToolPermission.ALWAYS,
- )
- if perm is ToolPermission.NEVER:
- return ToolDecision(
- verdict=ToolExecutionResponse.SKIP,
- approval_type=ToolPermission.NEVER,
- feedback=f"Tool '{tool_name}' is permanently disabled",
- )
-
- return await self._ask_approval(tool_name, args, tool_call_id)
+ match effective:
+ case ToolPermission.ALWAYS:
+ return ToolDecision(
+ verdict=ToolExecutionResponse.EXECUTE,
+ approval_type=ToolPermission.ALWAYS,
+ )
+ case ToolPermission.NEVER:
+ return ToolDecision(
+ verdict=ToolExecutionResponse.SKIP,
+ approval_type=ToolPermission.NEVER,
+ feedback=f"Tool '{tool_name}' is permanently disabled",
+ )
+ case _:
+ return await self._ask_approval(tool_name, args, tool_call_id)
async def _ask_approval(
self, tool_name: str, args: BaseModel, tool_call_id: str
@@ -852,9 +899,11 @@ class AgentLoop:
empty_response = LLMMessage(
role=Role.tool,
tool_call_id=tool_call_data.id or "",
- name=(tool_call_data.function.name or "")
- if tool_call_data.function
- else "",
+ name=(
+ (tool_call_data.function.name or "")
+ if tool_call_data.function
+ else ""
+ ),
content=str(
get_user_cancellation_message(
CancellationReason.TOOL_NO_RESPONSE
@@ -898,7 +947,7 @@ class AgentLoop:
self.tool_manager,
self.agent_profile,
)
- self.messages = self.messages[:1]
+ self.messages.reset(self.messages[:1])
self.stats = AgentStats.create_fresh(self.stats)
self.stats.trigger_listeners()
@@ -927,10 +976,14 @@ class AgentLoop:
)
summary_request = UtilityPrompt.COMPACT.read()
- self.messages.append(LLMMessage(role=Role.user, content=summary_request))
self.stats.steps += 1
- summary_result = await self._chat()
+ with self.messages.silent():
+ self.messages.append(
+ LLMMessage(role=Role.user, content=summary_request)
+ )
+ summary_result = await self._chat()
+
if summary_result.usage is None:
raise AgentLoopLLMResponseError(
"Usage data missing in compaction summary response"
@@ -939,8 +992,7 @@ class AgentLoop:
system_message = self.messages[0]
summary_message = LLMMessage(role=Role.user, content=summary_content)
- self.messages = [system_message, summary_message]
- self._last_observed_message_index = 1
+ self.messages.reset([system_message, summary_message])
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
@@ -950,6 +1002,9 @@ 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,
)
self.stats.context_tokens = actual_context_tokens
@@ -1015,17 +1070,19 @@ class AgentLoop:
if max_price is not None:
self._max_price = max_price
- self.tool_manager = ToolManager(lambda: self.config)
+ self.tool_manager = ToolManager(
+ lambda: self.config, mcp_registry=self._mcp_registry
+ )
self.skill_manager = SkillManager(lambda: self.config)
new_system_prompt = get_universal_system_prompt(
self.tool_manager, self.config, self.skill_manager, self.agent_manager
)
- self.messages = [
+ self.messages.reset([
LLMMessage(role=Role.system, content=new_system_prompt),
*[msg for msg in self.messages if msg.role != Role.system],
- ]
+ ])
if len(self.messages) == 1:
self.stats.reset_context_state()
diff --git a/vibe/core/agents/manager.py b/vibe/core/agents/manager.py
index 477ba09..2d1a346 100644
--- a/vibe/core/agents/manager.py
+++ b/vibe/core/agents/manager.py
@@ -1,7 +1,6 @@
from __future__ import annotations
from collections.abc import Callable
-from logging import getLogger
from pathlib import Path
from typing import TYPE_CHECKING
@@ -11,15 +10,14 @@ from vibe.core.agents.models import (
AgentType,
BuiltinAgentName,
)
-from vibe.core.paths.config_paths import resolve_local_agents_dir
+from vibe.core.logger import logger
+from vibe.core.paths.config_paths import discover_local_agents_dirs
from vibe.core.paths.global_paths import GLOBAL_AGENTS_DIR
from vibe.core.utils import name_matches
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
-logger = getLogger("vibe")
-
class AgentManager:
def __init__(
@@ -77,6 +75,10 @@ class AgentManager:
self.active_profile = self.get_agent(name)
self._cached_config = None
+ def register_agent(self, profile: AgentProfile) -> None:
+ self._available[profile.name] = profile
+ self._cached_config = None
+
def invalidate_config(self) -> None:
self._cached_config = None
@@ -86,8 +88,7 @@ class AgentManager:
for path in config.agent_paths:
if path.is_dir():
paths.append(path)
- if (agents_dir := resolve_local_agents_dir(Path.cwd())) is not None:
- paths.append(agents_dir)
+ paths.extend(discover_local_agents_dirs(Path.cwd()))
if GLOBAL_AGENTS_DIR.path.is_dir():
paths.append(GLOBAL_AGENTS_DIR.path)
unique: list[Path] = []
diff --git a/vibe/core/agents/models.py b/vibe/core/agents/models.py
index 608ab6a..a79066d 100644
--- a/vibe/core/agents/models.py
+++ b/vibe/core/agents/models.py
@@ -34,6 +34,7 @@ class AgentType(StrEnum):
class BuiltinAgentName(StrEnum):
DEFAULT = "default"
+ CHAT = "chat"
PLAN = "plan"
ACCEPT_EDITS = "accept-edits"
AUTO_APPROVE = "auto-approve"
@@ -69,6 +70,7 @@ class AgentProfile:
)
+CHAT_AGENT_TOOLS = ["grep", "read_file", "ask_user_question", "task"]
PLAN_AGENT_TOOLS = ["grep", "read_file", "todo", "ask_user_question", "task"]
DEFAULT = AgentProfile(
@@ -84,6 +86,13 @@ PLAN = AgentProfile(
AgentSafety.SAFE,
overrides={"auto_approve": True, "enabled_tools": PLAN_AGENT_TOOLS},
)
+CHAT = AgentProfile(
+ BuiltinAgentName.CHAT,
+ "Chat",
+ "Read-only conversational mode for questions and discussions",
+ AgentSafety.SAFE,
+ overrides={"auto_approve": True, "enabled_tools": CHAT_AGENT_TOOLS},
+)
ACCEPT_EDITS = AgentProfile(
BuiltinAgentName.ACCEPT_EDITS,
"Accept Edits",
diff --git a/vibe/core/autocompletion/file_indexer/ignore_rules.py b/vibe/core/autocompletion/file_indexer/ignore_rules.py
index 19eebe7..f59939c 100644
--- a/vibe/core/autocompletion/file_indexer/ignore_rules.py
+++ b/vibe/core/autocompletion/file_indexer/ignore_rules.py
@@ -60,6 +60,32 @@ class IgnoreRules:
self._patterns: list[CompiledPattern] | None = None
self._root: Path | None = None
+ def _compile_default_patterns(self) -> list[CompiledPattern]:
+ patterns: list[CompiledPattern] = []
+ for raw, is_exclude in self._defaults:
+ anchor_root = raw.startswith("/")
+ if anchor_root:
+ raw = raw[1:]
+ stripped = raw.rstrip("/")
+ patterns.append(
+ CompiledPattern(
+ raw=raw,
+ stripped=stripped,
+ is_exclude=is_exclude,
+ dir_only=raw.endswith("/"),
+ name_only="/" not in stripped,
+ anchor_root=anchor_root,
+ )
+ )
+ return patterns
+
+ def get_walk_skip_dir_names(self) -> frozenset[str]:
+ return frozenset(
+ p.stripped
+ for p in self._compile_default_patterns()
+ if p.dir_only and p.name_only and not p.anchor_root
+ )
+
def ensure_for_root(self, root: Path) -> None:
resolved_root = root.resolve()
if self._patterns is None or self._root != resolved_root:
@@ -81,24 +107,7 @@ class IgnoreRules:
self._root = None
def _build_patterns(self, root: Path) -> list[CompiledPattern]:
- patterns: list[CompiledPattern] = []
- for raw, is_exclude in self._defaults:
- anchor_root = raw.startswith("/")
- if anchor_root:
- raw = raw[1:]
-
- stripped = raw.rstrip("/")
- patterns.append(
- CompiledPattern(
- raw=raw,
- stripped=stripped,
- is_exclude=is_exclude,
- dir_only=raw.endswith("/"),
- name_only="/" not in stripped,
- anchor_root=anchor_root,
- )
- )
-
+ patterns = self._compile_default_patterns()
gitignore_path = root / ".gitignore"
if gitignore_path.exists():
try:
@@ -154,3 +163,6 @@ class IgnoreRules:
return False
return not pattern.dir_only or is_dir
+
+
+WALK_SKIP_DIR_NAMES: frozenset[str] = IgnoreRules().get_walk_skip_dir_names()
diff --git a/vibe/core/config.py b/vibe/core/config.py
index 349d6ea..9dadfdf 100644
--- a/vibe/core/config.py
+++ b/vibe/core/config.py
@@ -165,6 +165,10 @@ class _MCPBase(BaseModel):
tool_timeout_sec: float = Field(
default=60.0, gt=0, description="Timeout in seconds for tool execution."
)
+ sampling_enabled: bool = Field(
+ default=True,
+ description="Allow this MCP server to request LLM completions via sampling/createMessage.",
+ )
@field_validator("name", mode="after")
@classmethod
@@ -324,6 +328,7 @@ class VibeConfig(BaseSettings):
include_prompt_detail: bool = True
enable_update_checks: bool = True
enable_auto_update: bool = True
+ enable_notifications: bool = True
api_timeout: float = 720.0
# TODO(vibe-nuage): remove exclude=True once the feature is publicly available
diff --git a/vibe/core/llm/backend/anthropic.py b/vibe/core/llm/backend/anthropic.py
index 3533586..c88a2c7 100644
--- a/vibe/core/llm/backend/anthropic.py
+++ b/vibe/core/llm/backend/anthropic.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from collections.abc import Sequence
import json
import re
from typing import Any, ClassVar
@@ -22,7 +23,7 @@ class AnthropicMapper:
"""Shared mapper for converting messages to/from Anthropic API format."""
def prepare_messages(
- self, messages: list[LLMMessage]
+ self, messages: Sequence[LLMMessage]
) -> tuple[str | None, list[dict[str, Any]]]:
system_prompt: str | None = None
converted: list[dict[str, Any]] = []
@@ -457,7 +458,7 @@ class AnthropicAdapter(APIAdapter):
self,
*,
model_name: str,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
diff --git a/vibe/core/llm/backend/base.py b/vibe/core/llm/backend/base.py
index 6fa101d..741eb34 100644
--- a/vibe/core/llm/backend/base.py
+++ b/vibe/core/llm/backend/base.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol
from vibe.core.types import AvailableTool, LLMChunk, LLMMessage, StrToolChoice
@@ -22,7 +23,7 @@ class APIAdapter(Protocol):
self,
*,
model_name: str,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
diff --git a/vibe/core/llm/backend/generic.py b/vibe/core/llm/backend/generic.py
index c76214f..54a34eb 100644
--- a/vibe/core/llm/backend/generic.py
+++ b/vibe/core/llm/backend/generic.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Sequence
import json
import os
import types
@@ -82,7 +82,7 @@ class OpenAIAdapter(APIAdapter):
self,
*,
model_name: str,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
@@ -211,12 +211,13 @@ class GenericBackend:
self,
*,
model: ModelConfig,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
temperature: float = 0.2,
tools: list[AvailableTool] | None = None,
max_tokens: int | None = None,
tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None = None,
+ metadata: dict[str, str] | None = None,
) -> LLMChunk:
api_key = (
os.getenv(self._provider.api_key_env_var)
@@ -279,12 +280,13 @@ class GenericBackend:
self,
*,
model: ModelConfig,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
temperature: float = 0.2,
tools: list[AvailableTool] | None = None,
max_tokens: int | None = None,
tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None = None,
+ metadata: dict[str, str] | None = None,
) -> AsyncGenerator[LLMChunk, None]:
api_key = (
os.getenv(self._provider.api_key_env_var)
@@ -395,11 +397,12 @@ class GenericBackend:
self,
*,
model: ModelConfig,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
temperature: float = 0.0,
tools: list[AvailableTool] | None = None,
tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None = None,
+ metadata: dict[str, str] | None = None,
) -> int:
probe_messages = list(messages)
if not probe_messages or probe_messages[-1].role != Role.user:
diff --git a/vibe/core/llm/backend/mistral.py b/vibe/core/llm/backend/mistral.py
index 9522b6f..4c1bd81 100644
--- a/vibe/core/llm/backend/mistral.py
+++ b/vibe/core/llm/backend/mistral.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Sequence
import json
import os
import re
@@ -226,12 +226,13 @@ class MistralBackend:
self,
*,
model: ModelConfig,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None,
extra_headers: dict[str, str] | None,
+ metadata: dict[str, str] | None = None,
) -> LLMChunk:
try:
merged_messages = merge_consecutive_user_messages(messages)
@@ -247,6 +248,7 @@ class MistralBackend:
if tool_choice
else None,
http_headers=extra_headers,
+ metadata=metadata,
stream=False,
)
@@ -300,12 +302,13 @@ class MistralBackend:
self,
*,
model: ModelConfig,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None,
extra_headers: dict[str, str] | None,
+ metadata: dict[str, str] | None = None,
) -> AsyncGenerator[LLMChunk, None]:
try:
merged_messages = merge_consecutive_user_messages(messages)
@@ -321,6 +324,7 @@ class MistralBackend:
if tool_choice
else None,
http_headers=extra_headers,
+ metadata=metadata,
):
parsed = (
self._mapper.parse_content(chunk.data.choices[0].delta.content)
@@ -376,11 +380,12 @@ class MistralBackend:
self,
*,
model: ModelConfig,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
temperature: float = 0.0,
tools: list[AvailableTool] | None = None,
tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None = None,
+ metadata: dict[str, str] | None = None,
) -> int:
result = await self.complete(
model=model,
@@ -390,6 +395,7 @@ class MistralBackend:
max_tokens=1,
tool_choice=tool_choice,
extra_headers=extra_headers,
+ metadata=metadata,
)
if result.usage is None:
raise ValueError("Missing usage in non streaming completion")
diff --git a/vibe/core/llm/backend/vertex.py b/vibe/core/llm/backend/vertex.py
index 504c3ac..a49ba04 100644
--- a/vibe/core/llm/backend/vertex.py
+++ b/vibe/core/llm/backend/vertex.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from collections.abc import Sequence
import json
import threading
from typing import Any, ClassVar
@@ -67,7 +68,7 @@ class VertexAnthropicAdapter(AnthropicAdapter):
self,
*,
model_name: str,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
diff --git a/vibe/core/llm/exceptions.py b/vibe/core/llm/exceptions.py
index 674a601..ae2da6c 100644
--- a/vibe/core/llm/exceptions.py
+++ b/vibe/core/llm/exceptions.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from collections.abc import Mapping
+from collections.abc import Mapping, Sequence
from http import HTTPStatus
import json
from typing import Any
@@ -114,7 +114,7 @@ class BackendErrorBuilder:
response: httpx.Response,
headers: Mapping[str, str] | None,
model: str,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
temperature: float,
has_tools: bool,
tool_choice: StrToolChoice | AvailableTool | None,
@@ -146,7 +146,7 @@ class BackendErrorBuilder:
endpoint: str,
error: httpx.RequestError,
model: str,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
temperature: float,
has_tools: bool,
tool_choice: StrToolChoice | AvailableTool | None,
@@ -179,7 +179,7 @@ class BackendErrorBuilder:
@staticmethod
def _payload_summary(
model_name: str,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
temperature: float,
has_tools: bool,
tool_choice: StrToolChoice | AvailableTool | None,
diff --git a/vibe/core/llm/message_utils.py b/vibe/core/llm/message_utils.py
index db97e66..99d46f4 100644
--- a/vibe/core/llm/message_utils.py
+++ b/vibe/core/llm/message_utils.py
@@ -1,9 +1,11 @@
from __future__ import annotations
+from collections.abc import Sequence
+
from vibe.core.types import LLMMessage, Role
-def merge_consecutive_user_messages(messages: list[LLMMessage]) -> list[LLMMessage]:
+def merge_consecutive_user_messages(messages: Sequence[LLMMessage]) -> list[LLMMessage]:
"""Merge consecutive user messages into a single message.
This handles cases where middleware injects messages resulting in
diff --git a/vibe/core/llm/types.py b/vibe/core/llm/types.py
index 6b059f3..c8cc8a8 100644
--- a/vibe/core/llm/types.py
+++ b/vibe/core/llm/types.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Sequence
import types
from typing import TYPE_CHECKING, Protocol
@@ -29,12 +29,13 @@ class BackendLike(Protocol):
self,
*,
model: ModelConfig,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None,
extra_headers: dict[str, str] | None,
+ metadata: dict[str, str] | None = None,
) -> LLMChunk:
"""Complete a chat conversation using the specified model and provider.
@@ -46,6 +47,7 @@ class BackendLike(Protocol):
max_tokens: Maximum tokens to generate
tool_choice: How to choose tools (auto, none, or specific tool)
extra_headers: Additional HTTP headers to include
+ metadata: Optional metadata to attach to the request
Returns:
LLMChunk containing the response message and usage information
@@ -62,12 +64,13 @@ class BackendLike(Protocol):
self,
*,
model: ModelConfig,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None,
extra_headers: dict[str, str] | None,
+ metadata: dict[str, str] | None = None,
) -> AsyncGenerator[LLMChunk, None]:
"""Equivalent of the complete method, but yields LLMEvent objects
instead of a single LLMEvent.
@@ -80,6 +83,7 @@ class BackendLike(Protocol):
max_tokens: Maximum tokens to generate
tool_choice: How to choose tools (auto, none, or specific tool)
extra_headers: Additional HTTP headers to include
+ metadata: Optional metadata to attach to the request
Returns:
AsyncGenerator[LLMEvent, None] yielding LLMEvent objects
@@ -93,11 +97,12 @@ class BackendLike(Protocol):
self,
*,
model: ModelConfig,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
temperature: float = 0.0,
tools: list[AvailableTool] | None,
tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None,
+ metadata: dict[str, str] | None = None,
) -> int:
"""Count the number of tokens in the prompt without generating a real response.
@@ -113,6 +118,7 @@ class BackendLike(Protocol):
tools: Optional list of available tools
tool_choice: How to choose tools
extra_headers: Additional HTTP headers to include
+ metadata: Optional metadata to attach to the request
Returns:
The number of prompt tokens
diff --git a/vibe/core/logger.py b/vibe/core/logger.py
new file mode 100644
index 0000000..f2b8bb2
--- /dev/null
+++ b/vibe/core/logger.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+import logging
+from logging.handlers import RotatingFileHandler
+import os
+
+from vibe.core.paths.global_paths import LOG_DIR, LOG_FILE
+
+LOG_DIR.path.mkdir(parents=True, exist_ok=True)
+
+logger = logging.getLogger("vibe")
+
+
+class StructuredLogFormatter(logging.Formatter):
+ def format(self, record: logging.LogRecord) -> str:
+ timestamp = datetime.fromtimestamp(record.created, tz=UTC).isoformat()
+ ppid = os.getppid()
+ pid = os.getpid()
+ level = record.levelname
+ message = record.getMessage().replace("\\", "\\\\").replace("\n", "\\n")
+
+ line = f"{timestamp} {ppid} {pid} {level} {message}"
+
+ if record.exc_info:
+ exc_text = self.formatException(record.exc_info).replace("\n", "\\n")
+ line = f"{line} {exc_text}"
+
+ return line
+
+
+def apply_logging_config(target_logger: logging.Logger) -> None:
+ LOG_DIR.path.mkdir(parents=True, exist_ok=True)
+
+ max_bytes = int(os.environ.get("LOG_MAX_BYTES", 10 * 1024 * 1024))
+
+ if os.environ.get("DEBUG_MODE") == "true":
+ log_level_str = "DEBUG"
+ else:
+ log_level_str = os.environ.get("LOG_LEVEL", "WARNING").upper()
+ valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
+ if log_level_str not in valid_levels:
+ log_level_str = "WARNING"
+
+ handler = RotatingFileHandler(
+ LOG_FILE.path, maxBytes=max_bytes, backupCount=0, encoding="utf-8"
+ )
+ handler.setFormatter(StructuredLogFormatter())
+ log_level = getattr(logging, log_level_str, logging.WARNING)
+ handler.setLevel(log_level)
+
+ # Make sure the logger is not gating logs
+ target_logger.setLevel(logging.DEBUG)
+
+ target_logger.addHandler(handler)
+
+
+apply_logging_config(logger)
diff --git a/vibe/core/middleware.py b/vibe/core/middleware.py
index 9b70b11..4f8ee0d 100644
--- a/vibe/core/middleware.py
+++ b/vibe/core/middleware.py
@@ -6,12 +6,11 @@ from enum import StrEnum, auto
from typing import TYPE_CHECKING, Any, Protocol
from vibe.core.agents import AgentProfile
-from vibe.core.agents.models import BuiltinAgentName
from vibe.core.utils import VIBE_WARNING_TAG
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
- from vibe.core.types import AgentStats, LLMMessage
+ from vibe.core.types import AgentStats, MessageList
class MiddlewareAction(StrEnum):
@@ -28,7 +27,7 @@ class ResetReason(StrEnum):
@dataclass
class ConversationContext:
- messages: list[LLMMessage]
+ messages: MessageList
stats: AgentStats
config: VibeConfig
@@ -136,44 +135,53 @@ PLAN_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Plan mode is active. The user indi
PLAN_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Plan mode has ended. If you have a plan ready, you can now start executing it. If not, you can now use editing tools and make changes to the system.{VIBE_WARNING_TAG}>"""
+CHAT_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Chat mode is active. The user wants to have a conversation -- ask questions, get explanations, or discuss code and architecture. You MUST NOT make any edits, run any non-readonly tools, or otherwise make any changes to the system. This supersedes any other instructions you have received. Instead, you should:
+1. Answer the user's questions directly and comprehensively
+2. Explain code, concepts, or architecture as requested
+3. Use read-only tools (grep, read_file) to look up relevant code when needed
+4. Focus on being informative and conversational -- your response IS the deliverable, not a precursor to action{VIBE_WARNING_TAG}>"""
-class PlanAgentMiddleware:
+CHAT_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Chat mode has ended. You can now use editing tools and make changes to the system.{VIBE_WARNING_TAG}>"""
+
+
+class ReadOnlyAgentMiddleware:
def __init__(
self,
profile_getter: Callable[[], AgentProfile],
- reminder: str = PLAN_AGENT_REMINDER,
- exit_message: str = PLAN_AGENT_EXIT,
+ agent_name: str,
+ reminder: str,
+ exit_message: str,
) -> None:
self._profile_getter = profile_getter
+ self._agent_name = agent_name
self.reminder = reminder
self.exit_message = exit_message
- self._was_plan_agent = False
+ self._was_active = False
- def _is_plan_agent(self) -> bool:
- return self._profile_getter().name == BuiltinAgentName.PLAN
+ def _is_active(self) -> bool:
+ return self._profile_getter().name == self._agent_name
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
- is_plan = self._is_plan_agent()
- was_plan = self._was_plan_agent
+ is_active = self._is_active()
+ was_active = self._was_active
- if was_plan and not is_plan:
- self._was_plan_agent = False
+ if was_active and not is_active:
+ self._was_active = False
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=self.exit_message
)
- if is_plan and not was_plan:
- self._was_plan_agent = True
+ if is_active and not was_active:
+ self._was_active = True
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=self.reminder
)
- self._was_plan_agent = is_plan
-
+ self._was_active = is_active
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
- self._was_plan_agent = False
+ self._was_active = False
class MiddlewarePipeline:
diff --git a/vibe/core/paths/config_paths.py b/vibe/core/paths/config_paths.py
index 58188fd..ce01521 100644
--- a/vibe/core/paths/config_paths.py
+++ b/vibe/core/paths/config_paths.py
@@ -4,6 +4,7 @@ from pathlib import Path
from typing import Literal
from vibe.core.paths.global_paths import VIBE_HOME, GlobalPath
+from vibe.core.paths.local_config_walk import walk_local_config_dirs_all
from vibe.core.trusted_folders import trusted_folders_manager
_config_paths_locked: bool = True
@@ -31,30 +32,24 @@ def _resolve_config_path(basename: str, type: Literal["file", "dir"]) -> Path:
return VIBE_HOME.path / basename
-def resolve_local_tools_dir(dir: Path) -> Path | None:
- if not trusted_folders_manager.is_trusted(dir):
- return None
- if (candidate := dir / ".vibe" / "tools").is_dir():
- return candidate
- return None
+def _discover_local_config_dirs_all(
+ root: Path,
+) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]:
+ if not trusted_folders_manager.is_trusted(root):
+ return ((), (), ())
+ return walk_local_config_dirs_all(root)
-def resolve_local_skills_dirs(dir: Path) -> list[Path]:
- if not trusted_folders_manager.is_trusted(dir):
- return []
- return [
- candidate
- for candidate in [dir / ".vibe" / "skills", dir / ".agents" / "skills"]
- if candidate.is_dir()
- ]
+def discover_local_tools_dirs(root: Path) -> list[Path]:
+ return list(_discover_local_config_dirs_all(root)[0])
-def resolve_local_agents_dir(dir: Path) -> Path | None:
- if not trusted_folders_manager.is_trusted(dir):
- return None
- if (candidate := dir / ".vibe" / "agents").is_dir():
- return candidate
- return None
+def discover_local_skills_dirs(root: Path) -> list[Path]:
+ return list(_discover_local_config_dirs_all(root)[1])
+
+
+def discover_local_agents_dirs(root: Path) -> list[Path]:
+ return list(_discover_local_config_dirs_all(root)[2])
def unlock_config_paths() -> None:
diff --git a/vibe/core/paths/local_config_walk.py b/vibe/core/paths/local_config_walk.py
new file mode 100644
index 0000000..e973285
--- /dev/null
+++ b/vibe/core/paths/local_config_walk.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from functools import cache
+import os
+from pathlib import Path
+
+from vibe.core.autocompletion.file_indexer.ignore_rules import WALK_SKIP_DIR_NAMES
+
+_VIBE_DIR = ".vibe"
+_TOOLS_SUBDIR = Path(_VIBE_DIR) / "tools"
+_VIBE_SKILLS_SUBDIR = Path(_VIBE_DIR) / "skills"
+_AGENTS_SUBDIR = Path(_VIBE_DIR) / "agents"
+_AGENTS_DIR = ".agents"
+_AGENTS_SKILLS_SUBDIR = Path(_AGENTS_DIR) / "skills"
+
+
+@cache
+def walk_local_config_dirs_all(
+ root: Path,
+) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]:
+ tools_dirs: list[Path] = []
+ skills_dirs: list[Path] = []
+ agents_dirs: list[Path] = []
+ resolved_root = root.resolve()
+ for dirpath, dirnames, _ in os.walk(resolved_root, topdown=True):
+ dir_set = frozenset(dirnames)
+ path = Path(dirpath)
+ if _VIBE_DIR in dir_set:
+ if (candidate := path / _TOOLS_SUBDIR).is_dir():
+ tools_dirs.append(candidate)
+ if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir():
+ skills_dirs.append(candidate)
+ if (candidate := path / _AGENTS_SUBDIR).is_dir():
+ agents_dirs.append(candidate)
+ if _AGENTS_DIR in dir_set:
+ if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir():
+ skills_dirs.append(candidate)
+ dirnames[:] = sorted(d for d in dirnames if d not in WALK_SKIP_DIR_NAMES)
+ return (tuple(tools_dirs), tuple(skills_dirs), tuple(agents_dirs))
diff --git a/vibe/core/programmatic.py b/vibe/core/programmatic.py
index b00c4be..96ad2e2 100644
--- a/vibe/core/programmatic.py
+++ b/vibe/core/programmatic.py
@@ -2,12 +2,23 @@ from __future__ import annotations
import asyncio
+from vibe import __version__
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import VibeConfig
+from vibe.core.logger import logger
from vibe.core.output_formatters import create_formatter
-from vibe.core.types import AssistantEvent, LLMMessage, OutputFormat, Role
-from vibe.core.utils import ConversationLimitException, logger
+from vibe.core.types import (
+ AssistantEvent,
+ ClientMetadata,
+ EntrypointMetadata,
+ LLMMessage,
+ OutputFormat,
+ Role,
+)
+from vibe.core.utils import ConversationLimitException
+
+_DEFAULT_CLIENT_METADATA = ClientMetadata(name="vibe_programmatic", version=__version__)
def run_programmatic(
@@ -18,6 +29,7 @@ def run_programmatic(
output_format: OutputFormat = OutputFormat.TEXT,
previous_messages: list[LLMMessage] | None = None,
agent_name: str = BuiltinAgentName.AUTO_APPROVE,
+ client_metadata: ClientMetadata = _DEFAULT_CLIENT_METADATA,
) -> str | None:
formatter = create_formatter(output_format)
@@ -28,6 +40,12 @@ def run_programmatic(
max_turns=max_turns,
max_price=max_price,
enable_streaming=False,
+ entrypoint_metadata=EntrypointMetadata(
+ agent_entrypoint="programmatic",
+ agent_version=__version__,
+ client_name=client_metadata.name,
+ client_version=client_metadata.version,
+ ),
)
logger.info("USER: %s", prompt)
@@ -42,7 +60,7 @@ def run_programmatic(
"Loaded %d messages from previous session", len(non_system_messages)
)
- agent_loop.emit_new_session_telemetry("programmatic")
+ agent_loop.emit_new_session_telemetry()
async for event in agent_loop.act(prompt):
formatter.on_event(event)
diff --git a/vibe/core/prompts/cli.md b/vibe/core/prompts/cli.md
index d21527b..95630fe 100644
--- a/vibe/core/prompts/cli.md
+++ b/vibe/core/prompts/cli.md
@@ -1,4 +1,13 @@
-You are Mistral Vibe, a CLI coding agent built by Mistral AI, powered by the Devstral model family. You interact with a local codebase through tools.
+You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools. You have no internet access.
+CRITICAL: Users complain you are too verbose. Your responses must be minimal. Most tasks need <100 words. Code speaks for itself.
+
+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:
@@ -10,6 +19,7 @@ If unclear, default to investigate. It is better to explain what you would do th
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 multiple file paths or a complex task: Do not start reading files immediately. First, summarize your understanding of the task and propose a short plan. Wait for the user to confirm before exploring any files. This prevents wasted effort on the wrong path.
Phase 2 — Plan (Change tasks only)
State your plan before writing code:
@@ -24,6 +34,10 @@ After each unit, verify: run tests, or read back the file to confirm the edit la
Never claim completion without verification — a passing test, correct read-back, or successful build.
Hard Rules
+
+Never Commit
+Do not run `git commit`, `git push`, or `git add` unless the user explicitly asks you to. Saving files is sufficient — the user will review changes and commit themselves.
+
Respect User Constraints
"No writes", "just analyze", "plan only", "don't touch X" — these are hard constraints. Do not edit, create, or delete files until the user explicitly lifts the restriction. Violation of explicit user instructions is the worst failure mode.
@@ -107,5 +121,6 @@ Cite as file_path:line_number.
Professional Conduct
Prioritize technical accuracy over validating beliefs. Disagree when necessary.
When uncertain, investigate before confirming.
-No emojis unless requested. No over-the-top validation.
+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/session/session_loader.py b/vibe/core/session/session_loader.py
index bfba38c..ae3c3d3 100644
--- a/vibe/core/session/session_loader.py
+++ b/vibe/core/session/session_loader.py
@@ -5,13 +5,16 @@ import json
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypedDict
-from vibe.core.session.session_logger import MESSAGES_FILENAME, METADATA_FILENAME
-from vibe.core.types import LLMMessage
+from vibe.core.types import LLMMessage, SessionMetadata
if TYPE_CHECKING:
from vibe.core.config import SessionLoggingConfig
+METADATA_FILENAME = "meta.json"
+MESSAGES_FILENAME = "messages.jsonl"
+
+
class SessionInfo(TypedDict):
session_id: str
cwd: str
@@ -171,6 +174,22 @@ class SessionLoader:
return sessions
+ @staticmethod
+ def load_metadata(session_dir: Path) -> SessionMetadata:
+ metadata_path = session_dir / METADATA_FILENAME
+ if not metadata_path.exists():
+ raise ValueError(f"Session metadata not found at {session_dir}")
+
+ try:
+ metadata_content = metadata_path.read_text()
+ return SessionMetadata.model_validate_json(metadata_content)
+ except ValueError:
+ raise
+ except Exception as e:
+ raise ValueError(
+ f"Failed to load session metadata at {session_dir}: {e}"
+ ) from e
+
@staticmethod
def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]:
# Load session messages from MESSAGES_FILENAME
@@ -220,3 +239,37 @@ class SessionLoader:
metadata = {}
return messages, metadata
+
+ @staticmethod
+ def _clean_text(text: str) -> str:
+ text = text.strip().replace("\n", " ")
+ return text or "(empty message)"
+
+ @staticmethod
+ def _extract_text_from_content(content: str | None) -> str | None:
+ if not content:
+ return None
+ return SessionLoader._clean_text(content)
+
+ @staticmethod
+ def get_first_user_message(session_id: str, config: SessionLoggingConfig) -> str:
+ """Get the first user message from a session for preview."""
+ session_path = SessionLoader.find_session_by_id(session_id, config)
+ if not session_path:
+ return "(session not found)"
+
+ try:
+ messages, _ = SessionLoader.load_session(session_path)
+
+ for msg in messages:
+ if msg.role != "user":
+ continue
+ text = SessionLoader._extract_text_from_content(msg.content)
+ if text:
+ return text
+
+ return "(no user messages)"
+ except ValueError:
+ return "(corrupted session)"
+ except OSError:
+ return "(error reading session)"
diff --git a/vibe/core/session/session_logger.py b/vibe/core/session/session_logger.py
index b6b7e5f..c1f865e 100644
--- a/vibe/core/session/session_logger.py
+++ b/vibe/core/session/session_logger.py
@@ -1,15 +1,22 @@
from __future__ import annotations
+from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
import getpass
import json
import os
from pathlib import Path
import subprocess
+from threading import Lock
from typing import TYPE_CHECKING, Any
from anyio import NamedTemporaryFile, Path as AsyncPath
+from vibe.core.session.session_loader import (
+ MESSAGES_FILENAME,
+ METADATA_FILENAME,
+ SessionLoader,
+)
from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
from vibe.core.utils import is_windows, utc_now
@@ -19,14 +26,15 @@ if TYPE_CHECKING:
from vibe.core.tools.manager import ToolManager
-METADATA_FILENAME = "meta.json"
-MESSAGES_FILENAME = "messages.jsonl"
+TMP_CLEANUP_INTERVAL = timedelta(seconds=5)
class SessionLogger:
def __init__(self, session_config: SessionLoggingConfig, session_id: str) -> None:
self.session_config = session_config
self.enabled = session_config.enabled
+ self._last_tmp_cleanup_at: datetime | None = None
+ self._tmp_cleanup_lock = Lock()
if not self.enabled:
self.save_dir: Path | None = None
@@ -127,7 +135,7 @@ class SessionLogger:
environment={"working_directory": str(Path.cwd())},
)
- def _get_title(self, messages: list[LLMMessage]) -> str:
+ def _get_title(self, messages: Sequence[LLMMessage]) -> str:
first_user_message = None
for message in messages:
if message.role == Role.user:
@@ -196,7 +204,7 @@ class SessionLogger:
async def save_interaction(
self,
- messages: list[LLMMessage],
+ messages: Sequence[LLMMessage],
stats: AgentStats,
base_config: VibeConfig,
tool_manager: ToolManager,
@@ -208,6 +216,9 @@ class SessionLogger:
if self.session_metadata is None:
return
+ if not any(msg.role != Role.system for msg in messages):
+ return
+
# If the session directory does not exist, create it
try:
self.session_dir.mkdir(parents=True, exist_ok=True)
@@ -284,7 +295,7 @@ class SessionLogger:
f"Failed to save session to {self.session_dir}: {e}"
) from e
finally:
- self.cleanup_tmp_files()
+ self.maybe_cleanup_tmp_files()
def reset_session(self, session_id: str) -> None:
"""Clear existing session info and setup a new session"""
@@ -296,6 +307,17 @@ class SessionLogger:
self.session_dir = self.save_folder
self.session_metadata = self._initialize_session_metadata()
+ def resume_existing_session(self, session_id: str, session_dir: Path) -> None:
+ if not self.enabled:
+ return
+
+ self.session_id = session_id
+ self.session_dir = session_dir
+ self.session_metadata = SessionLoader.load_metadata(session_dir)
+
+ if self.session_metadata.start_time:
+ self.session_start_time = self.session_metadata.start_time
+
def cleanup_tmp_files(self) -> None:
"""Delete temporary files created more than 5 minutes ago"""
if not self.enabled or not self.save_dir:
@@ -316,3 +338,22 @@ class SessionLogger:
file_path.unlink()
except Exception:
continue
+
+ def maybe_cleanup_tmp_files(self) -> None:
+ if not self.enabled or not self.save_dir:
+ return
+
+ if not self._tmp_cleanup_lock.acquire(blocking=False):
+ return
+ try:
+ now = utc_now()
+ if (
+ self._last_tmp_cleanup_at is not None
+ and now - self._last_tmp_cleanup_at < TMP_CLEANUP_INTERVAL
+ ):
+ return
+
+ self.cleanup_tmp_files()
+ self._last_tmp_cleanup_at = now
+ finally:
+ self._tmp_cleanup_lock.release()
diff --git a/vibe/core/skills/manager.py b/vibe/core/skills/manager.py
index 58ae812..b6d6d07 100644
--- a/vibe/core/skills/manager.py
+++ b/vibe/core/skills/manager.py
@@ -1,11 +1,11 @@
from __future__ import annotations
from collections.abc import Callable
-from logging import getLogger
from pathlib import Path
from typing import TYPE_CHECKING
-from vibe.core.paths.config_paths import resolve_local_skills_dirs
+from vibe.core.logger import logger
+from vibe.core.paths.config_paths import discover_local_skills_dirs
from vibe.core.paths.global_paths import GLOBAL_SKILLS_DIR
from vibe.core.skills.models import SkillInfo, SkillMetadata
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
@@ -14,8 +14,6 @@ from vibe.core.utils import name_matches
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
-logger = getLogger("vibe")
-
class SkillManager:
def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
@@ -58,7 +56,7 @@ class SkillManager:
if path.is_dir():
paths.append(path)
- paths.extend(resolve_local_skills_dirs(Path.cwd()))
+ paths.extend(discover_local_skills_dirs(Path.cwd()))
if GLOBAL_SKILLS_DIR.path.is_dir():
paths.append(GLOBAL_SKILLS_DIR.path)
diff --git a/vibe/core/telemetry/send.py b/vibe/core/telemetry/send.py
index caa5e6e..386f206 100644
--- a/vibe/core/telemetry/send.py
+++ b/vibe/core/telemetry/send.py
@@ -141,25 +141,25 @@ class TelemetryClient:
"nb_files_created": nb_files_created,
"nb_files_modified": nb_files_modified,
}
- self.send_telemetry_event("vibe/tool_call_finished", payload)
+ self.send_telemetry_event("vibe.tool_call_finished", payload)
def send_user_copied_text(self, text: str) -> None:
payload = {"text_length": len(text)}
- self.send_telemetry_event("vibe/user_copied_text", payload)
+ self.send_telemetry_event("vibe.user_copied_text", payload)
def send_user_cancelled_action(self, action: str) -> None:
payload = {"action": action}
- self.send_telemetry_event("vibe/user_cancelled_action", payload)
+ self.send_telemetry_event("vibe.user_cancelled_action", payload)
def send_auto_compact_triggered(self) -> None:
payload = {}
- self.send_telemetry_event("vibe/auto_compact_triggered", payload)
+ self.send_telemetry_event("vibe.auto_compact_triggered", payload)
def send_slash_command_used(
self, command: str, command_type: Literal["builtin", "skill"]
) -> None:
payload = {"command": command.lstrip("/"), "command_type": command_type}
- self.send_telemetry_event("vibe/slash_command_used", payload)
+ self.send_telemetry_event("vibe.slash_command_used", payload)
def send_new_session(
self,
@@ -167,7 +167,8 @@ class TelemetryClient:
nb_skills: int,
nb_mcp_servers: int,
nb_models: int,
- entrypoint: Literal["cli", "acp", "programmatic"],
+ entrypoint: Literal["cli", "acp", "programmatic", "unknown"],
+ terminal_emulator: str | None = None,
) -> None:
payload = {
"has_agents_md": has_agents_md,
@@ -176,10 +177,11 @@ class TelemetryClient:
"nb_models": nb_models,
"entrypoint": entrypoint,
"version": __version__,
+ "terminal_emulator": terminal_emulator,
}
- self.send_telemetry_event("vibe/new_session", payload)
+ self.send_telemetry_event("vibe.new_session", payload)
def send_onboarding_api_key_added(self) -> None:
self.send_telemetry_event(
- "vibe/onboarding_api_key_added", {"version": __version__}
+ "vibe.onboarding_api_key_added", {"version": __version__}
)
diff --git a/vibe/core/tools/base.py b/vibe/core/tools/base.py
index d766ae8..aa2a90d 100644
--- a/vibe/core/tools/base.py
+++ b/vibe/core/tools/base.py
@@ -27,7 +27,8 @@ from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
from vibe.core.agents.manager import AgentManager
- from vibe.core.types import ApprovalCallback, UserInputCallback
+ from vibe.core.tools.mcp_sampling import MCPSamplingHandler
+ from vibe.core.types import ApprovalCallback, EntrypointMetadata, UserInputCallback
ARGS_COUNT = 4
@@ -40,6 +41,9 @@ class InvokeContext:
approval_callback: ApprovalCallback | None = field(default=None)
agent_manager: AgentManager | None = field(default=None)
user_input_callback: UserInputCallback | None = field(default=None)
+ sampling_callback: MCPSamplingHandler | None = field(default=None)
+ session_dir: Path | None = field(default=None)
+ entrypoint_metadata: EntrypointMetadata | None = field(default=None)
class ToolError(Exception):
@@ -316,6 +320,10 @@ class BaseTool[
snake_case = re.sub(r"(? bool:
+ return True
+
@classmethod
def create_config_with_permission(
cls, permission: ToolPermission
@@ -323,14 +331,13 @@ class BaseTool[
config_class = cls._get_tool_config_class()
return config_class(permission=permission)
- def check_allowlist_denylist(self, args: ToolArgs) -> ToolPermission | None:
- """Check if args match allowlist/denylist patterns.
+ def resolve_permission(self, args: ToolArgs) -> ToolPermission | None:
+ """Per-invocation permission override, checked before config-level permission.
Returns:
- ToolPermission.ALWAYS if allowlisted
- ToolPermission.NEVER if denylisted
- None if no match (proceed with normal permission check)
+ ALWAYS if auto-approved, NEVER if blocked, ASK to force approval,
+ or None to fall through to config permission.
- Base implementation returns None. Override in subclasses for specific logic.
+ Override in subclasses for domain-specific rules (e.g. workdir checks).
"""
return None
diff --git a/vibe/core/tools/builtins/ask_user_question.py b/vibe/core/tools/builtins/ask_user_question.py
index accc26d..ef89ea4 100644
--- a/vibe/core/tools/builtins/ask_user_question.py
+++ b/vibe/core/tools/builtins/ask_user_question.py
@@ -14,7 +14,7 @@ from vibe.core.tools.base import (
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
-from vibe.core.types import ToolCallEvent, ToolResultEvent
+from vibe.core.types import ToolResultEvent
class Choice(BaseModel):
@@ -84,16 +84,10 @@ class AskUserQuestion(
)
@classmethod
- def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
- if not isinstance(event.args, AskUserQuestionArgs):
- return ToolCallDisplay(summary="Asking user")
-
- args = event.args
+ def format_call_display(cls, args: AskUserQuestionArgs) -> ToolCallDisplay:
count = len(args.questions)
-
if count == 1:
return ToolCallDisplay(summary=f"Asking: {args.questions[0].question}")
-
return ToolCallDisplay(summary=f"Asking {count} questions")
@classmethod
diff --git a/vibe/core/tools/builtins/bash.py b/vibe/core/tools/builtins/bash.py
index bc1e850..c447bff 100644
--- a/vibe/core/tools/builtins/bash.py
+++ b/vibe/core/tools/builtins/bash.py
@@ -21,7 +21,7 @@ from vibe.core.tools.base import (
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
-from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
+from vibe.core.types import ToolResultEvent, ToolStreamEvent
from vibe.core.utils import is_windows
@@ -208,11 +208,8 @@ class Bash(
description: ClassVar[str] = "Run a one-off bash command and capture its output."
@classmethod
- def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
- if not isinstance(event.args, BashArgs):
- return ToolCallDisplay(summary="bash")
-
- return ToolCallDisplay(summary=f"bash: {event.args.command}")
+ def format_call_display(cls, args: BashArgs) -> ToolCallDisplay:
+ return ToolCallDisplay(summary=f"bash: {args.command}")
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
@@ -227,7 +224,7 @@ class Bash(
def get_status_text(cls) -> str:
return "Running command"
- def check_allowlist_denylist(self, args: BashArgs) -> ToolPermission | None:
+ def resolve_permission(self, args: BashArgs) -> ToolPermission | None:
if is_windows():
return None
diff --git a/vibe/core/tools/builtins/grep.py b/vibe/core/tools/builtins/grep.py
index b2beb39..652274e 100644
--- a/vibe/core/tools/builtins/grep.py
+++ b/vibe/core/tools/builtins/grep.py
@@ -21,7 +21,7 @@ from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
- from vibe.core.types import ToolCallEvent, ToolResultEvent
+ from vibe.core.types import ToolResultEvent
class GrepBackend(StrEnum):
@@ -75,10 +75,6 @@ class GrepToolConfig(BaseToolConfig):
)
-class GrepState(BaseToolState):
- search_history: list[str] = Field(default_factory=list)
-
-
class GrepArgs(BaseModel):
pattern: str
path: str = "."
@@ -99,7 +95,7 @@ class GrepResult(BaseModel):
class Grep(
- BaseTool[GrepArgs, GrepResult, GrepToolConfig, GrepState],
+ BaseTool[GrepArgs, GrepResult, GrepToolConfig, BaseToolState],
ToolUIData[GrepArgs, GrepResult],
):
description: ClassVar[str] = (
@@ -122,7 +118,6 @@ class Grep(
) -> AsyncGenerator[ToolStreamEvent | GrepResult, None]:
backend = self._detect_backend()
self._validate_args(args)
- self.state.search_history.append(args.pattern)
exclude_patterns = self._collect_exclude_patterns()
cmd = self._build_command(args, exclude_patterns, backend)
@@ -274,18 +269,14 @@ class Grep(
)
@classmethod
- def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
- if not isinstance(event.args, GrepArgs):
- return ToolCallDisplay(summary="grep")
-
- summary = f"Grepping '{event.args.pattern}'"
- if event.args.path != ".":
- summary += f" in {event.args.path}"
- if event.args.max_matches:
- summary += f" (max {event.args.max_matches} matches)"
- if not event.args.use_default_ignore:
+ def format_call_display(cls, args: GrepArgs) -> ToolCallDisplay:
+ summary = f"Grepping '{args.pattern}'"
+ if args.path != ".":
+ summary += f" in {args.path}"
+ if args.max_matches:
+ summary += f" (max {args.max_matches} matches)"
+ if not args.use_default_ignore:
summary += " [no-ignore]"
-
return ToolCallDisplay(summary=summary)
@classmethod
diff --git a/vibe/core/tools/builtins/prompts/read_file.md b/vibe/core/tools/builtins/prompts/read_file.md
index ccbe656..e1ffc1f 100644
--- a/vibe/core/tools/builtins/prompts/read_file.md
+++ b/vibe/core/tools/builtins/prompts/read_file.md
@@ -3,11 +3,16 @@ Use `read_file` to read the content of a file. It's designed to handle large fil
- By default, it reads from the beginning of the file.
- Use `offset` (line number) and `limit` (number of lines) to read specific parts or chunks of a file. This is efficient for exploring large files.
- The result includes `was_truncated: true` if the file content was cut short due to size limits.
+- This is more efficient than using `bash` with `cat` or `wc`.
**Strategy for large files:**
1. Call `read_file` with a `limit` (e.g., 1000 lines) to get the start of the file.
-2. If `was_truncated` is true, you know the file is large.
-3. To read the next chunk, call `read_file` again with an `offset`. For example, `offset=1000, limit=1000`.
+2. If `was_truncated` is true, the file is large. STOP and assess: do you already have enough information to answer the user's question? If yes, respond immediately — do not keep reading.
+3. If you need more, prefer targeted reads (e.g., jump to a specific offset, read the last 100 lines, search for a relevant section) over reading sequentially chunk by chunk.
+4. Do not call `read_file` more than 3 times on the same file without responding to the user first.
-This is more efficient than using `bash` with `cat` or `wc`.
+**Do not read or explore:**
+- Model checkpoint directories or weight files (.bin, .safetensors, .pt, .gguf, optimizer states, etc.)
+- Binary files of any kind
+- Entire directory trees of training runs or large codebases. If the user provides paths to such files, treat them as references. Do not open them unless the user explicitly asks you to inspect a specific file.
diff --git a/vibe/core/tools/builtins/prompts/webfetch.md b/vibe/core/tools/builtins/prompts/webfetch.md
new file mode 100644
index 0000000..aa61a27
--- /dev/null
+++ b/vibe/core/tools/builtins/prompts/webfetch.md
@@ -0,0 +1,6 @@
+Fetches content from a specified URL and converts HTML to markdown for readability.
+Use this tool when you need to retrieve and analyze web content.
+
+- Prefer a more specialized tool over `web_fetch` when one is available.
+- URLs must be valid.
+- Read-only: does not modify any files.
diff --git a/vibe/core/tools/builtins/prompts/websearch.md b/vibe/core/tools/builtins/prompts/websearch.md
new file mode 100644
index 0000000..f64e5eb
--- /dev/null
+++ b/vibe/core/tools/builtins/prompts/websearch.md
@@ -0,0 +1,25 @@
+Use `web_search` to find current information from the web.
+Returns answers with cited sources. Always reference sources when presenting information to the user.
+
+**Query Best Practices:**
+- Avoid relative time terms ("latest", "today", "this week") - resolve to actual dates when possible
+- Be specific and use concrete terms rather than vague queries
+
+**When to use:**
+- User asks about recent events or explicitly asks to search the web
+- Documentation, APIs, or libraries may have been updated since training cutoff
+- Verifying facts that could be outdated (versions, deprecations, breaking changes)
+- Looking up specific error messages or issues that may have known solutions
+- User mentions a library, framework, or version you're not familiar with
+
+**When NOT to use:**
+- General programming concepts and patterns (use training knowledge)
+- Searching the local codebase (use `grep` or file search instead)
+- Static reference information unlikely to change (math, algorithms, language syntax)
+- Information you're already confident about and is unlikely to have changed
+
+**Using results:**
+- Stay critical - web content may be outdated, wrong, or misleading
+- Cross-reference multiple sources when possible
+- Prefer official documentation over third-party sources
+- Always cite your sources so the user can verify
diff --git a/vibe/core/tools/builtins/read_file.py b/vibe/core/tools/builtins/read_file.py
index f5c3c57..939b9ad 100644
--- a/vibe/core/tools/builtins/read_file.py
+++ b/vibe/core/tools/builtins/read_file.py
@@ -16,10 +16,11 @@ from vibe.core.tools.base import (
ToolPermission,
)
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
if TYPE_CHECKING:
- from vibe.core.types import ToolCallEvent, ToolResultEvent
+ from vibe.core.types import ToolResultEvent
class _ReadResult(NamedTuple):
@@ -54,17 +55,10 @@ class ReadFileToolConfig(BaseToolConfig):
max_read_bytes: int = Field(
default=64_000, description="Maximum total bytes to read from a file in one go."
)
- max_state_history: int = Field(
- default=10, description="Number of recently read files to remember in state."
- )
-
-
-class ReadFileState(BaseToolState):
- recently_read_files: list[str] = Field(default_factory=list)
class ReadFile(
- BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, ReadFileState],
+ BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, BaseToolState],
ToolUIData[ReadFileArgs, ReadFileResult],
):
description: ClassVar[str] = (
@@ -80,8 +74,6 @@ class ReadFile(
read_result = await self._read_file(args, file_path)
- self._update_state_history(file_path)
-
yield ReadFileResult(
path=str(file_path),
content="".join(read_result.lines),
@@ -89,23 +81,13 @@ class ReadFile(
was_truncated=read_result.was_truncated,
)
- def check_allowlist_denylist(self, args: ReadFileArgs) -> ToolPermission | None:
- import fnmatch
-
- file_path = Path(args.path).expanduser()
- if not file_path.is_absolute():
- file_path = Path.cwd() / file_path
- file_str = str(file_path)
-
- for pattern in self.config.denylist:
- if fnmatch.fnmatch(file_str, pattern):
- return ToolPermission.NEVER
-
- for pattern in self.config.allowlist:
- if fnmatch.fnmatch(file_str, pattern):
- return ToolPermission.ALWAYS
-
- return None
+ def resolve_permission(self, args: ReadFileArgs) -> ToolPermission | None:
+ return resolve_file_tool_permission(
+ args.path,
+ allowlist=self.config.allowlist,
+ denylist=self.config.denylist,
+ config_permission=self.config.permission,
+ )
def _prepare_and_validate_path(self, args: ReadFileArgs) -> Path:
self._validate_inputs(args)
@@ -176,25 +158,16 @@ class ReadFile(
if resolved_path.is_dir():
raise ToolError(f"Path is a directory, not a file: {file_path}")
- def _update_state_history(self, file_path: Path) -> None:
- self.state.recently_read_files.append(str(file_path.resolve()))
- if len(self.state.recently_read_files) > self.config.max_state_history:
- self.state.recently_read_files.pop(0)
-
@classmethod
- def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
- if not isinstance(event.args, ReadFileArgs):
- return ToolCallDisplay(summary="read_file")
-
- summary = f"Reading {event.args.path}"
- if event.args.offset > 0 or event.args.limit is not None:
+ def format_call_display(cls, args: ReadFileArgs) -> ToolCallDisplay:
+ summary = f"Reading {args.path}"
+ if args.offset > 0 or args.limit is not None:
parts = []
- if event.args.offset > 0:
- parts.append(f"from line {event.args.offset}")
- if event.args.limit is not None:
- parts.append(f"limit {event.args.limit} lines")
+ if args.offset > 0:
+ parts.append(f"from line {args.offset}")
+ if args.limit is not None:
+ parts.append(f"limit {args.limit} lines")
summary += f" ({', '.join(parts)})"
-
return ToolCallDisplay(summary=summary)
@classmethod
diff --git a/vibe/core/tools/builtins/search_replace.py b/vibe/core/tools/builtins/search_replace.py
index f54ed75..9c730eb 100644
--- a/vibe/core/tools/builtins/search_replace.py
+++ b/vibe/core/tools/builtins/search_replace.py
@@ -16,9 +16,11 @@ from vibe.core.tools.base import (
BaseToolState,
InvokeContext,
ToolError,
+ ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
-from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
+from vibe.core.tools.utils import resolve_file_tool_permission
+from vibe.core.types import ToolResultEvent, ToolStreamEvent
SEARCH_REPLACE_BLOCK_RE = re.compile(
r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL
@@ -68,13 +70,9 @@ class SearchReplaceConfig(BaseToolConfig):
fuzzy_threshold: float = 0.9
-class SearchReplaceState(BaseToolState):
- pass
-
-
class SearchReplace(
BaseTool[
- SearchReplaceArgs, SearchReplaceResult, SearchReplaceConfig, SearchReplaceState
+ SearchReplaceArgs, SearchReplaceResult, SearchReplaceConfig, BaseToolState
],
ToolUIData[SearchReplaceArgs, SearchReplaceResult],
):
@@ -85,13 +83,8 @@ class SearchReplace(
)
@classmethod
- def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
- if not isinstance(event.args, SearchReplaceArgs):
- return ToolCallDisplay(summary="Invalid arguments")
-
- args = event.args
+ def format_call_display(cls, args: SearchReplaceArgs) -> ToolCallDisplay:
blocks = cls._parse_search_replace_blocks(args.content)
-
return ToolCallDisplay(
summary=f"Patching {args.file_path} ({len(blocks)} blocks)",
content=args.content,
@@ -112,6 +105,14 @@ class SearchReplace(
def get_status_text(cls) -> str:
return "Editing files"
+ def resolve_permission(self, args: SearchReplaceArgs) -> ToolPermission | None:
+ return resolve_file_tool_permission(
+ args.file_path,
+ allowlist=self.config.allowlist,
+ denylist=self.config.denylist,
+ config_permission=self.config.permission,
+ )
+
@final
async def run(
self, args: SearchReplaceArgs, ctx: InvokeContext | None = None
diff --git a/vibe/core/tools/builtins/task.py b/vibe/core/tools/builtins/task.py
index 49ffb5d..bac6d81 100644
--- a/vibe/core/tools/builtins/task.py
+++ b/vibe/core/tools/builtins/task.py
@@ -1,12 +1,13 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
+import fnmatch
from typing import ClassVar
from pydantic import BaseModel, Field
from vibe.core.agent_loop import AgentLoop
-from vibe.core.agents.models import AgentType
+from vibe.core.agents.models import AgentType, BuiltinAgentName
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.tools.base import (
BaseTool,
@@ -47,6 +48,7 @@ class TaskResult(BaseModel):
class TaskToolConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ASK
+ allowlist: list[str] = Field(default=[BuiltinAgentName.EXPLORE])
class Task(
@@ -56,8 +58,8 @@ class Task(
description: ClassVar[str] = (
"Delegate a task to a subagent for independent execution. "
"Useful for exploration, research, or parallel work that doesn't "
- "require user interaction. The subagent runs in-memory without "
- "saving interaction logs."
+ "require user interaction. The subagent runs in-memory and "
+ "saves interaction logs."
)
@classmethod
@@ -87,6 +89,19 @@ class Task(
def get_status_text(cls) -> str:
return "Running subagent"
+ def resolve_permission(self, args: TaskArgs) -> ToolPermission | None:
+ agent_name = args.agent
+
+ for pattern in self.config.denylist:
+ if fnmatch.fnmatch(agent_name, pattern):
+ return ToolPermission.NEVER
+
+ for pattern in self.config.allowlist:
+ if fnmatch.fnmatch(agent_name, pattern):
+ return ToolPermission.ALWAYS
+
+ return None
+
async def run(
self, args: TaskArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | TaskResult, None]:
@@ -107,10 +122,17 @@ class Task(
f"This is a security constraint to prevent recursive spawning."
)
- base_config = VibeConfig.load(
- session_logging=SessionLoggingConfig(enabled=False)
+ session_logging = SessionLoggingConfig(
+ save_dir=str(ctx.session_dir / "agents") if ctx.session_dir else "",
+ session_prefix=args.agent,
+ enabled=ctx.session_dir is not None,
+ )
+ base_config = VibeConfig.load(session_logging=session_logging)
+ subagent_loop = AgentLoop(
+ config=base_config,
+ agent_name=args.agent,
+ entrypoint_metadata=ctx.entrypoint_metadata,
)
- subagent_loop = AgentLoop(config=base_config, agent_name=args.agent)
if ctx and ctx.approval_callback:
subagent_loop.set_approval_callback(ctx.approval_callback)
diff --git a/vibe/core/tools/builtins/todo.py b/vibe/core/tools/builtins/todo.py
index ffafe2c..b21e860 100644
--- a/vibe/core/tools/builtins/todo.py
+++ b/vibe/core/tools/builtins/todo.py
@@ -15,7 +15,7 @@ from vibe.core.tools.base import (
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
-from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
+from vibe.core.types import ToolResultEvent, ToolStreamEvent
class TodoStatus(StrEnum):
@@ -69,12 +69,7 @@ class Todo(
)
@classmethod
- def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
- if not isinstance(event.args, TodoArgs):
- return ToolCallDisplay(summary="Invalid arguments")
-
- args = event.args
-
+ def format_call_display(cls, args: TodoArgs) -> ToolCallDisplay:
match args.action:
case "read":
return ToolCallDisplay(summary="Reading todos")
diff --git a/vibe/core/tools/builtins/webfetch.py b/vibe/core/tools/builtins/webfetch.py
new file mode 100644
index 0000000..9f9a325
--- /dev/null
+++ b/vibe/core/tools/builtins/webfetch.py
@@ -0,0 +1,200 @@
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+from typing import TYPE_CHECKING, ClassVar, final
+from urllib.parse import urlparse
+
+import httpx
+from markdownify import MarkdownConverter
+from pydantic import BaseModel, Field
+
+from vibe.core.tools.base import (
+ BaseTool,
+ BaseToolConfig,
+ BaseToolState,
+ InvokeContext,
+ ToolError,
+ ToolPermission,
+)
+from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
+from vibe.core.types import ToolStreamEvent
+
+if TYPE_CHECKING:
+ from vibe.core.types import ToolCallEvent, ToolResultEvent
+
+
+_HONEST_USER_AGENT = "vibe-cli"
+_HTTP_FORBIDDEN = 403
+
+
+class _Converter(MarkdownConverter):
+ convert_script = convert_style = convert_noscript = convert_iframe = (
+ convert_object
+ ) = convert_embed = lambda *_, **__: ""
+
+
+class WebFetchArgs(BaseModel):
+ url: str = Field(description="URL to fetch (http/https)")
+ timeout: int | None = Field(
+ default=None, description="Timeout in seconds (max 120)"
+ )
+
+
+class WebFetchResult(BaseModel):
+ url: str
+ content: str
+ content_type: str
+
+
+class WebFetchConfig(BaseToolConfig):
+ permission: ToolPermission = ToolPermission.ASK
+
+ default_timeout: int = Field(default=30, description="Default timeout in seconds.")
+ max_timeout: int = Field(default=120, description="Maximum allowed timeout.")
+ max_content_bytes: int = Field(
+ default=512_000, description="Maximum content size to fetch."
+ )
+ user_agent: str = Field(
+ default=(
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ ),
+ description="User agent string for requests.",
+ )
+
+
+class WebFetch(
+ BaseTool[WebFetchArgs, WebFetchResult, WebFetchConfig, BaseToolState],
+ ToolUIData[WebFetchArgs, WebFetchResult],
+):
+ description: ClassVar[str] = (
+ "Fetch content from a URL. Converts HTML to markdown for readability."
+ )
+
+ @final
+ async def run(
+ self, args: WebFetchArgs, ctx: InvokeContext | None = None
+ ) -> AsyncGenerator[ToolStreamEvent | WebFetchResult, None]:
+ self._validate_args(args)
+
+ raw = args.url.lstrip("/") if args.url.startswith("//") else args.url
+ url = raw if raw.startswith(("http://", "https://")) else "https://" + raw
+ timeout = self._resolve_timeout(args.timeout)
+
+ content, content_type = await self._fetch_url(url, timeout)
+
+ if "text/html" in content_type:
+ content = _html_to_markdown(content)
+
+ yield WebFetchResult(url=url, content=content, content_type=content_type)
+
+ def _validate_args(self, args: WebFetchArgs) -> None:
+ if not args.url.strip():
+ raise ToolError("URL cannot be empty")
+
+ parsed = urlparse(args.url)
+ if parsed.scheme and parsed.scheme not in {"http", "https"}:
+ raise ToolError(
+ f"Invalid URL scheme: {parsed.scheme}. Must be http or https."
+ )
+
+ if args.timeout is not None:
+ if args.timeout <= 0:
+ raise ToolError("Timeout must be a positive number")
+ if args.timeout > self.config.max_timeout:
+ raise ToolError(
+ f"Timeout cannot exceed {self.config.max_timeout} seconds"
+ )
+
+ def _resolve_timeout(self, timeout: int | None) -> int:
+ if timeout is None:
+ return self.config.default_timeout
+ return min(timeout, self.config.max_timeout)
+
+ async def _fetch_url(self, url: str, timeout: int) -> tuple[str, str]:
+ headers = {
+ "User-Agent": self.config.user_agent,
+ "Accept": (
+ "text/html,application/xhtml+xml,application/xml;q=0.9,"
+ "image/avif,image/webp,image/apng,*/*;q=0.8"
+ ),
+ "Accept-Language": "en-US,en;q=0.9",
+ }
+
+ try:
+ response = await self._do_fetch(url, timeout, headers)
+ except httpx.TimeoutException:
+ raise ToolError(f"Request timed out after {timeout} seconds")
+ except httpx.RequestError as e:
+ raise ToolError(f"Failed to fetch URL: {e}")
+
+ if response.is_error:
+ raise ToolError(
+ f"HTTP error {response.status_code}: {response.reason_phrase}"
+ )
+
+ content_type = response.headers.get("Content-Type", "text/plain")
+
+ content_bytes = response.content[: self.config.max_content_bytes]
+ content = content_bytes.decode("utf-8", errors="ignore")
+
+ if len(response.content) > self.config.max_content_bytes:
+ content += "[Content truncated due to size limit]"
+
+ return content, content_type
+
+ async def _do_fetch(
+ self, url: str, timeout: int, headers: dict[str, str]
+ ) -> httpx.Response:
+ async with httpx.AsyncClient(
+ follow_redirects=True, timeout=httpx.Timeout(timeout)
+ ) as client:
+ response = await client.get(url, headers=headers)
+
+ # In case we are hitting bot detection retry once honestly
+ if (
+ response.status_code == _HTTP_FORBIDDEN
+ and response.headers.get("cf-mitigated") == "challenge"
+ ):
+ headers["User-Agent"] = _HONEST_USER_AGENT
+ response = await client.get(url, headers=headers)
+
+ return response
+
+ @classmethod
+ def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
+ if event.args is None:
+ return ToolCallDisplay(summary="webfetch")
+ if not isinstance(event.args, WebFetchArgs):
+ return ToolCallDisplay(summary="webfetch")
+
+ parsed = urlparse(event.args.url)
+ domain = parsed.netloc or event.args.url[:50]
+ summary = f"Fetching: {domain}"
+
+ if event.args.timeout:
+ summary += f" (timeout {event.args.timeout}s)"
+
+ return ToolCallDisplay(summary=summary)
+
+ @classmethod
+ def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
+ if not isinstance(event.result, WebFetchResult):
+ return ToolResultDisplay(
+ success=False, message=event.error or event.skip_reason or "No result"
+ )
+
+ content_len = len(event.result.content)
+ message = (
+ f"Fetched {content_len:,} chars ({event.result.content_type.split(';')[0]})"
+ )
+
+ return ToolResultDisplay(success=True, message=message)
+
+ @classmethod
+ def get_status_text(cls) -> str:
+ return "Fetching URL"
+
+
+def _html_to_markdown(html: str) -> str:
+ return _Converter(heading_style="ATX", bullets="-").convert(html)
diff --git a/vibe/core/tools/builtins/websearch.py b/vibe/core/tools/builtins/websearch.py
new file mode 100644
index 0000000..c02c90b
--- /dev/null
+++ b/vibe/core/tools/builtins/websearch.py
@@ -0,0 +1,131 @@
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+import os
+from typing import TYPE_CHECKING, ClassVar, final
+
+import mistralai
+from pydantic import BaseModel, Field
+
+from vibe.core.tools.base import (
+ BaseTool,
+ BaseToolConfig,
+ BaseToolState,
+ InvokeContext,
+ ToolError,
+ ToolPermission,
+)
+from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
+from vibe.core.types import ToolStreamEvent
+
+if TYPE_CHECKING:
+ from vibe.core.types import ToolCallEvent, ToolResultEvent
+
+
+class WebSearchSource(BaseModel):
+ title: str
+ url: str
+
+
+class WebSearchArgs(BaseModel):
+ query: str = Field(min_length=1)
+
+
+class WebSearchResult(BaseModel):
+ answer: str
+ sources: list[WebSearchSource] = Field(default_factory=list)
+
+
+class WebSearchConfig(BaseToolConfig):
+ permission: ToolPermission = ToolPermission.ASK
+ timeout: int = Field(default=120, description="HTTP timeout in seconds.")
+ model: str = Field(
+ default="mistral-vibe-cli-with-tools",
+ description="Mistral model to use for web search.",
+ )
+
+
+class WebSearch(
+ BaseTool[WebSearchArgs, WebSearchResult, WebSearchConfig, BaseToolState],
+ ToolUIData[WebSearchArgs, WebSearchResult],
+):
+ description: ClassVar[str] = (
+ "Search the web for current information using Mistral's web search."
+ )
+
+ @classmethod
+ def is_available(cls) -> bool:
+ return bool(os.getenv("MISTRAL_API_KEY"))
+
+ @final
+ async def run(
+ self, args: WebSearchArgs, ctx: InvokeContext | None = None
+ ) -> AsyncGenerator[ToolStreamEvent | WebSearchResult, None]:
+ api_key = os.getenv("MISTRAL_API_KEY")
+ if not api_key:
+ raise ToolError("MISTRAL_API_KEY environment variable not set.")
+
+ client = mistralai.Mistral(
+ api_key=api_key, timeout_ms=self.config.timeout * 1000
+ )
+
+ try:
+ async with client:
+ response = await client.beta.conversations.start_async(
+ model=self.config.model,
+ instructions="Always use the web_search tool to answer queries. Never answer from memory alone.",
+ tools=[{"type": "web_search"}],
+ inputs=args.query,
+ store=False,
+ )
+
+ yield self._parse_response(response)
+
+ except mistralai.SDKError as exc:
+ raise ToolError(f"Mistral API error: {exc}") from exc
+
+ def _parse_response(
+ self, response: mistralai.ConversationResponse
+ ) -> WebSearchResult:
+ text_parts: list[str] = []
+ sources: dict[str, WebSearchSource] = {}
+
+ for entry in response.outputs:
+ if not isinstance(entry, mistralai.MessageOutputEntry):
+ continue
+ for chunk in entry.content:
+ if isinstance(chunk, mistralai.TextChunk):
+ text_parts.append(chunk.text)
+ elif isinstance(chunk, mistralai.ToolReferenceChunk) and chunk.url:
+ if chunk.url not in sources:
+ sources[chunk.url] = WebSearchSource(
+ title=chunk.title, url=chunk.url
+ )
+
+ answer = "".join(text_parts).strip()
+ if not answer:
+ raise ToolError("No text in agent response.")
+
+ return WebSearchResult(answer=answer, sources=list(sources.values()))
+
+ @classmethod
+ def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
+ if event.args is None:
+ return ToolCallDisplay(summary="websearch")
+ if not isinstance(event.args, WebSearchArgs):
+ return ToolCallDisplay(summary="websearch")
+ return ToolCallDisplay(summary=f"Searching the web: '{event.args.query}'")
+
+ @classmethod
+ def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
+ if not isinstance(event.result, WebSearchResult):
+ return ToolResultDisplay(
+ success=False, message=event.error or event.skip_reason or "No result"
+ )
+ return ToolResultDisplay(
+ success=True, message=f"{len(event.result.sources)} sources found"
+ )
+
+ @classmethod
+ def get_status_text(cls) -> str:
+ return "Searching the web"
diff --git a/vibe/core/tools/builtins/write_file.py b/vibe/core/tools/builtins/write_file.py
index 7080a58..3078125 100644
--- a/vibe/core/tools/builtins/write_file.py
+++ b/vibe/core/tools/builtins/write_file.py
@@ -16,7 +16,8 @@ from vibe.core.tools.base import (
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
-from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
+from vibe.core.tools.utils import resolve_file_tool_permission
+from vibe.core.types import ToolResultEvent, ToolStreamEvent
class WriteFileArgs(BaseModel):
@@ -40,12 +41,8 @@ class WriteFileConfig(BaseToolConfig):
create_parent_dirs: bool = True
-class WriteFileState(BaseToolState):
- recently_written_files: list[str] = Field(default_factory=list)
-
-
class WriteFile(
- BaseTool[WriteFileArgs, WriteFileResult, WriteFileConfig, WriteFileState],
+ BaseTool[WriteFileArgs, WriteFileResult, WriteFileConfig, BaseToolState],
ToolUIData[WriteFileArgs, WriteFileResult],
):
description: ClassVar[str] = (
@@ -53,12 +50,7 @@ class WriteFile(
)
@classmethod
- def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
- if not isinstance(event.args, WriteFileArgs):
- return ToolCallDisplay(summary="Invalid arguments")
-
- args = event.args
-
+ def format_call_display(cls, args: WriteFileArgs) -> ToolCallDisplay:
return ToolCallDisplay(
summary=f"Writing {args.path}{' (overwrite)' if args.overwrite else ''}",
content=args.content,
@@ -78,23 +70,13 @@ class WriteFile(
def get_status_text(cls) -> str:
return "Writing file"
- def check_allowlist_denylist(self, args: WriteFileArgs) -> ToolPermission | None:
- import fnmatch
-
- file_path = Path(args.path).expanduser()
- if not file_path.is_absolute():
- file_path = Path.cwd() / file_path
- file_str = str(file_path)
-
- for pattern in self.config.denylist:
- if fnmatch.fnmatch(file_str, pattern):
- return ToolPermission.NEVER
-
- for pattern in self.config.allowlist:
- if fnmatch.fnmatch(file_str, pattern):
- return ToolPermission.ALWAYS
-
- return None
+ def resolve_permission(self, args: WriteFileArgs) -> ToolPermission | None:
+ return resolve_file_tool_permission(
+ args.path,
+ allowlist=self.config.allowlist,
+ denylist=self.config.denylist,
+ config_permission=self.config.permission,
+ )
@final
async def run(
@@ -104,11 +86,6 @@ class WriteFile(
await self._write_file(args, file_path)
- BUFFER_SIZE = 10
- self.state.recently_written_files.append(str(file_path))
- if len(self.state.recently_written_files) > BUFFER_SIZE:
- self.state.recently_written_files.pop(0)
-
yield WriteFileResult(
path=str(file_path),
bytes_written=content_bytes,
@@ -131,11 +108,6 @@ class WriteFile(
file_path = Path.cwd() / file_path
file_path = file_path.resolve()
- try:
- file_path.relative_to(Path.cwd().resolve())
- except ValueError:
- raise ToolError(f"Cannot write outside project directory: {file_path}")
-
file_existed = file_path.exists()
if file_existed and not args.overwrite:
diff --git a/vibe/core/tools/manager.py b/vibe/core/tools/manager.py
index eec912d..cfdea36 100644
--- a/vibe/core/tools/manager.py
+++ b/vibe/core/tools/manager.py
@@ -4,28 +4,20 @@ from collections.abc import Callable, Iterator
import hashlib
import importlib.util
import inspect
-from logging import getLogger
from pathlib import Path
import re
import sys
from typing import TYPE_CHECKING, Any
-from vibe.core.paths.config_paths import resolve_local_tools_dir
+from vibe.core.logger import logger
+from vibe.core.paths.config_paths import discover_local_tools_dirs
from vibe.core.paths.global_paths import DEFAULT_TOOL_DIR, GLOBAL_TOOLS_DIR
from vibe.core.tools.base import BaseTool, BaseToolConfig
-from vibe.core.tools.mcp import (
- RemoteTool,
- create_mcp_http_proxy_tool_class,
- create_mcp_stdio_proxy_tool_class,
- list_tools_http,
- list_tools_stdio,
-)
-from vibe.core.utils import name_matches, run_sync
-
-logger = getLogger("vibe")
+from vibe.core.tools.mcp import MCPRegistry
+from vibe.core.utils import name_matches
if TYPE_CHECKING:
- from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp, VibeConfig
+ from vibe.core.config import VibeConfig
def _try_canonical_module_name(path: Path) -> str | None:
@@ -73,8 +65,13 @@ class ToolManager:
should have its own ToolManager instance.
"""
- def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
+ def __init__(
+ self,
+ config_getter: Callable[[], VibeConfig],
+ mcp_registry: MCPRegistry | None = None,
+ ) -> None:
self._config_getter = config_getter
+ self._mcp_registry = mcp_registry or MCPRegistry()
self._instances: dict[str, BaseTool] = {}
self._search_paths: list[Path] = self._compute_search_paths(self._config)
@@ -92,10 +89,7 @@ class ToolManager:
paths: list[Path] = [DEFAULT_TOOL_DIR.path]
paths.extend(config.tool_paths)
-
- if (tools_dir := resolve_local_tools_dir(Path.cwd())) is not None:
- paths.append(tools_dir)
-
+ paths.extend(discover_local_tools_dirs(Path.cwd()))
paths.append(GLOBAL_TOOLS_DIR.path)
unique: list[Path] = []
@@ -180,122 +174,38 @@ class ToolManager:
@property
def available_tools(self) -> dict[str, type[BaseTool]]:
+ runtime_available = {
+ name: cls for name, cls in self._available.items() if cls.is_available()
+ }
+
if self._config.enabled_tools:
return {
name: cls
- for name, cls in self._available.items()
+ for name, cls in runtime_available.items()
if name_matches(name, self._config.enabled_tools)
}
if self._config.disabled_tools:
return {
name: cls
- for name, cls in self._available.items()
+ for name, cls in runtime_available.items()
if not name_matches(name, self._config.disabled_tools)
}
- return dict(self._available)
+ return runtime_available
def _integrate_mcp(self) -> None:
if not self._config.mcp_servers:
return
- run_sync(self._integrate_mcp_async())
-
- async def _integrate_mcp_async(self) -> None:
- try:
- http_count = 0
- stdio_count = 0
-
- for srv in self._config.mcp_servers:
- match srv.transport:
- case "http" | "streamable-http":
- http_count += await self._register_http_server(srv)
- case "stdio":
- stdio_count += await self._register_stdio_server(srv)
- case _:
- logger.warning("Unsupported MCP transport: %r", srv.transport)
-
- logger.info(
- "MCP integration registered %d tools (http=%d, stdio=%d)",
- http_count + stdio_count,
- http_count,
- stdio_count,
- )
- except Exception as exc:
- logger.warning("Failed to integrate MCP tools: %s", exc)
-
- async def _register_http_server(self, srv: MCPHttp | MCPStreamableHttp) -> int:
- url = (srv.url or "").strip()
- if not url:
- logger.warning("MCP server '%s' missing url for http transport", srv.name)
- return 0
-
- headers = srv.http_headers()
- try:
- tools: list[RemoteTool] = await list_tools_http(
- url, headers=headers, startup_timeout_sec=srv.startup_timeout_sec
- )
- except Exception as exc:
- logger.warning("MCP HTTP discovery failed for %s: %s", url, exc)
- return 0
-
- added = 0
- for remote in tools:
- try:
- proxy_cls = create_mcp_http_proxy_tool_class(
- url=url,
- remote=remote,
- alias=srv.name,
- server_hint=srv.prompt,
- headers=headers,
- startup_timeout_sec=srv.startup_timeout_sec,
- tool_timeout_sec=srv.tool_timeout_sec,
- )
- self._available[proxy_cls.get_name()] = proxy_cls
- added += 1
- except Exception as exc:
- logger.warning(
- "Failed to register MCP HTTP tool '%s' from %s: %r",
- getattr(remote, "name", ""),
- url,
- exc,
- )
- return added
-
- async def _register_stdio_server(self, srv: MCPStdio) -> int:
- cmd = srv.argv()
- if not cmd:
- logger.warning("MCP stdio server '%s' has invalid/empty command", srv.name)
- return 0
try:
- tools: list[RemoteTool] = await list_tools_stdio(
- cmd, env=srv.env or None, startup_timeout_sec=srv.startup_timeout_sec
- )
+ mcp_tools = self._mcp_registry.get_tools(self._config.mcp_servers)
except Exception as exc:
- logger.warning("MCP stdio discovery failed for %r: %s", cmd, exc)
- return 0
+ logger.warning("MCP integration failed: %s", exc)
+ return
- added = 0
- for remote in tools:
- try:
- proxy_cls = create_mcp_stdio_proxy_tool_class(
- command=cmd,
- remote=remote,
- alias=srv.name,
- server_hint=srv.prompt,
- env=srv.env or None,
- startup_timeout_sec=srv.startup_timeout_sec,
- tool_timeout_sec=srv.tool_timeout_sec,
- )
- self._available[proxy_cls.get_name()] = proxy_cls
- added += 1
- except Exception as exc:
- logger.warning(
- "Failed to register MCP stdio tool '%s' from %r: %r",
- getattr(remote, "name", ""),
- cmd,
- exc,
- )
- return added
+ self._available.update(mcp_tools)
+ logger.info(
+ "MCP integration registered %d tools (via registry)", len(mcp_tools)
+ )
def get_tool_config(self, tool_name: str) -> BaseToolConfig:
tool_class = self._available.get(tool_name)
diff --git a/vibe/core/tools/mcp/__init__.py b/vibe/core/tools/mcp/__init__.py
new file mode 100644
index 0000000..81cdccc
--- /dev/null
+++ b/vibe/core/tools/mcp/__init__.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from vibe.core.tools.mcp.registry import MCPRegistry
+from vibe.core.tools.mcp.tools import (
+ MCPToolResult,
+ RemoteTool,
+ _mcp_stderr_capture,
+ _parse_call_result,
+ _stderr_logger_thread,
+ call_tool_http,
+ call_tool_stdio,
+ create_mcp_http_proxy_tool_class,
+ create_mcp_stdio_proxy_tool_class,
+ list_tools_http,
+ list_tools_stdio,
+)
+
+__all__ = [
+ "MCPRegistry",
+ "MCPToolResult",
+ "RemoteTool",
+ "_mcp_stderr_capture",
+ "_parse_call_result",
+ "_stderr_logger_thread",
+ "call_tool_http",
+ "call_tool_stdio",
+ "create_mcp_http_proxy_tool_class",
+ "create_mcp_stdio_proxy_tool_class",
+ "list_tools_http",
+ "list_tools_stdio",
+]
diff --git a/vibe/core/tools/mcp/registry.py b/vibe/core/tools/mcp/registry.py
new file mode 100644
index 0000000..85b1bcf
--- /dev/null
+++ b/vibe/core/tools/mcp/registry.py
@@ -0,0 +1,167 @@
+from __future__ import annotations
+
+import asyncio
+import hashlib
+from typing import TYPE_CHECKING, cast
+
+from vibe.core.logger import logger
+from vibe.core.tools.base import BaseTool
+from vibe.core.tools.mcp.tools import (
+ create_mcp_http_proxy_tool_class,
+ create_mcp_stdio_proxy_tool_class,
+ list_tools_http,
+ list_tools_stdio,
+)
+from vibe.core.utils import run_sync
+
+if TYPE_CHECKING:
+ from vibe.core.config import MCPHttp, MCPServer, MCPStdio, MCPStreamableHttp
+
+
+class MCPRegistry:
+ """Shared cache for MCP server tool discovery.
+
+ Survives agent switches so that shift-tab does not re-discover
+ servers whose config has not changed. The cache is keyed by a
+ stable fingerprint derived from each server's full config.
+ """
+
+ def __init__(self) -> None:
+ self._cache: dict[str, dict[str, type[BaseTool]]] = {}
+
+ @staticmethod
+ def _server_key(srv: MCPServer) -> str:
+ raw = srv.model_dump_json(exclude_none=False)
+ return hashlib.sha256(raw.encode()).hexdigest()
+
+ def get_tools(self, servers: list[MCPServer]) -> dict[str, type[BaseTool]]:
+ """Return proxy tool classes for *servers*, using cache when possible."""
+ result: dict[str, type[BaseTool]] = {}
+ to_discover: list[tuple[str, MCPServer]] = []
+
+ for srv in servers:
+ key = self._server_key(srv)
+ if key in self._cache:
+ result.update(self._cache[key])
+ else:
+ to_discover.append((key, srv))
+
+ if to_discover:
+ discovered = run_sync(self._discover_all(to_discover))
+ result.update(discovered)
+
+ return result
+
+ async def _discover_all(
+ self, servers: list[tuple[str, MCPServer]]
+ ) -> dict[str, type[BaseTool]]:
+ results = await asyncio.gather(
+ *(self._discover_server(srv) for _, srv in servers), return_exceptions=True
+ )
+ out: dict[str, type[BaseTool]] = {}
+ for (key, srv), result in zip(servers, results, strict=True):
+ if isinstance(result, BaseException):
+ logger.warning(
+ "MCP discovery failed for server %r: %s", srv.name, result
+ )
+ continue
+ if result is None:
+ continue
+ self._cache[key] = result
+ out.update(result)
+ return out
+
+ async def _discover_server(
+ self, srv: MCPServer
+ ) -> dict[str, type[BaseTool]] | None:
+ match srv.transport:
+ case "http" | "streamable-http":
+ return await self._discover_http(
+ cast("MCPHttp | MCPStreamableHttp", srv)
+ )
+ case "stdio":
+ return await self._discover_stdio(cast("MCPStdio", srv))
+ case _:
+ logger.warning("Unsupported MCP transport: %r", srv.transport)
+ return {}
+
+ async def _discover_http(
+ self, srv: MCPHttp | MCPStreamableHttp
+ ) -> dict[str, type[BaseTool]] | None:
+ url = (srv.url or "").strip()
+ if not url:
+ logger.warning("MCP server '%s' missing url for http transport", srv.name)
+ return {}
+
+ headers = srv.http_headers()
+ try:
+ remotes = await list_tools_http(
+ url, headers=headers, startup_timeout_sec=srv.startup_timeout_sec
+ )
+ except Exception as exc:
+ logger.warning("MCP HTTP discovery failed for %s: %s", url, exc)
+ return None
+
+ tools: dict[str, type[BaseTool]] = {}
+ for remote in remotes:
+ try:
+ proxy_cls = create_mcp_http_proxy_tool_class(
+ url=url,
+ remote=remote,
+ alias=srv.name,
+ server_hint=srv.prompt,
+ headers=headers,
+ startup_timeout_sec=srv.startup_timeout_sec,
+ tool_timeout_sec=srv.tool_timeout_sec,
+ sampling_enabled=srv.sampling_enabled,
+ )
+ tools[proxy_cls.get_name()] = proxy_cls
+ except Exception as exc:
+ logger.warning(
+ "Failed to register MCP HTTP tool '%s' from %s: %r",
+ getattr(remote, "name", ""),
+ url,
+ exc,
+ )
+ return tools
+
+ async def _discover_stdio(self, srv: MCPStdio) -> dict[str, type[BaseTool]] | None:
+ cmd = srv.argv()
+ if not cmd:
+ logger.warning("MCP stdio server '%s' has invalid/empty command", srv.name)
+ return {}
+
+ try:
+ remotes = await list_tools_stdio(
+ cmd, env=srv.env or None, startup_timeout_sec=srv.startup_timeout_sec
+ )
+ except Exception as exc:
+ logger.warning("MCP stdio discovery failed for %r: %s", cmd, exc)
+ return None
+
+ tools: dict[str, type[BaseTool]] = {}
+ for remote in remotes:
+ try:
+ proxy_cls = create_mcp_stdio_proxy_tool_class(
+ command=cmd,
+ remote=remote,
+ alias=srv.name,
+ server_hint=srv.prompt,
+ env=srv.env or None,
+ startup_timeout_sec=srv.startup_timeout_sec,
+ tool_timeout_sec=srv.tool_timeout_sec,
+ sampling_enabled=srv.sampling_enabled,
+ )
+ tools[proxy_cls.get_name()] = proxy_cls
+ except Exception as exc:
+ logger.warning(
+ "Failed to register MCP stdio tool '%s' from %r: %r",
+ getattr(remote, "name", ""),
+ cmd,
+ exc,
+ )
+ return tools
+
+ def clear(self) -> None:
+ """Drop all cached entries, forcing re-discovery on next use."""
+ self._cache.clear()
diff --git a/vibe/core/tools/mcp.py b/vibe/core/tools/mcp/tools.py
similarity index 90%
rename from vibe/core/tools/mcp.py
rename to vibe/core/tools/mcp/tools.py
index 374bf59..156a6c5 100644
--- a/vibe/core/tools/mcp.py
+++ b/vibe/core/tools/mcp/tools.py
@@ -4,17 +4,17 @@ from collections.abc import AsyncGenerator
import contextlib
from datetime import timedelta
import hashlib
-from logging import getLogger
import os
from pathlib import Path
import threading
from typing import TYPE_CHECKING, Any, ClassVar, TextIO
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
from mcp import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamablehttp_client
-from pydantic import BaseModel, ConfigDict, Field, field_validator
-
+from vibe.core.logger import logger
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
@@ -22,13 +22,12 @@ from vibe.core.tools.base import (
InvokeContext,
ToolError,
)
-from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay
+from vibe.core.tools.mcp_sampling import MCPSamplingHandler
+from vibe.core.tools.ui import ToolResultDisplay, ToolUIData
from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
- from vibe.core.types import ToolCallEvent, ToolResultEvent
-
-logger = getLogger("vibe")
+ from vibe.core.types import ToolResultEvent
def _stderr_logger_thread(read_fd: int) -> None:
@@ -169,6 +168,7 @@ async def call_tool_http(
headers: dict[str, str] | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
+ sampling_callback: MCPSamplingHandler | None = None,
) -> MCPToolResult:
init_timeout = (
timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
@@ -176,7 +176,10 @@ async def call_tool_http(
call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
async with streamablehttp_client(url, headers=headers) as (read, write, _):
async with ClientSession(
- read, write, read_timeout_seconds=init_timeout
+ read,
+ write,
+ read_timeout_seconds=init_timeout,
+ sampling_callback=sampling_callback,
) as session:
await session.initialize()
result = await session.call_tool(
@@ -194,6 +197,7 @@ def create_mcp_http_proxy_tool_class(
headers: dict[str, str] | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
+ sampling_enabled: bool = True,
) -> type[BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]]:
from urllib.parse import urlparse
@@ -206,7 +210,8 @@ def create_mcp_http_proxy_tool_class(
published_name = f"{(alias or _alias_from_url(url))}_{remote.name}"
class MCPHttpProxyTool(
- BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]
+ BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState],
+ ToolUIData[_OpenArgs, MCPToolResult],
):
description: ClassVar[str] = (
(f"[{alias}] " if alias else "")
@@ -219,6 +224,7 @@ def create_mcp_http_proxy_tool_class(
_headers: ClassVar[dict[str, str]] = dict(headers or {})
_startup_timeout_sec: ClassVar[float | None] = startup_timeout_sec
_tool_timeout_sec: ClassVar[float | None] = tool_timeout_sec
+ _sampling_enabled: ClassVar[bool] = sampling_enabled
@classmethod
def get_name(cls) -> str:
@@ -232,6 +238,9 @@ def create_mcp_http_proxy_tool_class(
self, args: _OpenArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
try:
+ sampling_callback = (
+ ctx.sampling_callback if ctx and self._sampling_enabled else None
+ )
payload = args.model_dump(exclude_none=True)
yield await call_tool_http(
self._mcp_url,
@@ -240,14 +249,11 @@ def create_mcp_http_proxy_tool_class(
headers=self._headers,
startup_timeout_sec=self._startup_timeout_sec,
tool_timeout_sec=self._tool_timeout_sec,
+ sampling_callback=sampling_callback,
)
except Exception as exc:
raise ToolError(f"MCP call failed: {exc}") from exc
- @classmethod
- def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
- return ToolCallDisplay(summary=f"{published_name}")
-
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if not isinstance(event.result, MCPToolResult):
@@ -293,6 +299,7 @@ async def call_tool_stdio(
env: dict[str, str] | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
+ sampling_callback: MCPSamplingHandler | None = None,
) -> MCPToolResult:
params = StdioServerParameters(command=command[0], args=command[1:], env=env)
init_timeout = (
@@ -302,7 +309,12 @@ async def call_tool_stdio(
async with (
_mcp_stderr_capture() as errlog,
stdio_client(params, errlog=errlog) as (read, write),
- ClientSession(read, write, read_timeout_seconds=init_timeout) as session,
+ ClientSession(
+ read,
+ write,
+ read_timeout_seconds=init_timeout,
+ sampling_callback=sampling_callback,
+ ) as session,
):
await session.initialize()
result = await session.call_tool(
@@ -320,6 +332,7 @@ def create_mcp_stdio_proxy_tool_class(
env: dict[str, str] | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
+ sampling_enabled: bool = True,
) -> type[BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]]:
def _alias_from_command(cmd: list[str]) -> str:
prog = Path(cmd[0]).name.replace(".", "_") if cmd else "mcp"
@@ -332,7 +345,8 @@ def create_mcp_stdio_proxy_tool_class(
published_name = f"{computed_alias}_{remote.name}"
class MCPStdioProxyTool(
- BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]
+ BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState],
+ ToolUIData[_OpenArgs, MCPToolResult],
):
description: ClassVar[str] = (
(f"[{computed_alias}] " if computed_alias else "")
@@ -348,6 +362,7 @@ def create_mcp_stdio_proxy_tool_class(
_env: ClassVar[dict[str, str] | None] = env
_startup_timeout_sec: ClassVar[float | None] = startup_timeout_sec
_tool_timeout_sec: ClassVar[float | None] = tool_timeout_sec
+ _sampling_enabled: ClassVar[bool] = sampling_enabled
@classmethod
def get_name(cls) -> str:
@@ -361,6 +376,9 @@ def create_mcp_stdio_proxy_tool_class(
self, args: _OpenArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
try:
+ sampling_callback = (
+ ctx.sampling_callback if ctx and self._sampling_enabled else None
+ )
payload = args.model_dump(exclude_none=True)
result = await call_tool_stdio(
self._stdio_command,
@@ -369,15 +387,12 @@ def create_mcp_stdio_proxy_tool_class(
env=self._env,
startup_timeout_sec=self._startup_timeout_sec,
tool_timeout_sec=self._tool_timeout_sec,
+ sampling_callback=sampling_callback,
)
yield result
except Exception as exc:
raise ToolError(f"MCP stdio call failed: {exc!r}") from exc
- @classmethod
- def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
- return ToolCallDisplay(summary=f"{published_name}")
-
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if not isinstance(event.result, MCPToolResult):
diff --git a/vibe/core/tools/mcp_sampling.py b/vibe/core/tools/mcp_sampling.py
new file mode 100644
index 0000000..5adaef6
--- /dev/null
+++ b/vibe/core/tools/mcp_sampling.py
@@ -0,0 +1,115 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from logging import getLogger
+from typing import Any
+
+from mcp.client.session import ClientSession
+from mcp.shared.context import RequestContext
+from mcp.types import (
+ CreateMessageRequestParams,
+ CreateMessageResult,
+ ErrorData,
+ TextContent,
+)
+
+from vibe.core.llm.types import BackendLike
+from vibe.core.types import LLMMessage, Role
+
+logger = getLogger("vibe")
+
+
+class MCPSamplingHandler:
+ def __init__(
+ self,
+ backend_getter: Callable[[], BackendLike],
+ config_getter: Callable[[], Any],
+ ) -> None:
+ self._backend_getter = backend_getter
+ self._config_getter = config_getter
+
+ async def __call__(
+ self,
+ context: RequestContext[ClientSession, Any],
+ params: CreateMessageRequestParams,
+ ) -> CreateMessageResult | ErrorData:
+ try:
+ config = self._config_getter()
+ model = config.get_active_model()
+
+ messages = _map_sampling_messages(params.messages)
+
+ if params.systemPrompt:
+ messages.insert(
+ 0, LLMMessage(role=Role.system, content=params.systemPrompt)
+ )
+
+ result = await self._backend_getter().complete(
+ model=model,
+ messages=messages,
+ temperature=params.temperature
+ if params.temperature is not None
+ else model.temperature,
+ tools=None,
+ max_tokens=params.maxTokens,
+ tool_choice=None,
+ extra_headers=None,
+ )
+
+ content_text = result.message.content or ""
+
+ return CreateMessageResult(
+ role="assistant",
+ content=TextContent(type="text", text=content_text),
+ model=model.name,
+ stopReason="endTurn",
+ )
+
+ except Exception as exc:
+ logger.warning("MCP sampling request failed: %s", exc)
+ return ErrorData(code=-1, message=f"Sampling failed: {exc}")
+
+
+def _map_sampling_messages(messages: list[Any]) -> list[LLMMessage]:
+ result: list[LLMMessage] = []
+ for msg in messages:
+ match msg.role:
+ case "user":
+ role = Role.user
+ case "assistant":
+ role = Role.assistant
+ case _:
+ logger.error(
+ "MCP sampling: unexpected message role, treating as assistant",
+ extra={"role": getattr(msg, "role", None)},
+ )
+ role = Role.assistant
+ content = _extract_text_content(msg.content)
+ result.append(LLMMessage(role=role, content=content))
+ return result
+
+
+def _extract_text_content(content: Any) -> str:
+ if isinstance(content, list):
+ parts: list[str] = []
+ for block in content:
+ if (
+ hasattr(block, "type")
+ and block.type == "text"
+ and hasattr(block, "text")
+ ):
+ parts.append(block.text)
+ else:
+ block_type = getattr(block, "type", "unknown")
+ logger.debug(
+ "MCP sampling: skipping unsupported content block type=%s",
+ block_type,
+ )
+ return "\n".join(parts) if parts else ""
+
+ if hasattr(content, "type") and content.type == "text" and hasattr(content, "text"):
+ return content.text
+
+ block_type = getattr(content, "type", "unknown")
+ logger.debug("MCP sampling: unsupported single content block type=%s", block_type)
+ return ""
diff --git a/vibe/core/tools/ui.py b/vibe/core/tools/ui.py
index aa9c549..925c27e 100644
--- a/vibe/core/tools/ui.py
+++ b/vibe/core/tools/ui.py
@@ -1,6 +1,8 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
+from abc import ABC, abstractmethod
+from collections.abc import Callable
+from typing import TYPE_CHECKING, Any, cast
from pydantic import BaseModel, Field
@@ -19,15 +21,46 @@ class ToolResultDisplay(BaseModel):
warnings: list[str] = Field(default_factory=list)
-@runtime_checkable
-class ToolUIData[TArgs: BaseModel, TResult: BaseModel](Protocol):
+class ToolUIData[TArgs: BaseModel, TResult: BaseModel](ABC):
@classmethod
- def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay: ...
+ def _display_name(cls) -> str:
+ get_name = cast(Callable[[], str] | None, getattr(cls, "get_name", None))
+ return get_name() if get_name is not None else cls.__name__.lower()
@classmethod
+ def get_no_args_display(cls) -> ToolCallDisplay:
+ return ToolCallDisplay(summary=cls._display_name())
+
+ @classmethod
+ def get_invalid_args_display(cls) -> ToolCallDisplay:
+ return ToolCallDisplay(summary="Invalid Arguments")
+
+ @classmethod
+ def format_call_display(cls, args: TArgs) -> ToolCallDisplay:
+ return ToolCallDisplay(summary=cls._display_name())
+
+ @classmethod
+ def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
+ if event.args is None:
+ return cls.get_no_args_display()
+
+ introspect = cast(
+ Callable[[], tuple[type, ...]] | None,
+ getattr(cls, "_get_tool_args_results", None),
+ )
+ if introspect is not None:
+ expected_type = introspect()[0]
+ if not isinstance(event.args, expected_type):
+ return cls.get_invalid_args_display()
+
+ return cls.format_call_display(cast(TArgs, event.args))
+
+ @classmethod
+ @abstractmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: ...
@classmethod
+ @abstractmethod
def get_status_text(cls) -> str: ...
@@ -42,7 +75,11 @@ class ToolUIDataAdapter:
if self.ui_data_class:
return self.ui_data_class.get_call_display(event)
- args_dict = event.args.model_dump() if hasattr(event.args, "model_dump") else {}
+ args_dict = (
+ event.args.model_dump()
+ if event.args and hasattr(event.args, "model_dump")
+ else {}
+ )
args_str = ", ".join(f"{k}={v!r}" for k, v in list(args_dict.items())[:3])
return ToolCallDisplay(summary=f"{event.tool_name}({args_str})")
diff --git a/vibe/core/tools/utils.py b/vibe/core/tools/utils.py
new file mode 100644
index 0000000..6d99a69
--- /dev/null
+++ b/vibe/core/tools/utils.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import fnmatch
+from pathlib import Path
+
+from vibe.core.tools.base import ToolPermission
+
+
+def resolve_path_permission(
+ path_str: str, *, allowlist: list[str], denylist: list[str]
+) -> ToolPermission | None:
+ """Resolve permission for a file path against glob patterns.
+
+ Returns NEVER on denylist match, ALWAYS on allowlist match, None otherwise.
+ """
+ file_path = Path(path_str).expanduser()
+ if not file_path.is_absolute():
+ file_path = Path.cwd() / file_path
+ file_str = str(file_path.resolve())
+
+ for pattern in denylist:
+ if fnmatch.fnmatch(file_str, pattern):
+ return ToolPermission.NEVER
+
+ for pattern in allowlist:
+ if fnmatch.fnmatch(file_str, pattern):
+ return ToolPermission.ALWAYS
+
+ return None
+
+
+def is_path_within_workdir(path_str: str) -> bool:
+ """Return True if the resolved path is inside cwd."""
+ file_path = Path(path_str).expanduser()
+ if not file_path.is_absolute():
+ file_path = Path.cwd() / file_path
+ try:
+ file_path.resolve().relative_to(Path.cwd().resolve())
+ return True
+ except ValueError:
+ return False
+
+
+def resolve_file_tool_permission(
+ path_str: str,
+ *,
+ allowlist: list[str],
+ denylist: list[str],
+ config_permission: ToolPermission,
+) -> ToolPermission | None:
+ """Resolve permission for a file-based tool invocation.
+
+ Checks allowlist/denylist first, then escalates to ASK for paths outside
+ the working directory (unless the tool is configured as NEVER).
+ Returns None to fall back to the tool's default config permission.
+ """
+ if (
+ result := resolve_path_permission(
+ path_str, allowlist=allowlist, denylist=denylist
+ )
+ ) is not None:
+ return result
+
+ if not is_path_within_workdir(path_str):
+ if config_permission == ToolPermission.NEVER:
+ return ToolPermission.NEVER
+ return ToolPermission.ASK
+
+ return None
diff --git a/vibe/core/trusted_folders.py b/vibe/core/trusted_folders.py
index 969aa8b..fa0b314 100644
--- a/vibe/core/trusted_folders.py
+++ b/vibe/core/trusted_folders.py
@@ -6,6 +6,7 @@ import tomllib
import tomli_w
from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE
+from vibe.core.paths.local_config_walk import walk_local_config_dirs_all
AGENTS_MD_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
@@ -15,11 +16,10 @@ def has_agents_md_file(path: Path) -> bool:
def has_trustable_content(path: Path) -> bool:
- return (
- (path / ".vibe").exists()
- or (path / ".agents").exists()
- or has_agents_md_file(path)
- )
+ if (path / ".vibe").exists():
+ return True
+ tools_dirs, skills_dirs, agents_dirs = walk_local_config_dirs_all(path)
+ return bool(tools_dirs or skills_dirs or agents_dirs) or has_agents_md_file(path)
class TrustedFoldersManager:
diff --git a/vibe/core/types.py b/vibe/core/types.py
index d9904c8..16a4bd3 100644
--- a/vibe/core/types.py
+++ b/vibe/core/types.py
@@ -2,10 +2,11 @@ from __future__ import annotations
from abc import ABC
from collections import OrderedDict
-from collections.abc import Awaitable, Callable
+from collections.abc import Awaitable, Callable, Iterator, Sequence
+from contextlib import contextmanager
import copy
from enum import StrEnum, auto
-from typing import TYPE_CHECKING, Annotated, Any, Literal
+from typing import TYPE_CHECKING, Annotated, Any, Literal, overload
from uuid import uuid4
if TYPE_CHECKING:
@@ -136,6 +137,18 @@ class SessionMetadata(BaseModel):
username: str
+class ClientMetadata(BaseModel):
+ name: str
+ version: str
+
+
+class EntrypointMetadata(BaseModel):
+ agent_entrypoint: Literal["cli", "acp", "programmatic"]
+ agent_version: str
+ client_name: str
+ client_version: str
+
+
StrToolChoice = Literal["auto", "none", "any", "required"]
@@ -159,7 +172,7 @@ class ToolCall(BaseModel):
id: str | None = None
index: int | None = None
function: FunctionCall = Field(default_factory=FunctionCall)
- type: str = "function"
+ type: Literal["function"] = "function"
def _content_before(v: Any) -> str:
@@ -339,10 +352,11 @@ class ReasoningEvent(BaseEvent):
class ToolCallEvent(BaseEvent):
+ tool_call_id: str
tool_name: str
tool_class: type[BaseTool]
- args: BaseModel
- tool_call_id: str
+ tool_call_index: int | None = None
+ args: BaseModel | None = None
class ToolResultEvent(BaseEvent):
@@ -402,6 +416,68 @@ type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
type UserInputCallback = Callable[[BaseModel], Awaitable[BaseModel]]
+class MessageList(Sequence[LLMMessage]):
+ def __init__(
+ self,
+ initial: list[LLMMessage] | None = None,
+ observer: Callable[[LLMMessage], None] | None = None,
+ ) -> None:
+ self._data: list[LLMMessage] = list(initial) if initial else []
+ self._observer = observer
+ self._silent = False
+ if self._observer:
+ for msg in self._data:
+ self._observer(msg)
+
+ def _notify(self, msg: LLMMessage) -> None:
+ if not self._silent and self._observer is not None:
+ self._observer(msg)
+
+ def append(self, msg: LLMMessage) -> None:
+ self._data.append(msg)
+ self._notify(msg)
+
+ def insert(self, i: int, msg: LLMMessage) -> None:
+ self._data.insert(i, msg)
+
+ def extend(self, msgs: list[LLMMessage]) -> None:
+ for msg in msgs:
+ self.append(msg)
+
+ def reset(self, new: list[LLMMessage]) -> None:
+ """Replace contents silently (never notifies)."""
+ self._data = list(new)
+
+ @contextmanager
+ def silent(self) -> Iterator[None]:
+ """Context manager that suppresses notifications."""
+ prev = self._silent
+ self._silent = True
+ try:
+ yield
+ finally:
+ self._silent = prev
+
+ def __len__(self) -> int:
+ return len(self._data)
+
+ @overload
+ def __getitem__(self, index: int) -> LLMMessage: ...
+ @overload
+ def __getitem__(self, index: slice) -> list[LLMMessage]: ...
+ def __getitem__(self, index: int | slice) -> LLMMessage | list[LLMMessage]:
+ return self._data[index]
+
+ def __iter__(self) -> Iterator[LLMMessage]:
+ return iter(self._data)
+
+ def __contains__(self, item: object) -> bool:
+ return item in self._data
+
+ def __bool__(self) -> bool:
+ return bool(self._data)
+
+
class RateLimitError(Exception):
def __init__(self, provider: str, model: str) -> None:
self.provider = provider
diff --git a/vibe/core/utils.py b/vibe/core/utils.py
index a8eabdb..66e243d 100644
--- a/vibe/core/utils.py
+++ b/vibe/core/utils.py
@@ -7,9 +7,6 @@ from datetime import UTC, datetime
from enum import Enum, auto
from fnmatch import fnmatch
import functools
-import logging
-from logging.handlers import RotatingFileHandler
-import os
from pathlib import Path
import re
import sys
@@ -19,7 +16,6 @@ import httpx
from vibe import __version__
from vibe.core.config import Backend
-from vibe.core.paths.global_paths import LOG_DIR, LOG_FILE
from vibe.core.types import BaseEvent, ToolResultEvent
CANCELLATION_TAG = "user_cancellation"
@@ -139,57 +135,6 @@ def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:
return False, ""
-LOG_DIR.path.mkdir(parents=True, exist_ok=True)
-
-logger = logging.getLogger("vibe")
-
-
-class StructuredLogFormatter(logging.Formatter):
- def format(self, record: logging.LogRecord) -> str:
- timestamp = datetime.fromtimestamp(record.created, tz=UTC).isoformat()
- ppid = os.getppid()
- pid = os.getpid()
- level = record.levelname
- message = record.getMessage().replace("\\", "\\\\").replace("\n", "\\n")
-
- line = f"{timestamp} {ppid} {pid} {level} {message}"
-
- if record.exc_info:
- exc_text = self.formatException(record.exc_info).replace("\n", "\\n")
- line = f"{line} {exc_text}"
-
- return line
-
-
-def apply_logging_config(target_logger: logging.Logger) -> None:
- LOG_DIR.path.mkdir(parents=True, exist_ok=True)
-
- max_bytes = int(os.environ.get("LOG_MAX_BYTES", 10 * 1024 * 1024))
-
- if os.environ.get("DEBUG_MODE") == "true":
- log_level_str = "DEBUG"
- else:
- log_level_str = os.environ.get("LOG_LEVEL", "WARNING").upper()
- valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
- if log_level_str not in valid_levels:
- log_level_str = "WARNING"
-
- handler = RotatingFileHandler(
- LOG_FILE.path, maxBytes=max_bytes, backupCount=0, encoding="utf-8"
- )
- handler.setFormatter(StructuredLogFormatter())
- log_level = getattr(logging, log_level_str, logging.WARNING)
- handler.setLevel(log_level)
-
- # Make sure the logger is not gating logs
- target_logger.setLevel(logging.DEBUG)
-
- target_logger.addHandler(handler)
-
-
-apply_logging_config(logger)
-
-
def get_user_agent(backend: Backend | None) -> str:
user_agent = f"Mistral-Vibe/{__version__}"
if backend == Backend.MISTRAL:
diff --git a/vibe/whats_new.md b/vibe/whats_new.md
index 811eeee..d064621 100644
--- a/vibe/whats_new.md
+++ b/vibe/whats_new.md
@@ -1,4 +1,6 @@
# What's new
-- **Performance**: general improvements (streaming, scrolling).
-- **Fix**: autocopying now behaves correctly in cases where it previously failed.
+- **Interactive resume**: Added a /resume command to choose which session to resume
+- **Web Search & Web Fetch**: New tools to search the web and fetch content from URLs directly from your session.
+- **MCP Sampling**: MCP servers can now request LLM completions through the sampling protocol.
+- **Notification Indicator**: Terminal bell and window title update when Vibe needs your attention or completes a task.