Co-authored-by: Carlo <carloantonio.patti@mistral.ai>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com>
Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-authored-by: Thomas Kenbeek <thomas.kenbeek@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Quentin 2026-02-27 17:31:58 +01:00 committed by GitHub
parent a560a47ce8
commit 5d2e01a6d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
139 changed files with 7152 additions and 1457 deletions

2
.vscode/launch.json vendored
View file

@ -1,5 +1,5 @@
{ {
"version": "2.2.1", "version": "2.3.0",
"configurations": [ "configurations": [
{ {
"name": "ACP Server", "name": "ACP Server",

View file

@ -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/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.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 ## [2.2.1] - 2026-02-18
### Added ### Added
@ -33,7 +71,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Google Vertex AI support - 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/` - Skill discovery from `.agents/skills/` (Agent Skills standard) in addition to `.vibe/skills/`
- ACP: `session/load` and `session/list` for loading and listing sessions - ACP: `session/load` and `session/list` for loading and listing sessions
- New model behavior prompts (CLI and explore) - New model behavior prompts (CLI and explore)

View file

@ -571,6 +571,16 @@ To disable auto-updates, add this to your `config.toml`:
enable_auto_update = false 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 ### Custom Vibe Home Directory
By default, Vibe stores its configuration in `~/.vibe/`. You can override this by setting the `VIBE_HOME` environment variable: By default, Vibe stores its configuration in `~/.vibe/`. You can override this by setting the `VIBE_HOME` environment variable:

View file

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

View file

@ -1,6 +1,6 @@
[project] [project]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.2.1" version = "2.3.0"
description = "Minimal CLI coding agent by Mistral" description = "Minimal CLI coding agent by Mistral"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@ -27,16 +27,18 @@ classifiers = [
"Topic :: Utilities", "Topic :: Utilities",
] ]
dependencies = [ dependencies = [
"agent-client-protocol==0.8.0", "agent-client-protocol==0.8.1",
"anyio>=4.12.0", "anyio>=4.12.0",
"cachetools>=5.5.0",
"cryptography>=44.0.0,<=46.0.3", "cryptography>=44.0.0,<=46.0.3",
"gitpython>=3.1.46", "gitpython>=3.1.46",
"giturlparse>=0.14.0", "giturlparse>=0.14.0",
"google-auth>=2.0.0", "google-auth>=2.0.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"keyring>=25.6.0", "keyring>=25.6.0",
"markdownify>=1.2.2",
"mcp>=1.14.0", "mcp>=1.14.0",
"mistralai==1.9.11", "mistralai==1.12.4",
"packaging>=24.1", "packaging>=24.1",
"pexpect>=4.9.0", "pexpect>=4.9.0",
"pydantic>=2.12.4", "pydantic>=2.12.4",
@ -169,3 +171,7 @@ ignore_decorators = ["@*"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-vvvv -q -n auto --durations=10 --import-mode=importlib --maxschedchunk=1" addopts = "-vvvv -q -n auto --durations=10 --import-mode=importlib --maxschedchunk=1"
timeout = 10 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",
]

View file

@ -28,7 +28,7 @@ class TestACPInitialize:
session_capabilities=SessionCapabilities(list=SessionListCapabilities()), session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
) )
assert response.agent_info == Implementation( assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.2.1" name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.3.0"
) )
assert response.auth_methods == [] assert response.auth_methods == []
@ -52,7 +52,7 @@ class TestACPInitialize:
session_capabilities=SessionCapabilities(list=SessionListCapabilities()), session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
) )
assert response.agent_info == Implementation( assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.2.1" name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.3.0"
) )
assert response.auth_methods is not None assert response.auth_methods is not None

View file

@ -34,7 +34,10 @@ def acp_agent_with_session_config(
models=[ models=[
ModelConfig( ModelConfig(
name="devstral-latest", provider="mistral", alias="devstral-latest" name="devstral-latest", provider="mistral", alias="devstral-latest"
) ),
ModelConfig(
name="devstral-small", provider="mistral", alias="devstral-small"
),
], ],
session_logging=session_config, session_logging=session_config,
) )
@ -58,7 +61,7 @@ def acp_agent_with_session_config(
class TestLoadSession: class TestLoadSession:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_load_session_returns_response_with_models_and_modes( async def test_load_session_response_structure(
self, self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path, temp_session_dir: Path,
@ -76,9 +79,49 @@ class TestLoadSession:
assert response is not None assert response is not None
assert response.models 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.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 is not None
assert response.modes.current_mode_id == BuiltinAgentName.DEFAULT 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 @pytest.mark.asyncio
async def test_load_session_registers_session_with_original_id( async def test_load_session_registers_session_with_original_id(

View file

@ -48,7 +48,7 @@ class TestACPNewSession:
) )
new_session_events = [ 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 len(new_session_events) == 1
assert new_session_events[0]["properties"]["entrypoint"] == "acp" 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 is not None
assert session_response.modes.current_mode_id is not None assert session_response.modes.current_mode_id is not None
assert session_response.modes.available_modes 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 assert session_response.modes.current_mode_id == BuiltinAgentName.DEFAULT
# Check that all primary agents are available (order may vary) # Check that all primary agents are available (order may vary)
mode_ids = {m.id for m in session_response.modes.available_modes} mode_ids = {m.id for m in session_response.modes.available_modes}
assert mode_ids == { assert mode_ids == {
BuiltinAgentName.DEFAULT, BuiltinAgentName.DEFAULT,
BuiltinAgentName.CHAT,
BuiltinAgentName.AUTO_APPROVE, BuiltinAgentName.AUTO_APPROVE,
BuiltinAgentName.PLAN, BuiltinAgentName.PLAN,
BuiltinAgentName.ACCEPT_EDITS, 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.skip(reason="TODO: Fix this test")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_new_session_preserves_model_after_set_model( async def test_new_session_preserves_model_after_set_model(

View file

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

View file

@ -106,6 +106,29 @@ class TestACPSetMode:
acp_session.agent_loop.auto_approve is False acp_session.agent_loop.auto_approve is False
) # Accept Edits mode doesn't auto-approve all ) # 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 @pytest.mark.asyncio
async def test_set_mode_invalid_mode_returns_none( async def test_set_mode_invalid_mode_returns_none(
self, acp_agent_loop: VibeAcpAgentLoop self, acp_agent_loop: VibeAcpAgentLoop

View file

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

View file

@ -89,11 +89,15 @@ def test_updates_index_on_file_rename(
old_file.rename(new_file) old_file.rename(new_file)
assert _wait_for( assert _wait_for(
lambda: all( lambda: (
entry.rel != old_file.name for entry in file_indexer.get_index(Path(".")) all(
) entry.rel != old_file.name
and any( for entry in file_indexer.get_index(Path("."))
entry.rel == new_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("."))
)
) )
) )

View file

@ -118,7 +118,7 @@ async def test_pressing_enter_submits_selected_command_and_hides_popup(
slash_used = [ slash_used = [
e e
for e in telemetry_events 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( assert any(
e.get("properties", {}).get("command") == "help" e.get("properties", {}).get("command") == "help"

View file

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

View file

@ -52,3 +52,11 @@ class TestCommandRegistry:
assert registry.get_command_name("/exit") is None assert registry.get_command_name("/exit") is None
assert registry.find_command("/exit") is None assert registry.find_command("/exit") is None
assert registry.get_command_name("/help") == "help" 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"

View file

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

View file

@ -144,8 +144,9 @@ async def test_ui_session_incremental_loader_load_more_batches_until_done(
app.post_message(HistoryLoadMoreRequested()) app.post_message(HistoryLoadMoreRequested())
await _wait_until( await _wait_until(
pilot.pause, pilot.pause,
lambda current_count=current_count: len(app.query(UserMessage)) lambda current_count=current_count: (
> current_count, len(app.query(UserMessage)) > current_count
),
) )
await _wait_until( await _wait_until(

View file

View file

@ -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()

View file

@ -3,29 +3,33 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from unittest.mock import patch 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: def test_returns_empty_list_when_dir_not_trusted(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "skills").mkdir(parents=True) (tmp_path / ".vibe" / "skills").mkdir(parents=True)
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = False 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( def test_returns_empty_list_when_trusted_but_no_skills_dirs(
self, tmp_path: Path self, tmp_path: Path
) -> None: ) -> None:
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = True 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: def test_returns_vibe_skills_only_when_only_it_exists(self, tmp_path: Path) -> None:
vibe_skills = tmp_path / ".vibe" / "skills" vibe_skills = tmp_path / ".vibe" / "skills"
vibe_skills.mkdir(parents=True) vibe_skills.mkdir(parents=True)
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = True 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] assert result == [vibe_skills]
def test_returns_agents_skills_only_when_only_it_exists( def test_returns_agents_skills_only_when_only_it_exists(
@ -35,7 +39,7 @@ class TestResolveLocalSkillsDirs:
agents_skills.mkdir(parents=True) agents_skills.mkdir(parents=True)
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = True 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] assert result == [agents_skills]
def test_returns_both_in_order_when_both_exist(self, tmp_path: Path) -> None: 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) agents_skills.mkdir(parents=True)
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = True 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] assert result == [vibe_skills, agents_skills]
def test_ignores_vibe_skills_when_file_not_dir(self, tmp_path: Path) -> None: 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") (tmp_path / ".vibe" / "skills").write_text("", encoding="utf-8")
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = True mock_tfm.is_trusted.return_value = True
result = resolve_local_skills_dirs(tmp_path) result = discover_local_skills_dirs(tmp_path)
assert result == [] 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"]

View file

@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from vibe.core.utils import StructuredLogFormatter, apply_logging_config from vibe.core.logger import StructuredLogFormatter, apply_logging_config
@pytest.fixture @pytest.fixture
@ -19,8 +19,8 @@ def mock_log_dir(tmp_path: Path):
mock_file = MagicMock() mock_file = MagicMock()
mock_file.path = tmp_path / "vibe.log" mock_file.path = tmp_path / "vibe.log"
with ( with (
patch("vibe.core.utils.LOG_DIR", mock_dir), patch("vibe.core.logger.LOG_DIR", mock_dir),
patch("vibe.core.utils.LOG_FILE", mock_file), patch("vibe.core.logger.LOG_FILE", mock_file),
): ):
yield tmp_path yield tmp_path
@ -222,8 +222,8 @@ class TestApplyLoggingConfig:
mock_file = MagicMock() mock_file = MagicMock()
mock_file.path = log_dir / "vibe.log" mock_file.path = log_dir / "vibe.log"
with ( with (
patch("vibe.core.utils.LOG_DIR", mock_dir), patch("vibe.core.logger.LOG_DIR", mock_dir),
patch("vibe.core.utils.LOG_FILE", mock_file), patch("vibe.core.logger.LOG_FILE", mock_file),
): ):
monkeypatch.setenv("LOG_LEVEL", "DEBUG") monkeypatch.setenv("LOG_LEVEL", "DEBUG")
test_logger = logging.getLogger("test_mkdir") test_logger = logging.getLogger("test_mkdir")

View file

@ -47,7 +47,7 @@ class TestTelemetryClient:
def test_send_telemetry_event_does_nothing_when_api_key_is_none( def test_send_telemetry_event_does_nothing_when_api_key_is_none(
self, monkeypatch: pytest.MonkeyPatch self, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
config = build_test_vibe_config(disable_telemetry=False) config = build_test_vibe_config(enable_telemetry=True)
env_key = config.get_provider_for_model( env_key = config.get_provider_for_model(
config.get_active_model() config.get_active_model()
).api_key_env_var ).api_key_env_var
@ -57,7 +57,7 @@ class TestTelemetryClient:
client._client = MagicMock() client._client = MagicMock()
client._client.post = AsyncMock() client._client.post = AsyncMock()
client.send_telemetry_event("vibe/test", {}) client.send_telemetry_event("vibe.test", {})
_run_telemetry_tasks() _run_telemetry_tasks()
client._client.post.assert_not_called() client._client.post.assert_not_called()
@ -65,7 +65,10 @@ class TestTelemetryClient:
def test_send_telemetry_event_does_nothing_when_disabled( def test_send_telemetry_event_does_nothing_when_disabled(
self, monkeypatch: pytest.MonkeyPatch self, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> 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( env_key = config.get_provider_for_model(
config.get_active_model() config.get_active_model()
).api_key_env_var ).api_key_env_var
@ -74,7 +77,7 @@ class TestTelemetryClient:
client._client = MagicMock() client._client = MagicMock()
client._client.post = AsyncMock() client._client.post = AsyncMock()
client.send_telemetry_event("vibe/test", {}) client.send_telemetry_event("vibe.test", {})
_run_telemetry_tasks() _run_telemetry_tasks()
client._client.post.assert_not_called() client._client.post.assert_not_called()
@ -86,7 +89,7 @@ class TestTelemetryClient:
monkeypatch.setattr( monkeypatch.setattr(
TelemetryClient, "send_telemetry_event", _original_send_telemetry_event 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( env_key = config.get_provider_for_model(
config.get_active_model() config.get_active_model()
).api_key_env_var ).api_key_env_var
@ -97,12 +100,12 @@ class TestTelemetryClient:
client._client.post = mock_post client._client.post = mock_post
client._client.aclose = AsyncMock() 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() await client.aclose()
mock_post.assert_called_once_with( mock_post.assert_called_once_with(
DATALAKE_EVENTS_URL, DATALAKE_EVENTS_URL,
json={"event": "vibe/test_event", "properties": {"key": "value"}}, json={"event": "vibe.test_event", "properties": {"key": "value"}},
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": "Bearer sk-test", "Authorization": "Bearer sk-test",
@ -113,7 +116,7 @@ class TestTelemetryClient:
def test_send_tool_call_finished_payload_shape( def test_send_tool_call_finished_payload_shape(
self, telemetry_events: list[dict[str, Any]] self, telemetry_events: list[dict[str, Any]]
) -> None: ) -> None:
config = build_test_vibe_config(disable_telemetry=False) config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config) client = TelemetryClient(config_getter=lambda: config)
tool_call = _make_resolved_tool_call("todo", {}) tool_call = _make_resolved_tool_call("todo", {})
decision = ToolDecision( decision = ToolDecision(
@ -129,7 +132,7 @@ class TestTelemetryClient:
assert len(telemetry_events) == 1 assert len(telemetry_events) == 1
event_name = telemetry_events[0]["event_name"] 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"] properties = telemetry_events[0]["properties"]
assert properties["tool_name"] == "todo" assert properties["tool_name"] == "todo"
assert properties["status"] == "success" assert properties["status"] == "success"
@ -142,7 +145,7 @@ class TestTelemetryClient:
def test_send_tool_call_finished_nb_files_created_write_file_new( def test_send_tool_call_finished_nb_files_created_write_file_new(
self, telemetry_events: list[dict[str, Any]] self, telemetry_events: list[dict[str, Any]]
) -> None: ) -> None:
config = build_test_vibe_config(disable_telemetry=False) config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config) client = TelemetryClient(config_getter=lambda: config)
tool_call = _make_resolved_tool_call("write_file", {"overwrite": False}) 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( def test_send_tool_call_finished_nb_files_modified_write_file_overwrite(
self, telemetry_events: list[dict[str, Any]] self, telemetry_events: list[dict[str, Any]]
) -> None: ) -> None:
config = build_test_vibe_config(disable_telemetry=False) config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config) client = TelemetryClient(config_getter=lambda: config)
tool_call = _make_resolved_tool_call("write_file", {"overwrite": True}) tool_call = _make_resolved_tool_call("write_file", {"overwrite": True})
@ -178,7 +181,7 @@ class TestTelemetryClient:
def test_send_tool_call_finished_decision_none( def test_send_tool_call_finished_decision_none(
self, telemetry_events: list[dict[str, Any]] self, telemetry_events: list[dict[str, Any]]
) -> None: ) -> None:
config = build_test_vibe_config(disable_telemetry=False) config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config) client = TelemetryClient(config_getter=lambda: config)
tool_call = _make_resolved_tool_call("todo", {}) tool_call = _make_resolved_tool_call("todo", {})
@ -195,49 +198,49 @@ class TestTelemetryClient:
def test_send_user_copied_text_payload( def test_send_user_copied_text_payload(
self, telemetry_events: list[dict[str, Any]] self, telemetry_events: list[dict[str, Any]]
) -> None: ) -> None:
config = build_test_vibe_config(disable_telemetry=False) config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config) client = TelemetryClient(config_getter=lambda: config)
client.send_user_copied_text("hello world") client.send_user_copied_text("hello world")
assert len(telemetry_events) == 1 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 assert telemetry_events[0]["properties"]["text_length"] == 11
def test_send_user_cancelled_action_payload( def test_send_user_cancelled_action_payload(
self, telemetry_events: list[dict[str, Any]] self, telemetry_events: list[dict[str, Any]]
) -> None: ) -> None:
config = build_test_vibe_config(disable_telemetry=False) config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config) client = TelemetryClient(config_getter=lambda: config)
client.send_user_cancelled_action("interrupt_agent") client.send_user_cancelled_action("interrupt_agent")
assert len(telemetry_events) == 1 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" assert telemetry_events[0]["properties"]["action"] == "interrupt_agent"
def test_send_auto_compact_triggered_payload( def test_send_auto_compact_triggered_payload(
self, telemetry_events: list[dict[str, Any]] self, telemetry_events: list[dict[str, Any]]
) -> None: ) -> None:
config = build_test_vibe_config(disable_telemetry=False) config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config) client = TelemetryClient(config_getter=lambda: config)
client.send_auto_compact_triggered() client.send_auto_compact_triggered()
assert len(telemetry_events) == 1 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( def test_send_slash_command_used_payload(
self, telemetry_events: list[dict[str, Any]] self, telemetry_events: list[dict[str, Any]]
) -> None: ) -> None:
config = build_test_vibe_config(disable_telemetry=False) config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config) client = TelemetryClient(config_getter=lambda: config)
client.send_slash_command_used("help", "builtin") client.send_slash_command_used("help", "builtin")
client.send_slash_command_used("my_skill", "skill") client.send_slash_command_used("my_skill", "skill")
assert len(telemetry_events) == 2 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"] == "help"
assert telemetry_events[0]["properties"]["command_type"] == "builtin" assert telemetry_events[0]["properties"]["command_type"] == "builtin"
assert telemetry_events[1]["properties"]["command"] == "my_skill" assert telemetry_events[1]["properties"]["command"] == "my_skill"
@ -246,7 +249,7 @@ class TestTelemetryClient:
def test_send_new_session_payload( def test_send_new_session_payload(
self, telemetry_events: list[dict[str, Any]] self, telemetry_events: list[dict[str, Any]]
) -> None: ) -> None:
config = build_test_vibe_config(disable_telemetry=False) config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config) client = TelemetryClient(config_getter=lambda: config)
client.send_new_session( client.send_new_session(
@ -255,15 +258,17 @@ class TestTelemetryClient:
nb_mcp_servers=1, nb_mcp_servers=1,
nb_models=3, nb_models=3,
entrypoint="cli", entrypoint="cli",
terminal_emulator="vscode",
) )
assert len(telemetry_events) == 1 assert len(telemetry_events) == 1
event_name = telemetry_events[0]["event_name"] event_name = telemetry_events[0]["event_name"]
assert event_name == "vibe/new_session" assert event_name == "vibe.new_session"
properties = telemetry_events[0]["properties"] properties = telemetry_events[0]["properties"]
assert properties["has_agents_md"] is True assert properties["has_agents_md"] is True
assert properties["nb_skills"] == 2 assert properties["nb_skills"] == 2
assert properties["nb_mcp_servers"] == 1 assert properties["nb_mcp_servers"] == 1
assert properties["nb_models"] == 3 assert properties["nb_models"] == 3
assert properties["entrypoint"] == "cli" assert properties["entrypoint"] == "cli"
assert properties["terminal_emulator"] == "vscode"
assert "version" in properties assert "version" in properties

View file

@ -237,11 +237,11 @@ class TestHasAgentsMdFile:
class TestHasTrustableContent: class TestHasTrustableContent:
def test_returns_true_when_vibe_dir_exists(self, tmp_path: Path) -> None: 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 assert has_trustable_content(tmp_path) is True
def test_returns_true_when_agents_dir_exists(self, tmp_path: Path) -> None: 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 assert has_trustable_content(tmp_path) is True
def test_returns_true_when_agents_md_filename_exists(self, tmp_path: Path) -> None: 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: def test_returns_false_when_no_trustable_content(self, tmp_path: Path) -> None:
(tmp_path / "other.txt").write_text("", encoding="utf-8") (tmp_path / "other.txt").write_text("", encoding="utf-8")
assert has_trustable_content(tmp_path) is False 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

86
tests/e2e/common.py Normal file
View file

@ -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}"
)

82
tests/e2e/conftest.py Normal file
View file

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

149
tests/e2e/mock_server.py Normal file
View file

@ -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)

View file

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

View file

@ -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
)

View file

@ -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)

View file

@ -822,3 +822,133 @@ class TestSessionLoaderListSessions:
assert len(result) == 1 assert len(result) == 1
assert result[0]["session_id"] == "notitle0" assert result[0]["session_id"] == "notitle0"
assert result[0]["title"] is None 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"

View file

@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
import json import json
import os import os
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@ -502,6 +502,61 @@ class TestSessionLoggerSaveInteraction:
with open(messages_file) as f: with open(messages_file) as f:
assert len(f.readlines()) == 2 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: class TestSessionLoggerResetSession:
def test_reset_session(self, session_config: SessionLoggingConfig) -> None: def test_reset_session(self, session_config: SessionLoggingConfig) -> None:
@ -701,3 +756,27 @@ class TestSessionLoggerCleanupTmpFiles:
assert old_tmp_file.exists() assert old_tmp_file.exists()
assert not another_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

View file

@ -0,0 +1,203 @@
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #ff8205 }
.terminal-r5 { fill: #98a84b }
.terminal-r6 { fill: #9a9b99;font-style: italic; }
.terminal-r7 { fill: #9a9b99 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1463.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">SnapshotTestAppEmptyAssistantThenReasoning</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="0" y="459.2" textLength="134.2" clip-path="url(#terminal-line-18)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="459.2" textLength="146.4" clip-path="url(#terminal-line-18)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="459.2" textLength="122" clip-path="url(#terminal-line-18)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="459.2" textLength="183" clip-path="url(#terminal-line-18)">devstral-latest</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="483.6" textLength="414.8" clip-path="url(#terminal-line-19)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="508" textLength="61" clip-path="url(#terminal-line-20)">Type&#160;</text><text class="terminal-r3" x="231.8" y="508" textLength="61" clip-path="url(#terminal-line-20)">/help</text><text class="terminal-r1" x="292.8" y="508" textLength="256.2" clip-path="url(#terminal-line-20)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r2" x="24.4" y="581.2" textLength="231.8" clip-path="url(#terminal-line-23)">What&#160;is&#160;the&#160;answer?</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r5" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r6" x="24.4" y="630" textLength="85.4" clip-path="url(#terminal-line-25)">Thought</text><text class="terminal-r6" x="122" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="207.4" clip-path="url(#terminal-line-27)">The&#160;answer&#160;is&#160;42.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r7" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">&gt;</text><text class="terminal-r7" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r7" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r7" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r7" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r7" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r7" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r7" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r7" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,114 @@
<svg class="rich-terminal" viewBox="0 0 994 416.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #d9d9d9 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="975.0" height="365.0" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="414" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">ParallelToolCallsApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="50.3" width="268.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="292.8" y="50.3" width="683.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="268.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="292.8" y="74.7" width="683.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="99.1" width="268.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="292.8" y="99.1" width="683.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r2" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r2" x="24.4" y="68.8" textLength="268.4" clip-path="url(#terminal-line-2)">Reading&#160;/src/file_0.py</text><text class="terminal-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r2" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r2" x="24.4" y="93.2" textLength="268.4" clip-path="url(#terminal-line-3)">Reading&#160;/src/file_1.py</text><text class="terminal-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r2" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r2" x="24.4" y="117.6" textLength="268.4" clip-path="url(#terminal-line-4)">Reading&#160;/src/file_2.py</text><text class="terminal-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -33,13 +33,12 @@
} }
.terminal-r1 { fill: #c5c8c6 } .terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #4b4e55 } .terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 } .terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #292929 } .terminal-r4 { fill: #9a9b99 }
.terminal-r5 { fill: #9a9b99 } .terminal-r5 { fill: #608ab1;font-weight: bold }
.terminal-r6 { fill: #608ab1;font-weight: bold } .terminal-r6 { fill: #949798 }
.terminal-r7 { fill: #868887 } .terminal-r7 { fill: #868887 }
.terminal-r8 { fill: #949798 }
</style> </style>
<defs> <defs>
@ -161,44 +160,44 @@
</g> </g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)"> <g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#4b4e55" x="1207.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1207.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1207.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="48.8" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/> <rect fill="#4b4e55" x="48.8" y="538.3" width="12.2" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix"> <g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="20" textLength="414.8" clip-path="url(#terminal-line-0)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r2" x="1207.8" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"></text><text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"> <text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">Type&#160;</text><text class="terminal-r3" x="231.8" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">/help</text><text class="terminal-r1" x="292.8" y="44.4" textLength="256.2" clip-path="url(#terminal-line-1)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"> </text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"> </text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r5" x="24.4" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r1" x="48.8" y="93.2" textLength="256.2" clip-path="url(#terminal-line-3)">Proxy&#160;setup&#160;opened...</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"> </text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"> </text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"> </text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r5" x="0" y="166.4" textLength="1220" clip-path="url(#terminal-line-6)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"> </text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r5" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r6" x="24.4" y="190.8" textLength="231.8" clip-path="url(#terminal-line-7)">Proxy&#160;Configuration</text><text class="terminal-r5" x="1207.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"> </text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r5" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r5" x="1207.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"> </text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r5" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r6" x="24.4" y="239.6" textLength="122" clip-path="url(#terminal-line-9)">HTTP_PROXY</text><text class="terminal-r7" x="170.8" y="239.6" textLength="329.4" clip-path="url(#terminal-line-9)">Proxy&#160;URL&#160;for&#160;HTTP&#160;requests</text><text class="terminal-r5" x="1207.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"> </text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r5" x="0" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"></text><text class="terminal-r5" x="1207.8" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"></text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"> </text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r5" x="0" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r5" x="24.4" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r8" x="48.8" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">N</text><text class="terminal-r7" x="61" y="288.4" textLength="73.2" clip-path="url(#terminal-line-11)">OT&#160;SET</text><text class="terminal-r5" x="1207.8" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"> </text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r5" x="0" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></text><text class="terminal-r5" x="1207.8" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"> </text><text class="terminal-r1" x="0" y="312.8" textLength="134.2" clip-path="url(#terminal-line-12)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="312.8" textLength="146.4" clip-path="url(#terminal-line-12)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="312.8" textLength="122" clip-path="url(#terminal-line-12)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="312.8" textLength="183" clip-path="url(#terminal-line-12)">devstral-latest</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r5" x="0" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"></text><text class="terminal-r6" x="24.4" y="337.2" textLength="134.2" clip-path="url(#terminal-line-13)">HTTPS_PROXY</text><text class="terminal-r7" x="183" y="337.2" textLength="341.6" clip-path="url(#terminal-line-13)">Proxy&#160;URL&#160;for&#160;HTTPS&#160;requests</text><text class="terminal-r5" x="1207.8" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"></text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"> </text><text class="terminal-r1" x="0" y="337.2" textLength="134.2" clip-path="url(#terminal-line-13)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="337.2" textLength="414.8" clip-path="url(#terminal-line-13)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r5" x="0" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r5" x="1207.8" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"> </text><text class="terminal-r1" x="0" y="361.6" textLength="134.2" clip-path="url(#terminal-line-14)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="361.6" textLength="61" clip-path="url(#terminal-line-14)">Type&#160;</text><text class="terminal-r3" x="231.8" y="361.6" textLength="61" clip-path="url(#terminal-line-14)">/help</text><text class="terminal-r1" x="292.8" y="361.6" textLength="256.2" clip-path="url(#terminal-line-14)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r5" x="0" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r5" x="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r7" x="48.8" y="386" textLength="85.4" clip-path="url(#terminal-line-15)">NOT&#160;SET</text><text class="terminal-r5" x="1207.8" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"> </text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r5" x="0" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r5" x="1207.8" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"> </text><text class="terminal-r4" x="24.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r1" x="48.8" y="410.4" textLength="256.2" clip-path="url(#terminal-line-16)">Proxy&#160;setup&#160;opened...</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r5" x="0" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r6" x="24.4" y="434.8" textLength="109.8" clip-path="url(#terminal-line-17)">ALL_PROXY</text><text class="terminal-r7" x="158.6" y="434.8" textLength="451.4" clip-path="url(#terminal-line-17)">Proxy&#160;URL&#160;for&#160;all&#160;requests&#160;(fallback)</text><text class="terminal-r5" x="1207.8" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"> </text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r5" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r5" x="1207.8" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"> </text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r5" x="0" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r5" x="24.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r7" x="48.8" y="483.6" textLength="85.4" clip-path="url(#terminal-line-19)">NOT&#160;SET</text><text class="terminal-r5" x="1207.8" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"> </text><text class="terminal-r4" x="0" y="483.6" textLength="1220" clip-path="url(#terminal-line-19)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r5" x="0" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r5" x="1207.8" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"> </text><text class="terminal-r4" x="0" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r5" x="24.4" y="508" textLength="231.8" clip-path="url(#terminal-line-20)">Proxy&#160;Configuration</text><text class="terminal-r4" x="1207.8" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r5" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r6" x="24.4" y="532.4" textLength="97.6" clip-path="url(#terminal-line-21)">NO_PROXY</text><text class="terminal-r7" x="146.4" y="532.4" textLength="549" clip-path="url(#terminal-line-21)">Comma-separated&#160;list&#160;of&#160;hosts&#160;to&#160;bypass&#160;proxy</text><text class="terminal-r5" x="1207.8" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"> </text><text class="terminal-r4" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r5" x="24.4" y="532.4" textLength="122" clip-path="url(#terminal-line-21)">HTTP_PROXY</text><text class="terminal-r4" x="1207.8" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r5" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r5" x="1207.8" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"> </text><text class="terminal-r4" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r4" x="24.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r6" x="48.8" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">P</text><text class="terminal-r7" x="61" y="556.8" textLength="317.2" clip-path="url(#terminal-line-22)">roxy&#160;URL&#160;for&#160;HTTP&#160;requests</text><text class="terminal-r4" x="1207.8" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r5" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r5" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r7" x="48.8" y="581.2" textLength="85.4" clip-path="url(#terminal-line-23)">NOT&#160;SET</text><text class="terminal-r5" x="1207.8" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> </text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r5" x="24.4" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">HTTPS_PROXY</text><text class="terminal-r4" x="1207.8" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r5" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r5" x="1207.8" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> </text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r4" x="24.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r7" x="48.8" y="605.6" textLength="341.6" clip-path="url(#terminal-line-24)">Proxy&#160;URL&#160;for&#160;HTTPS&#160;requests</text><text class="terminal-r4" x="1207.8" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r5" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r6" x="24.4" y="630" textLength="158.6" clip-path="url(#terminal-line-25)">SSL_CERT_FILE</text><text class="terminal-r7" x="207.4" y="630" textLength="427" clip-path="url(#terminal-line-25)">Path&#160;to&#160;custom&#160;SSL&#160;certificate&#160;file</text><text class="terminal-r5" x="1207.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> </text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r5" x="24.4" y="630" textLength="109.8" clip-path="url(#terminal-line-25)">ALL_PROXY</text><text class="terminal-r4" x="1207.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r5" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r5" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> </text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r4" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r7" x="48.8" y="654.4" textLength="451.4" clip-path="url(#terminal-line-26)">Proxy&#160;URL&#160;for&#160;all&#160;requests&#160;(fallback)</text><text class="terminal-r4" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r5" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r5" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r7" x="48.8" y="678.8" textLength="85.4" clip-path="url(#terminal-line-27)">NOT&#160;SET</text><text class="terminal-r5" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> </text><text class="terminal-r4" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r5" x="24.4" y="678.8" textLength="97.6" clip-path="url(#terminal-line-27)">NO_PROXY</text><text class="terminal-r4" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r5" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r5" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> </text><text class="terminal-r4" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r4" x="24.4" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r7" x="48.8" y="703.2" textLength="549" clip-path="url(#terminal-line-28)">Comma-separated&#160;list&#160;of&#160;hosts&#160;to&#160;bypass&#160;proxy</text><text class="terminal-r4" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r5" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r6" x="24.4" y="727.6" textLength="146.4" clip-path="url(#terminal-line-29)">SSL_CERT_DIR</text><text class="terminal-r7" x="195.2" y="727.6" textLength="549" clip-path="url(#terminal-line-29)">Path&#160;to&#160;directory&#160;containing&#160;SSL&#160;certificates</text><text class="terminal-r5" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"> </text><text class="terminal-r4" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r5" x="24.4" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">SSL_CERT_FILE</text><text class="terminal-r4" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r5" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r5" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="1220" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"> </text><text class="terminal-r4" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r4" x="24.4" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r7" x="48.8" y="752" textLength="427" clip-path="url(#terminal-line-30)">Path&#160;to&#160;custom&#160;SSL&#160;certificate&#160;file</text><text class="terminal-r4" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="1220" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r5" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r5" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r7" x="48.8" y="776.4" textLength="85.4" clip-path="url(#terminal-line-31)">NOT&#160;SET</text><text class="terminal-r5" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1220" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"> </text><text class="terminal-r4" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r5" x="24.4" y="776.4" textLength="146.4" clip-path="url(#terminal-line-31)">SSL_CERT_DIR</text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1220" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r5" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r5" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"> </text><text class="terminal-r4" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r4" x="24.4" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r7" x="48.8" y="800.8" textLength="549" clip-path="url(#terminal-line-32)">Path&#160;to&#160;directory&#160;containing&#160;SSL&#160;certificates</text><text class="terminal-r4" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r5" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r5" x="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓&#160;navigate&#160;&#160;Enter&#160;save&#160;&amp;&#160;exit&#160;&#160;ESC&#160;cancel</text><text class="terminal-r5" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"> </text><text class="terminal-r4" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r4" x="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓&#160;navigate&#160;&#160;Enter&#160;save&#160;&amp;&#160;exit&#160;&#160;ESC&#160;cancel</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r5" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"> </text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text> </text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

View file

@ -33,12 +33,11 @@
} }
.terminal-r1 { fill: #c5c8c6 } .terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #4b4e55 } .terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 } .terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #292929 } .terminal-r4 { fill: #9a9b99 }
.terminal-r5 { fill: #9a9b99 } .terminal-r5 { fill: #608ab1;font-weight: bold }
.terminal-r6 { fill: #608ab1;font-weight: bold } .terminal-r6 { fill: #868887 }
.terminal-r7 { fill: #868887 }
</style> </style>
<defs> <defs>
@ -160,44 +159,44 @@
</g> </g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)"> <g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#4b4e55" x="1207.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1207.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1207.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="48.8" y="269.9" width="256.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="305" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/> <rect fill="#608ab1" x="48.8" y="538.3" width="256.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="305" y="538.3" width="12.2" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix"> <g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="20" textLength="414.8" clip-path="url(#terminal-line-0)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r2" x="1207.8" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"></text><text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"> <text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">Type&#160;</text><text class="terminal-r3" x="231.8" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">/help</text><text class="terminal-r1" x="292.8" y="44.4" textLength="256.2" clip-path="url(#terminal-line-1)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"> </text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"> </text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r5" x="24.4" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r1" x="48.8" y="93.2" textLength="256.2" clip-path="url(#terminal-line-3)">Proxy&#160;setup&#160;opened...</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"> </text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"> </text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"> </text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r5" x="0" y="166.4" textLength="1220" clip-path="url(#terminal-line-6)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"> </text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r5" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r6" x="24.4" y="190.8" textLength="231.8" clip-path="url(#terminal-line-7)">Proxy&#160;Configuration</text><text class="terminal-r5" x="1207.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"> </text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r5" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r5" x="1207.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"> </text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r5" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r6" x="24.4" y="239.6" textLength="122" clip-path="url(#terminal-line-9)">HTTP_PROXY</text><text class="terminal-r7" x="170.8" y="239.6" textLength="329.4" clip-path="url(#terminal-line-9)">Proxy&#160;URL&#160;for&#160;HTTP&#160;requests</text><text class="terminal-r5" x="1207.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"> </text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r5" x="0" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"></text><text class="terminal-r5" x="1207.8" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"></text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"> </text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r5" x="0" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r5" x="24.4" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r1" x="48.8" y="288.4" textLength="256.2" clip-path="url(#terminal-line-11)">http://old-proxy:8080</text><text class="terminal-r5" x="1207.8" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"> </text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r5" x="0" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></text><text class="terminal-r5" x="1207.8" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"> </text><text class="terminal-r1" x="0" y="312.8" textLength="134.2" clip-path="url(#terminal-line-12)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="312.8" textLength="146.4" clip-path="url(#terminal-line-12)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="312.8" textLength="122" clip-path="url(#terminal-line-12)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="312.8" textLength="183" clip-path="url(#terminal-line-12)">devstral-latest</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r5" x="0" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"></text><text class="terminal-r6" x="24.4" y="337.2" textLength="134.2" clip-path="url(#terminal-line-13)">HTTPS_PROXY</text><text class="terminal-r7" x="183" y="337.2" textLength="341.6" clip-path="url(#terminal-line-13)">Proxy&#160;URL&#160;for&#160;HTTPS&#160;requests</text><text class="terminal-r5" x="1207.8" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"></text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"> </text><text class="terminal-r1" x="0" y="337.2" textLength="134.2" clip-path="url(#terminal-line-13)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="337.2" textLength="414.8" clip-path="url(#terminal-line-13)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r5" x="0" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r5" x="1207.8" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"> </text><text class="terminal-r1" x="0" y="361.6" textLength="134.2" clip-path="url(#terminal-line-14)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="361.6" textLength="61" clip-path="url(#terminal-line-14)">Type&#160;</text><text class="terminal-r3" x="231.8" y="361.6" textLength="61" clip-path="url(#terminal-line-14)">/help</text><text class="terminal-r1" x="292.8" y="361.6" textLength="256.2" clip-path="url(#terminal-line-14)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r5" x="0" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r5" x="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r1" x="48.8" y="386" textLength="1146.8" clip-path="url(#terminal-line-15)">https://old-proxy:8443&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r5" x="1207.8" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"> </text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r5" x="0" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r5" x="1207.8" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"> </text><text class="terminal-r4" x="24.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r1" x="48.8" y="410.4" textLength="256.2" clip-path="url(#terminal-line-16)">Proxy&#160;setup&#160;opened...</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r5" x="0" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r6" x="24.4" y="434.8" textLength="109.8" clip-path="url(#terminal-line-17)">ALL_PROXY</text><text class="terminal-r7" x="158.6" y="434.8" textLength="451.4" clip-path="url(#terminal-line-17)">Proxy&#160;URL&#160;for&#160;all&#160;requests&#160;(fallback)</text><text class="terminal-r5" x="1207.8" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"> </text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r5" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r5" x="1207.8" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"> </text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r5" x="0" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r5" x="24.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r7" x="48.8" y="483.6" textLength="85.4" clip-path="url(#terminal-line-19)">NOT&#160;SET</text><text class="terminal-r5" x="1207.8" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"> </text><text class="terminal-r4" x="0" y="483.6" textLength="1220" clip-path="url(#terminal-line-19)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r5" x="0" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r5" x="1207.8" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"> </text><text class="terminal-r4" x="0" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r5" x="24.4" y="508" textLength="231.8" clip-path="url(#terminal-line-20)">Proxy&#160;Configuration</text><text class="terminal-r4" x="1207.8" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r5" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r6" x="24.4" y="532.4" textLength="97.6" clip-path="url(#terminal-line-21)">NO_PROXY</text><text class="terminal-r7" x="146.4" y="532.4" textLength="549" clip-path="url(#terminal-line-21)">Comma-separated&#160;list&#160;of&#160;hosts&#160;to&#160;bypass&#160;proxy</text><text class="terminal-r5" x="1207.8" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"> </text><text class="terminal-r4" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r5" x="24.4" y="532.4" textLength="122" clip-path="url(#terminal-line-21)">HTTP_PROXY</text><text class="terminal-r4" x="1207.8" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r5" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r5" x="1207.8" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"> </text><text class="terminal-r4" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r4" x="24.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="48.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)">http://old-proxy:8080</text><text class="terminal-r4" x="1207.8" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r5" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r5" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r7" x="48.8" y="581.2" textLength="85.4" clip-path="url(#terminal-line-23)">NOT&#160;SET</text><text class="terminal-r5" x="1207.8" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> </text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r5" x="24.4" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">HTTPS_PROXY</text><text class="terminal-r4" x="1207.8" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r5" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r5" x="1207.8" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> </text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r4" x="24.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="48.8" y="605.6" textLength="1146.8" clip-path="url(#terminal-line-24)">https://old-proxy:8443&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r4" x="1207.8" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r5" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r6" x="24.4" y="630" textLength="158.6" clip-path="url(#terminal-line-25)">SSL_CERT_FILE</text><text class="terminal-r7" x="207.4" y="630" textLength="427" clip-path="url(#terminal-line-25)">Path&#160;to&#160;custom&#160;SSL&#160;certificate&#160;file</text><text class="terminal-r5" x="1207.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> </text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r5" x="24.4" y="630" textLength="109.8" clip-path="url(#terminal-line-25)">ALL_PROXY</text><text class="terminal-r4" x="1207.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r5" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r5" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> </text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r4" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r6" x="48.8" y="654.4" textLength="451.4" clip-path="url(#terminal-line-26)">Proxy&#160;URL&#160;for&#160;all&#160;requests&#160;(fallback)</text><text class="terminal-r4" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r5" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r5" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r7" x="48.8" y="678.8" textLength="85.4" clip-path="url(#terminal-line-27)">NOT&#160;SET</text><text class="terminal-r5" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> </text><text class="terminal-r4" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r5" x="24.4" y="678.8" textLength="97.6" clip-path="url(#terminal-line-27)">NO_PROXY</text><text class="terminal-r4" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r5" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r5" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> </text><text class="terminal-r4" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r4" x="24.4" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r6" x="48.8" y="703.2" textLength="549" clip-path="url(#terminal-line-28)">Comma-separated&#160;list&#160;of&#160;hosts&#160;to&#160;bypass&#160;proxy</text><text class="terminal-r4" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r5" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r6" x="24.4" y="727.6" textLength="146.4" clip-path="url(#terminal-line-29)">SSL_CERT_DIR</text><text class="terminal-r7" x="195.2" y="727.6" textLength="549" clip-path="url(#terminal-line-29)">Path&#160;to&#160;directory&#160;containing&#160;SSL&#160;certificates</text><text class="terminal-r5" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"> </text><text class="terminal-r4" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r5" x="24.4" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">SSL_CERT_FILE</text><text class="terminal-r4" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r5" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r5" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="1220" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"> </text><text class="terminal-r4" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r4" x="24.4" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r6" x="48.8" y="752" textLength="427" clip-path="url(#terminal-line-30)">Path&#160;to&#160;custom&#160;SSL&#160;certificate&#160;file</text><text class="terminal-r4" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="1220" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r5" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r5" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r7" x="48.8" y="776.4" textLength="85.4" clip-path="url(#terminal-line-31)">NOT&#160;SET</text><text class="terminal-r5" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1220" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"> </text><text class="terminal-r4" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r5" x="24.4" y="776.4" textLength="146.4" clip-path="url(#terminal-line-31)">SSL_CERT_DIR</text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1220" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r5" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r5" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"> </text><text class="terminal-r4" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r4" x="24.4" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r6" x="48.8" y="800.8" textLength="549" clip-path="url(#terminal-line-32)">Path&#160;to&#160;directory&#160;containing&#160;SSL&#160;certificates</text><text class="terminal-r4" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r5" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r5" x="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓&#160;navigate&#160;&#160;Enter&#160;save&#160;&amp;&#160;exit&#160;&#160;ESC&#160;cancel</text><text class="terminal-r5" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"> </text><text class="terminal-r4" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r4" x="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓&#160;navigate&#160;&#160;Enter&#160;save&#160;&amp;&#160;exit&#160;&#160;ESC&#160;cancel</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r5" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"> </text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text> </text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

View file

@ -0,0 +1,94 @@
<svg class="rich-terminal" viewBox="0 0 994 294.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #d9d9d9 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="975.0" height="243.0" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="292" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">ToolCallStreamingUpdateTest</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="109.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="134.2" y="25.9" width="841.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r2" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r2" x="24.4" y="44.4" textLength="109.8" clip-path="url(#terminal-line-1)">read_file</text><text class="terminal-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -0,0 +1,94 @@
<svg class="rich-terminal" viewBox="0 0 994 294.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #d9d9d9 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="975.0" height="243.0" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="292" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">ToolCallStreamingUpdateTest</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="25.9" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r2" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r2" x="24.4" y="44.4" textLength="292.8" clip-path="url(#terminal-line-1)">Reading&#160;/test/example.py</text><text class="terminal-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -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,
)

View file

@ -24,6 +24,7 @@ def test_snapshot_cycle_to_plan_mode(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None: async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1) await pilot.pause(0.1)
await pilot.press("shift+tab") # default -> plan await pilot.press("shift+tab") # default -> plan
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1) await pilot.pause(0.1)
assert snap_compare( 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.pause(0.1)
await pilot.press("shift+tab") # default -> plan await pilot.press("shift+tab") # default -> plan
await pilot.press("shift+tab") # plan -> accept edits await pilot.press("shift+tab") # plan -> accept edits
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1) await pilot.pause(0.1)
assert snap_compare( 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") # default -> plan
await pilot.press("shift+tab") # plan -> accept edits await pilot.press("shift+tab") # plan -> accept edits
await pilot.press("shift+tab") # accept edits -> auto approve await pilot.press("shift+tab") # accept edits -> auto approve
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1) await pilot.pause(0.1)
assert snap_compare( 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") # plan -> accept edits
await pilot.press("shift+tab") # accept edits -> auto approve await pilot.press("shift+tab") # accept edits -> auto approve
await pilot.press("shift+tab") # auto approve -> default (wrap) await pilot.press("shift+tab") # auto approve -> default (wrap)
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1) await pilot.pause(0.1)
assert snap_compare( assert snap_compare(

View file

@ -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,
)

View file

@ -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,
)

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import AsyncGenerator, Callable, Iterable from collections.abc import AsyncGenerator, Callable, Iterable, Sequence
from typing import cast from typing import cast
from tests.mock.utils import mock_llm_chunk from tests.mock.utils import mock_llm_chunk
@ -21,7 +21,7 @@ class FakeBackend:
| Iterable[Iterable[LLMChunk]] | Iterable[Iterable[LLMChunk]]
| None = None, | None = None,
*, *,
token_counter: Callable[[list[LLMMessage]], int] | None = None, token_counter: Callable[[Sequence[LLMMessage]], int] | None = None,
exception_to_raise: Exception | None = None, exception_to_raise: Exception | None = None,
) -> None: ) -> None:
"""Fake backend that will output the given chunks in the order they are given. """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_messages: list[list[LLMMessage]] = []
self._requests_extra_headers: list[dict[str, str] | None] = [] 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._count_tokens_calls: list[list[LLMMessage]] = []
self._token_counter = token_counter or self._default_token_counter self._token_counter = token_counter or self._default_token_counter
self._exception_to_raise = exception_to_raise self._exception_to_raise = exception_to_raise
@ -65,8 +66,12 @@ class FakeBackend:
def requests_extra_headers(self) -> list[dict[str, str] | None]: def requests_extra_headers(self) -> list[dict[str, str] | None]:
return self._requests_extra_headers return self._requests_extra_headers
@property
def requests_metadata(self) -> list[dict[str, str] | None]:
return self._requests_metadata
@staticmethod @staticmethod
def _default_token_counter(messages: list[LLMMessage]) -> int: def _default_token_counter(messages: Sequence[LLMMessage]) -> int:
return 1 return 1
async def __aenter__(self): async def __aenter__(self):
@ -85,12 +90,14 @@ class FakeBackend:
tool_choice, tool_choice,
extra_headers, extra_headers,
max_tokens, max_tokens,
metadata=None,
) -> LLMChunk: ) -> LLMChunk:
if self._exception_to_raise: if self._exception_to_raise:
raise self._exception_to_raise raise self._exception_to_raise
self._requests_messages.append(messages) self._requests_messages.append(messages)
self._requests_extra_headers.append(extra_headers) self._requests_extra_headers.append(extra_headers)
self._requests_metadata.append(metadata)
if self._streams: if self._streams:
stream = self._streams.pop(0) stream = self._streams.pop(0)
@ -111,12 +118,14 @@ class FakeBackend:
tool_choice, tool_choice,
extra_headers, extra_headers,
max_tokens, max_tokens,
metadata=None,
) -> AsyncGenerator[LLMChunk]: ) -> AsyncGenerator[LLMChunk]:
if self._exception_to_raise: if self._exception_to_raise:
raise self._exception_to_raise raise self._exception_to_raise
self._requests_messages.append(messages) self._requests_messages.append(messages)
self._requests_extra_headers.append(extra_headers) self._requests_extra_headers.append(extra_headers)
self._requests_metadata.append(metadata)
if self._streams: if self._streams:
stream = list(self._streams.pop(0)) stream = list(self._streams.pop(0))
@ -134,6 +143,7 @@ class FakeBackend:
tools, tools,
tool_choice=None, tool_choice=None,
extra_headers, extra_headers,
metadata=None,
) -> int: ) -> int:
self._count_tokens_calls.append(list(messages)) self._count_tokens_calls.append(list(messages))
return self._token_counter(messages) return self._token_counter(messages)

View file

@ -16,22 +16,13 @@ from vibe.core.types import (
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_triggers_and_batches_observer( async def test_auto_compact_emits_correct_events(telemetry_events: list[dict]) -> None:
telemetry_events: list[dict],
) -> None:
observed: list[tuple[Role, str | None]] = []
def observer(msg: LLMMessage) -> None:
observed.append((msg.role, msg.content))
backend = FakeBackend([ backend = FakeBackend([
[mock_llm_chunk(content="<summary>")], [mock_llm_chunk(content="<summary>")],
[mock_llm_chunk(content="<final>")], [mock_llm_chunk(content="<final>")],
]) ])
cfg = build_test_vibe_config(auto_compact_threshold=1) cfg = build_test_vibe_config(auto_compact_threshold=1)
agent = build_test_agent_loop( agent = build_test_agent_loop(config=cfg, backend=backend)
config=cfg, message_observer=observer, backend=backend
)
agent.stats.context_tokens = 2 agent.stats.context_tokens = 2
events = [ev async for ev in agent.act("Hello")] 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 end.new_context_tokens >= 1
assert final.content == "<final>" assert final.content == "<final>"
roles = [r for r, _ in observed]
assert roles == [Role.system, Role.user, Role.assistant]
assert observed[1][1] is not None and "<summary>" in observed[1][1]
assert observed[2][1] == "<final>"
auto_compact = [ auto_compact = [
e e
for e in telemetry_events 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 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="<summary>")],
[mock_llm_chunk(content="<final>")],
])
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] == "<final>"
@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="<summary>")],
[mock_llm_chunk(content="<final>")],
])
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 "<summary>" 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="<summary>")],
[mock_llm_chunk(content="<final>")],
])
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 == "<final>"

View file

@ -1,11 +1,54 @@
from __future__ import annotations from __future__ import annotations
from unittest.mock import MagicMock
from mcp.types import (
CreateMessageRequestParams,
CreateMessageResult,
SamplingMessage,
TextContent,
)
import pytest 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.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend 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 @pytest.mark.asyncio
@ -66,3 +109,79 @@ async def test_updates_tokens_stats_based_on_backend_response_streaming(
[_ async for _ in agent.act("Hello")] [_ async for _ in agent.act("Hello")]
assert agent.stats.context_tokens == 275 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"

View file

@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
from collections import Counter
from collections.abc import Callable from collections.abc import Callable
from http import HTTPStatus from http import HTTPStatus
from typing import cast from typing import cast
from unittest.mock import AsyncMock from unittest.mock import AsyncMock, Mock
import httpx import httpx
import pytest 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" 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 @pytest.mark.asyncio
async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None: async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None:
todo_tool_call = ToolCall( todo_tool_call = ToolCall(
@ -210,6 +232,7 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None
UserMessageEvent, UserMessageEvent,
AssistantEvent, AssistantEvent,
ToolCallEvent, ToolCallEvent,
ToolCallEvent,
ToolResultEvent, ToolResultEvent,
AssistantEvent, AssistantEvent,
] ]
@ -217,12 +240,17 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None
assert isinstance(events[1], AssistantEvent) assert isinstance(events[1], AssistantEvent)
assert events[1].content == "Checking your todos." assert events[1].content == "Checking your todos."
assert isinstance(events[2], ToolCallEvent) 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 events[2].tool_name == "todo"
assert isinstance(events[3], ToolResultEvent) assert isinstance(events[3], ToolCallEvent)
assert events[3].error is None assert events[3].args is not None
assert events[3].skipped is False assert events[3].tool_name == "todo"
assert isinstance(events[4], AssistantEvent) assert isinstance(events[4], ToolResultEvent)
assert events[4].content == "Done reviewing todos." 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." 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.")] events = [event async for event in agent.act("Check todos with content.")]
assert [type(event) for event in events] == [ event_types = [type(e) for e in events]
UserMessageEvent, assert Counter(event_types) == Counter({
AssistantEvent, UserMessageEvent: 1,
AssistantEvent, AssistantEvent: 3,
AssistantEvent, ToolCallEvent: 2,
ToolCallEvent, ToolResultEvent: 1,
ToolResultEvent, })
]
assert isinstance(events[0], UserMessageEvent) tool_call_events = [e for e in events if isinstance(e, ToolCallEvent)]
assert isinstance(events[1], AssistantEvent) assert len(tool_call_events) == 2
assert isinstance(events[2], AssistantEvent) assert any(
assert isinstance(events[3], AssistantEvent) tc.args is None and tc.tool_call_id == "tc_content" for tc in tool_call_events
assert events[1].content == "Preparing " )
assert events[2].content == "todo request" assert any(tc.args is not None for tc in tool_call_events)
assert events[3].content == " complete"
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( assert any(
m.role == Role.assistant and m.content == "Preparing todo request complete" m.role == Role.assistant and m.content == "Preparing todo request complete"
for m in agent.messages for m in agent.messages
@ -304,17 +338,21 @@ async def test_act_merges_streamed_tool_call_arguments() -> None:
UserMessageEvent, UserMessageEvent,
AssistantEvent, AssistantEvent,
ToolCallEvent, ToolCallEvent,
ToolCallEvent,
ToolResultEvent, ToolResultEvent,
] ]
assert isinstance(events[0], UserMessageEvent) 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 isinstance(call_event, ToolCallEvent)
assert call_event.tool_call_id == "tc_merge" assert call_event.tool_call_id == "tc_merge"
call_args = cast(TodoArgs, call_event.args) call_args = cast(TodoArgs, call_event.args)
assert call_args.action == "read" assert call_args.action == "read"
assert isinstance(events[3], ToolResultEvent) assert isinstance(events[4], ToolResultEvent)
assert events[3].error is None assert events[4].error is None
assert events[3].skipped is False assert events[4].skipped is False
assistant_with_calls = next( assistant_with_calls = next(
m for m in agent.messages if m.role == Role.assistant and m.tool_calls 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] == [ assert [type(event) for event in events] == [
UserMessageEvent, UserMessageEvent,
AssistantEvent, AssistantEvent,
ToolCallEvent,
AssistantEvent, AssistantEvent,
ToolCallEvent, ToolCallEvent,
ToolResultEvent, ToolResultEvent,

View file

@ -531,7 +531,7 @@ class TestAutoCompactIntegration:
roles = [r for r, _ in observed] roles = [r for r, _ in observed]
assert roles == [Role.system, Role.user, Role.assistant] assert roles == [Role.system, Role.user, Role.assistant]
assert observed[1][1] is not None and "<summary>" in observed[1][1] assert observed[1][1] == "Hello"
class TestClearHistoryFullReset: class TestClearHistoryFullReset:
@ -623,6 +623,37 @@ class TestClearHistoryFullReset:
assert agent.session_id == agent.session_logger.session_id 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: class TestStatsEdgeCases:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_session_cost_approximation_on_model_change( async def test_session_cost_approximation_on_model_change(

View file

@ -113,7 +113,7 @@ async def test_single_tool_call_executes_under_auto_approve(
assert "total_count" in (tool_msgs[-1].content or "") assert "total_count" in (tool_msgs[-1].content or "")
tool_finished = [ 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 len(tool_finished) == 1
assert tool_finished[0]["properties"]["tool_name"] == "todo" 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 assert agent_loop.stats.tool_calls_succeeded == 0
tool_finished = [ 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 len(tool_finished) == 1
assert tool_finished[0]["properties"]["approval_type"] == "ask" 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 assert agent_loop.stats.tool_calls_succeeded == 1
tool_finished = [ 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 len(tool_finished) == 1
assert tool_finished[0]["properties"]["approval_type"] == "ask" 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 assert agent_loop.stats.tool_calls_succeeded == 0
tool_finished = [ 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 len(tool_finished) == 1
assert tool_finished[0]["properties"]["approval_type"] == "ask" 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 assert agent_loop.stats.tool_calls_succeeded == 0
tool_finished = [ 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 len(tool_finished) == 1
assert tool_finished[0]["properties"]["approval_type"] == "never" 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( assistant_msg = LLMMessage(
role=Role.assistant, content="Calling tools...", tool_calls=tool_calls_messages role=Role.assistant, content="Calling tools...", tool_calls=tool_calls_messages
) )
agent_loop.messages = [ agent_loop.messages.reset([
agent_loop.messages[0], agent_loop.messages[0],
assistant_msg, assistant_msg,
# only one tool responded: the second is missing # only one tool responded: the second is missing
LLMMessage( LLMMessage(
role=Role.tool, tool_call_id="tc1", name="todo", content="Retrieved 0 todos" role=Role.tool, tool_call_id="tc1", name="todo", content="Retrieved 0 todos"
), ),
] ])
await act_and_collect_events(agent_loop, "Proceed") 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( tool_msg = LLMMessage(
role=Role.tool, tool_call_id="tc_z", name="todo", content="Done" 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") await act_and_collect_events(agent_loop, "Next")

View file

@ -126,8 +126,9 @@ class TestAgentSafety:
class TestAgentProfile: class TestAgentProfile:
def test_all_builtin_agents_exist(self) -> None: def test_all_builtin_agents_have_valid_names(self) -> None:
assert set(BUILTIN_AGENTS.keys()) == set(BuiltinAgentName) acp_only = {BuiltinAgentName.CHAT}
assert set(BUILTIN_AGENTS.keys()) == set(BuiltinAgentName) - acp_only
def test_display_name_property(self) -> None: def test_display_name_property(self) -> None:
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].display_name == "Default" assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].display_name == "Default"

View file

@ -79,7 +79,7 @@ def test_run_programmatic_preload_streaming_is_batched(
] ]
new_session = [ 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 len(new_session) == 1
assert new_session[0]["properties"]["entrypoint"] == "programmatic" assert new_session[0]["properties"]["entrypoint"] == "programmatic"

View file

@ -3,44 +3,67 @@ from __future__ import annotations
import pytest import pytest
from tests.conftest import build_test_agent_loop, build_test_vibe_config 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.config import VibeConfig
from vibe.core.middleware import ( from vibe.core.middleware import (
CHAT_AGENT_EXIT,
CHAT_AGENT_REMINDER,
PLAN_AGENT_EXIT, PLAN_AGENT_EXIT,
PLAN_AGENT_REMINDER, PLAN_AGENT_REMINDER,
ConversationContext, ConversationContext,
MiddlewareAction, MiddlewareAction,
MiddlewarePipeline, MiddlewarePipeline,
PlanAgentMiddleware, ReadOnlyAgentMiddleware,
ResetReason, 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 @pytest.fixture
def ctx(vibe_config: VibeConfig) -> ConversationContext: 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 @pytest.mark.asyncio
async def test_injects_reminder_when_plan_agent_active( async def test_injects_reminder_when_target_agent_active(
self, ctx: ConversationContext self, ctx: ConversationContext
) -> None: ) -> None:
middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN]) middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
result = await middleware.before_turn(ctx) result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_REMINDER assert result.message == REMINDER
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_does_not_inject_when_default_agent( @pytest.mark.parametrize(
self, ctx: ConversationContext "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: ) -> None:
middleware = PlanAgentMiddleware( middleware = _build_middleware(lambda: BUILTIN_AGENTS[agent_name])
lambda: BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
)
result = await middleware.before_turn(ctx) result = await middleware.before_turn(ctx)
@ -48,89 +71,56 @@ class TestPlanAgentMiddleware:
assert result.message is None assert result.message is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_does_not_inject_when_auto_approve_agent( async def test_injects_reminder_only_once(self, ctx: ConversationContext) -> None:
self, ctx: ConversationContext middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
) -> 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])
result1 = await middleware.before_turn(ctx) result1 = await middleware.before_turn(ctx)
assert result1.action == MiddlewareAction.INJECT_MESSAGE assert result1.action == MiddlewareAction.INJECT_MESSAGE
assert result1.message == PLAN_AGENT_REMINDER assert result1.message == REMINDER
result2 = await middleware.before_turn(ctx) result2 = await middleware.before_turn(ctx)
assert result2.action == MiddlewareAction.CONTINUE assert result2.action == MiddlewareAction.CONTINUE
assert result2.message is None assert result2.message is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_injects_exit_message_when_leaving_plan_mode( async def test_injects_exit_message_when_leaving(
self, ctx: ConversationContext self, ctx: ConversationContext
) -> None: ) -> None:
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] 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) await middleware.before_turn(ctx)
# Leave plan mode
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT] current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
result = await middleware.before_turn(ctx) result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_EXIT assert result.message == EXIT_MSG
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reinjects_reminder_when_reentering_plan_mode( async def test_reinjects_reminder_on_reentry(
self, ctx: ConversationContext self, ctx: ConversationContext
) -> None: ) -> None:
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] 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) result1 = await middleware.before_turn(ctx)
assert result1.action == MiddlewareAction.INJECT_MESSAGE 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] current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
result2 = await middleware.before_turn(ctx) result2 = await middleware.before_turn(ctx)
assert result2.action == MiddlewareAction.INJECT_MESSAGE 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] current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
result3 = await middleware.before_turn(ctx) result3 = await middleware.before_turn(ctx)
assert result3.action == MiddlewareAction.INJECT_MESSAGE assert result3.action == MiddlewareAction.INJECT_MESSAGE
assert result3.message == PLAN_AGENT_REMINDER assert result3.message == REMINDER
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_custom_reminder(self, ctx: ConversationContext) -> None: async def test_custom_reminder(self, ctx: ConversationContext) -> None:
custom_reminder = "Custom plan agent reminder" custom_reminder = "Custom reminder"
middleware = PlanAgentMiddleware( middleware = _build_middleware(
lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN], reminder=custom_reminder lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN], reminder=custom_reminder
) )
@ -138,246 +128,216 @@ class TestPlanAgentMiddleware:
assert result.message == custom_reminder 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 @pytest.mark.asyncio
async def test_custom_exit_message(self, ctx: ConversationContext) -> None: async def test_custom_exit_message(self, ctx: ConversationContext) -> None:
custom_exit = "Custom exit message" custom_exit = "Custom exit message"
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
middleware = PlanAgentMiddleware( middleware = _build_middleware(
lambda: current_profile, exit_message=custom_exit 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] current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
result = await middleware.before_turn(ctx) result = await middleware.before_turn(ctx)
assert result.message == custom_exit assert result.message == custom_exit
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_plan_entry_then_immediate_exit_same_not_possible( async def test_reset_clears_state(self, ctx: ConversationContext) -> None:
self, ctx: ConversationContext middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
) -> None: await middleware.before_turn(ctx)
"""Even if profile changes between two calls, each call sees one transition."""
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] middleware.reset()
middleware = PlanAgentMiddleware(lambda: current_profile)
# First call: entry
result = await middleware.before_turn(ctx) result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_REMINDER
# Second call (still plan): no injection @pytest.mark.asyncio
result = await middleware.before_turn(ctx) async def test_exit_message_fires_only_once(self, ctx: ConversationContext) -> None:
assert result.action == MiddlewareAction.CONTINUE 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] current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
result = await middleware.before_turn(ctx) result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE 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) result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE assert result.action == MiddlewareAction.CONTINUE
class TestMiddlewarePipelineWithPlanAgent: class TestMiddlewarePipelineWithReadOnlyAgent:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pipeline_includes_plan_agent_injection( async def test_pipeline_includes_injection(self, ctx: ConversationContext) -> None:
self, ctx: ConversationContext
) -> None:
pipeline = MiddlewarePipeline() 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) result = await pipeline.run_before_turn(ctx)
@ -385,20 +345,73 @@ class TestMiddlewarePipelineWithPlanAgent:
assert PLAN_AGENT_REMINDER in (result.message or "") assert PLAN_AGENT_REMINDER in (result.message or "")
@pytest.mark.asyncio @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 self, ctx: ConversationContext
) -> None: ) -> None:
pipeline = MiddlewarePipeline() pipeline = MiddlewarePipeline()
pipeline.add( 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) result = await pipeline.run_before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE 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 @pytest.mark.asyncio
async def test_switch_agent_preserves_middleware_state_for_exit_message( async def test_switch_agent_preserves_middleware_state_for_exit_message(
self, self,
@ -414,11 +427,7 @@ class TestPlanAgentMiddlewareIntegration:
) )
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN) agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
plan_middleware = next( plan_middleware = _find_plan_middleware(agent)
mw
for mw in agent.middleware_pipeline.middlewares
if isinstance(mw, PlanAgentMiddleware)
)
ctx = ConversationContext( ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config messages=agent.messages, stats=agent.stats, config=agent.config
@ -429,11 +438,7 @@ class TestPlanAgentMiddlewareIntegration:
await agent.switch_agent(BuiltinAgentName.DEFAULT) await agent.switch_agent(BuiltinAgentName.DEFAULT)
plan_middleware_after = next( plan_middleware_after = _find_plan_middleware(agent)
mw
for mw in agent.middleware_pipeline.middlewares
if isinstance(mw, PlanAgentMiddleware)
)
assert plan_middleware is plan_middleware_after assert plan_middleware is plan_middleware_after
ctx = ConversationContext( ctx = ConversationContext(
@ -456,11 +461,7 @@ class TestPlanAgentMiddlewareIntegration:
) )
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN) agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
plan_middleware = next( plan_middleware = _find_plan_middleware(agent)
mw
for mw in agent.middleware_pipeline.middlewares
if isinstance(mw, PlanAgentMiddleware)
)
ctx = ConversationContext( ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config 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) agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
plan_middleware = next( plan_middleware = _find_plan_middleware(agent)
mw
for mw in agent.middleware_pipeline.middlewares
if isinstance(mw, PlanAgentMiddleware)
)
ctx = ConversationContext( ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config messages=agent.messages, stats=agent.stats, config=agent.config
@ -532,11 +529,7 @@ class TestPlanAgentMiddlewareIntegration:
config=config, agent_name=BuiltinAgentName.DEFAULT config=config, agent_name=BuiltinAgentName.DEFAULT
) )
plan_middleware = next( plan_middleware = _find_plan_middleware(agent)
mw
for mw in agent.middleware_pipeline.middlewares
if isinstance(mw, PlanAgentMiddleware)
)
ctx = ConversationContext( ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config 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) agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
plan_middleware = next( plan_middleware = _find_plan_middleware(agent)
mw
for mw in agent.middleware_pipeline.middlewares
if isinstance(mw, PlanAgentMiddleware)
)
def _ctx(): def _ctx():
return ConversationContext( return ConversationContext(

View file

@ -76,14 +76,14 @@ async def test_decodes_non_utf8_bytes(bash):
assert result.stderr == "" assert result.stderr == ""
def test_check_allowlist_denylist(): def test_resolve_permission():
config = BashToolConfig(allowlist=["echo", "pwd"], denylist=["rm"]) config = BashToolConfig(allowlist=["echo", "pwd"], denylist=["rm"])
bash_tool = Bash(config=config, state=BaseToolState()) bash_tool = Bash(config=config, state=BaseToolState())
allowlisted = bash_tool.check_allowlist_denylist(BashArgs(command="echo hi")) allowlisted = bash_tool.resolve_permission(BashArgs(command="echo hi"))
denylisted = bash_tool.check_allowlist_denylist(BashArgs(command="rm -rf /tmp")) denylisted = bash_tool.resolve_permission(BashArgs(command="rm -rf /tmp"))
mixed = bash_tool.check_allowlist_denylist(BashArgs(command="pwd && whoami")) mixed = bash_tool.resolve_permission(BashArgs(command="pwd && whoami"))
empty = bash_tool.check_allowlist_denylist(BashArgs(command="")) empty = bash_tool.resolve_permission(BashArgs(command=""))
assert allowlisted is ToolPermission.ALWAYS assert allowlisted is ToolPermission.ALWAYS
assert denylisted is ToolPermission.NEVER assert denylisted is ToolPermission.NEVER

View file

@ -5,21 +5,15 @@ import shutil
import pytest import pytest
from tests.mock.utils import collect_result from tests.mock.utils import collect_result
from vibe.core.tools.base import ToolError from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.grep import ( from vibe.core.tools.builtins.grep import Grep, GrepArgs, GrepBackend, GrepToolConfig
Grep,
GrepArgs,
GrepBackend,
GrepState,
GrepToolConfig,
)
@pytest.fixture @pytest.fixture
def grep(tmp_path, monkeypatch): def grep(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
config = GrepToolConfig() config = GrepToolConfig()
return Grep(config=config, state=GrepState()) return Grep(config=config, state=BaseToolState())
@pytest.fixture @pytest.fixture
@ -34,7 +28,7 @@ def grep_gnu_only(tmp_path, monkeypatch):
monkeypatch.setattr("shutil.which", mock_which) monkeypatch.setattr("shutil.which", mock_which)
config = GrepToolConfig() config = GrepToolConfig()
return Grep(config=config, state=GrepState()) return Grep(config=config, state=BaseToolState())
def test_detects_ripgrep_when_available(grep): 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): async def test_truncates_to_max_output_bytes(grep, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
config = GrepToolConfig(max_output_bytes=100) 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))) (tmp_path / "test.py").write_text("\n".join("x" * 100 for _ in range(10)))
result = await collect_result(grep_tool.run(GrepArgs(pattern="x"))) 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 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 @pytest.mark.asyncio
async def test_uses_effective_workdir(tmp_path, monkeypatch): async def test_uses_effective_workdir(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
config = GrepToolConfig() config = GrepToolConfig()
grep_tool = Grep(config=config, state=GrepState()) grep_tool = Grep(config=config, state=BaseToolState())
(tmp_path / "test.py").write_text("match\n") (tmp_path / "test.py").write_text("match\n")
result = await collect_result(grep_tool.run(GrepArgs(pattern="match", path="."))) result = await collect_result(grep_tool.run(GrepArgs(pattern="match", path=".")))

View file

@ -227,6 +227,70 @@ class FileTool(BaseTool[FileToolArgs, FileToolResult, BaseToolConfig, BaseToolSt
assert "file_tool" in tools 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: class TestToolManagerModuleReuse:
"""Tests for module reuse across ToolManager instances. """Tests for module reuse across ToolManager instances.

View file

@ -11,6 +11,7 @@ import pytest
from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp
from vibe.core.tools.mcp import ( from vibe.core.tools.mcp import (
MCPRegistry,
MCPToolResult, MCPToolResult,
RemoteTool, RemoteTool,
_mcp_stderr_capture, _mcp_stderr_capture,
@ -372,3 +373,181 @@ class TestMCPConfigModels:
# Trailing special chars become underscores which are then stripped # Trailing special chars become underscores which are then stripped
assert config.name == "my_server" 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

View file

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

View file

@ -8,7 +8,7 @@ from tests.conftest import build_test_vibe_config
from tests.mock.utils import collect_result from tests.mock.utils import collect_result
from vibe.core.agents.manager import AgentManager from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import BUILTIN_AGENTS, AgentType 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.tools.builtins.task import Task, TaskArgs, TaskResult, TaskToolConfig
from vibe.core.types import AssistantEvent, LLMMessage, Role from vibe.core.types import AssistantEvent, LLMMessage, Role
@ -76,6 +76,50 @@ class TestTaskToolValidation:
assert agent.agent_type == AgentType.SUBAGENT 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: class TestTaskToolExecution:
@pytest.fixture @pytest.fixture
def ctx(self) -> InvokeContext: def ctx(self) -> InvokeContext:

View file

@ -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 = "<html><body><h1>Title</h1><p>Hello world</p></body></html>"
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 = "<html><body><script>alert('xss')</script><style>.x{}</style><p>Clean</p></body></html>"
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 = (
"<html><body><h2>first title</h2>"
+ "x" * 200
+ "<h2>second title</h2></body></html>"
)
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"

View file

@ -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"

549
uv.lock generated
View file

@ -1,21 +1,17 @@
version = 1 version = 1
revision = 3 revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.13'",
"python_full_version < '3.13'",
]
[[package]] [[package]]
name = "agent-client-protocol" name = "agent-client-protocol"
version = "0.8.0" version = "0.8.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/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 = [ 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]] [[package]]
@ -38,15 +34,15 @@ wheels = [
[[package]] [[package]]
name = "anyio" name = "anyio"
version = "4.12.0" version = "4.12.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "idna" }, { name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" }, { 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 = [ 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]] [[package]]
@ -59,12 +55,34 @@ wheels = [
] ]
[[package]] [[package]]
name = "certifi" name = "beautifulsoup4"
version = "2025.11.12" version = "4.14.3"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -269,23 +287,23 @@ wheels = [
[[package]] [[package]]
name = "debugpy" name = "debugpy"
version = "1.8.19" version = "1.8.20"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] [[package]]
@ -299,11 +317,11 @@ wheels = [
[[package]] [[package]]
name = "docutils" name = "docutils"
version = "0.22.3" version = "0.22.4"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -326,11 +344,11 @@ wheels = [
[[package]] [[package]]
name = "filelock" name = "filelock"
version = "3.20.0" version = "3.24.3"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[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" }, { 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]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
@ -428,23 +458,23 @@ wheels = [
[[package]] [[package]]
name = "id" name = "id"
version = "1.5.0" version = "1.6.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ 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 = [ 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]] [[package]]
name = "identify" name = "identify"
version = "2.6.15" version = "2.6.16"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[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" }, { 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]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.0" version = "2.3.0"
@ -488,23 +530,23 @@ wheels = [
[[package]] [[package]]
name = "jaraco-context" name = "jaraco-context"
version = "6.0.1" version = "6.1.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
name = "jaraco-functools" name = "jaraco-functools"
version = "4.3.0" version = "4.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "more-itertools" }, { 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 = [ 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]] [[package]]
@ -530,7 +572,7 @@ wheels = [
[[package]] [[package]]
name = "jsonschema" name = "jsonschema"
version = "4.25.1" version = "4.26.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "attrs" }, { name = "attrs" },
@ -538,9 +580,9 @@ dependencies = [
{ name = "referencing" }, { name = "referencing" },
{ name = "rpds-py" }, { 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 = [ 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]] [[package]]
@ -613,6 +655,19 @@ linkify = [
{ name = "linkify-it-py" }, { 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]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "3.0.3" version = "3.0.3"
@ -678,7 +733,7 @@ wheels = [
[[package]] [[package]]
name = "mcp" name = "mcp"
version = "1.23.0" version = "1.26.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
@ -696,9 +751,9 @@ dependencies = [
{ name = "typing-inspection" }, { name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" }, { 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 = [ 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]] [[package]]
@ -724,17 +779,19 @@ wheels = [
[[package]] [[package]]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.2.1" version = "2.3.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "agent-client-protocol" }, { name = "agent-client-protocol" },
{ name = "anyio" }, { name = "anyio" },
{ name = "cachetools" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "gitpython" }, { name = "gitpython" },
{ name = "giturlparse" }, { name = "giturlparse" },
{ name = "google-auth" }, { name = "google-auth" },
{ name = "httpx" }, { name = "httpx" },
{ name = "keyring" }, { name = "keyring" },
{ name = "markdownify" },
{ name = "mcp" }, { name = "mcp" },
{ name = "mistralai" }, { name = "mistralai" },
{ name = "packaging" }, { name = "packaging" },
@ -777,16 +834,18 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ 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 = "anyio", specifier = ">=4.12.0" },
{ name = "cachetools", specifier = ">=5.5.0" },
{ name = "cryptography", specifier = ">=44.0.0,<=46.0.3" }, { name = "cryptography", specifier = ">=44.0.0,<=46.0.3" },
{ name = "gitpython", specifier = ">=3.1.46" }, { name = "gitpython", specifier = ">=3.1.46" },
{ name = "giturlparse", specifier = ">=0.14.0" }, { name = "giturlparse", specifier = ">=0.14.0" },
{ name = "google-auth", specifier = ">=2.0.0" }, { name = "google-auth", specifier = ">=2.0.0" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "keyring", specifier = ">=25.6.0" }, { name = "keyring", specifier = ">=25.6.0" },
{ name = "markdownify", specifier = ">=1.2.2" },
{ name = "mcp", specifier = ">=1.14.0" }, { name = "mcp", specifier = ">=1.14.0" },
{ name = "mistralai", specifier = "==1.9.11" }, { name = "mistralai", specifier = "==1.12.4" },
{ name = "packaging", specifier = ">=24.1" }, { name = "packaging", specifier = ">=24.1" },
{ name = "pexpect", specifier = ">=4.9.0" }, { name = "pexpect", specifier = ">=4.9.0" },
{ name = "pydantic", specifier = ">=2.12.4" }, { name = "pydantic", specifier = ">=2.12.4" },
@ -825,20 +884,23 @@ dev = [
[[package]] [[package]]
name = "mistralai" name = "mistralai"
version = "1.9.11" version = "1.12.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "eval-type-backport" }, { name = "eval-type-backport" },
{ name = "httpx" }, { name = "httpx" },
{ name = "invoke" }, { name = "invoke" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
{ name = "opentelemetry-sdk" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "typing-inspection" }, { 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 = [ 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]] [[package]]
@ -852,53 +914,136 @@ wheels = [
[[package]] [[package]]
name = "nh3" name = "nh3"
version = "0.3.2" version = "0.3.3"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] [[package]]
name = "nodeenv" name = "nodeenv"
version = "1.9.1" version = "1.10.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
name = "packaging" name = "packaging"
version = "25.0" version = "26.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -924,11 +1069,11 @@ wheels = [
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.5.0" version = "4.9.2"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -942,7 +1087,7 @@ wheels = [
[[package]] [[package]]
name = "pre-commit" name = "pre-commit"
version = "4.5.0" version = "4.5.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cfgv" }, { name = "cfgv" },
@ -951,9 +1096,24 @@ dependencies = [
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "virtualenv" }, { 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 = [ 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]] [[package]]
@ -988,11 +1148,11 @@ wheels = [
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "2.23" version = "3.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -1083,16 +1243,16 @@ wheels = [
[[package]] [[package]]
name = "pydantic-settings" name = "pydantic-settings"
version = "2.12.0" version = "2.13.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "typing-inspection" }, { 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 = [ 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]] [[package]]
@ -1106,7 +1266,7 @@ wheels = [
[[package]] [[package]]
name = "pyinstaller" name = "pyinstaller"
version = "6.17.0" version = "6.19.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "altgraph" }, { name = "altgraph" },
@ -1117,41 +1277,41 @@ dependencies = [
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "setuptools" }, { 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] [[package]]
name = "pyinstaller-hooks-contrib" name = "pyinstaller-hooks-contrib"
version = "2025.10" version = "2026.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "packaging" }, { name = "packaging" },
{ name = "setuptools" }, { 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 = [ 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]] [[package]]
name = "pyjwt" name = "pyjwt"
version = "2.10.1" version = "2.11.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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] [package.optional-dependencies]
@ -1170,15 +1330,15 @@ wheels = [
[[package]] [[package]]
name = "pyright" name = "pyright"
version = "1.1.407" version = "1.1.408"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "nodeenv" }, { name = "nodeenv" },
{ name = "typing-extensions" }, { 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 = [ 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]] [[package]]
@ -1274,11 +1434,11 @@ wheels = [
[[package]] [[package]]
name = "python-multipart" name = "python-multipart"
version = "0.0.20" version = "0.0.22"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -1430,15 +1590,15 @@ wheels = [
[[package]] [[package]]
name = "rich" name = "rich"
version = "14.2.0" version = "14.3.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markdown-it-py" }, { name = "markdown-it-py" },
{ name = "pygments" }, { 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 = [ 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]] [[package]]
@ -1536,28 +1696,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.14.7" version = "0.15.2"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
{ 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" },
] ]
[[package]] [[package]]
@ -1575,11 +1734,11 @@ wheels = [
[[package]] [[package]]
name = "setuptools" name = "setuptools"
version = "80.9.0" version = "82.0.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[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" }, { 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]] [[package]]
name = "sse-starlette" name = "sse-starlette"
version = "3.0.3" version = "3.2.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { 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 = [ 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]] [[package]]
name = "starlette" name = "starlette"
version = "0.46.2" version = "0.52.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { 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 = [ 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]] [[package]]
@ -1638,7 +1808,7 @@ wheels = [
[[package]] [[package]]
name = "textual" name = "textual"
version = "7.4.0" version = "8.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] }, { name = "markdown-it-py", extra = ["linkify"] },
@ -1648,9 +1818,9 @@ dependencies = [
{ name = "rich" }, { name = "rich" },
{ name = "typing-extensions" }, { 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 = [ 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]] [[package]]
@ -1813,19 +1983,19 @@ wheels = [
[[package]] [[package]]
name = "typos" name = "typos"
version = "1.40.0" version = "1.43.5"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] [[package]]
@ -1839,38 +2009,38 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.5.0" version = "2.6.3"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.38.0" version = "0.41.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "h11" }, { 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 = [ 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]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "20.35.4" version = "20.38.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "distlib" }, { name = "distlib" },
{ name = "filelock" }, { name = "filelock" },
{ name = "platformdirs" }, { 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 = [ 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]] [[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" }, { 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]] [[package]]
name = "zstandard" name = "zstandard"
version = "0.25.0" version = "0.25.0"

View file

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

View file

@ -37,14 +37,12 @@ from acp.schema import (
Implementation, Implementation,
ListSessionsResponse, ListSessionsResponse,
McpServerStdio, McpServerStdio,
ModelInfo,
PromptCapabilities, PromptCapabilities,
ResumeSessionResponse, ResumeSessionResponse,
SessionCapabilities, SessionCapabilities,
SessionInfo, SessionInfo,
SessionListCapabilities, SessionListCapabilities,
SessionModelState, SetSessionConfigOptionResponse,
SessionModeState,
SseMcpServer, SseMcpServer,
TextContentBlock, TextContentBlock,
TextResourceContents, TextResourceContents,
@ -56,6 +54,7 @@ from acp.schema import (
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from vibe import VIBE_ROOT, __version__ 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.base import BaseAcpTool
from vibe.acp.tools.session_update import ( from vibe.acp.tools.session_update import (
tool_call_session_update, tool_call_session_update,
@ -71,12 +70,13 @@ from vibe.acp.utils import (
create_tool_call_replay, create_tool_call_replay,
create_tool_result_replay, create_tool_result_replay,
create_user_message_replay, create_user_message_replay,
get_all_acp_session_modes,
get_proxy_help_text, 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.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.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import ( from vibe.core.config import (
MissingAPIKeyError, MissingAPIKeyError,
@ -98,6 +98,7 @@ from vibe.core.types import (
AsyncApprovalCallback, AsyncApprovalCallback,
CompactEndEvent, CompactEndEvent,
CompactStartEvent, CompactStartEvent,
EntrypointMetadata,
LLMMessage, LLMMessage,
ReasoningEvent, ReasoningEvent,
Role, Role,
@ -121,7 +122,8 @@ class VibeAcpAgentLoop(AcpAgent):
def __init__(self) -> None: def __init__(self) -> None:
self.sessions: dict[str, AcpSessionLoop] = {} self.sessions: dict[str, AcpSessionLoop] = {}
self.client_capabilities = None self.client_capabilities: ClientCapabilities | None = None
self.client_info: Implementation | None = None
@override @override
async def initialize( async def initialize(
@ -132,6 +134,7 @@ class VibeAcpAgentLoop(AcpAgent):
**kwargs: Any, **kwargs: Any,
) -> InitializeResponse: ) -> InitializeResponse:
self.client_capabilities = client_capabilities self.client_capabilities = client_capabilities
self.client_info = client_info
# The ACP Agent process can be launched in 3 different ways, depending on installation # 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 # - dev mode: `uv run vibe-acp`, ran from the project root
@ -200,6 +203,14 @@ class VibeAcpAgentLoop(AcpAgent):
) -> AuthenticateResponse | None: ) -> AuthenticateResponse | None:
raise NotImplementedError("Not implemented yet") 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: def _load_config(self) -> VibeConfig:
try: try:
config = VibeConfig.load(disabled_tools=["ask_user_question"]) config = VibeConfig.load(disabled_tools=["ask_user_question"])
@ -223,21 +234,6 @@ class VibeAcpAgentLoop(AcpAgent):
return session 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 @override
async def new_session( async def new_session(
self, self,
@ -251,19 +247,32 @@ class VibeAcpAgentLoop(AcpAgent):
config = self._load_config() config = self._load_config()
agent_loop = AgentLoop( 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. # 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 # We should just use agent_loop.session_id everywhere, but it can still change during
# session lifetime (e.g. agent_loop.compact is called). # session lifetime (e.g. agent_loop.compact is called).
# We should refactor agent_loop.session_id to make it immutable in ACP context. # 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) 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( return NewSessionResponse(
session_id=session.id, session_id=session.id,
models=self._build_session_model_state(agent_loop), models=models_state,
modes=self._build_session_mode_state(session), modes=modes_state,
config_options=[modes_config, models_config],
) )
def _get_acp_tool_overrides(self) -> list[Path]: def _get_acp_tool_overrides(self) -> list[Path]:
@ -447,8 +456,12 @@ class VibeAcpAgentLoop(AcpAgent):
}) from e }) from e
agent_loop = AgentLoop( 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 = [ non_system_messages = [
msg for msg in loaded_messages if msg.role != Role.system 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) await self._replay_conversation_history(session_id, non_system_messages)
return LoadSessionResponse( modes_state, modes_config = make_mode_response(
models=self._build_session_model_state(agent_loop), list(agent_loop.agent_manager.available_agents.values()),
modes=self._build_session_mode_state(session), session.agent_loop.agent_profile.name,
)
models_state, models_config = make_model_response(
agent_loop.config.models, agent_loop.config.active_model
) )
@override return LoadSessionResponse(
async def set_session_mode( models=models_state,
self, mode_id: str, session_id: str, **kwargs: Any modes=modes_state,
) -> SetSessionModeResponse | None: config_options=[modes_config, models_config],
session = self._get_session(session_id) )
if not is_valid_acp_agent(session.agent_loop.agent_manager, mode_id): async def _apply_mode_change(self, session: AcpSessionLoop, mode_id: str) -> bool:
return None 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) await session.agent_loop.switch_agent(mode_id)
@ -483,17 +501,12 @@ class VibeAcpAgentLoop(AcpAgent):
self._create_approval_callback(session.id) self._create_approval_callback(session.id)
) )
return SetSessionModeResponse() return True
@override
async def set_session_model(
self, model_id: str, session_id: str, **kwargs: Any
) -> SetSessionModelResponse | None:
session = self._get_session(session_id)
async def _apply_model_change(self, session: AcpSessionLoop, model_id: str) -> bool:
model_aliases = [model.alias for model in session.agent_loop.config.models] model_aliases = [model.alias for model in session.agent_loop.config.models]
if model_id not in model_aliases: if model_id not in model_aliases:
return None return False
VibeConfig.save_updates({"active_model": model_id}) 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) 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() 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 @override
async def list_sessions( async def list_sessions(
self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any
@ -736,7 +800,13 @@ class VibeAcpAgentLoop(AcpAgent):
def run_acp_server() -> None: def run_acp_server() -> None:
try: 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: except KeyboardInterrupt:
# This is expected when the server is terminated # This is expected when the server is terminated
pass pass

97
vibe/acp/acp_logger.py Normal file
View file

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

View file

@ -7,8 +7,8 @@ import sys
from vibe import __version__ from vibe import __version__
from vibe.core.config import VibeConfig 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.paths.config_paths import CONFIG_FILE, HISTORY_FILE, unlock_config_paths
from vibe.core.utils import logger
# Configure line buffering for subprocess communication # Configure line buffering for subprocess communication
sys.stdout.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue] sys.stdout.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue]

View file

@ -8,10 +8,10 @@ from acp.helpers import SessionUpdate, ToolCallContentVariant
from acp.schema import ToolCallProgress from acp.schema import ToolCallProgress
from pydantic import BaseModel, ConfigDict, Field, SkipValidation 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.base import BaseTool, ToolError
from vibe.core.tools.manager import ToolManager from vibe.core.tools.manager import ToolManager
from vibe.core.types import ToolCallEvent, ToolResultEvent from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.utils import logger
@runtime_checkable @runtime_checkable

View file

@ -13,10 +13,10 @@ from acp.schema import (
from vibe import VIBE_ROOT from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool 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.base import BaseToolState, InvokeContext, ToolError
from vibe.core.tools.builtins.bash import Bash as CoreBashTool, BashArgs, BashResult from vibe.core.tools.builtins.bash import Bash as CoreBashTool, BashArgs, BashResult
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
from vibe.core.utils import logger
class AcpBashState(BaseToolState, AcpToolState): class AcpBashState(BaseToolState, AcpToolState):
@ -110,7 +110,16 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
raise self._build_timeout_error(command, timeout) raise self._build_timeout_error(command, timeout)
@classmethod @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): if not isinstance(event.args, BashArgs):
raise ValueError(f"Unexpected tool args: {event.args}") raise ValueError(f"Unexpected tool args: {event.args}")

View file

@ -4,19 +4,18 @@ from pathlib import Path
from vibe import VIBE_ROOT from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool 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 ( from vibe.core.tools.builtins.read_file import (
ReadFile as CoreReadFileTool, ReadFile as CoreReadFileTool,
ReadFileArgs, ReadFileArgs,
ReadFileResult, ReadFileResult,
ReadFileState,
_ReadResult, _ReadResult,
) )
ReadFileResult = ReadFileResult ReadFileResult = ReadFileResult
class AcpReadFileState(ReadFileState, AcpToolState): class AcpReadFileState(BaseToolState, AcpToolState):
pass pass

View file

@ -12,17 +12,16 @@ from acp.schema import (
from vibe import VIBE_ROOT from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool 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 ( from vibe.core.tools.builtins.search_replace import (
SearchReplace as CoreSearchReplaceTool, SearchReplace as CoreSearchReplaceTool,
SearchReplaceArgs, SearchReplaceArgs,
SearchReplaceResult, SearchReplaceResult,
SearchReplaceState,
) )
from vibe.core.types import ToolCallEvent, ToolResultEvent from vibe.core.types import ToolCallEvent, ToolResultEvent
class AcpSearchReplaceState(SearchReplaceState, AcpToolState): class AcpSearchReplaceState(BaseToolState, AcpToolState):
file_backup_content: str | None = None file_backup_content: str | None = None
@ -73,6 +72,15 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
@classmethod @classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None: def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
args = event.args 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): if not isinstance(args, SearchReplaceArgs):
return None return None

View file

@ -12,17 +12,16 @@ from acp.schema import (
from vibe import VIBE_ROOT from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool 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 ( from vibe.core.tools.builtins.write_file import (
WriteFile as CoreWriteFileTool, WriteFile as CoreWriteFileTool,
WriteFileArgs, WriteFileArgs,
WriteFileResult, WriteFileResult,
WriteFileState,
) )
from vibe.core.types import ToolCallEvent, ToolResultEvent from vibe.core.types import ToolCallEvent, ToolResultEvent
class AcpWriteFileState(WriteFileState, AcpToolState): class AcpWriteFileState(BaseToolState, AcpToolState):
pass pass
@ -51,6 +50,15 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
@classmethod @classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None: def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
args = event.args 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): if not isinstance(args, WriteFileArgs):
return None return None

View file

@ -50,7 +50,7 @@ def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
content=content, content=content,
tool_call_id=event.tool_call_id, tool_call_id=event.tool_call_id,
kind=TOOL_KIND.get(event.tool_name, "other"), 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,
) )

View file

@ -7,8 +7,14 @@ from acp.schema import (
AgentMessageChunk, AgentMessageChunk,
AgentThoughtChunk, AgentThoughtChunk,
ContentToolCallContent, ContentToolCallContent,
ModelInfo,
PermissionOption, PermissionOption,
SessionConfigOption,
SessionConfigOptionSelect,
SessionConfigSelectOption,
SessionMode, SessionMode,
SessionModelState,
SessionModeState,
TextContentBlock, TextContentBlock,
ToolCallProgress, ToolCallProgress,
ToolCallStart, ToolCallStart,
@ -21,7 +27,7 @@ from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
from vibe.core.utils import compact_reduction_display from vibe.core.utils import compact_reduction_display
if TYPE_CHECKING: if TYPE_CHECKING:
from vibe.core.agents.manager import AgentManager from vibe.core.config import ModelConfig
class ToolOption(StrEnum): class ToolOption(StrEnum):
@ -50,22 +56,80 @@ TOOL_OPTIONS = [
] ]
def agent_profile_to_acp(profile: AgentProfile) -> SessionMode: def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
return SessionMode( return any(
id=profile.name, name=profile.display_name, description=profile.description 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: def make_mode_response(
return agent_name in agent_manager.available_agents 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]: def make_model_response(
return [ models: list[ModelConfig], current_model_id: str
agent_profile_to_acp(profile) ) -> tuple[SessionModelState, SessionConfigOption]:
for profile in agent_manager.available_agents.values() model_infos: list[ModelInfo] = []
if profile.agent_type == AgentType.AGENT 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: def create_compact_start_session_update(event: CompactStartEvent) -> ToolCallStart:

View file

@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from pathlib import Path
import sys import sys
from rich import print as rprint from rich import print as rprint
from vibe import __version__
from vibe.cli.textual_ui.app import run_textual_ui from vibe.cli.textual_ui.app import run_textual_ui
from vibe.core.agent_loop import AgentLoop from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName from vibe.core.agents.models import BuiltinAgentName
@ -14,11 +16,12 @@ from vibe.core.config import (
VibeConfig, VibeConfig,
load_dotenv_values, load_dotenv_values,
) )
from vibe.core.logger import logger
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE
from vibe.core.programmatic import run_programmatic from vibe.core.programmatic import run_programmatic
from vibe.core.session.session_loader import SessionLoader from vibe.core.session.session_loader import SessionLoader
from vibe.core.types import LLMMessage, OutputFormat, Role from vibe.core.types import EntrypointMetadata, LLMMessage, OutputFormat, Role
from vibe.core.utils import ConversationLimitException, logger from vibe.core.utils import ConversationLimitException
from vibe.setup.onboarding import run_onboarding from vibe.setup.onboarding import run_onboarding
@ -74,7 +77,7 @@ def bootstrap_config_files() -> None:
def load_session( def load_session(
args: argparse.Namespace, config: VibeConfig args: argparse.Namespace, config: VibeConfig
) -> list[LLMMessage] | None: ) -> tuple[list[LLMMessage], Path] | None:
if not args.continue_session and not args.resume: if not args.continue_session and not args.resume:
return None return None
@ -107,18 +110,26 @@ def load_session(
try: try:
loaded_messages, _ = SessionLoader.load_session(session_to_load) loaded_messages, _ = SessionLoader.load_session(session_to_load)
return loaded_messages return loaded_messages, session_to_load
except Exception as e: except Exception as e:
rprint(f"[red]Failed to load session: {e}[/]") rprint(f"[red]Failed to load session: {e}[/]")
sys.exit(1) sys.exit(1)
def _load_messages_from_previous_session( def _resume_previous_session(
agent_loop: AgentLoop, loaded_messages: list[LLMMessage] agent_loop: AgentLoop, loaded_messages: list[LLMMessage], session_path: Path
) -> None: ) -> None:
non_system_messages = [msg for msg in loaded_messages if msg.role != Role.system] non_system_messages = [msg for msg in loaded_messages if msg.role != Role.system]
agent_loop.messages.extend(non_system_messages) 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: def run_cli(args: argparse.Namespace) -> None:
@ -136,7 +147,7 @@ def run_cli(args: argparse.Namespace) -> None:
if args.enabled_tools: if args.enabled_tools:
config.enabled_tools = 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() stdin_prompt = get_prompt_from_stdin()
if args.prompt is not None: if args.prompt is not None:
@ -157,7 +168,7 @@ def run_cli(args: argparse.Namespace) -> None:
max_turns=args.max_turns, max_turns=args.max_turns,
max_price=args.max_price, max_price=args.max_price,
output_format=output_format, output_format=output_format,
previous_messages=loaded_messages, previous_messages=loaded_session[0] if loaded_session else None,
agent_name=initial_agent_name, agent_name=initial_agent_name,
) )
if final_response: if final_response:
@ -171,11 +182,19 @@ def run_cli(args: argparse.Namespace) -> None:
sys.exit(1) sys.exit(1)
else: else:
agent_loop = AgentLoop( 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: if loaded_session:
_load_messages_from_previous_session(agent_loop, loaded_messages) _resume_previous_session(agent_loop, *loaded_session)
run_textual_ui( run_textual_ui(
agent_loop=agent_loop, agent_loop=agent_loop,

View file

@ -72,6 +72,11 @@ class CommandRegistry:
description="Configure proxy and SSL certificate settings", description="Configure proxy and SSL certificate settings",
handler="_show_proxy_setup", 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: for command in excluded_commands:

View file

@ -14,6 +14,7 @@ class Terminal(Enum):
VSCODE = "vscode" VSCODE = "vscode"
VSCODE_INSIDERS = "vscode_insiders" VSCODE_INSIDERS = "vscode_insiders"
CURSOR = "cursor" CURSOR = "cursor"
JETBRAINS = "jetbrains"
ITERM2 = "iterm2" ITERM2 = "iterm2"
WEZTERM = "wezterm" WEZTERM = "wezterm"
GHOSTTY = "ghostty" GHOSTTY = "ghostty"
@ -50,6 +51,16 @@ def _detect_vscode_terminal() -> Literal[Terminal.VSCODE, Terminal.VSCODE_INSIDE
return Terminal.VSCODE 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: def detect_terminal() -> Terminal:
term_program = os.environ.get("TERM_PROGRAM", "").lower() term_program = os.environ.get("TERM_PROGRAM", "").lower()
@ -66,12 +77,7 @@ def detect_terminal() -> Terminal:
if term_program in term_map: if term_program in term_map:
return term_map[term_program] return term_map[term_program]
if os.environ.get("WEZTERM_PANE"): return _detect_terminal_from_env() or Terminal.UNKNOWN
return Terminal.WEZTERM
if os.environ.get("GHOSTTY_RESOURCES_DIR"):
return Terminal.GHOSTTY
return Terminal.UNKNOWN
def _get_vscode_keybindings_path(is_stable: bool) -> Path | None: def _get_vscode_keybindings_path(is_stable: bool) -> Path | None:
@ -303,6 +309,13 @@ def setup_terminal() -> SetupResult:
match terminal: match terminal:
case Terminal.VSCODE | Terminal.VSCODE_INSIDERS | Terminal.CURSOR: case Terminal.VSCODE | Terminal.VSCODE_INSIDERS | Terminal.CURSOR:
return _setup_vscode_like_terminal(terminal) 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: case Terminal.ITERM2:
return _setup_iterm2() return _setup_iterm2()
case Terminal.WEZTERM: case Terminal.WEZTERM:

View file

@ -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.plan_offer.ports.whoami_gateway import WhoAmIGateway
from vibe.cli.terminal_setup import setup_terminal from vibe.cli.terminal_setup import setup_terminal
from vibe.cli.textual_ui.handlers.event_handler import EventHandler 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.approval_app import ApprovalApp
from vibe.cli.textual_ui.widgets.banner.banner import Banner from vibe.cli.textual_ui.widgets.banner.banner import Banner
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer 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.path_display import PathDisplay
from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp 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.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.teleport_message import TeleportMessage
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
from vibe.cli.textual_ui.windowing import ( 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.agents import AgentProfile
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.paths.config_paths import HISTORY_FILE from vibe.core.paths.config_paths import HISTORY_FILE
from vibe.core.session.session_loader import SessionLoader from vibe.core.session.session_loader import SessionLoader
from vibe.core.teleport.types import ( from vibe.core.teleport.types import (
@ -115,7 +122,6 @@ from vibe.core.utils import (
CancellationReason, CancellationReason,
get_user_cancellation_message, get_user_cancellation_message,
is_dangerous_directory, is_dangerous_directory,
logger,
) )
@ -132,11 +138,16 @@ class BottomApp(StrEnum):
Input = auto() Input = auto()
ProxySetup = auto() ProxySetup = auto()
Question = auto() Question = auto()
SessionPicker = auto()
class ChatScroll(VerticalScroll): class ChatScroll(VerticalScroll):
"""Optimized scroll container that skips cascading style recalculations.""" """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: def update_node_styles(self, animate: bool = True) -> None:
pass pass
@ -145,34 +156,40 @@ PRUNE_LOW_MARK = 1000
PRUNE_HIGH_MARK = 1500 PRUNE_HIGH_MARK = 1500
async def prune_by_height(messages_area: Widget, low_mark: int, high_mark: int) -> bool: async def prune_oldest_children(
"""Remove older children to keep virtual height within bounds. messages_area: Widget, low_mark: int, high_mark: int
Implementation from https://github.com/batrachianai/toad/blob/a335b56c9015514d5f38654e3909aaa78850c510/src/toad/widgets/conversation.py#L1495 ) -> 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 total_height = messages_area.virtual_size.height
if height <= high_mark: if total_height <= high_mark:
return False return False
prune_children: list[Widget] = []
bottom_margin = 0 children = messages_area.children
prune_height = 0 if not children:
for child in messages_area.children: return False
accumulated = 0
cut = len(children)
for child in reversed(children):
if not child.display: if not child.display:
prune_children.append(child) cut -= 1
continue continue
top, _, bottom, _ = child.styles.margin accumulated += child.outer_size.height
child_height = child.outer_size.height cut -= 1
prune_height = ( if accumulated >= low_mark:
(prune_height - bottom_margin + max(bottom_margin, top))
+ bottom
+ child_height
)
bottom_margin = bottom
if height - prune_height <= low_mark:
break break
prune_children.append(child)
if prune_children: to_remove = list(children[:cut])
await messages_area.remove_children(prune_children) if not to_remove:
return bool(prune_children) return False
await messages_area.remove_children(to_remove)
return True
class VibeApp(App): # noqa: PLR0904 class VibeApp(App): # noqa: PLR0904
@ -203,10 +220,16 @@ class VibeApp(App): # noqa: PLR0904
update_cache_repository: UpdateCacheRepository | None = None, update_cache_repository: UpdateCacheRepository | None = None,
current_version: str = CORE_VERSION, current_version: str = CORE_VERSION,
plan_offer_gateway: WhoAmIGateway | None = None, plan_offer_gateway: WhoAmIGateway | None = None,
terminal_notifier: NotificationPort | None = None,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.agent_loop = agent_loop 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._agent_running = False
self._interrupt_requested = False self._interrupt_requested = False
self._agent_task: asyncio.Task | None = None 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_messages_area: Widget | None = None
self._cached_chat: ChatScroll | None = None self._cached_chat: ChatScroll | None = None
self._cached_loading_area: Widget | None = None self._cached_loading_area: Widget | None = None
self._switch_agent_generation = 0
@property @property
def config(self) -> VibeConfig: def config(self) -> VibeConfig:
@ -279,6 +303,7 @@ class VibeApp(App): # noqa: PLR0904
async def on_mount(self) -> None: async def on_mount(self) -> None:
self.theme = "textual-ansi" self.theme = "textual-ansi"
self._terminal_notifier.restore()
self._cached_messages_area = self.query_one("#messages") self._cached_messages_area = self.query_one("#messages")
self._cached_chat = self.query_one("#chat", ChatScroll) 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._resume_history_from_messages()
await self._check_and_show_whats_new() await self._check_and_show_whats_new()
self._schedule_update_notification() 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) self.call_after_refresh(self._refresh_banner)
@ -575,12 +600,16 @@ class VibeApp(App): # noqa: PLR0904
self._handle_agent_loop_turn(message) 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: async def _resume_history_from_messages(self) -> None:
messages_area = self._cached_messages_area or self.query_one("#messages") messages_area = self._cached_messages_area or self.query_one("#messages")
if not should_resume_history(list(messages_area.children)): if not should_resume_history(list(messages_area.children)):
return return
self._windowing.reset()
history_messages = non_system_history_messages(self.agent_loop.messages) history_messages = non_system_history_messages(self.agent_loop.messages)
if ( if (
plan := create_resume_plan(history_messages, HISTORY_RESUME_TAIL_MESSAGES) plan := create_resume_plan(history_messages, HISTORY_RESUME_TAIL_MESSAGES)
@ -592,7 +621,8 @@ class VibeApp(App): # noqa: PLR0904
plan.tool_call_map, plan.tool_call_map,
start_index=plan.tail_start_index, 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._tool_call_map = plan.tool_call_map
self._windowing.set_backfill(plan.backfill_messages) self._windowing.set_backfill(plan.backfill_messages)
await self._load_more.set_visible( await self._load_more.set_visible(
@ -643,6 +673,7 @@ class VibeApp(App): # noqa: PLR0904
return (ApprovalResponse.YES, None) return (ApprovalResponse.YES, None)
self._pending_approval = asyncio.Future() self._pending_approval = asyncio.Future()
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
with paused_timer(self._loading_widget): with paused_timer(self._loading_widget):
await self._switch_to_approval_app(tool, args) await self._switch_to_approval_app(tool, args)
result = await self._pending_approval result = await self._pending_approval
@ -654,6 +685,7 @@ class VibeApp(App): # noqa: PLR0904
question_args = cast(AskUserQuestionArgs, args) question_args = cast(AskUserQuestionArgs, args)
self._pending_question = asyncio.Future() self._pending_question = asyncio.Future()
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
with paused_timer(self._loading_widget): with paused_timer(self._loading_widget):
await self._switch_to_question_app(question_args) await self._switch_to_question_app(question_args)
result = await self._pending_question result = await self._pending_question
@ -714,6 +746,7 @@ class VibeApp(App): # noqa: PLR0904
if self.event_handler: if self.event_handler:
await self.event_handler.finalize_streaming() await self.event_handler.finalize_streaming()
await self._refresh_windowing_from_history() await self._refresh_windowing_from_history()
self._terminal_notifier.notify(NotificationContext.COMPLETE)
async def _teleport_command(self) -> None: async def _teleport_command(self) -> None:
await self._handle_teleport_command(show_message=False) await self._handle_teleport_command(show_message=False)
@ -864,11 +897,108 @@ class VibeApp(App): # noqa: PLR0904
return return
await self._switch_to_proxy_setup_app() 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: async def _reload_config(self) -> None:
try: try:
self._windowing.reset() self._reset_ui_state()
self._tool_call_map = None
self._history_widget_indices = WeakKeyDictionary()
await self._load_more.hide() await self._load_more.hide()
base_config = VibeConfig.load() base_config = VibeConfig.load()
@ -886,9 +1016,7 @@ class VibeApp(App): # noqa: PLR0904
async def _clear_history(self) -> None: async def _clear_history(self) -> None:
try: try:
self._windowing.reset() self._reset_ui_state()
self._tool_call_map = None
self._history_widget_indices = WeakKeyDictionary()
await self.agent_loop.clear_history() await self.agent_loop.clear_history()
if self.event_handler: if self.event_handler:
await self.event_handler.finalize_streaming() 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: async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
bottom_container = self.query_one("#bottom-app-container") 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: if self._chat_input_container:
self._chat_input_container.display = False self._chat_input_container.display = False
@ -1028,8 +1158,8 @@ class VibeApp(App): # noqa: PLR0904
await bottom_container.mount(widget) await bottom_container.mount(widget)
self.call_after_refresh(widget.focus) self.call_after_refresh(widget.focus)
if scroll: if should_scroll:
self.call_after_refresh(self._scroll_chat_to_end) self.call_after_refresh(chat.anchor)
async def _switch_to_config_app(self) -> None: async def _switch_to_config_app(self) -> None:
if self._current_bottom_app == BottomApp.Config: if self._current_bottom_app == BottomApp.Config:
@ -1069,7 +1199,9 @@ class VibeApp(App): # noqa: PLR0904
self._chat_input_container.display = True self._chat_input_container.display = True
self._current_bottom_app = BottomApp.Input self._current_bottom_app = BottomApp.Input
self.call_after_refresh(self._chat_input_container.focus_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: def _focus_current_bottom_app(self) -> None:
try: try:
@ -1084,6 +1216,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(ApprovalApp).focus() self.query_one(ApprovalApp).focus()
case BottomApp.Question: case BottomApp.Question:
self.query_one(QuestionApp).focus() self.query_one(QuestionApp).focus()
case BottomApp.SessionPicker:
self.query_one(SessionPickerApp).focus()
case app: case app:
assert_never(app) assert_never(app)
except Exception: except Exception:
@ -1115,6 +1249,14 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question") self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
self._last_escape_time = None 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: def _handle_input_app_escape(self) -> None:
try: try:
input_widget = self.query_one(ChatInputContainer) input_widget = self.query_one(ChatInputContainer)
@ -1151,6 +1293,10 @@ class VibeApp(App): # noqa: PLR0904
self._handle_question_app_escape() self._handle_question_app_escape()
return return
if self._current_bottom_app == BottomApp.SessionPicker:
self._handle_session_picker_app_escape()
return
if ( if (
self._current_bottom_app == BottomApp.Input self._current_bottom_app == BottomApp.Input
and self._last_escape_time is not None and self._last_escape_time is not None
@ -1163,7 +1309,9 @@ class VibeApp(App): # noqa: PLR0904
self._handle_agent_running_escape() self._handle_agent_running_escape()
self._last_escape_time = current_time 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() self._focus_current_bottom_app()
async def on_history_load_more_requested(self, _: HistoryLoadMoreRequested) -> None: 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.agent_loop.agent_profile
) )
self._update_profile_widgets(new_profile) self._update_profile_widgets(new_profile)
await self.agent_loop.switch_agent(new_profile.name) if self._chat_input_container:
self.agent_loop.set_approval_callback(self._approval_callback) self._chat_input_container.switching_mode = True
self.agent_loop.set_user_input_callback(self._user_input_callback)
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: def action_clear_quit(self) -> None:
input_widgets = self.query(ChatInputContainer) input_widgets = self.query(ChatInputContainer)
@ -1296,9 +1467,11 @@ class VibeApp(App): # noqa: PLR0904
whats_new_message.add_class("after-history") whats_new_message.add_class("after-history")
messages_area = self._cached_messages_area or self.query_one("#messages") messages_area = self._cached_messages_area or self.query_one("#messages")
chat = self._cached_chat or self.query_one("#chat", ChatScroll) 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) await chat.mount(whats_new_message, after=messages_area)
self._whats_new_message = whats_new_message 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) await mark_version_as_seen(self._current_version, self._update_cache_repository)
async def _plan_offer_cta(self) -> str | None: async def _plan_offer_cta(self) -> str | None:
@ -1324,29 +1497,37 @@ class VibeApp(App): # noqa: PLR0904
) )
return return
def _scroll_chat_to_end(self) -> None: async def _mount_and_scroll(
chat = self._cached_chat or self.query_one("#chat", ChatScroll) self, widget: Widget, after: Widget | None = None
chat.scroll_end(animate=False) ) -> None:
async def _mount_and_scroll(self, widget: Widget) -> None:
messages_area = self._cached_messages_area or self.query_one("#messages") messages_area = self._cached_messages_area or self.query_one("#messages")
chat = self._cached_chat or self.query_one("#chat", ChatScroll) 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): if isinstance(widget, StreamingMessageBase):
await widget.write_initial_content() await widget.write_initial_content()
self.call_after_refresh(self._try_prune) self.call_after_refresh(self._try_prune)
chat.anchor() if should_anchor:
chat.anchor()
async def _try_prune(self) -> None: async def _try_prune(self) -> None:
messages_area = self._cached_messages_area or self.query_one("#messages") 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: if self._load_more.widget and not self._load_more.widget.parent:
self._load_more.widget = None self._load_more.widget = None
if pruned: if pruned:
chat = self._cached_chat or self.query_one("#chat", ChatScroll) 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: async def _refresh_windowing_from_history(self) -> None:
if self._load_more.widget is 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) self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
def on_app_blur(self, event: AppBlur) -> None: 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: if self._chat_input_container and self._chat_input_container.input_widget:
self._chat_input_container.input_widget.set_app_focus(False) self._chat_input_container.input_widget.set_app_focus(False)
def on_app_focus(self, event: AppFocus) -> None: 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: if self._chat_input_container and self._chat_input_container.input_widget:
self._chat_input_container.input_widget.set_app_focus(True) self._chat_input_container.input_widget.set_app_focus(True)

View file

@ -118,7 +118,8 @@ TextArea > .text-area--cursor {
height: auto; height: auto;
} }
#prompt { #prompt,
#prompt-spinner {
width: auto; width: auto;
background: transparent; background: transparent;
color: $mistral_orange; color: $mistral_orange;
@ -719,17 +720,6 @@ StatusMessage {
height: auto; height: auto;
} }
.proxy-label {
height: auto;
color: ansi_blue;
text-style: bold;
}
.proxy-description {
height: auto;
color: ansi_bright_black;
}
.proxy-label-line { .proxy-label-line {
height: auto; height: auto;
} }
@ -739,7 +729,6 @@ StatusMessage {
height: auto; height: auto;
border: none; border: none;
border-left: wide ansi_bright_black; border-left: wide ansi_bright_black;
margin-top: 1;
padding: 0 0 0 1; padding: 0 0 0 1;
} }
@ -1020,3 +1009,36 @@ ContextProgress {
.whats-new-message.after-history { .whats-new-message.after-history {
margin-top: 1; 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;
}

View file

@ -31,7 +31,7 @@ class EventHandler:
) -> None: ) -> None:
self.mount_callback = mount_callback self.mount_callback = mount_callback
self.get_tools_collapsed = get_tools_collapsed 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_compact: CompactMessage | None = None
self.current_streaming_message: AssistantMessage | None = None self.current_streaming_message: AssistantMessage | None = None
self.current_streaming_reasoning: ReasoningMessage | None = None self.current_streaming_reasoning: ReasoningMessage | None = None
@ -90,30 +90,40 @@ class EventHandler:
async def _handle_tool_call( async def _handle_tool_call(
self, event: ToolCallEvent, loading_widget: LoadingWidget | None = None self, event: ToolCallEvent, loading_widget: LoadingWidget | None = None
) -> ToolCallMessage | 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: if loading_widget and event.tool_class:
adapter = ToolUIDataAdapter(event.tool_class) adapter = ToolUIDataAdapter(event.tool_class)
status_text = adapter.get_status_text() loading_widget.set_status(adapter.get_status_text())
loading_widget.set_status(status_text)
self.current_tool_call = tool_call
await self.mount_callback(tool_call)
return tool_call return tool_call
async def _handle_tool_result(self, event: ToolResultEvent) -> None: async def _handle_tool_result(self, event: ToolResultEvent) -> None:
tools_collapsed = self.get_tools_collapsed() 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: async def _handle_tool_stream(self, event: ToolStreamEvent) -> None:
if self.current_tool_call: tool_call = self.tool_calls.get(event.tool_call_id)
self.current_tool_call.set_stream_message(event.message) if tool_call:
tool_call.set_stream_message(event.message)
async def _handle_assistant_message(self, event: AssistantEvent) -> None: async def _handle_assistant_message(self, event: AssistantEvent) -> None:
if self.current_streaming_reasoning is not None: if self.current_streaming_reasoning is not None:
@ -131,6 +141,8 @@ class EventHandler:
async def _handle_reasoning_message(self, event: ReasoningEvent) -> None: async def _handle_reasoning_message(self, event: ReasoningEvent) -> None:
if self.current_streaming_message is not None: if self.current_streaming_message is not None:
await self.current_streaming_message.stop_stream() 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 self.current_streaming_message = None
if self.current_streaming_reasoning is None: if self.current_streaming_reasoning is None:
@ -166,9 +178,9 @@ class EventHandler:
self.current_streaming_message = None self.current_streaming_message = None
def stop_current_tool_call(self, success: bool = True) -> None: def stop_current_tool_call(self, success: bool = True) -> None:
if self.current_tool_call: for tool_call in self.tool_calls.values():
self.current_tool_call.stop_spinning(success=success) tool_call.stop_spinning(success=success)
self.current_tool_call = None self.tool_calls.clear()
def stop_current_compact(self) -> None: def stop_current_compact(self) -> None:
if self.current_compact: if self.current_compact:

View file

@ -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"]

View file

@ -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")

View file

@ -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: ...

View file

@ -2,16 +2,31 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, ClassVar
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Horizontal from textual.containers import Horizontal
from textual.message import Message from textual.message import Message
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Static
from vibe.cli.history_manager import HistoryManager 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.chat_input.text_area import ChatTextArea, InputMode
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic 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): class ChatInputBody(Widget):
@ -30,6 +45,7 @@ class ChatInputBody(Widget):
self.input_widget: ChatTextArea | None = None self.input_widget: ChatTextArea | None = None
self.prompt_widget: NoMarkupStatic | None = None self.prompt_widget: NoMarkupStatic | None = None
self._nuage_enabled = nuage_enabled self._nuage_enabled = nuage_enabled
self._switching_mode = False
if history_file: if history_file:
self.history = HistoryManager(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: def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None:
event.stop() event.stop()
if self._switching_mode:
return
if not self.input_widget: if not self.input_widget:
return return
@ -175,6 +194,25 @@ class ChatInputBody(Widget):
self.post_message(self.Submitted(value)) 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 @property
def value(self) -> str: def value(self) -> str:
if not self.input_widget: if not self.input_widget:

View file

@ -180,6 +180,15 @@ class ChatInputContainer(Vertical):
event.stop() event.stop()
self.post_message(self.Submitted(event.value)) 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: def set_safety(self, safety: AgentSafety) -> None:
self._safety = safety self._safety = safety

View file

@ -69,6 +69,7 @@ class LoadingWidget(SpinnerMixin, Static):
self.init_spinner() self.init_spinner()
self.status = status or self._get_default_status() self.status = status or self._get_default_status()
self.current_color_index = 0 self.current_color_index = 0
self._color_direction = 1
self.transition_progress = 0 self.transition_progress = 0
self._status_widget: Static | None = None self._status_widget: Static | None = None
self.hint_widget: Static | None = None self.hint_widget: Static | None = None
@ -141,11 +142,12 @@ class LoadingWidget(SpinnerMixin, Static):
return return
self._update_animation() 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: def _get_color_for_position(self, position: int) -> str:
current_color = self.TARGET_COLORS[self.current_color_index] current_color = self.TARGET_COLORS[self.current_color_index]
next_color = self.TARGET_COLORS[ next_color = self.TARGET_COLORS[self._next_color_index()]
(self.current_color_index + 1) % len(self.TARGET_COLORS)
]
if position < self.transition_progress: if position < self.transition_progress:
return next_color return next_color
return current_color return current_color
@ -173,9 +175,9 @@ class LoadingWidget(SpinnerMixin, Static):
self.transition_progress += 1 self.transition_progress += 1
if self.transition_progress > total_elements: if self.transition_progress > total_elements:
self.current_color_index = (self.current_color_index + 1) % len( self.current_color_index = self._next_color_index()
self.TARGET_COLORS if not 0 < self.current_color_index < len(self.TARGET_COLORS) - 1:
) self._color_direction *= -1
self.transition_progress = 0 self.transition_progress = 0
if self.hint_widget and self.start_time is not None: if self.hint_widget and self.start_time is not None:

View file

@ -106,6 +106,9 @@ class StreamingMessageBase(Static):
def _should_write_content(self) -> bool: def _should_write_content(self) -> bool:
return True return True
def is_stripped_content_empty(self) -> bool:
return self._content.strip() == ""
class AssistantMessage(StreamingMessageBase): class AssistantMessage(StreamingMessageBase):
def __init__(self, content: str) -> None: def __init__(self, content: str) -> None:

View file

@ -43,26 +43,20 @@ class ProxySetupApp(Container):
with Vertical(id="proxysetup-content"): with Vertical(id="proxysetup-content"):
yield NoMarkupStatic("Proxy Configuration", classes="settings-title") yield NoMarkupStatic("Proxy Configuration", classes="settings-title")
yield NoMarkupStatic("")
for key, description in SUPPORTED_PROXY_VARS.items(): for key, description in SUPPORTED_PROXY_VARS.items():
yield Static( yield Static(f"[bold ansi_blue]{key}[/]", classes="proxy-label-line")
f"[bold ansi_blue]{key}[/] [dim]{description}[/dim]",
classes="proxy-label-line",
)
initial_value = self.initial_values.get(key) or "" initial_value = self.initial_values.get(key) or ""
input_widget = Input( input_widget = Input(
value=initial_value, value=initial_value,
placeholder="NOT SET", placeholder=description,
id=f"proxy-input-{key}", id=f"proxy-input-{key}",
classes="proxy-input", classes="proxy-input",
) )
self.inputs[key] = input_widget self.inputs[key] = input_widget
yield input_widget yield input_widget
yield NoMarkupStatic("")
yield NoMarkupStatic( yield NoMarkupStatic(
"↑↓ navigate Enter save & exit ESC cancel", classes="settings-help" "↑↓ navigate Enter save & exit ESC cancel", classes="settings-help"
) )
@ -92,7 +86,7 @@ class ProxySetupApp(Container):
prev_idx = (idx - 1) % len(inputs) prev_idx = (idx - 1) % len(inputs)
inputs[prev_idx].focus() 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() self._save_and_close()
def on_blur(self, _event: events.Blur) -> None: def on_blur(self, _event: events.Blur) -> None:

View file

@ -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())

View file

@ -42,8 +42,8 @@ def parse_search_replace_to_diff(content: str) -> list[str]:
for i, (search_text, replace_text) in enumerate(matches): for i, (search_text, replace_text) in enumerate(matches):
if i > 0: if i > 0:
all_diff_lines.append("") # Separator between blocks all_diff_lines.append("") # Separator between blocks
search_lines = search_text.strip().split("\n") search_lines = search_text.strip("\n").split("\n")
replace_lines = replace_text.strip().split("\n") replace_lines = replace_text.strip("\n").split("\n")
diff = difflib.unified_diff(search_lines, replace_lines, lineterm="", n=2) diff = difflib.unified_diff(search_lines, replace_lines, lineterm="", n=2)
all_diff_lines.extend(list(diff)[2:]) # Skip file headers all_diff_lines.extend(list(diff)[2:]) # Skip file headers
@ -74,8 +74,10 @@ class ToolApprovalWidget[TArgs: BaseModel](Vertical):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
MAX_MSG_SIZE = 150 MAX_MSG_SIZE = 150
for field_name in type(self.args).model_fields: model_cls = type(self.args)
value = getattr(self.args, field_name) 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 ("", []): if value is None or value in ("", []):
continue continue
value_str = str(value) value_str = str(value)

View file

@ -20,7 +20,7 @@ class ToolCallMessage(StatusMessage):
raise ValueError("Either event or tool_name must be provided") raise ValueError("Either event or tool_name must be provided")
self._event = event 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._is_history = event is None
self._stream_widget: NoMarkupStatic | None = None self._stream_widget: NoMarkupStatic | None = None
@ -44,6 +44,7 @@ class ToolCallMessage(StatusMessage):
yield self._stream_widget yield self._stream_widget
def on_mount(self) -> None: def on_mount(self) -> None:
super().on_mount()
siblings = list(self.parent.children) if self.parent else [] siblings = list(self.parent.children) if self.parent else []
idx = siblings.index(self) if self in siblings else -1 idx = siblings.index(self) if self in siblings else -1
if idx > 0 and isinstance( if idx > 0 and isinstance(
@ -51,13 +52,23 @@ class ToolCallMessage(StatusMessage):
): ):
self.add_class("no-gap") 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: def get_content(self) -> str:
if self._event and self._event.tool_class: if self._event:
adapter = ToolUIDataAdapter(self._event.tool_class) adapter = ToolUIDataAdapter(self._event.tool_class)
display = adapter.get_call_display(self._event) display = adapter.get_call_display(self._event)
return display.summary return display.summary
return self._tool_name 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: def set_stream_message(self, message: str) -> None:
"""Update the stream message displayed below the tool call indicator.""" """Update the stream message displayed below the tool call indicator."""
if self._stream_widget: if self._stream_widget:
@ -151,7 +162,13 @@ class ToolResultMessage(Static):
await self._content_container.remove_children() await self._content_container.remove_children()
if self._event is None: 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 return
if self._event.error: if self._event.error:

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence
from weakref import WeakKeyDictionary from weakref import WeakKeyDictionary
from textual.widget import Widget 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 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] 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] = {} tool_call_map: dict[str, str] = {}
for msg in messages: for msg in messages:
if msg.role != Role.assistant or not msg.tool_calls: 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( def build_history_widgets(
batch: list[LLMMessage], batch: Sequence[LLMMessage],
tool_call_map: dict[str, str], tool_call_map: dict[str, str],
*, *,
start_index: int, start_index: int,

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import AsyncGenerator, Callable from collections.abc import AsyncGenerator, Callable, Generator
from enum import StrEnum, auto from enum import StrEnum, auto
from http import HTTPStatus from http import HTTPStatus
import json import json
@ -13,6 +13,7 @@ from uuid import uuid4
from pydantic import BaseModel from pydantic import BaseModel
from vibe.cli.terminal_setup import detect_terminal
from vibe.core.agents.manager import AgentManager from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import AgentProfile, BuiltinAgentName from vibe.core.agents.models import AgentProfile, BuiltinAgentName
from vibe.core.config import Backend, ProviderConfig, VibeConfig 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.llm.types import BackendLike
from vibe.core.middleware import ( from vibe.core.middleware import (
CHAT_AGENT_EXIT,
CHAT_AGENT_REMINDER,
PLAN_AGENT_EXIT,
PLAN_AGENT_REMINDER,
AutoCompactMiddleware, AutoCompactMiddleware,
ContextWarningMiddleware, ContextWarningMiddleware,
ConversationContext, ConversationContext,
MiddlewareAction, MiddlewareAction,
MiddlewarePipeline, MiddlewarePipeline,
MiddlewareResult, MiddlewareResult,
PlanAgentMiddleware,
PriceLimitMiddleware, PriceLimitMiddleware,
ReadOnlyAgentMiddleware,
ResetReason, ResetReason,
TurnLimitMiddleware, TurnLimitMiddleware,
) )
@ -52,6 +57,8 @@ from vibe.core.tools.base import (
ToolPermissionError, ToolPermissionError,
) )
from vibe.core.tools.manager import ToolManager 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.trusted_folders import has_agents_md_file
from vibe.core.types import ( from vibe.core.types import (
AgentStats, AgentStats,
@ -62,13 +69,16 @@ from vibe.core.types import (
BaseEvent, BaseEvent,
CompactEndEvent, CompactEndEvent,
CompactStartEvent, CompactStartEvent,
EntrypointMetadata,
LLMChunk, LLMChunk,
LLMMessage, LLMMessage,
LLMUsage, LLMUsage,
MessageList,
RateLimitError, RateLimitError,
ReasoningEvent, ReasoningEvent,
Role, Role,
SyncApprovalCallback, SyncApprovalCallback,
ToolCall,
ToolCallEvent, ToolCallEvent,
ToolResultEvent, ToolResultEvent,
ToolStreamEvent, ToolStreamEvent,
@ -139,6 +149,7 @@ class AgentLoop:
max_price: float | None = None, max_price: float | None = None,
backend: BackendLike | None = None, backend: BackendLike | None = None,
enable_streaming: bool = False, enable_streaming: bool = False,
entrypoint_metadata: EntrypointMetadata | None = None,
) -> None: ) -> None:
self._base_config = config self._base_config = config
self._max_turns = max_turns self._max_turns = max_turns
@ -147,15 +158,20 @@ class AgentLoop:
self.agent_manager = AgentManager( self.agent_manager = AgentManager(
lambda: self._base_config, initial_agent=agent_name 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.skill_manager = SkillManager(lambda: self.config)
self.format_handler = APIToolFormatHandler() self.format_handler = APIToolFormatHandler()
self.backend_factory = lambda: backend or self._select_backend() self.backend_factory = lambda: backend or self._select_backend()
self.backend = self.backend_factory() 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.message_observer = message_observer
self._last_observed_message_index: int = 0
self.enable_streaming = enable_streaming self.enable_streaming = enable_streaming
self.middleware_pipeline = MiddlewarePipeline() self.middleware_pipeline = MiddlewarePipeline()
self._setup_middleware() self._setup_middleware()
@ -163,11 +179,8 @@ class AgentLoop:
system_prompt = get_universal_system_prompt( system_prompt = get_universal_system_prompt(
self.tool_manager, self.config, self.skill_manager, self.agent_manager self.tool_manager, self.config, self.skill_manager, self.agent_manager
) )
self.messages = [LLMMessage(role=Role.system, content=system_prompt)] system_message = LLMMessage(role=Role.system, content=system_prompt)
self.messages = MessageList(initial=[system_message], observer=message_observer)
if self.message_observer:
self.message_observer(self.messages[0])
self._last_observed_message_index = 1
self.stats = AgentStats() self.stats = AgentStats()
try: try:
@ -180,6 +193,7 @@ class AgentLoop:
self.approval_callback: ApprovalCallback | None = None self.approval_callback: ApprovalCallback | None = None
self.user_input_callback: UserInputCallback | None = None self.user_input_callback: UserInputCallback | None = None
self.entrypoint_metadata = entrypoint_metadata
self.session_id = str(uuid4()) self.session_id = str(uuid4())
self._current_user_message_id: str | None = None self._current_user_message_id: str | None = None
@ -221,19 +235,28 @@ class AgentLoop:
self.config.tools[tool_name].permission = permission self.config.tools[tool_name].permission = permission
self.tool_manager.invalidate_tool(tool_name) self.tool_manager.invalidate_tool(tool_name)
def emit_new_session_telemetry( def emit_new_session_telemetry(self) -> None:
self, entrypoint: Literal["cli", "acp", "programmatic"] entrypoint = (
) -> None: self.entrypoint_metadata.agent_entrypoint
if self.entrypoint_metadata
else "unknown"
)
has_agents_md = has_agents_md_file(Path.cwd()) has_agents_md = has_agents_md_file(Path.cwd())
nb_skills = len(self.skill_manager.available_skills) nb_skills = len(self.skill_manager.available_skills)
nb_mcp_servers = len(self.config.mcp_servers) nb_mcp_servers = len(self.config.mcp_servers)
nb_models = len(self.config.models) nb_models = len(self.config.models)
terminal_emulator = None
if entrypoint == "cli":
terminal_emulator = detect_terminal().value
self.telemetry_client.send_new_session( self.telemetry_client.send_new_session(
has_agents_md=has_agents_md, has_agents_md=has_agents_md,
nb_skills=nb_skills, nb_skills=nb_skills,
nb_mcp_servers=nb_mcp_servers, nb_mcp_servers=nb_mcp_servers,
nb_models=nb_models, nb_models=nb_models,
entrypoint=entrypoint, entrypoint=entrypoint,
terminal_emulator=terminal_emulator,
) )
def _select_backend(self) -> BackendLike: def _select_backend(self) -> BackendLike:
@ -242,9 +265,6 @@ class AgentLoop:
timeout = self.config.api_timeout timeout = self.config.api_timeout
return BACKEND_FACTORY[provider.backend](provider=provider, timeout=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: async def _save_messages(self) -> None:
await self.session_logger.save_interaction( await self.session_logger.save_interaction(
self.messages, self.messages,
@ -254,19 +274,6 @@ class AgentLoop:
self.agent_profile, 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]: async def act(self, msg: str) -> AsyncGenerator[BaseEvent]:
self._clean_message_history() self._clean_message_history()
async for event in self._conversation_loop(msg): async for event in self._conversation_loop(msg):
@ -345,7 +352,22 @@ class AgentLoop:
ContextWarningMiddleware(0.5, self.config.auto_compact_threshold) 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( async def _handle_middleware_result(
self, result: MiddlewareResult self, result: MiddlewareResult
@ -440,7 +462,7 @@ class AgentLoop:
if is_user_cancellation_event(event): if is_user_cancellation_event(event):
user_cancelled = True user_cancelled = True
yield event yield event
await self._flush_new_messages() await self._save_messages()
last_message = self.messages[-1] last_message = self.messages[-1]
should_break_loop = last_message.role != Role.tool should_break_loop = last_message.role != Role.tool
@ -449,7 +471,7 @@ class AgentLoop:
return return
finally: finally:
await self._flush_new_messages() await self._save_messages()
async def _perform_llm_turn(self) -> AsyncGenerator[BaseEvent, None]: async def _perform_llm_turn(self) -> AsyncGenerator[BaseEvent, None]:
if self.enable_streaming: if self.enable_streaming:
@ -471,15 +493,42 @@ class AgentLoop:
async for event in self._handle_tool_calls(resolved): async for event in self._handle_tool_calls(resolved):
yield event 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( async def _stream_assistant_events(
self, self,
) -> AsyncGenerator[AssistantEvent | ReasoningEvent]: ) -> AsyncGenerator[AssistantEvent | ReasoningEvent | ToolCallEvent]:
message_id: str | None = None message_id: str | None = None
emitted_tool_call_ids = set[str]()
async for chunk in self._chat_streaming(): async for chunk in self._chat_streaming():
if message_id is None: if message_id is None:
message_id = chunk.message.message_id message_id = chunk.message.message_id
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: if chunk.message.reasoning_content:
yield ReasoningEvent( yield ReasoningEvent(
content=chunk.message.reasoning_content, message_id=message_id content=chunk.message.reasoning_content, message_id=message_id
@ -560,9 +609,12 @@ class AgentLoop:
async for item in tool_instance.invoke( async for item in tool_instance.invoke(
ctx=InvokeContext( ctx=InvokeContext(
tool_call_id=tool_call.call_id, tool_call_id=tool_call.call_id,
approval_callback=self.approval_callback,
agent_manager=self.agent_manager, 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, user_input_callback=self.user_input_callback,
sampling_callback=self._sampling_handler,
), ),
**tool_call.args_dict, **tool_call.args_dict,
): ):
@ -671,6 +723,9 @@ class AgentLoop:
tool_choice=tool_choice, tool_choice=tool_choice,
extra_headers=self._get_extra_headers(provider), extra_headers=self._get_extra_headers(provider),
max_tokens=max_tokens, max_tokens=max_tokens,
metadata=self.entrypoint_metadata.model_dump()
if self.entrypoint_metadata
else None,
) )
end_time = time.perf_counter() end_time = time.perf_counter()
@ -714,6 +769,9 @@ class AgentLoop:
tool_choice=tool_choice, tool_choice=tool_choice,
extra_headers=self._get_extra_headers(provider), extra_headers=self._get_extra_headers(provider),
max_tokens=max_tokens, 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( processed_message = self.format_handler.process_api_response_message(
chunk.message chunk.message
@ -759,37 +817,26 @@ class AgentLoop:
approval_type=ToolPermission.ALWAYS, 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() 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: match effective:
return ToolDecision( case ToolPermission.ALWAYS:
verdict=ToolExecutionResponse.EXECUTE, return ToolDecision(
approval_type=ToolPermission.ALWAYS, verdict=ToolExecutionResponse.EXECUTE,
) approval_type=ToolPermission.ALWAYS,
if perm is ToolPermission.NEVER: )
return ToolDecision( case ToolPermission.NEVER:
verdict=ToolExecutionResponse.SKIP, return ToolDecision(
approval_type=ToolPermission.NEVER, verdict=ToolExecutionResponse.SKIP,
feedback=f"Tool '{tool_name}' is permanently disabled", approval_type=ToolPermission.NEVER,
) feedback=f"Tool '{tool_name}' is permanently disabled",
)
return await self._ask_approval(tool_name, args, tool_call_id) case _:
return await self._ask_approval(tool_name, args, tool_call_id)
async def _ask_approval( async def _ask_approval(
self, tool_name: str, args: BaseModel, tool_call_id: str self, tool_name: str, args: BaseModel, tool_call_id: str
@ -852,9 +899,11 @@ class AgentLoop:
empty_response = LLMMessage( empty_response = LLMMessage(
role=Role.tool, role=Role.tool,
tool_call_id=tool_call_data.id or "", tool_call_id=tool_call_data.id or "",
name=(tool_call_data.function.name or "") name=(
if tool_call_data.function (tool_call_data.function.name or "")
else "", if tool_call_data.function
else ""
),
content=str( content=str(
get_user_cancellation_message( get_user_cancellation_message(
CancellationReason.TOOL_NO_RESPONSE CancellationReason.TOOL_NO_RESPONSE
@ -898,7 +947,7 @@ class AgentLoop:
self.tool_manager, self.tool_manager,
self.agent_profile, self.agent_profile,
) )
self.messages = self.messages[:1] self.messages.reset(self.messages[:1])
self.stats = AgentStats.create_fresh(self.stats) self.stats = AgentStats.create_fresh(self.stats)
self.stats.trigger_listeners() self.stats.trigger_listeners()
@ -927,10 +976,14 @@ class AgentLoop:
) )
summary_request = UtilityPrompt.COMPACT.read() summary_request = UtilityPrompt.COMPACT.read()
self.messages.append(LLMMessage(role=Role.user, content=summary_request))
self.stats.steps += 1 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: if summary_result.usage is None:
raise AgentLoopLLMResponseError( raise AgentLoopLLMResponseError(
"Usage data missing in compaction summary response" "Usage data missing in compaction summary response"
@ -939,8 +992,7 @@ class AgentLoop:
system_message = self.messages[0] system_message = self.messages[0]
summary_message = LLMMessage(role=Role.user, content=summary_content) summary_message = LLMMessage(role=Role.user, content=summary_content)
self.messages = [system_message, summary_message] self.messages.reset([system_message, summary_message])
self._last_observed_message_index = 1
active_model = self.config.get_active_model() active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model) provider = self.config.get_provider_for_model(active_model)
@ -950,6 +1002,9 @@ class AgentLoop:
messages=self.messages, messages=self.messages,
tools=self.format_handler.get_available_tools(self.tool_manager), tools=self.format_handler.get_available_tools(self.tool_manager),
extra_headers={"user-agent": get_user_agent(provider.backend)}, 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 self.stats.context_tokens = actual_context_tokens
@ -1015,17 +1070,19 @@ class AgentLoop:
if max_price is not None: if max_price is not None:
self._max_price = max_price 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) self.skill_manager = SkillManager(lambda: self.config)
new_system_prompt = get_universal_system_prompt( new_system_prompt = get_universal_system_prompt(
self.tool_manager, self.config, self.skill_manager, self.agent_manager self.tool_manager, self.config, self.skill_manager, self.agent_manager
) )
self.messages = [ self.messages.reset([
LLMMessage(role=Role.system, content=new_system_prompt), LLMMessage(role=Role.system, content=new_system_prompt),
*[msg for msg in self.messages if msg.role != Role.system], *[msg for msg in self.messages if msg.role != Role.system],
] ])
if len(self.messages) == 1: if len(self.messages) == 1:
self.stats.reset_context_state() self.stats.reset_context_state()

View file

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from logging import getLogger
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -11,15 +10,14 @@ from vibe.core.agents.models import (
AgentType, AgentType,
BuiltinAgentName, 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.paths.global_paths import GLOBAL_AGENTS_DIR
from vibe.core.utils import name_matches from vibe.core.utils import name_matches
if TYPE_CHECKING: if TYPE_CHECKING:
from vibe.core.config import VibeConfig from vibe.core.config import VibeConfig
logger = getLogger("vibe")
class AgentManager: class AgentManager:
def __init__( def __init__(
@ -77,6 +75,10 @@ class AgentManager:
self.active_profile = self.get_agent(name) self.active_profile = self.get_agent(name)
self._cached_config = None 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: def invalidate_config(self) -> None:
self._cached_config = None self._cached_config = None
@ -86,8 +88,7 @@ class AgentManager:
for path in config.agent_paths: for path in config.agent_paths:
if path.is_dir(): if path.is_dir():
paths.append(path) paths.append(path)
if (agents_dir := resolve_local_agents_dir(Path.cwd())) is not None: paths.extend(discover_local_agents_dirs(Path.cwd()))
paths.append(agents_dir)
if GLOBAL_AGENTS_DIR.path.is_dir(): if GLOBAL_AGENTS_DIR.path.is_dir():
paths.append(GLOBAL_AGENTS_DIR.path) paths.append(GLOBAL_AGENTS_DIR.path)
unique: list[Path] = [] unique: list[Path] = []

View file

@ -34,6 +34,7 @@ class AgentType(StrEnum):
class BuiltinAgentName(StrEnum): class BuiltinAgentName(StrEnum):
DEFAULT = "default" DEFAULT = "default"
CHAT = "chat"
PLAN = "plan" PLAN = "plan"
ACCEPT_EDITS = "accept-edits" ACCEPT_EDITS = "accept-edits"
AUTO_APPROVE = "auto-approve" 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"] PLAN_AGENT_TOOLS = ["grep", "read_file", "todo", "ask_user_question", "task"]
DEFAULT = AgentProfile( DEFAULT = AgentProfile(
@ -84,6 +86,13 @@ PLAN = AgentProfile(
AgentSafety.SAFE, AgentSafety.SAFE,
overrides={"auto_approve": True, "enabled_tools": PLAN_AGENT_TOOLS}, 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( ACCEPT_EDITS = AgentProfile(
BuiltinAgentName.ACCEPT_EDITS, BuiltinAgentName.ACCEPT_EDITS,
"Accept Edits", "Accept Edits",

View file

@ -60,6 +60,32 @@ class IgnoreRules:
self._patterns: list[CompiledPattern] | None = None self._patterns: list[CompiledPattern] | None = None
self._root: Path | 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: def ensure_for_root(self, root: Path) -> None:
resolved_root = root.resolve() resolved_root = root.resolve()
if self._patterns is None or self._root != resolved_root: if self._patterns is None or self._root != resolved_root:
@ -81,24 +107,7 @@ class IgnoreRules:
self._root = None self._root = None
def _build_patterns(self, root: Path) -> list[CompiledPattern]: def _build_patterns(self, root: Path) -> list[CompiledPattern]:
patterns: list[CompiledPattern] = [] patterns = self._compile_default_patterns()
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,
)
)
gitignore_path = root / ".gitignore" gitignore_path = root / ".gitignore"
if gitignore_path.exists(): if gitignore_path.exists():
try: try:
@ -154,3 +163,6 @@ class IgnoreRules:
return False return False
return not pattern.dir_only or is_dir return not pattern.dir_only or is_dir
WALK_SKIP_DIR_NAMES: frozenset[str] = IgnoreRules().get_walk_skip_dir_names()

View file

@ -165,6 +165,10 @@ class _MCPBase(BaseModel):
tool_timeout_sec: float = Field( tool_timeout_sec: float = Field(
default=60.0, gt=0, description="Timeout in seconds for tool execution." 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") @field_validator("name", mode="after")
@classmethod @classmethod
@ -324,6 +328,7 @@ class VibeConfig(BaseSettings):
include_prompt_detail: bool = True include_prompt_detail: bool = True
enable_update_checks: bool = True enable_update_checks: bool = True
enable_auto_update: bool = True enable_auto_update: bool = True
enable_notifications: bool = True
api_timeout: float = 720.0 api_timeout: float = 720.0
# TODO(vibe-nuage): remove exclude=True once the feature is publicly available # TODO(vibe-nuage): remove exclude=True once the feature is publicly available

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence
import json import json
import re import re
from typing import Any, ClassVar from typing import Any, ClassVar
@ -22,7 +23,7 @@ class AnthropicMapper:
"""Shared mapper for converting messages to/from Anthropic API format.""" """Shared mapper for converting messages to/from Anthropic API format."""
def prepare_messages( def prepare_messages(
self, messages: list[LLMMessage] self, messages: Sequence[LLMMessage]
) -> tuple[str | None, list[dict[str, Any]]]: ) -> tuple[str | None, list[dict[str, Any]]]:
system_prompt: str | None = None system_prompt: str | None = None
converted: list[dict[str, Any]] = [] converted: list[dict[str, Any]] = []
@ -457,7 +458,7 @@ class AnthropicAdapter(APIAdapter):
self, self,
*, *,
model_name: str, model_name: str,
messages: list[LLMMessage], messages: Sequence[LLMMessage],
temperature: float, temperature: float,
tools: list[AvailableTool] | None, tools: list[AvailableTool] | None,
max_tokens: int | None, max_tokens: int | None,

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol
from vibe.core.types import AvailableTool, LLMChunk, LLMMessage, StrToolChoice from vibe.core.types import AvailableTool, LLMChunk, LLMMessage, StrToolChoice
@ -22,7 +23,7 @@ class APIAdapter(Protocol):
self, self,
*, *,
model_name: str, model_name: str,
messages: list[LLMMessage], messages: Sequence[LLMMessage],
temperature: float, temperature: float,
tools: list[AvailableTool] | None, tools: list[AvailableTool] | None,
max_tokens: int | None, max_tokens: int | None,

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator, Sequence
import json import json
import os import os
import types import types
@ -82,7 +82,7 @@ class OpenAIAdapter(APIAdapter):
self, self,
*, *,
model_name: str, model_name: str,
messages: list[LLMMessage], messages: Sequence[LLMMessage],
temperature: float, temperature: float,
tools: list[AvailableTool] | None, tools: list[AvailableTool] | None,
max_tokens: int | None, max_tokens: int | None,
@ -211,12 +211,13 @@ class GenericBackend:
self, self,
*, *,
model: ModelConfig, model: ModelConfig,
messages: list[LLMMessage], messages: Sequence[LLMMessage],
temperature: float = 0.2, temperature: float = 0.2,
tools: list[AvailableTool] | None = None, tools: list[AvailableTool] | None = None,
max_tokens: int | None = None, max_tokens: int | None = None,
tool_choice: StrToolChoice | AvailableTool | None = None, tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None = None, extra_headers: dict[str, str] | None = None,
metadata: dict[str, str] | None = None,
) -> LLMChunk: ) -> LLMChunk:
api_key = ( api_key = (
os.getenv(self._provider.api_key_env_var) os.getenv(self._provider.api_key_env_var)
@ -279,12 +280,13 @@ class GenericBackend:
self, self,
*, *,
model: ModelConfig, model: ModelConfig,
messages: list[LLMMessage], messages: Sequence[LLMMessage],
temperature: float = 0.2, temperature: float = 0.2,
tools: list[AvailableTool] | None = None, tools: list[AvailableTool] | None = None,
max_tokens: int | None = None, max_tokens: int | None = None,
tool_choice: StrToolChoice | AvailableTool | None = None, tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None = None, extra_headers: dict[str, str] | None = None,
metadata: dict[str, str] | None = None,
) -> AsyncGenerator[LLMChunk, None]: ) -> AsyncGenerator[LLMChunk, None]:
api_key = ( api_key = (
os.getenv(self._provider.api_key_env_var) os.getenv(self._provider.api_key_env_var)
@ -395,11 +397,12 @@ class GenericBackend:
self, self,
*, *,
model: ModelConfig, model: ModelConfig,
messages: list[LLMMessage], messages: Sequence[LLMMessage],
temperature: float = 0.0, temperature: float = 0.0,
tools: list[AvailableTool] | None = None, tools: list[AvailableTool] | None = None,
tool_choice: StrToolChoice | AvailableTool | None = None, tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None = None, extra_headers: dict[str, str] | None = None,
metadata: dict[str, str] | None = None,
) -> int: ) -> int:
probe_messages = list(messages) probe_messages = list(messages)
if not probe_messages or probe_messages[-1].role != Role.user: if not probe_messages or probe_messages[-1].role != Role.user:

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator, Sequence
import json import json
import os import os
import re import re
@ -226,12 +226,13 @@ class MistralBackend:
self, self,
*, *,
model: ModelConfig, model: ModelConfig,
messages: list[LLMMessage], messages: Sequence[LLMMessage],
temperature: float, temperature: float,
tools: list[AvailableTool] | None, tools: list[AvailableTool] | None,
max_tokens: int | None, max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None, tool_choice: StrToolChoice | AvailableTool | None,
extra_headers: dict[str, str] | None, extra_headers: dict[str, str] | None,
metadata: dict[str, str] | None = None,
) -> LLMChunk: ) -> LLMChunk:
try: try:
merged_messages = merge_consecutive_user_messages(messages) merged_messages = merge_consecutive_user_messages(messages)
@ -247,6 +248,7 @@ class MistralBackend:
if tool_choice if tool_choice
else None, else None,
http_headers=extra_headers, http_headers=extra_headers,
metadata=metadata,
stream=False, stream=False,
) )
@ -300,12 +302,13 @@ class MistralBackend:
self, self,
*, *,
model: ModelConfig, model: ModelConfig,
messages: list[LLMMessage], messages: Sequence[LLMMessage],
temperature: float, temperature: float,
tools: list[AvailableTool] | None, tools: list[AvailableTool] | None,
max_tokens: int | None, max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None, tool_choice: StrToolChoice | AvailableTool | None,
extra_headers: dict[str, str] | None, extra_headers: dict[str, str] | None,
metadata: dict[str, str] | None = None,
) -> AsyncGenerator[LLMChunk, None]: ) -> AsyncGenerator[LLMChunk, None]:
try: try:
merged_messages = merge_consecutive_user_messages(messages) merged_messages = merge_consecutive_user_messages(messages)
@ -321,6 +324,7 @@ class MistralBackend:
if tool_choice if tool_choice
else None, else None,
http_headers=extra_headers, http_headers=extra_headers,
metadata=metadata,
): ):
parsed = ( parsed = (
self._mapper.parse_content(chunk.data.choices[0].delta.content) self._mapper.parse_content(chunk.data.choices[0].delta.content)
@ -376,11 +380,12 @@ class MistralBackend:
self, self,
*, *,
model: ModelConfig, model: ModelConfig,
messages: list[LLMMessage], messages: Sequence[LLMMessage],
temperature: float = 0.0, temperature: float = 0.0,
tools: list[AvailableTool] | None = None, tools: list[AvailableTool] | None = None,
tool_choice: StrToolChoice | AvailableTool | None = None, tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None = None, extra_headers: dict[str, str] | None = None,
metadata: dict[str, str] | None = None,
) -> int: ) -> int:
result = await self.complete( result = await self.complete(
model=model, model=model,
@ -390,6 +395,7 @@ class MistralBackend:
max_tokens=1, max_tokens=1,
tool_choice=tool_choice, tool_choice=tool_choice,
extra_headers=extra_headers, extra_headers=extra_headers,
metadata=metadata,
) )
if result.usage is None: if result.usage is None:
raise ValueError("Missing usage in non streaming completion") raise ValueError("Missing usage in non streaming completion")

Some files were not shown because too many files have changed in this diff Show more