v2.3.0 (#429)
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>
2
.vscode/launch.json
vendored
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "2.2.1",
|
||||
"version": "2.3.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ACP Server",
|
||||
|
|
|
|||
40
CHANGELOG.md
|
|
@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.3.0] - 2026-02-27
|
||||
|
||||
### Added
|
||||
|
||||
- /resume command to choose which session to resume
|
||||
- Web search and web fetch tools for retrieving and searching web content
|
||||
- MCP sampling support: MCP servers can request LLM completions via the sampling protocol
|
||||
- MCP server discovery cache (`MCPRegistry`): survives agent switches without re-discovering unchanged servers
|
||||
- Chat mode for ACP (`session/set_config_options` with `mode=chat`)
|
||||
- ACP `session/set_config_options` support for switching mode and model
|
||||
- Tool call streaming: tool call arguments are now streamed incrementally in the UI
|
||||
- Notification indicator in CLI: terminal bell and window title change on action required or completion
|
||||
- Subagent traces saved in `agents/` subfolder of parent session directory
|
||||
- IDE detection in `new_session` telemetry
|
||||
- Discover agents, tools, and skills in subfolders of trusted directories (monorepo support)
|
||||
- E2E test infrastructure for CLI TUI
|
||||
|
||||
### Changed
|
||||
|
||||
- System prompts rewritten for improved model behavior (3-phase Orient/Plan/Execute workflow, brevity rules)
|
||||
- Tool call display refactored with `ToolCallDisplay`/`ToolResultDisplay` models and per-tool UI customization
|
||||
- Middleware pipeline replaces observer pattern for system message injections
|
||||
- Improved permission handling for `write_file`, `read_file`, `search_replace` (allowlist/denylist globs, out-of-cwd detection)
|
||||
- Proxy setup UI updated with guided bottom-panel wizard
|
||||
- Smoother color transitions in CLI loader animation
|
||||
- Dead tool state classes removed (`Grep`, `ReadFile`, `WriteFile` state)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Agent switch (Shift+Tab) no longer freezes the UI (moved to thread worker)
|
||||
- Empty assistant messages are no longer displayed
|
||||
- Tool results returned to LLM in correct order matching tool calls
|
||||
- Auto-scroll suspended when user has scrolled up; resumes at bottom
|
||||
- Retry and timeout handling in Mistral backend (backoff strategy, configurable timeout)
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
## [2.2.1] - 2026-02-18
|
||||
|
||||
### Added
|
||||
|
|
@ -33,7 +71,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Added
|
||||
|
||||
- Google Vertex AI support
|
||||
- Telemetry: user interaction and tool usage events sent to datalake (configurable via `disable_telemetry`)
|
||||
- Telemetry: user interaction and tool usage events sent to datalake (configurable via `enable_telemetry`)
|
||||
- Skill discovery from `.agents/skills/` (Agent Skills standard) in addition to `.vibe/skills/`
|
||||
- ACP: `session/load` and `session/list` for loading and listing sessions
|
||||
- New model behavior prompts (CLI and explore)
|
||||
|
|
|
|||
10
README.md
|
|
@ -571,6 +571,16 @@ To disable auto-updates, add this to your `config.toml`:
|
|||
enable_auto_update = false
|
||||
```
|
||||
|
||||
### Notification Settings
|
||||
|
||||
Vibe can notify you when the agent needs your attention (awaiting approval, asking a question, or task complete). This is useful when you switch to another window while the agent works.
|
||||
|
||||
To disable notifications:
|
||||
|
||||
```toml
|
||||
enable_notifications = false
|
||||
```
|
||||
|
||||
### Custom Vibe Home Directory
|
||||
|
||||
By default, Vibe stores its configuration in `~/.vibe/`. You can override this by setting the `VIBE_HOME` environment variable:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
id = "mistral-vibe"
|
||||
name = "Mistral Vibe"
|
||||
description = "Mistral's open-source coding assistant"
|
||||
version = "2.2.1"
|
||||
version = "2.3.0"
|
||||
schema_version = 1
|
||||
authors = ["Mistral AI"]
|
||||
repository = "https://github.com/mistralai/mistral-vibe"
|
||||
|
|
@ -11,25 +11,25 @@ name = "Mistral Vibe"
|
|||
icon = "./icons/mistral_vibe.svg"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-darwin-aarch64-2.2.1.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.3.0/vibe-acp-darwin-aarch64-2.3.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-darwin-x86_64-2.2.1.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.3.0/vibe-acp-darwin-x86_64-2.3.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-linux-aarch64-2.2.1.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.3.0/vibe-acp-linux-aarch64-2.3.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-linux-x86_64-2.2.1.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.3.0/vibe-acp-linux-x86_64-2.3.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.windows-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-windows-aarch64-2.2.1.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.3.0/vibe-acp-windows-aarch64-2.3.0.zip"
|
||||
cmd = "./vibe-acp.exe"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-windows-x86_64-2.2.1.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.3.0/vibe-acp-windows-x86_64-2.3.0.zip"
|
||||
cmd = "./vibe-acp.exe"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "mistral-vibe"
|
||||
version = "2.2.1"
|
||||
version = "2.3.0"
|
||||
description = "Minimal CLI coding agent by Mistral"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
|
@ -27,16 +27,18 @@ classifiers = [
|
|||
"Topic :: Utilities",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-client-protocol==0.8.0",
|
||||
"agent-client-protocol==0.8.1",
|
||||
"anyio>=4.12.0",
|
||||
"cachetools>=5.5.0",
|
||||
"cryptography>=44.0.0,<=46.0.3",
|
||||
"gitpython>=3.1.46",
|
||||
"giturlparse>=0.14.0",
|
||||
"google-auth>=2.0.0",
|
||||
"httpx>=0.28.1",
|
||||
"keyring>=25.6.0",
|
||||
"markdownify>=1.2.2",
|
||||
"mcp>=1.14.0",
|
||||
"mistralai==1.9.11",
|
||||
"mistralai==1.12.4",
|
||||
"packaging>=24.1",
|
||||
"pexpect>=4.9.0",
|
||||
"pydantic>=2.12.4",
|
||||
|
|
@ -169,3 +171,7 @@ ignore_decorators = ["@*"]
|
|||
[tool.pytest.ini_options]
|
||||
addopts = "-vvvv -q -n auto --durations=10 --import-mode=importlib --maxschedchunk=1"
|
||||
timeout = 10
|
||||
filterwarnings = [
|
||||
# (e2e) streaming mock server is started in a thread; and the cli is spawn in a fork
|
||||
"ignore:This process \\(pid=.*\\) is multi-threaded, use of forkpty\\(\\) may lead to deadlocks in the child\\.:DeprecationWarning",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class TestACPInitialize:
|
|||
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.2.1"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.3.0"
|
||||
)
|
||||
|
||||
assert response.auth_methods == []
|
||||
|
|
@ -52,7 +52,7 @@ class TestACPInitialize:
|
|||
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.2.1"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.3.0"
|
||||
)
|
||||
|
||||
assert response.auth_methods is not None
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ def acp_agent_with_session_config(
|
|||
models=[
|
||||
ModelConfig(
|
||||
name="devstral-latest", provider="mistral", alias="devstral-latest"
|
||||
)
|
||||
),
|
||||
ModelConfig(
|
||||
name="devstral-small", provider="mistral", alias="devstral-small"
|
||||
),
|
||||
],
|
||||
session_logging=session_config,
|
||||
)
|
||||
|
|
@ -58,7 +61,7 @@ def acp_agent_with_session_config(
|
|||
|
||||
class TestLoadSession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_returns_response_with_models_and_modes(
|
||||
async def test_load_session_response_structure(
|
||||
self,
|
||||
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||
temp_session_dir: Path,
|
||||
|
|
@ -76,9 +79,49 @@ class TestLoadSession:
|
|||
|
||||
assert response is not None
|
||||
assert response.models is not None
|
||||
assert len(response.models.available_models) == 2
|
||||
|
||||
assert response.models.current_model_id == "devstral-latest"
|
||||
assert response.models.available_models[0].model_id == "devstral-latest"
|
||||
assert response.models.available_models[0].name == "devstral-latest"
|
||||
assert response.models.available_models[1].model_id == "devstral-small"
|
||||
assert response.models.available_models[1].name == "devstral-small"
|
||||
|
||||
assert response.modes is not None
|
||||
assert response.modes.current_mode_id == BuiltinAgentName.DEFAULT
|
||||
modes_ids = {m.id for m in response.modes.available_modes}
|
||||
assert modes_ids == {
|
||||
BuiltinAgentName.DEFAULT,
|
||||
BuiltinAgentName.CHAT,
|
||||
BuiltinAgentName.AUTO_APPROVE,
|
||||
BuiltinAgentName.PLAN,
|
||||
BuiltinAgentName.ACCEPT_EDITS,
|
||||
}
|
||||
|
||||
assert response.config_options is not None
|
||||
assert len(response.config_options) == 2
|
||||
assert response.config_options[0].root.id == "mode"
|
||||
assert response.config_options[0].root.category == "mode"
|
||||
assert response.config_options[0].root.current_value == BuiltinAgentName.DEFAULT
|
||||
assert len(response.config_options[0].root.options) == 5
|
||||
mode_option_values = {
|
||||
opt.value for opt in response.config_options[0].root.options
|
||||
}
|
||||
assert mode_option_values == {
|
||||
BuiltinAgentName.DEFAULT,
|
||||
BuiltinAgentName.CHAT,
|
||||
BuiltinAgentName.AUTO_APPROVE,
|
||||
BuiltinAgentName.PLAN,
|
||||
BuiltinAgentName.ACCEPT_EDITS,
|
||||
}
|
||||
assert response.config_options[1].root.id == "model"
|
||||
assert response.config_options[1].root.category == "model"
|
||||
assert response.config_options[1].root.current_value == "devstral-latest"
|
||||
assert len(response.config_options[1].root.options) == 2
|
||||
model_option_values = {
|
||||
opt.value for opt in response.config_options[1].root.options
|
||||
}
|
||||
assert model_option_values == {"devstral-latest", "devstral-small"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_registers_session_with_original_id(
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class TestACPNewSession:
|
|||
)
|
||||
|
||||
new_session_events = [
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe/new_session"
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe.new_session"
|
||||
]
|
||||
assert len(new_session_events) == 1
|
||||
assert new_session_events[0]["properties"]["entrypoint"] == "acp"
|
||||
|
|
@ -84,18 +84,47 @@ class TestACPNewSession:
|
|||
assert session_response.modes is not None
|
||||
assert session_response.modes.current_mode_id is not None
|
||||
assert session_response.modes.available_modes is not None
|
||||
assert len(session_response.modes.available_modes) == 4
|
||||
assert len(session_response.modes.available_modes) == 5
|
||||
|
||||
assert session_response.modes.current_mode_id == BuiltinAgentName.DEFAULT
|
||||
# Check that all primary agents are available (order may vary)
|
||||
mode_ids = {m.id for m in session_response.modes.available_modes}
|
||||
assert mode_ids == {
|
||||
BuiltinAgentName.DEFAULT,
|
||||
BuiltinAgentName.CHAT,
|
||||
BuiltinAgentName.AUTO_APPROVE,
|
||||
BuiltinAgentName.PLAN,
|
||||
BuiltinAgentName.ACCEPT_EDITS,
|
||||
}
|
||||
|
||||
# Check config_options
|
||||
assert session_response.config_options is not None
|
||||
assert len(session_response.config_options) == 2
|
||||
|
||||
# Mode config option
|
||||
mode_config = session_response.config_options[0]
|
||||
assert mode_config.root.id == "mode"
|
||||
assert mode_config.root.category == "mode"
|
||||
assert mode_config.root.current_value == BuiltinAgentName.DEFAULT
|
||||
assert len(mode_config.root.options) == 5
|
||||
mode_option_values = {opt.value for opt in mode_config.root.options}
|
||||
assert mode_option_values == {
|
||||
BuiltinAgentName.DEFAULT,
|
||||
BuiltinAgentName.CHAT,
|
||||
BuiltinAgentName.AUTO_APPROVE,
|
||||
BuiltinAgentName.PLAN,
|
||||
BuiltinAgentName.ACCEPT_EDITS,
|
||||
}
|
||||
|
||||
# Model config option
|
||||
model_config = session_response.config_options[1]
|
||||
assert model_config.root.id == "model"
|
||||
assert model_config.root.category == "model"
|
||||
assert model_config.root.current_value == "devstral-latest"
|
||||
assert len(model_config.root.options) == 2
|
||||
model_option_values = {opt.value for opt in model_config.root.options}
|
||||
assert model_option_values == {"devstral-latest", "devstral-small"}
|
||||
|
||||
@pytest.mark.skip(reason="TODO: Fix this test")
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_preserves_model_after_set_model(
|
||||
|
|
|
|||
287
tests/acp/test_set_config_option.py
Normal 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
|
||||
|
|
@ -106,6 +106,29 @@ class TestACPSetMode:
|
|||
acp_session.agent_loop.auto_approve is False
|
||||
) # Accept Edits mode doesn't auto-approve all
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_to_chat(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
|
||||
|
||||
response = await acp_agent_loop.set_session_mode(
|
||||
session_id=session_id, mode_id=BuiltinAgentName.CHAT
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.CHAT
|
||||
assert (
|
||||
acp_session.agent_loop.auto_approve is True
|
||||
) # Chat mode auto-approves read-only tools
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_invalid_mode_returns_none(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
|
|
|
|||
44
tests/acp/test_tool_call_session_update.py
Normal 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
|
||||
|
|
@ -89,11 +89,15 @@ def test_updates_index_on_file_rename(
|
|||
old_file.rename(new_file)
|
||||
|
||||
assert _wait_for(
|
||||
lambda: all(
|
||||
entry.rel != old_file.name for entry in file_indexer.get_index(Path("."))
|
||||
lambda: (
|
||||
all(
|
||||
entry.rel != old_file.name
|
||||
for entry in file_indexer.get_index(Path("."))
|
||||
)
|
||||
and any(
|
||||
entry.rel == new_file.name for entry in file_indexer.get_index(Path("."))
|
||||
entry.rel == new_file.name
|
||||
for entry in file_indexer.get_index(Path("."))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ async def test_pressing_enter_submits_selected_command_and_hides_popup(
|
|||
slash_used = [
|
||||
e
|
||||
for e in telemetry_events
|
||||
if e.get("event_name") == "vibe/slash_command_used"
|
||||
if e.get("event_name") == "vibe.slash_command_used"
|
||||
]
|
||||
assert any(
|
||||
e.get("properties", {}).get("command") == "help"
|
||||
|
|
|
|||
151
tests/cli/test_bell_notifications.py
Normal 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
|
||||
|
|
@ -52,3 +52,11 @@ class TestCommandRegistry:
|
|||
assert registry.get_command_name("/exit") is None
|
||||
assert registry.find_command("/exit") is None
|
||||
assert registry.get_command_name("/help") == "help"
|
||||
|
||||
def test_resume_command_registration(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
assert registry.get_command_name("/resume") == "resume"
|
||||
assert registry.get_command_name("/continue") == "resume"
|
||||
cmd = registry.find_command("/resume")
|
||||
assert cmd is not None
|
||||
assert cmd.handler == "_show_session_picker"
|
||||
|
|
|
|||
109
tests/cli/test_switching_mode.py
Normal 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
|
||||
|
|
@ -144,8 +144,9 @@ async def test_ui_session_incremental_loader_load_more_batches_until_done(
|
|||
app.post_message(HistoryLoadMoreRequested())
|
||||
await _wait_until(
|
||||
pilot.pause,
|
||||
lambda current_count=current_count: len(app.query(UserMessage))
|
||||
> current_count,
|
||||
lambda current_count=current_count: (
|
||||
len(app.query(UserMessage)) > current_count
|
||||
),
|
||||
)
|
||||
|
||||
await _wait_until(
|
||||
|
|
|
|||
0
tests/cli/textual_ui/__init__.py
Normal file
131
tests/cli/textual_ui/test_session_picker.py
Normal 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()
|
||||
|
|
@ -3,29 +3,33 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from vibe.core.paths.config_paths import resolve_local_skills_dirs
|
||||
from vibe.core.paths.config_paths import (
|
||||
discover_local_agents_dirs,
|
||||
discover_local_skills_dirs,
|
||||
discover_local_tools_dirs,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveLocalSkillsDirs:
|
||||
class TestDiscoverLocalSkillsDirs:
|
||||
def test_returns_empty_list_when_dir_not_trusted(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = False
|
||||
assert resolve_local_skills_dirs(tmp_path) == []
|
||||
assert discover_local_skills_dirs(tmp_path) == []
|
||||
|
||||
def test_returns_empty_list_when_trusted_but_no_skills_dirs(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
assert resolve_local_skills_dirs(tmp_path) == []
|
||||
assert discover_local_skills_dirs(tmp_path) == []
|
||||
|
||||
def test_returns_vibe_skills_only_when_only_it_exists(self, tmp_path: Path) -> None:
|
||||
vibe_skills = tmp_path / ".vibe" / "skills"
|
||||
vibe_skills.mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = resolve_local_skills_dirs(tmp_path)
|
||||
result = discover_local_skills_dirs(tmp_path)
|
||||
assert result == [vibe_skills]
|
||||
|
||||
def test_returns_agents_skills_only_when_only_it_exists(
|
||||
|
|
@ -35,7 +39,7 @@ class TestResolveLocalSkillsDirs:
|
|||
agents_skills.mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = resolve_local_skills_dirs(tmp_path)
|
||||
result = discover_local_skills_dirs(tmp_path)
|
||||
assert result == [agents_skills]
|
||||
|
||||
def test_returns_both_in_order_when_both_exist(self, tmp_path: Path) -> None:
|
||||
|
|
@ -45,7 +49,7 @@ class TestResolveLocalSkillsDirs:
|
|||
agents_skills.mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = resolve_local_skills_dirs(tmp_path)
|
||||
result = discover_local_skills_dirs(tmp_path)
|
||||
assert result == [vibe_skills, agents_skills]
|
||||
|
||||
def test_ignores_vibe_skills_when_file_not_dir(self, tmp_path: Path) -> None:
|
||||
|
|
@ -53,5 +57,128 @@ class TestResolveLocalSkillsDirs:
|
|||
(tmp_path / ".vibe" / "skills").write_text("", encoding="utf-8")
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = resolve_local_skills_dirs(tmp_path)
|
||||
result = discover_local_skills_dirs(tmp_path)
|
||||
assert result == []
|
||||
|
||||
def test_finds_skills_dirs_recursively_in_trusted_folder(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
(tmp_path / "sub" / ".agents" / "skills").mkdir(parents=True)
|
||||
(tmp_path / "sub" / "deep" / ".vibe" / "skills").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_skills_dirs(tmp_path)
|
||||
assert result == [
|
||||
tmp_path / ".vibe" / "skills",
|
||||
tmp_path / "sub" / ".agents" / "skills",
|
||||
tmp_path / "sub" / "deep" / ".vibe" / "skills",
|
||||
]
|
||||
|
||||
def test_does_not_descend_into_ignored_dirs(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_skills_dirs(tmp_path)
|
||||
assert result == [tmp_path / ".vibe" / "skills"]
|
||||
|
||||
|
||||
class TestDiscoverLocalToolsDirs:
|
||||
def test_returns_empty_list_when_dir_not_trusted(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = False
|
||||
assert discover_local_tools_dirs(tmp_path) == []
|
||||
|
||||
def test_returns_empty_list_when_trusted_but_no_tools_dir(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
assert discover_local_tools_dirs(tmp_path) == []
|
||||
|
||||
def test_returns_tools_dir_when_exists(self, tmp_path: Path) -> None:
|
||||
vibe_tools = tmp_path / ".vibe" / "tools"
|
||||
vibe_tools.mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_tools_dirs(tmp_path)
|
||||
assert result == [vibe_tools]
|
||||
|
||||
def test_ignores_tools_when_file_not_dir(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe").mkdir()
|
||||
(tmp_path / ".vibe" / "tools").write_text("", encoding="utf-8")
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_tools_dirs(tmp_path)
|
||||
assert result == []
|
||||
|
||||
def test_finds_tools_dirs_recursively(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
(tmp_path / "sub" / ".vibe" / "tools").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_tools_dirs(tmp_path)
|
||||
assert result == [
|
||||
tmp_path / ".vibe" / "tools",
|
||||
tmp_path / "sub" / ".vibe" / "tools",
|
||||
]
|
||||
|
||||
def test_does_not_descend_into_ignored_dirs(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
(tmp_path / ".git" / ".vibe" / "tools").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_tools_dirs(tmp_path)
|
||||
assert result == [tmp_path / ".vibe" / "tools"]
|
||||
|
||||
|
||||
class TestDiscoverLocalAgentsDirs:
|
||||
def test_returns_empty_list_when_dir_not_trusted(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "agents").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = False
|
||||
assert discover_local_agents_dirs(tmp_path) == []
|
||||
|
||||
def test_returns_empty_list_when_trusted_but_no_agents_dir(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
assert discover_local_agents_dirs(tmp_path) == []
|
||||
|
||||
def test_returns_agents_dir_when_exists(self, tmp_path: Path) -> None:
|
||||
vibe_agents = tmp_path / ".vibe" / "agents"
|
||||
vibe_agents.mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_agents_dirs(tmp_path)
|
||||
assert result == [vibe_agents]
|
||||
|
||||
def test_ignores_agents_when_file_not_dir(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe").mkdir()
|
||||
(tmp_path / ".vibe" / "agents").write_text("", encoding="utf-8")
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_agents_dirs(tmp_path)
|
||||
assert result == []
|
||||
|
||||
def test_finds_agents_dirs_recursively(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "agents").mkdir(parents=True)
|
||||
(tmp_path / "sub" / "deep" / ".vibe" / "agents").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_agents_dirs(tmp_path)
|
||||
assert result == [
|
||||
tmp_path / ".vibe" / "agents",
|
||||
tmp_path / "sub" / "deep" / ".vibe" / "agents",
|
||||
]
|
||||
|
||||
def test_does_not_descend_into_ignored_dirs(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "agents").mkdir(parents=True)
|
||||
(tmp_path / "__pycache__" / ".vibe" / "agents").mkdir(parents=True)
|
||||
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
|
||||
mock_tfm.is_trusted.return_value = True
|
||||
result = discover_local_agents_dirs(tmp_path)
|
||||
assert result == [tmp_path / ".vibe" / "agents"]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
|
|||
|
||||
import pytest
|
||||
|
||||
from vibe.core.utils import StructuredLogFormatter, apply_logging_config
|
||||
from vibe.core.logger import StructuredLogFormatter, apply_logging_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -19,8 +19,8 @@ def mock_log_dir(tmp_path: Path):
|
|||
mock_file = MagicMock()
|
||||
mock_file.path = tmp_path / "vibe.log"
|
||||
with (
|
||||
patch("vibe.core.utils.LOG_DIR", mock_dir),
|
||||
patch("vibe.core.utils.LOG_FILE", mock_file),
|
||||
patch("vibe.core.logger.LOG_DIR", mock_dir),
|
||||
patch("vibe.core.logger.LOG_FILE", mock_file),
|
||||
):
|
||||
yield tmp_path
|
||||
|
||||
|
|
@ -222,8 +222,8 @@ class TestApplyLoggingConfig:
|
|||
mock_file = MagicMock()
|
||||
mock_file.path = log_dir / "vibe.log"
|
||||
with (
|
||||
patch("vibe.core.utils.LOG_DIR", mock_dir),
|
||||
patch("vibe.core.utils.LOG_FILE", mock_file),
|
||||
patch("vibe.core.logger.LOG_DIR", mock_dir),
|
||||
patch("vibe.core.logger.LOG_FILE", mock_file),
|
||||
):
|
||||
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
||||
test_logger = logging.getLogger("test_mkdir")
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class TestTelemetryClient:
|
|||
def test_send_telemetry_event_does_nothing_when_api_key_is_none(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
env_key = config.get_provider_for_model(
|
||||
config.get_active_model()
|
||||
).api_key_env_var
|
||||
|
|
@ -57,7 +57,7 @@ class TestTelemetryClient:
|
|||
client._client = MagicMock()
|
||||
client._client.post = AsyncMock()
|
||||
|
||||
client.send_telemetry_event("vibe/test", {})
|
||||
client.send_telemetry_event("vibe.test", {})
|
||||
_run_telemetry_tasks()
|
||||
|
||||
client._client.post.assert_not_called()
|
||||
|
|
@ -65,7 +65,10 @@ class TestTelemetryClient:
|
|||
def test_send_telemetry_event_does_nothing_when_disabled(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=True)
|
||||
monkeypatch.setattr(
|
||||
TelemetryClient, "send_telemetry_event", _original_send_telemetry_event
|
||||
)
|
||||
config = build_test_vibe_config(enable_telemetry=False)
|
||||
env_key = config.get_provider_for_model(
|
||||
config.get_active_model()
|
||||
).api_key_env_var
|
||||
|
|
@ -74,7 +77,7 @@ class TestTelemetryClient:
|
|||
client._client = MagicMock()
|
||||
client._client.post = AsyncMock()
|
||||
|
||||
client.send_telemetry_event("vibe/test", {})
|
||||
client.send_telemetry_event("vibe.test", {})
|
||||
_run_telemetry_tasks()
|
||||
|
||||
client._client.post.assert_not_called()
|
||||
|
|
@ -86,7 +89,7 @@ class TestTelemetryClient:
|
|||
monkeypatch.setattr(
|
||||
TelemetryClient, "send_telemetry_event", _original_send_telemetry_event
|
||||
)
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
env_key = config.get_provider_for_model(
|
||||
config.get_active_model()
|
||||
).api_key_env_var
|
||||
|
|
@ -97,12 +100,12 @@ class TestTelemetryClient:
|
|||
client._client.post = mock_post
|
||||
client._client.aclose = AsyncMock()
|
||||
|
||||
client.send_telemetry_event("vibe/test_event", {"key": "value"})
|
||||
client.send_telemetry_event("vibe.test_event", {"key": "value"})
|
||||
await client.aclose()
|
||||
|
||||
mock_post.assert_called_once_with(
|
||||
DATALAKE_EVENTS_URL,
|
||||
json={"event": "vibe/test_event", "properties": {"key": "value"}},
|
||||
json={"event": "vibe.test_event", "properties": {"key": "value"}},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer sk-test",
|
||||
|
|
@ -113,7 +116,7 @@ class TestTelemetryClient:
|
|||
def test_send_tool_call_finished_payload_shape(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
tool_call = _make_resolved_tool_call("todo", {})
|
||||
decision = ToolDecision(
|
||||
|
|
@ -129,7 +132,7 @@ class TestTelemetryClient:
|
|||
|
||||
assert len(telemetry_events) == 1
|
||||
event_name = telemetry_events[0]["event_name"]
|
||||
assert event_name == "vibe/tool_call_finished"
|
||||
assert event_name == "vibe.tool_call_finished"
|
||||
properties = telemetry_events[0]["properties"]
|
||||
assert properties["tool_name"] == "todo"
|
||||
assert properties["status"] == "success"
|
||||
|
|
@ -142,7 +145,7 @@ class TestTelemetryClient:
|
|||
def test_send_tool_call_finished_nb_files_created_write_file_new(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
tool_call = _make_resolved_tool_call("write_file", {"overwrite": False})
|
||||
|
||||
|
|
@ -160,7 +163,7 @@ class TestTelemetryClient:
|
|||
def test_send_tool_call_finished_nb_files_modified_write_file_overwrite(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
tool_call = _make_resolved_tool_call("write_file", {"overwrite": True})
|
||||
|
||||
|
|
@ -178,7 +181,7 @@ class TestTelemetryClient:
|
|||
def test_send_tool_call_finished_decision_none(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
tool_call = _make_resolved_tool_call("todo", {})
|
||||
|
||||
|
|
@ -195,49 +198,49 @@ class TestTelemetryClient:
|
|||
def test_send_user_copied_text_payload(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_user_copied_text("hello world")
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
assert telemetry_events[0]["event_name"] == "vibe/user_copied_text"
|
||||
assert telemetry_events[0]["event_name"] == "vibe.user_copied_text"
|
||||
assert telemetry_events[0]["properties"]["text_length"] == 11
|
||||
|
||||
def test_send_user_cancelled_action_payload(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_user_cancelled_action("interrupt_agent")
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
assert telemetry_events[0]["event_name"] == "vibe/user_cancelled_action"
|
||||
assert telemetry_events[0]["event_name"] == "vibe.user_cancelled_action"
|
||||
assert telemetry_events[0]["properties"]["action"] == "interrupt_agent"
|
||||
|
||||
def test_send_auto_compact_triggered_payload(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_auto_compact_triggered()
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
assert telemetry_events[0]["event_name"] == "vibe/auto_compact_triggered"
|
||||
assert telemetry_events[0]["event_name"] == "vibe.auto_compact_triggered"
|
||||
|
||||
def test_send_slash_command_used_payload(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_slash_command_used("help", "builtin")
|
||||
client.send_slash_command_used("my_skill", "skill")
|
||||
|
||||
assert len(telemetry_events) == 2
|
||||
assert telemetry_events[0]["event_name"] == "vibe/slash_command_used"
|
||||
assert telemetry_events[0]["event_name"] == "vibe.slash_command_used"
|
||||
assert telemetry_events[0]["properties"]["command"] == "help"
|
||||
assert telemetry_events[0]["properties"]["command_type"] == "builtin"
|
||||
assert telemetry_events[1]["properties"]["command"] == "my_skill"
|
||||
|
|
@ -246,7 +249,7 @@ class TestTelemetryClient:
|
|||
def test_send_new_session_payload(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_new_session(
|
||||
|
|
@ -255,15 +258,17 @@ class TestTelemetryClient:
|
|||
nb_mcp_servers=1,
|
||||
nb_models=3,
|
||||
entrypoint="cli",
|
||||
terminal_emulator="vscode",
|
||||
)
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
event_name = telemetry_events[0]["event_name"]
|
||||
assert event_name == "vibe/new_session"
|
||||
assert event_name == "vibe.new_session"
|
||||
properties = telemetry_events[0]["properties"]
|
||||
assert properties["has_agents_md"] is True
|
||||
assert properties["nb_skills"] == 2
|
||||
assert properties["nb_mcp_servers"] == 1
|
||||
assert properties["nb_models"] == 3
|
||||
assert properties["entrypoint"] == "cli"
|
||||
assert properties["terminal_emulator"] == "vscode"
|
||||
assert "version" in properties
|
||||
|
|
|
|||
|
|
@ -237,11 +237,11 @@ class TestHasAgentsMdFile:
|
|||
|
||||
class TestHasTrustableContent:
|
||||
def test_returns_true_when_vibe_dir_exists(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe").mkdir()
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_trustable_content(tmp_path) is True
|
||||
|
||||
def test_returns_true_when_agents_dir_exists(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".agents").mkdir()
|
||||
(tmp_path / ".agents" / "skills").mkdir(parents=True)
|
||||
assert has_trustable_content(tmp_path) is True
|
||||
|
||||
def test_returns_true_when_agents_md_filename_exists(self, tmp_path: Path) -> None:
|
||||
|
|
@ -253,3 +253,17 @@ class TestHasTrustableContent:
|
|||
def test_returns_false_when_no_trustable_content(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "other.txt").write_text("", encoding="utf-8")
|
||||
assert has_trustable_content(tmp_path) is False
|
||||
|
||||
def test_returns_true_when_vibe_config_in_subfolder(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "sub" / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_trustable_content(tmp_path) is True
|
||||
|
||||
def test_returns_true_when_agents_skills_in_subfolder(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "deep" / "nested" / ".agents" / "skills").mkdir(parents=True)
|
||||
assert has_trustable_content(tmp_path) is True
|
||||
|
||||
def test_returns_false_when_config_only_inside_ignored_dir(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_trustable_content(tmp_path) is False
|
||||
|
|
|
|||
86
tests/e2e/common.py
Normal 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
|
|
@ -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
|
|
@ -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)
|
||||
31
tests/e2e/test_cli_tui_onboarding.py
Normal 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
|
||||
51
tests/e2e/test_cli_tui_streaming.py
Normal 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
|
||||
)
|
||||
87
tests/e2e/test_cli_tui_tool_approval.py
Normal 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)
|
||||
|
|
@ -822,3 +822,133 @@ class TestSessionLoaderListSessions:
|
|||
assert len(result) == 1
|
||||
assert result[0]["session_id"] == "notitle0"
|
||||
assert result[0]["title"] is None
|
||||
|
||||
|
||||
class TestSessionLoaderGetFirstUserMessage:
|
||||
"""Tests for SessionLoader.get_first_user_message method."""
|
||||
|
||||
def test_returns_first_user_message(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test that get_first_user_message returns the first user message."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content="First user message"),
|
||||
LLMMessage(role=Role.assistant, content="First response"),
|
||||
LLMMessage(role=Role.user, content="Second user message"),
|
||||
LLMMessage(role=Role.assistant, content="Second response"),
|
||||
]
|
||||
create_test_session(session_dir, "test-sess", messages=messages)
|
||||
|
||||
result = SessionLoader.get_first_user_message("test-sess", session_config)
|
||||
|
||||
assert result == "First user message"
|
||||
|
||||
def test_returns_fallback_for_missing_session(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that get_first_user_message returns fallback when session not found."""
|
||||
result = SessionLoader.get_first_user_message("nonexistent", session_config)
|
||||
|
||||
assert result == "(session not found)"
|
||||
|
||||
def test_returns_no_user_messages_fallback(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test fallback when session has no user messages."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.assistant, content="Assistant only"),
|
||||
]
|
||||
create_test_session(session_dir, "no-user0", messages=messages)
|
||||
|
||||
result = SessionLoader.get_first_user_message("no-user0", session_config)
|
||||
|
||||
assert result == "(no user messages)"
|
||||
|
||||
def test_replaces_newlines_with_spaces(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test that newlines in messages are replaced with spaces."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content="Line one\nLine two\nLine three"),
|
||||
]
|
||||
create_test_session(session_dir, "newline0", messages=messages)
|
||||
|
||||
result = SessionLoader.get_first_user_message("newline0", session_config)
|
||||
|
||||
assert "\n" not in result
|
||||
assert "Line one Line two Line three" == result
|
||||
|
||||
def test_handles_empty_user_message(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test handling of empty user message content."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content=""),
|
||||
]
|
||||
create_test_session(session_dir, "empty-ms", messages=messages)
|
||||
|
||||
result = SessionLoader.get_first_user_message("empty-ms", session_config)
|
||||
|
||||
assert result == "(no user messages)"
|
||||
|
||||
def test_handles_whitespace_only_message(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test handling of whitespace-only user message."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content=" \n\t "),
|
||||
]
|
||||
create_test_session(session_dir, "whitespc", messages=messages)
|
||||
|
||||
result = SessionLoader.get_first_user_message("whitespc", session_config)
|
||||
|
||||
assert result == "(empty message)"
|
||||
|
||||
def test_handles_invalid_session_as_not_found(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
"""Test that invalid sessions (bad JSON) are treated as not found.
|
||||
|
||||
Note: Sessions with invalid JSON are filtered out by _is_valid_session
|
||||
during find_session_by_id, so they return 'not found' rather than
|
||||
'corrupted'. This is the expected behavior.
|
||||
"""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
# Create a session with invalid JSON - will fail validation
|
||||
session_folder = session_dir / "test_20230101_120000_corrupt0"
|
||||
session_folder.mkdir()
|
||||
(session_folder / "messages.jsonl").write_text("{invalid json}")
|
||||
(session_folder / "meta.json").write_text('{"session_id": "corrupt0"}')
|
||||
|
||||
result = SessionLoader.get_first_user_message("corrupt0", session_config)
|
||||
|
||||
# Invalid sessions are filtered by _is_valid_session, so not found
|
||||
assert result == "(session not found)"
|
||||
|
||||
def test_skips_non_user_messages(
|
||||
self, session_config: SessionLoggingConfig, create_test_session
|
||||
) -> None:
|
||||
"""Test that only user messages are considered, not assistant/system."""
|
||||
session_dir = Path(session_config.save_dir)
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content="User question"),
|
||||
LLMMessage(role=Role.assistant, content="Assistant response"),
|
||||
]
|
||||
create_test_session(session_dir, "skip-non", messages=messages)
|
||||
|
||||
result = SessionLoader.get_first_user_message("skip-non", session_config)
|
||||
|
||||
# Should return "User question", not "Assistant response"
|
||||
assert result == "User question"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -502,6 +502,61 @@ class TestSessionLoggerSaveInteraction:
|
|||
with open(messages_file) as f:
|
||||
assert len(f.readlines()) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_interaction_throttles_tmp_cleanup(
|
||||
self,
|
||||
session_config: SessionLoggingConfig,
|
||||
mock_vibe_config: VibeConfig,
|
||||
mock_tool_manager: ToolManager,
|
||||
mock_agent_profile: AgentProfile,
|
||||
) -> None:
|
||||
logger = SessionLogger(session_config, "test-session-123")
|
||||
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System prompt"),
|
||||
LLMMessage(role=Role.user, content="Hello"),
|
||||
LLMMessage(role=Role.assistant, content="Hi there!"),
|
||||
]
|
||||
|
||||
cleanup_spy = MagicMock()
|
||||
with (
|
||||
patch.object(
|
||||
SessionLogger, "persist_messages", new_callable=AsyncMock
|
||||
) as persist_messages_mock,
|
||||
patch.object(
|
||||
SessionLogger, "persist_metadata", new_callable=AsyncMock
|
||||
) as persist_metadata_mock,
|
||||
patch.object(logger, "cleanup_tmp_files", cleanup_spy),
|
||||
patch(
|
||||
"vibe.core.session.session_logger.utc_now",
|
||||
# a bit brittle, but required for the call-count choregraphy...
|
||||
side_effect=[
|
||||
datetime(2026, 1, 1, 10, 0, 0, tzinfo=UTC),
|
||||
datetime(2026, 1, 1, 10, 0, 0, tzinfo=UTC),
|
||||
datetime(2026, 1, 1, 10, 0, 1, tzinfo=UTC),
|
||||
datetime(2026, 1, 1, 10, 0, 1, tzinfo=UTC),
|
||||
],
|
||||
),
|
||||
):
|
||||
await logger.save_interaction(
|
||||
messages=messages,
|
||||
stats=AgentStats(steps=1),
|
||||
base_config=mock_vibe_config,
|
||||
tool_manager=mock_tool_manager,
|
||||
agent_profile=mock_agent_profile,
|
||||
)
|
||||
await logger.save_interaction(
|
||||
messages=messages,
|
||||
stats=AgentStats(steps=2),
|
||||
base_config=mock_vibe_config,
|
||||
tool_manager=mock_tool_manager,
|
||||
agent_profile=mock_agent_profile,
|
||||
)
|
||||
|
||||
assert persist_messages_mock.await_count == 2
|
||||
assert persist_metadata_mock.await_count == 2
|
||||
assert cleanup_spy.call_count == 1
|
||||
|
||||
|
||||
class TestSessionLoggerResetSession:
|
||||
def test_reset_session(self, session_config: SessionLoggingConfig) -> None:
|
||||
|
|
@ -701,3 +756,27 @@ class TestSessionLoggerCleanupTmpFiles:
|
|||
|
||||
assert old_tmp_file.exists()
|
||||
assert not another_old_tmp_file.exists()
|
||||
|
||||
def test_maybe_cleanup_tmp_files_throttles_calls(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
session_id = "test-session-123"
|
||||
logger = SessionLogger(session_config, session_id)
|
||||
|
||||
cleanup_spy = MagicMock()
|
||||
with (
|
||||
patch.object(logger, "cleanup_tmp_files", cleanup_spy),
|
||||
patch(
|
||||
"vibe.core.session.session_logger.utc_now",
|
||||
side_effect=[
|
||||
datetime(2026, 1, 1, 10, 0, 0, tzinfo=UTC),
|
||||
datetime(2026, 1, 1, 10, 0, 1, tzinfo=UTC),
|
||||
datetime(2026, 1, 1, 10, 0, 6, tzinfo=UTC),
|
||||
],
|
||||
),
|
||||
):
|
||||
logger.maybe_cleanup_tmp_files()
|
||||
logger.maybe_cleanup_tmp_files()
|
||||
logger.maybe_cleanup_tmp_files()
|
||||
|
||||
assert cleanup_spy.call_count == 2
|
||||
|
|
|
|||
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="459.2" textLength="146.4" clip-path="url(#terminal-line-18)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="459.2" textLength="122" clip-path="url(#terminal-line-18)"> v0.0.0 · </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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="483.6" textLength="414.8" clip-path="url(#terminal-line-19)">1 model · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="508" textLength="61" clip-path="url(#terminal-line-20)">Type </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)"> for more 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 is the 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 answer is 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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)">></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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -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 /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 /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 /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 |
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -33,13 +33,12 @@
|
|||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #4b4e55 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #292929 }
|
||||
.terminal-r5 { fill: #9a9b99 }
|
||||
.terminal-r6 { fill: #608ab1;font-weight: bold }
|
||||
.terminal-r4 { fill: #9a9b99 }
|
||||
.terminal-r5 { fill: #608ab1;font-weight: bold }
|
||||
.terminal-r6 { fill: #949798 }
|
||||
.terminal-r7 { fill: #868887 }
|
||||
.terminal-r8 { fill: #949798 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
|
|
@ -161,44 +160,44 @@
|
|||
</g>
|
||||
|
||||
<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">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="20" textLength="414.8" clip-path="url(#terminal-line-0)">1 model · 0 MCP servers · 0 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><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">Type </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)"> for more information</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</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-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 setup 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="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-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 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-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-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 URL for HTTP 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-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-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 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-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-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 URL for HTTPS 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-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-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 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-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-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 URL for all requests (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-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-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 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-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-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 list of hosts to bypass 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-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-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 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-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-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 to custom SSL certificate 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-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-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 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-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-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 to directory containing SSL 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-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-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 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-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-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)">↑↓ navigate  Enter save & exit  ESC 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-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-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% of 200k tokens</text>
|
||||
</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="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</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="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</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="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="0" y="312.8" textLength="134.2" clip-path="url(#terminal-line-12)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="312.8" textLength="146.4" clip-path="url(#terminal-line-12)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="312.8" textLength="122" clip-path="url(#terminal-line-12)"> v0.0.0 · </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-r1" x="0" y="337.2" textLength="134.2" clip-path="url(#terminal-line-13)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="337.2" textLength="414.8" clip-path="url(#terminal-line-13)">1 model · 0 MCP servers · 0 skills</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="361.6" textLength="134.2" clip-path="url(#terminal-line-14)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="361.6" textLength="61" clip-path="url(#terminal-line-14)">Type </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)"> for more information</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="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</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 setup opened...</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</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="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</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-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 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-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-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 URL for HTTP 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-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-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 URL for HTTPS 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-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-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 URL for all requests (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-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-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 list of hosts to bypass 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-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-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 to custom SSL certificate 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-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-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 to directory containing SSL 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-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)">↑↓ navigate  Enter save & exit  ESC 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-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-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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 18 KiB |
|
|
@ -33,12 +33,11 @@
|
|||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #4b4e55 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #292929 }
|
||||
.terminal-r5 { fill: #9a9b99 }
|
||||
.terminal-r6 { fill: #608ab1;font-weight: bold }
|
||||
.terminal-r7 { fill: #868887 }
|
||||
.terminal-r4 { fill: #9a9b99 }
|
||||
.terminal-r5 { fill: #608ab1;font-weight: bold }
|
||||
.terminal-r6 { fill: #868887 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
|
|
@ -160,44 +159,44 @@
|
|||
</g>
|
||||
|
||||
<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">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="20" textLength="414.8" clip-path="url(#terminal-line-0)">1 model · 0 MCP servers · 0 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><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">Type </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)"> for more information</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</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-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 setup 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="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-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 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-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-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 URL for HTTP 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-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-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-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-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 URL for HTTPS 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-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-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                                                                        </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-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-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 URL for all requests (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-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-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 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-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-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 list of hosts to bypass 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-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-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 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-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-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 to custom SSL certificate 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-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-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 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-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-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 to directory containing SSL 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-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-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 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-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-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)">↑↓ navigate  Enter save & exit  ESC 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-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-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% of 200k tokens</text>
|
||||
</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="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</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="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</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="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="0" y="312.8" textLength="134.2" clip-path="url(#terminal-line-12)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="312.8" textLength="146.4" clip-path="url(#terminal-line-12)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="312.8" textLength="122" clip-path="url(#terminal-line-12)"> v0.0.0 · </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-r1" x="0" y="337.2" textLength="134.2" clip-path="url(#terminal-line-13)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="337.2" textLength="414.8" clip-path="url(#terminal-line-13)">1 model · 0 MCP servers · 0 skills</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="361.6" textLength="134.2" clip-path="url(#terminal-line-14)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="361.6" textLength="61" clip-path="url(#terminal-line-14)">Type </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)"> for more information</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="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</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 setup opened...</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</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="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</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-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 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-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-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-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-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                                                                        </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-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-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 URL for all requests (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-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-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 list of hosts to bypass 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-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-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 to custom SSL certificate 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-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-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 to directory containing SSL 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-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)">↑↓ navigate  Enter save & exit  ESC 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-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-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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 18 KiB |
|
|
@ -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 |
|
|
@ -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 /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 |
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -24,6 +24,7 @@ def test_snapshot_cycle_to_plan_mode(snap_compare: SnapCompare) -> None:
|
|||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("shift+tab") # default -> plan
|
||||
await pilot.app.workers.wait_for_complete()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
|
|
@ -40,6 +41,7 @@ def test_snapshot_cycle_to_accept_edits_mode(snap_compare: SnapCompare) -> None:
|
|||
await pilot.pause(0.1)
|
||||
await pilot.press("shift+tab") # default -> plan
|
||||
await pilot.press("shift+tab") # plan -> accept edits
|
||||
await pilot.app.workers.wait_for_complete()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
|
|
@ -57,6 +59,7 @@ def test_snapshot_cycle_to_auto_approve_mode(snap_compare: SnapCompare) -> None:
|
|||
await pilot.press("shift+tab") # default -> plan
|
||||
await pilot.press("shift+tab") # plan -> accept edits
|
||||
await pilot.press("shift+tab") # accept edits -> auto approve
|
||||
await pilot.app.workers.wait_for_complete()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
|
|
@ -75,6 +78,7 @@ def test_snapshot_cycle_wraps_to_default(snap_compare: SnapCompare) -> None:
|
|||
await pilot.press("shift+tab") # plan -> accept edits
|
||||
await pilot.press("shift+tab") # accept edits -> auto approve
|
||||
await pilot.press("shift+tab") # auto approve -> default (wrap)
|
||||
await pilot.app.workers.wait_for_complete()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
|
|
|
|||
114
tests/snapshots/test_ui_snapshot_parallel_tool_calls.py
Normal 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,
|
||||
)
|
||||
75
tests/snapshots/test_ui_snapshot_streaming_tool_call.py
Normal 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,
|
||||
)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable, Iterable
|
||||
from collections.abc import AsyncGenerator, Callable, Iterable, Sequence
|
||||
from typing import cast
|
||||
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
|
|
@ -21,7 +21,7 @@ class FakeBackend:
|
|||
| Iterable[Iterable[LLMChunk]]
|
||||
| None = None,
|
||||
*,
|
||||
token_counter: Callable[[list[LLMMessage]], int] | None = None,
|
||||
token_counter: Callable[[Sequence[LLMMessage]], int] | None = None,
|
||||
exception_to_raise: Exception | None = None,
|
||||
) -> None:
|
||||
"""Fake backend that will output the given chunks in the order they are given.
|
||||
|
|
@ -35,6 +35,7 @@ class FakeBackend:
|
|||
"""
|
||||
self._requests_messages: list[list[LLMMessage]] = []
|
||||
self._requests_extra_headers: list[dict[str, str] | None] = []
|
||||
self._requests_metadata: list[dict[str, str] | None] = []
|
||||
self._count_tokens_calls: list[list[LLMMessage]] = []
|
||||
self._token_counter = token_counter or self._default_token_counter
|
||||
self._exception_to_raise = exception_to_raise
|
||||
|
|
@ -65,8 +66,12 @@ class FakeBackend:
|
|||
def requests_extra_headers(self) -> list[dict[str, str] | None]:
|
||||
return self._requests_extra_headers
|
||||
|
||||
@property
|
||||
def requests_metadata(self) -> list[dict[str, str] | None]:
|
||||
return self._requests_metadata
|
||||
|
||||
@staticmethod
|
||||
def _default_token_counter(messages: list[LLMMessage]) -> int:
|
||||
def _default_token_counter(messages: Sequence[LLMMessage]) -> int:
|
||||
return 1
|
||||
|
||||
async def __aenter__(self):
|
||||
|
|
@ -85,12 +90,14 @@ class FakeBackend:
|
|||
tool_choice,
|
||||
extra_headers,
|
||||
max_tokens,
|
||||
metadata=None,
|
||||
) -> LLMChunk:
|
||||
if self._exception_to_raise:
|
||||
raise self._exception_to_raise
|
||||
|
||||
self._requests_messages.append(messages)
|
||||
self._requests_extra_headers.append(extra_headers)
|
||||
self._requests_metadata.append(metadata)
|
||||
|
||||
if self._streams:
|
||||
stream = self._streams.pop(0)
|
||||
|
|
@ -111,12 +118,14 @@ class FakeBackend:
|
|||
tool_choice,
|
||||
extra_headers,
|
||||
max_tokens,
|
||||
metadata=None,
|
||||
) -> AsyncGenerator[LLMChunk]:
|
||||
if self._exception_to_raise:
|
||||
raise self._exception_to_raise
|
||||
|
||||
self._requests_messages.append(messages)
|
||||
self._requests_extra_headers.append(extra_headers)
|
||||
self._requests_metadata.append(metadata)
|
||||
|
||||
if self._streams:
|
||||
stream = list(self._streams.pop(0))
|
||||
|
|
@ -134,6 +143,7 @@ class FakeBackend:
|
|||
tools,
|
||||
tool_choice=None,
|
||||
extra_headers,
|
||||
metadata=None,
|
||||
) -> int:
|
||||
self._count_tokens_calls.append(list(messages))
|
||||
return self._token_counter(messages)
|
||||
|
|
|
|||
|
|
@ -16,22 +16,13 @@ from vibe.core.types import (
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_triggers_and_batches_observer(
|
||||
telemetry_events: list[dict],
|
||||
) -> None:
|
||||
observed: list[tuple[Role, str | None]] = []
|
||||
|
||||
def observer(msg: LLMMessage) -> None:
|
||||
observed.append((msg.role, msg.content))
|
||||
|
||||
async def test_auto_compact_emits_correct_events(telemetry_events: list[dict]) -> None:
|
||||
backend = FakeBackend([
|
||||
[mock_llm_chunk(content="<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 = build_test_agent_loop(config=cfg, backend=backend)
|
||||
agent.stats.context_tokens = 2
|
||||
|
||||
events = [ev async for ev in agent.act("Hello")]
|
||||
|
|
@ -50,14 +41,83 @@ async def test_auto_compact_triggers_and_batches_observer(
|
|||
assert end.new_context_tokens >= 1
|
||||
assert final.content == "<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 = [
|
||||
e
|
||||
for e in telemetry_events
|
||||
if e.get("event_name") == "vibe/auto_compact_triggered"
|
||||
if e.get("event_name") == "vibe.auto_compact_triggered"
|
||||
]
|
||||
assert len(auto_compact) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_observer_sees_user_msg_not_summary() -> None:
|
||||
"""Observer sees the original user message and final response.
|
||||
|
||||
Compact internals (summary request, LLM summary) are invisible
|
||||
to the observer because they happen inside silent() / reset().
|
||||
"""
|
||||
observed: list[tuple[Role, str | None]] = []
|
||||
|
||||
def observer(msg: LLMMessage) -> None:
|
||||
observed.append((msg.role, msg.content))
|
||||
|
||||
backend = FakeBackend([
|
||||
[mock_llm_chunk(content="<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>"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,54 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from mcp.types import (
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
SamplingMessage,
|
||||
TextContent,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_agent_loop
|
||||
from tests.conftest import build_test_agent_loop, build_test_vibe_config
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.config import Backend, ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.types import EntrypointMetadata
|
||||
|
||||
|
||||
def _two_model_vibe_config(active_model: str) -> VibeConfig:
|
||||
"""VibeConfig with two models so we can switch active_model."""
|
||||
models = [
|
||||
ModelConfig(
|
||||
name="mistral-vibe-cli-latest", provider="mistral", alias="devstral-latest"
|
||||
),
|
||||
ModelConfig(
|
||||
name="devstral-small-latest", provider="mistral", alias="devstral-small"
|
||||
),
|
||||
]
|
||||
providers = [
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
]
|
||||
return build_test_vibe_config(
|
||||
active_model=active_model, models=models, providers=providers
|
||||
)
|
||||
|
||||
|
||||
def _make_sampling_params() -> CreateMessageRequestParams:
|
||||
return CreateMessageRequestParams(
|
||||
messages=[
|
||||
SamplingMessage(role="user", content=TextContent(type="text", text="Hi"))
|
||||
],
|
||||
systemPrompt=None,
|
||||
temperature=None,
|
||||
maxTokens=100,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -66,3 +109,79 @@ async def test_updates_tokens_stats_based_on_backend_response_streaming(
|
|||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
assert agent.stats.context_tokens == 275
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_entrypoint_metadata_to_backend(vibe_config: VibeConfig):
|
||||
metadata = EntrypointMetadata(
|
||||
agent_entrypoint="acp",
|
||||
agent_version="2.0.0",
|
||||
client_name="vibe_ide",
|
||||
client_version="0.5.0",
|
||||
)
|
||||
backend = FakeBackend([mock_llm_chunk(content="Response")])
|
||||
agent = build_test_agent_loop(
|
||||
config=vibe_config,
|
||||
backend=backend,
|
||||
enable_streaming=True,
|
||||
entrypoint_metadata=metadata,
|
||||
)
|
||||
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
assert len(backend.requests_metadata) > 0
|
||||
assert backend.requests_metadata[0] == {
|
||||
"agent_entrypoint": "acp",
|
||||
"agent_version": "2.0.0",
|
||||
"client_name": "vibe_ide",
|
||||
"client_version": "0.5.0",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_sampling_handler_uses_updated_backend_when_agent_backend_changes():
|
||||
"""AgentLoop's MCP sampling handler uses current backend when backend is reassigned."""
|
||||
backend1 = FakeBackend([mock_llm_chunk(content="from-backend-1")])
|
||||
backend2 = FakeBackend([mock_llm_chunk(content="from-backend-2")])
|
||||
config = _two_model_vibe_config("devstral-latest")
|
||||
agent = build_test_agent_loop(config=config, backend=backend1)
|
||||
handler = agent._sampling_handler
|
||||
params = _make_sampling_params()
|
||||
context = MagicMock()
|
||||
|
||||
result1 = await handler(context, params)
|
||||
assert isinstance(result1, CreateMessageResult)
|
||||
assert result1.content.type == "text"
|
||||
assert result1.content.text == "from-backend-1"
|
||||
assert len(backend1.requests_messages) == 1
|
||||
assert len(backend2.requests_messages) == 0
|
||||
|
||||
agent.backend = backend2
|
||||
result2 = await handler(context, params)
|
||||
assert isinstance(result2, CreateMessageResult)
|
||||
assert result2.content.type == "text"
|
||||
assert result2.content.text == "from-backend-2"
|
||||
assert len(backend1.requests_messages) == 1
|
||||
assert len(backend2.requests_messages) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_sampling_handler_uses_updated_config_when_agent_config_changes():
|
||||
chunk = mock_llm_chunk(content="ok")
|
||||
backend = FakeBackend([chunk])
|
||||
config1 = _two_model_vibe_config("devstral-latest")
|
||||
config2 = _two_model_vibe_config("devstral-small")
|
||||
agent = build_test_agent_loop(config=config1, backend=backend)
|
||||
handler = agent._sampling_handler
|
||||
params = _make_sampling_params()
|
||||
context = MagicMock()
|
||||
|
||||
result1 = await handler(context, params)
|
||||
assert isinstance(result1, CreateMessageResult)
|
||||
assert result1.model == "mistral-vibe-cli-latest"
|
||||
|
||||
agent._base_config = config2
|
||||
agent.agent_manager.invalidate_config()
|
||||
result2 = await handler(context, params)
|
||||
assert isinstance(result2, CreateMessageResult)
|
||||
assert result2.model == "devstral-small-latest"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
|
@ -180,6 +181,27 @@ async def test_act_streams_chunks_in_order() -> None:
|
|||
assert agent.messages[-1].content == "Hello from Vibe! More and end"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_act_streaming_does_not_cleanup_tmp_files_directly() -> None:
|
||||
backend = FakeBackend([
|
||||
mock_llm_chunk(content="Hello"),
|
||||
mock_llm_chunk(content=" from"),
|
||||
mock_llm_chunk(content=" Vibe"),
|
||||
])
|
||||
agent = build_test_agent_loop(
|
||||
config=make_config(), backend=backend, enable_streaming=True
|
||||
)
|
||||
agent.session_logger.save_interaction = AsyncMock(return_value=None)
|
||||
cleanup_spy = Mock()
|
||||
agent.session_logger.maybe_cleanup_tmp_files = cleanup_spy
|
||||
|
||||
events = [event async for event in agent.act("Stream, please.")]
|
||||
|
||||
assistant_events = [event for event in events if isinstance(event, AssistantEvent)]
|
||||
assert len(assistant_events) == 3
|
||||
assert cleanup_spy.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None:
|
||||
todo_tool_call = ToolCall(
|
||||
|
|
@ -210,6 +232,7 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None
|
|||
UserMessageEvent,
|
||||
AssistantEvent,
|
||||
ToolCallEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
AssistantEvent,
|
||||
]
|
||||
|
|
@ -217,12 +240,17 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None
|
|||
assert isinstance(events[1], AssistantEvent)
|
||||
assert events[1].content == "Checking your todos."
|
||||
assert isinstance(events[2], ToolCallEvent)
|
||||
assert events[2].args is None # streaming event
|
||||
assert events[2].tool_call_id == "tc_stream"
|
||||
assert events[2].tool_name == "todo"
|
||||
assert isinstance(events[3], ToolResultEvent)
|
||||
assert events[3].error is None
|
||||
assert events[3].skipped is False
|
||||
assert isinstance(events[4], AssistantEvent)
|
||||
assert events[4].content == "Done reviewing todos."
|
||||
assert isinstance(events[3], ToolCallEvent)
|
||||
assert events[3].args is not None
|
||||
assert events[3].tool_name == "todo"
|
||||
assert isinstance(events[4], ToolResultEvent)
|
||||
assert events[4].error is None
|
||||
assert events[4].skipped is False
|
||||
assert isinstance(events[5], AssistantEvent)
|
||||
assert events[5].content == "Done reviewing todos."
|
||||
assert agent.messages[-1].content == "Done reviewing todos."
|
||||
|
||||
|
||||
|
|
@ -250,21 +278,27 @@ async def test_act_handles_tool_call_chunk_with_content() -> None:
|
|||
|
||||
events = [event async for event in agent.act("Check todos with content.")]
|
||||
|
||||
assert [type(event) for event in events] == [
|
||||
UserMessageEvent,
|
||||
AssistantEvent,
|
||||
AssistantEvent,
|
||||
AssistantEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
]
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
assert isinstance(events[1], AssistantEvent)
|
||||
assert isinstance(events[2], AssistantEvent)
|
||||
assert isinstance(events[3], AssistantEvent)
|
||||
assert events[1].content == "Preparing "
|
||||
assert events[2].content == "todo request"
|
||||
assert events[3].content == " complete"
|
||||
event_types = [type(e) for e in events]
|
||||
assert Counter(event_types) == Counter({
|
||||
UserMessageEvent: 1,
|
||||
AssistantEvent: 3,
|
||||
ToolCallEvent: 2,
|
||||
ToolResultEvent: 1,
|
||||
})
|
||||
|
||||
tool_call_events = [e for e in events if isinstance(e, ToolCallEvent)]
|
||||
assert len(tool_call_events) == 2
|
||||
assert any(
|
||||
tc.args is None and tc.tool_call_id == "tc_content" for tc in tool_call_events
|
||||
)
|
||||
assert any(tc.args is not None for tc in tool_call_events)
|
||||
|
||||
assistant_events = [e for e in events if isinstance(e, AssistantEvent)]
|
||||
assistant_contents = {e.content for e in assistant_events}
|
||||
assert "Preparing " in assistant_contents
|
||||
assert "todo request" in assistant_contents
|
||||
assert " complete" in assistant_contents
|
||||
|
||||
assert any(
|
||||
m.role == Role.assistant and m.content == "Preparing todo request complete"
|
||||
for m in agent.messages
|
||||
|
|
@ -304,17 +338,21 @@ async def test_act_merges_streamed_tool_call_arguments() -> None:
|
|||
UserMessageEvent,
|
||||
AssistantEvent,
|
||||
ToolCallEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
]
|
||||
assert isinstance(events[0], UserMessageEvent)
|
||||
call_event = events[2]
|
||||
assert isinstance(events[2], ToolCallEvent)
|
||||
assert events[2].args is None # streaming event
|
||||
assert events[2].tool_call_id == "tc_merge"
|
||||
call_event = events[3]
|
||||
assert isinstance(call_event, ToolCallEvent)
|
||||
assert call_event.tool_call_id == "tc_merge"
|
||||
call_args = cast(TodoArgs, call_event.args)
|
||||
assert call_args.action == "read"
|
||||
assert isinstance(events[3], ToolResultEvent)
|
||||
assert events[3].error is None
|
||||
assert events[3].skipped is False
|
||||
assert isinstance(events[4], ToolResultEvent)
|
||||
assert events[4].error is None
|
||||
assert events[4].skipped is False
|
||||
assistant_with_calls = next(
|
||||
m for m in agent.messages if m.role == Role.assistant and m.tool_calls
|
||||
)
|
||||
|
|
@ -371,6 +409,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
|
|||
assert [type(event) for event in events] == [
|
||||
UserMessageEvent,
|
||||
AssistantEvent,
|
||||
ToolCallEvent,
|
||||
AssistantEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
|
|
|
|||
|
|
@ -531,7 +531,7 @@ class TestAutoCompactIntegration:
|
|||
|
||||
roles = [r for r, _ in observed]
|
||||
assert roles == [Role.system, Role.user, Role.assistant]
|
||||
assert observed[1][1] is not None and "<summary>" in observed[1][1]
|
||||
assert observed[1][1] == "Hello"
|
||||
|
||||
|
||||
class TestClearHistoryFullReset:
|
||||
|
|
@ -623,6 +623,37 @@ class TestClearHistoryFullReset:
|
|||
assert agent.session_id == agent.session_logger.session_id
|
||||
|
||||
|
||||
class TestClearHistoryObserverBugfix:
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_history_observer_sees_new_messages(
|
||||
self, observer_capture
|
||||
) -> None:
|
||||
"""Bug fix: clear_history previously left a stale index, so new messages
|
||||
appended after clearing were never observed.
|
||||
"""
|
||||
observed, observer = observer_capture
|
||||
backend = FakeBackend([
|
||||
[mock_llm_chunk(content="First")],
|
||||
[mock_llm_chunk(content="Second")],
|
||||
])
|
||||
agent = build_test_agent_loop(
|
||||
config=make_config(), message_observer=observer, backend=backend
|
||||
)
|
||||
|
||||
async for _ in agent.act("Hello"):
|
||||
pass
|
||||
|
||||
await agent.clear_history()
|
||||
observed.clear()
|
||||
|
||||
async for _ in agent.act("After clear"):
|
||||
pass
|
||||
|
||||
roles = [msg.role for msg in observed]
|
||||
assert Role.user in roles
|
||||
assert Role.assistant in roles
|
||||
|
||||
|
||||
class TestStatsEdgeCases:
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_cost_approximation_on_model_change(
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ async def test_single_tool_call_executes_under_auto_approve(
|
|||
assert "total_count" in (tool_msgs[-1].content or "")
|
||||
|
||||
tool_finished = [
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
|
||||
]
|
||||
assert len(tool_finished) == 1
|
||||
assert tool_finished[0]["properties"]["tool_name"] == "todo"
|
||||
|
|
@ -158,7 +158,7 @@ async def test_tool_call_requires_approval_if_not_auto_approved(
|
|||
assert agent_loop.stats.tool_calls_succeeded == 0
|
||||
|
||||
tool_finished = [
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
|
||||
]
|
||||
assert len(tool_finished) == 1
|
||||
assert tool_finished[0]["properties"]["approval_type"] == "ask"
|
||||
|
|
@ -198,7 +198,7 @@ async def test_tool_call_approved_by_callback(telemetry_events: list[dict]) -> N
|
|||
assert agent_loop.stats.tool_calls_succeeded == 1
|
||||
|
||||
tool_finished = [
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
|
||||
]
|
||||
assert len(tool_finished) == 1
|
||||
assert tool_finished[0]["properties"]["approval_type"] == "ask"
|
||||
|
|
@ -243,7 +243,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
|
|||
assert agent_loop.stats.tool_calls_succeeded == 0
|
||||
|
||||
tool_finished = [
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
|
||||
]
|
||||
assert len(tool_finished) == 1
|
||||
assert tool_finished[0]["properties"]["approval_type"] == "ask"
|
||||
|
|
@ -287,7 +287,7 @@ async def test_tool_call_skipped_when_permission_is_never(
|
|||
assert agent_loop.stats.tool_calls_succeeded == 0
|
||||
|
||||
tool_finished = [
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
|
||||
]
|
||||
assert len(tool_finished) == 1
|
||||
assert tool_finished[0]["properties"]["approval_type"] == "never"
|
||||
|
|
@ -492,14 +492,14 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
|
|||
assistant_msg = LLMMessage(
|
||||
role=Role.assistant, content="Calling tools...", tool_calls=tool_calls_messages
|
||||
)
|
||||
agent_loop.messages = [
|
||||
agent_loop.messages.reset([
|
||||
agent_loop.messages[0],
|
||||
assistant_msg,
|
||||
# only one tool responded: the second is missing
|
||||
LLMMessage(
|
||||
role=Role.tool, tool_call_id="tc1", name="todo", content="Retrieved 0 todos"
|
||||
),
|
||||
]
|
||||
])
|
||||
|
||||
await act_and_collect_events(agent_loop, "Proceed")
|
||||
|
||||
|
|
@ -524,7 +524,7 @@ async def test_ensure_assistant_after_tool_appends_understood() -> None:
|
|||
tool_msg = LLMMessage(
|
||||
role=Role.tool, tool_call_id="tc_z", name="todo", content="Done"
|
||||
)
|
||||
agent_loop.messages = [agent_loop.messages[0], tool_msg]
|
||||
agent_loop.messages.reset([agent_loop.messages[0], tool_msg])
|
||||
|
||||
await act_and_collect_events(agent_loop, "Next")
|
||||
|
||||
|
|
|
|||
|
|
@ -126,8 +126,9 @@ class TestAgentSafety:
|
|||
|
||||
|
||||
class TestAgentProfile:
|
||||
def test_all_builtin_agents_exist(self) -> None:
|
||||
assert set(BUILTIN_AGENTS.keys()) == set(BuiltinAgentName)
|
||||
def test_all_builtin_agents_have_valid_names(self) -> None:
|
||||
acp_only = {BuiltinAgentName.CHAT}
|
||||
assert set(BUILTIN_AGENTS.keys()) == set(BuiltinAgentName) - acp_only
|
||||
|
||||
def test_display_name_property(self) -> None:
|
||||
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].display_name == "Default"
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ def test_run_programmatic_preload_streaming_is_batched(
|
|||
]
|
||||
|
||||
new_session = [
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe/new_session"
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe.new_session"
|
||||
]
|
||||
assert len(new_session) == 1
|
||||
assert new_session[0]["properties"]["entrypoint"] == "programmatic"
|
||||
|
|
|
|||
|
|
@ -3,44 +3,67 @@ from __future__ import annotations
|
|||
import pytest
|
||||
|
||||
from tests.conftest import build_test_agent_loop, build_test_vibe_config
|
||||
from vibe.core.agents.models import BUILTIN_AGENTS, AgentProfile, BuiltinAgentName
|
||||
from vibe.core.agents.models import BUILTIN_AGENTS, CHAT, AgentProfile, BuiltinAgentName
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.middleware import (
|
||||
CHAT_AGENT_EXIT,
|
||||
CHAT_AGENT_REMINDER,
|
||||
PLAN_AGENT_EXIT,
|
||||
PLAN_AGENT_REMINDER,
|
||||
ConversationContext,
|
||||
MiddlewareAction,
|
||||
MiddlewarePipeline,
|
||||
PlanAgentMiddleware,
|
||||
ReadOnlyAgentMiddleware,
|
||||
ResetReason,
|
||||
)
|
||||
from vibe.core.types import AgentStats
|
||||
from vibe.core.types import AgentStats, MessageList
|
||||
|
||||
REMINDER = "test reminder"
|
||||
EXIT_MSG = "test exit"
|
||||
TARGET_AGENT = BuiltinAgentName.PLAN
|
||||
|
||||
|
||||
def _build_middleware(
|
||||
profile_getter,
|
||||
agent_name: str = TARGET_AGENT,
|
||||
reminder: str = REMINDER,
|
||||
exit_message: str = EXIT_MSG,
|
||||
) -> ReadOnlyAgentMiddleware:
|
||||
return ReadOnlyAgentMiddleware(profile_getter, agent_name, reminder, exit_message)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(vibe_config: VibeConfig) -> ConversationContext:
|
||||
return ConversationContext(messages=[], stats=AgentStats(), config=vibe_config)
|
||||
return ConversationContext(
|
||||
messages=MessageList(), stats=AgentStats(), config=vibe_config
|
||||
)
|
||||
|
||||
|
||||
class TestPlanAgentMiddleware:
|
||||
class TestReadOnlyAgentMiddleware:
|
||||
@pytest.mark.asyncio
|
||||
async def test_injects_reminder_when_plan_agent_active(
|
||||
async def test_injects_reminder_when_target_agent_active(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_REMINDER
|
||||
assert result.message == REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_inject_when_default_agent(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
middleware = PlanAgentMiddleware(
|
||||
lambda: BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
@pytest.mark.parametrize(
|
||||
"agent_name",
|
||||
[
|
||||
BuiltinAgentName.DEFAULT,
|
||||
BuiltinAgentName.AUTO_APPROVE,
|
||||
BuiltinAgentName.ACCEPT_EDITS,
|
||||
],
|
||||
)
|
||||
async def test_does_not_inject_when_non_target_agent(
|
||||
self, ctx: ConversationContext, agent_name: str
|
||||
) -> None:
|
||||
middleware = _build_middleware(lambda: BUILTIN_AGENTS[agent_name])
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
|
||||
|
|
@ -48,89 +71,56 @@ class TestPlanAgentMiddleware:
|
|||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_inject_when_auto_approve_agent(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
middleware = PlanAgentMiddleware(
|
||||
lambda: BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
|
||||
)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_inject_when_accept_edits_agent(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
middleware = PlanAgentMiddleware(
|
||||
lambda: BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS]
|
||||
)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injects_reminder_only_once_while_in_plan_mode(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
async def test_injects_reminder_only_once(self, ctx: ConversationContext) -> None:
|
||||
middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
|
||||
result1 = await middleware.before_turn(ctx)
|
||||
assert result1.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result1.message == PLAN_AGENT_REMINDER
|
||||
assert result1.message == REMINDER
|
||||
|
||||
result2 = await middleware.before_turn(ctx)
|
||||
assert result2.action == MiddlewareAction.CONTINUE
|
||||
assert result2.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injects_exit_message_when_leaving_plan_mode(
|
||||
async def test_injects_exit_message_when_leaving(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
# Enter plan mode
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
# Leave plan mode
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_EXIT
|
||||
assert result.message == EXIT_MSG
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reinjects_reminder_when_reentering_plan_mode(
|
||||
async def test_reinjects_reminder_on_reentry(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
# Enter plan mode - should inject reminder
|
||||
result1 = await middleware.before_turn(ctx)
|
||||
assert result1.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result1.message == PLAN_AGENT_REMINDER
|
||||
assert result1.message == REMINDER
|
||||
|
||||
# Leave plan mode - should inject exit message
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
result2 = await middleware.before_turn(ctx)
|
||||
assert result2.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result2.message == PLAN_AGENT_EXIT
|
||||
assert result2.message == EXIT_MSG
|
||||
|
||||
# Re-enter plan mode - should inject reminder again
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
result3 = await middleware.before_turn(ctx)
|
||||
assert result3.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result3.message == PLAN_AGENT_REMINDER
|
||||
assert result3.message == REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_reminder(self, ctx: ConversationContext) -> None:
|
||||
custom_reminder = "Custom plan agent reminder"
|
||||
middleware = PlanAgentMiddleware(
|
||||
custom_reminder = "Custom reminder"
|
||||
middleware = _build_middleware(
|
||||
lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN], reminder=custom_reminder
|
||||
)
|
||||
|
||||
|
|
@ -138,246 +128,216 @@ class TestPlanAgentMiddleware:
|
|||
|
||||
assert result.message == custom_reminder
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_clears_state(self, ctx: ConversationContext) -> None:
|
||||
middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
await middleware.before_turn(ctx) # Enter and inject
|
||||
|
||||
middleware.reset()
|
||||
|
||||
# Should inject again after reset
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exit_message_fires_only_once(self, ctx: ConversationContext) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
# Enter plan mode
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
# Leave plan mode - first call should inject exit
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_EXIT
|
||||
|
||||
# Subsequent calls in default mode should be CONTINUE
|
||||
result2 = await middleware.before_turn(ctx)
|
||||
assert result2.action == MiddlewareAction.CONTINUE
|
||||
assert result2.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_turns_in_plan_mode_after_entry(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
|
||||
# First turn: inject reminder
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
|
||||
# Several more turns in plan mode: all should be CONTINUE
|
||||
for _ in range(5):
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_turns_in_default_mode_after_exit(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx) # enter plan
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
await middleware.before_turn(ctx) # exit plan (fires exit message)
|
||||
|
||||
# Several more turns in default mode: all should be CONTINUE
|
||||
for _ in range(5):
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rapid_toggling_plan_default_multiple_cycles(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
for _ in range(3):
|
||||
# Enter plan
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_REMINDER
|
||||
|
||||
# Leave plan
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_EXIT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exit_to_non_default_agent(self, ctx: ConversationContext) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx) # enter plan
|
||||
|
||||
# Switch to auto_approve (not default)
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_EXIT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exit_to_accept_edits_agent(self, ctx: ConversationContext) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx) # enter plan
|
||||
|
||||
# Switch to accept_edits
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_EXIT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switching_between_non_plan_agents(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_plan_to_plan_entry(self, ctx: ConversationContext) -> None:
|
||||
"""Starting in a non-plan agent then entering plan should inject reminder."""
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
# Now switch to plan
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_while_in_default_after_exiting_plan(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx) # enter plan
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
await middleware.before_turn(ctx) # exit plan
|
||||
|
||||
middleware.reset()
|
||||
|
||||
# Still in default mode - should CONTINUE (no phantom exit message)
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_while_in_default_then_reenter_plan(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx) # enter plan
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
await middleware.before_turn(ctx) # exit plan
|
||||
|
||||
middleware.reset()
|
||||
|
||||
# Re-enter plan after reset
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_with_compact_reason(self, ctx: ConversationContext) -> None:
|
||||
middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
await middleware.before_turn(ctx) # enter and inject
|
||||
|
||||
middleware.reset(ResetReason.COMPACT)
|
||||
|
||||
# Should reinject reminder after compact reset
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_exit_message(self, ctx: ConversationContext) -> None:
|
||||
custom_exit = "Custom exit message"
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(
|
||||
middleware = _build_middleware(
|
||||
lambda: current_profile, exit_message=custom_exit
|
||||
)
|
||||
|
||||
await middleware.before_turn(ctx) # enter plan
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.message == custom_exit
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_entry_then_immediate_exit_same_not_possible(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
"""Even if profile changes between two calls, each call sees one transition."""
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
async def test_reset_clears_state(self, ctx: ConversationContext) -> None:
|
||||
middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
middleware.reset()
|
||||
|
||||
# First call: entry
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_REMINDER
|
||||
|
||||
# Second call (still plan): no injection
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
@pytest.mark.asyncio
|
||||
async def test_exit_message_fires_only_once(self, ctx: ConversationContext) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
# Third call (switched to default): exit
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_EXIT
|
||||
assert result.message == EXIT_MSG
|
||||
|
||||
result2 = await middleware.before_turn(ctx)
|
||||
assert result2.action == MiddlewareAction.CONTINUE
|
||||
assert result2.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_turns_after_entry(self, ctx: ConversationContext) -> None:
|
||||
middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
|
||||
for _ in range(5):
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_turns_after_exit(self, ctx: ConversationContext) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
for _ in range(5):
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rapid_toggling_multiple_cycles(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
for _ in range(3):
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == REMINDER
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == EXIT_MSG
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exit_to_non_default_agent(self, ctx: ConversationContext) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == EXIT_MSG
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switching_between_non_target_agents(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_target_to_target_entry(self, ctx: ConversationContext) -> None:
|
||||
"""Starting in a non-target agent then entering target should inject reminder."""
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_while_inactive_after_exit(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx)
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
middleware.reset()
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_while_inactive_then_reenter(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
await middleware.before_turn(ctx)
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
middleware.reset()
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_with_compact_reason(self, ctx: ConversationContext) -> None:
|
||||
middleware = _build_middleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
middleware.reset(ResetReason.COMPACT)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entry_then_continuation_then_exit_then_continuation(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
"""Each call sees one transition at a time."""
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = _build_middleware(lambda: current_profile)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == REMINDER
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == EXIT_MSG
|
||||
|
||||
# Fourth call (still default): no injection
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
|
||||
class TestMiddlewarePipelineWithPlanAgent:
|
||||
class TestMiddlewarePipelineWithReadOnlyAgent:
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_includes_plan_agent_injection(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
async def test_pipeline_includes_injection(self, ctx: ConversationContext) -> None:
|
||||
pipeline = MiddlewarePipeline()
|
||||
pipeline.add(PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN]))
|
||||
pipeline.add(
|
||||
ReadOnlyAgentMiddleware(
|
||||
lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN],
|
||||
BuiltinAgentName.PLAN,
|
||||
PLAN_AGENT_REMINDER,
|
||||
PLAN_AGENT_EXIT,
|
||||
)
|
||||
)
|
||||
|
||||
result = await pipeline.run_before_turn(ctx)
|
||||
|
||||
|
|
@ -385,20 +345,73 @@ class TestMiddlewarePipelineWithPlanAgent:
|
|||
assert PLAN_AGENT_REMINDER in (result.message or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_skips_injection_when_not_plan_agent(
|
||||
async def test_pipeline_skips_injection_when_not_target_agent(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
pipeline = MiddlewarePipeline()
|
||||
pipeline.add(
|
||||
PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.DEFAULT])
|
||||
ReadOnlyAgentMiddleware(
|
||||
lambda: BUILTIN_AGENTS[BuiltinAgentName.DEFAULT],
|
||||
BuiltinAgentName.PLAN,
|
||||
PLAN_AGENT_REMINDER,
|
||||
PLAN_AGENT_EXIT,
|
||||
)
|
||||
)
|
||||
|
||||
result = await pipeline.run_before_turn(ctx)
|
||||
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_plan_to_chat_transition_delivers_both_messages(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
pipeline = MiddlewarePipeline()
|
||||
pipeline.add(
|
||||
ReadOnlyAgentMiddleware(
|
||||
lambda: current_profile,
|
||||
BuiltinAgentName.PLAN,
|
||||
PLAN_AGENT_REMINDER,
|
||||
PLAN_AGENT_EXIT,
|
||||
)
|
||||
)
|
||||
pipeline.add(
|
||||
ReadOnlyAgentMiddleware(
|
||||
lambda: current_profile,
|
||||
BuiltinAgentName.CHAT,
|
||||
CHAT_AGENT_REMINDER,
|
||||
CHAT_AGENT_EXIT,
|
||||
)
|
||||
)
|
||||
|
||||
class TestPlanAgentMiddlewareIntegration:
|
||||
result = await pipeline.run_before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert PLAN_AGENT_REMINDER in (result.message or "")
|
||||
|
||||
current_profile = CHAT
|
||||
result = await pipeline.run_before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert PLAN_AGENT_EXIT in (result.message or "")
|
||||
assert CHAT_AGENT_REMINDER in (result.message or "")
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
result = await pipeline.run_before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert CHAT_AGENT_EXIT in (result.message or "")
|
||||
assert PLAN_AGENT_REMINDER in (result.message or "")
|
||||
|
||||
|
||||
def _find_plan_middleware(agent) -> ReadOnlyAgentMiddleware:
|
||||
return next(
|
||||
mw
|
||||
for mw in agent.middleware_pipeline.middlewares
|
||||
if isinstance(mw, ReadOnlyAgentMiddleware)
|
||||
and mw._agent_name == BuiltinAgentName.PLAN
|
||||
)
|
||||
|
||||
|
||||
class TestReadOnlyAgentMiddlewareIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_agent_preserves_middleware_state_for_exit_message(
|
||||
self,
|
||||
|
|
@ -414,11 +427,7 @@ class TestPlanAgentMiddlewareIntegration:
|
|||
)
|
||||
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
|
||||
|
||||
plan_middleware = next(
|
||||
mw
|
||||
for mw in agent.middleware_pipeline.middlewares
|
||||
if isinstance(mw, PlanAgentMiddleware)
|
||||
)
|
||||
plan_middleware = _find_plan_middleware(agent)
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
|
|
@ -429,11 +438,7 @@ class TestPlanAgentMiddlewareIntegration:
|
|||
|
||||
await agent.switch_agent(BuiltinAgentName.DEFAULT)
|
||||
|
||||
plan_middleware_after = next(
|
||||
mw
|
||||
for mw in agent.middleware_pipeline.middlewares
|
||||
if isinstance(mw, PlanAgentMiddleware)
|
||||
)
|
||||
plan_middleware_after = _find_plan_middleware(agent)
|
||||
assert plan_middleware is plan_middleware_after
|
||||
|
||||
ctx = ConversationContext(
|
||||
|
|
@ -456,11 +461,7 @@ class TestPlanAgentMiddlewareIntegration:
|
|||
)
|
||||
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
|
||||
|
||||
plan_middleware = next(
|
||||
mw
|
||||
for mw in agent.middleware_pipeline.middlewares
|
||||
if isinstance(mw, PlanAgentMiddleware)
|
||||
)
|
||||
plan_middleware = _find_plan_middleware(agent)
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
|
|
@ -497,11 +498,7 @@ class TestPlanAgentMiddlewareIntegration:
|
|||
)
|
||||
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
|
||||
|
||||
plan_middleware = next(
|
||||
mw
|
||||
for mw in agent.middleware_pipeline.middlewares
|
||||
if isinstance(mw, PlanAgentMiddleware)
|
||||
)
|
||||
plan_middleware = _find_plan_middleware(agent)
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
|
|
@ -532,11 +529,7 @@ class TestPlanAgentMiddlewareIntegration:
|
|||
config=config, agent_name=BuiltinAgentName.DEFAULT
|
||||
)
|
||||
|
||||
plan_middleware = next(
|
||||
mw
|
||||
for mw in agent.middleware_pipeline.middlewares
|
||||
if isinstance(mw, PlanAgentMiddleware)
|
||||
)
|
||||
plan_middleware = _find_plan_middleware(agent)
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
|
|
@ -566,11 +559,7 @@ class TestPlanAgentMiddlewareIntegration:
|
|||
)
|
||||
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
|
||||
|
||||
plan_middleware = next(
|
||||
mw
|
||||
for mw in agent.middleware_pipeline.middlewares
|
||||
if isinstance(mw, PlanAgentMiddleware)
|
||||
)
|
||||
plan_middleware = _find_plan_middleware(agent)
|
||||
|
||||
def _ctx():
|
||||
return ConversationContext(
|
||||
|
|
|
|||
|
|
@ -76,14 +76,14 @@ async def test_decodes_non_utf8_bytes(bash):
|
|||
assert result.stderr == ""
|
||||
|
||||
|
||||
def test_check_allowlist_denylist():
|
||||
def test_resolve_permission():
|
||||
config = BashToolConfig(allowlist=["echo", "pwd"], denylist=["rm"])
|
||||
bash_tool = Bash(config=config, state=BaseToolState())
|
||||
|
||||
allowlisted = bash_tool.check_allowlist_denylist(BashArgs(command="echo hi"))
|
||||
denylisted = bash_tool.check_allowlist_denylist(BashArgs(command="rm -rf /tmp"))
|
||||
mixed = bash_tool.check_allowlist_denylist(BashArgs(command="pwd && whoami"))
|
||||
empty = bash_tool.check_allowlist_denylist(BashArgs(command=""))
|
||||
allowlisted = bash_tool.resolve_permission(BashArgs(command="echo hi"))
|
||||
denylisted = bash_tool.resolve_permission(BashArgs(command="rm -rf /tmp"))
|
||||
mixed = bash_tool.resolve_permission(BashArgs(command="pwd && whoami"))
|
||||
empty = bash_tool.resolve_permission(BashArgs(command=""))
|
||||
|
||||
assert allowlisted is ToolPermission.ALWAYS
|
||||
assert denylisted is ToolPermission.NEVER
|
||||
|
|
|
|||
|
|
@ -5,21 +5,15 @@ import shutil
|
|||
import pytest
|
||||
|
||||
from tests.mock.utils import collect_result
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.builtins.grep import (
|
||||
Grep,
|
||||
GrepArgs,
|
||||
GrepBackend,
|
||||
GrepState,
|
||||
GrepToolConfig,
|
||||
)
|
||||
from vibe.core.tools.base import BaseToolState, ToolError
|
||||
from vibe.core.tools.builtins.grep import Grep, GrepArgs, GrepBackend, GrepToolConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def grep(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
config = GrepToolConfig()
|
||||
return Grep(config=config, state=GrepState())
|
||||
return Grep(config=config, state=BaseToolState())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -34,7 +28,7 @@ def grep_gnu_only(tmp_path, monkeypatch):
|
|||
|
||||
monkeypatch.setattr("shutil.which", mock_which)
|
||||
config = GrepToolConfig()
|
||||
return Grep(config=config, state=GrepState())
|
||||
return Grep(config=config, state=BaseToolState())
|
||||
|
||||
|
||||
def test_detects_ripgrep_when_available(grep):
|
||||
|
|
@ -143,7 +137,7 @@ async def test_truncates_to_max_matches(grep, tmp_path):
|
|||
async def test_truncates_to_max_output_bytes(grep, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
config = GrepToolConfig(max_output_bytes=100)
|
||||
grep_tool = Grep(config=config, state=GrepState())
|
||||
grep_tool = Grep(config=config, state=BaseToolState())
|
||||
(tmp_path / "test.py").write_text("\n".join("x" * 100 for _ in range(10)))
|
||||
|
||||
result = await collect_result(grep_tool.run(GrepArgs(pattern="x")))
|
||||
|
|
@ -191,22 +185,11 @@ async def test_ignores_comments_in_vibeignore(grep, tmp_path):
|
|||
assert result.match_count >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tracks_search_history(grep, tmp_path):
|
||||
(tmp_path / "test.py").write_text("content\n")
|
||||
|
||||
await collect_result(grep.run(GrepArgs(pattern="first")))
|
||||
await collect_result(grep.run(GrepArgs(pattern="second")))
|
||||
await collect_result(grep.run(GrepArgs(pattern="third")))
|
||||
|
||||
assert grep.state.search_history == ["first", "second", "third"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_effective_workdir(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
config = GrepToolConfig()
|
||||
grep_tool = Grep(config=config, state=GrepState())
|
||||
grep_tool = Grep(config=config, state=BaseToolState())
|
||||
(tmp_path / "test.py").write_text("match\n")
|
||||
|
||||
result = await collect_result(grep_tool.run(GrepArgs(pattern="match", path=".")))
|
||||
|
|
|
|||
|
|
@ -227,6 +227,70 @@ class FileTool(BaseTool[FileToolArgs, FileToolResult, BaseToolConfig, BaseToolSt
|
|||
assert "file_tool" in tools
|
||||
|
||||
|
||||
class TestToolRuntimeAvailability:
|
||||
"""Tests for is_available() filtering in ToolManager."""
|
||||
|
||||
def test_unavailable_tool_excluded_from_available_tools(
|
||||
self, tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Tools where is_available() returns False should be excluded."""
|
||||
import sys
|
||||
|
||||
tool_dir = tmp_path / "tools"
|
||||
tool_dir.mkdir()
|
||||
(tool_dir / "conditional_tool.py").write_text("""
|
||||
import os
|
||||
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ConditionalToolArgs(BaseModel):
|
||||
pass
|
||||
|
||||
class ConditionalToolResult(BaseModel):
|
||||
pass
|
||||
|
||||
class ConditionalTool(BaseTool[ConditionalToolArgs, ConditionalToolResult, BaseToolConfig, BaseToolState]):
|
||||
description = "Tool that requires TEST_VAR"
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
return bool(os.getenv("TEST_VAR"))
|
||||
|
||||
async def run(self, args, ctx=None):
|
||||
yield ConditionalToolResult()
|
||||
""")
|
||||
|
||||
to_remove = [k for k in sys.modules if "conditional_tool" in k]
|
||||
for k in to_remove:
|
||||
del sys.modules[k]
|
||||
|
||||
monkeypatch.delenv("TEST_VAR", raising=False)
|
||||
vibe_config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
tool_paths=[tool_dir],
|
||||
)
|
||||
manager = ToolManager(lambda: vibe_config)
|
||||
assert "conditional_tool" not in manager.available_tools
|
||||
|
||||
to_remove = [k for k in sys.modules if "conditional_tool" in k]
|
||||
for k in to_remove:
|
||||
del sys.modules[k]
|
||||
|
||||
monkeypatch.setenv("TEST_VAR", "1")
|
||||
manager2 = ToolManager(lambda: vibe_config)
|
||||
assert "conditional_tool" in manager2.available_tools
|
||||
|
||||
def test_default_is_available_returns_true(self):
|
||||
"""Tools without is_available() override should be available."""
|
||||
vibe_config = build_test_vibe_config(
|
||||
system_prompt_id="tests", include_project_context=False
|
||||
)
|
||||
manager = ToolManager(lambda: vibe_config)
|
||||
|
||||
assert "bash" in manager.available_tools
|
||||
|
||||
|
||||
class TestToolManagerModuleReuse:
|
||||
"""Tests for module reuse across ToolManager instances.
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import pytest
|
|||
|
||||
from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp
|
||||
from vibe.core.tools.mcp import (
|
||||
MCPRegistry,
|
||||
MCPToolResult,
|
||||
RemoteTool,
|
||||
_mcp_stderr_capture,
|
||||
|
|
@ -372,3 +373,181 @@ class TestMCPConfigModels:
|
|||
|
||||
# Trailing special chars become underscores which are then stripped
|
||||
assert config.name == "my_server"
|
||||
|
||||
|
||||
class TestMCPRegistry:
|
||||
def _make_http_server(
|
||||
self, name: str, url: str = "http://localhost:8080"
|
||||
) -> MCPHttp:
|
||||
return MCPHttp(name=name, transport="http", url=url)
|
||||
|
||||
def _make_stdio_server(self, name: str, command: str = "python -m srv") -> MCPStdio:
|
||||
return MCPStdio(name=name, transport="stdio", command=command)
|
||||
|
||||
def test_server_key_is_stable(self):
|
||||
srv = self._make_http_server("s1")
|
||||
registry = MCPRegistry()
|
||||
|
||||
assert registry._server_key(srv) == registry._server_key(srv)
|
||||
|
||||
def test_different_configs_produce_different_keys(self):
|
||||
registry = MCPRegistry()
|
||||
s1 = self._make_http_server("s1", url="http://a:1")
|
||||
s2 = self._make_http_server("s2", url="http://b:2")
|
||||
|
||||
assert registry._server_key(s1) != registry._server_key(s2)
|
||||
|
||||
def test_get_tools_caches_discovery(self):
|
||||
registry = MCPRegistry()
|
||||
srv = self._make_http_server("cached")
|
||||
remote = RemoteTool(name="tool_a", description="A tool")
|
||||
proxy = create_mcp_http_proxy_tool_class(
|
||||
url="http://localhost:8080", remote=remote, alias="cached"
|
||||
)
|
||||
|
||||
key = registry._server_key(srv)
|
||||
registry._cache[key] = {proxy.get_name(): proxy}
|
||||
|
||||
tools = registry.get_tools([srv])
|
||||
assert "cached_tool_a" in tools
|
||||
assert tools["cached_tool_a"] is proxy
|
||||
|
||||
def test_get_tools_returns_empty_for_no_servers(self):
|
||||
registry = MCPRegistry()
|
||||
|
||||
assert registry.get_tools([]) == {}
|
||||
|
||||
def test_clear_drops_cache(self):
|
||||
registry = MCPRegistry()
|
||||
srv = self._make_http_server("s")
|
||||
proxy = create_mcp_http_proxy_tool_class(
|
||||
url="http://localhost:8080", remote=RemoteTool(name="t"), alias="s"
|
||||
)
|
||||
key = registry._server_key(srv)
|
||||
registry._cache[key] = {proxy.get_name(): proxy}
|
||||
|
||||
registry.clear()
|
||||
|
||||
assert len(registry._cache) == 0
|
||||
|
||||
def test_cache_survives_multiple_get_tools_calls(self):
|
||||
registry = MCPRegistry()
|
||||
srv = self._make_http_server("stable")
|
||||
remote = RemoteTool(name="t1")
|
||||
proxy = create_mcp_http_proxy_tool_class(
|
||||
url="http://localhost:8080", remote=remote, alias="stable"
|
||||
)
|
||||
|
||||
key = registry._server_key(srv)
|
||||
registry._cache[key] = {proxy.get_name(): proxy}
|
||||
|
||||
first = registry.get_tools([srv])
|
||||
second = registry.get_tools([srv])
|
||||
|
||||
assert first == second
|
||||
assert first["stable_t1"] is second["stable_t1"]
|
||||
|
||||
def test_disjoint_server_lists_across_agents(self):
|
||||
registry = MCPRegistry()
|
||||
|
||||
srv_x = self._make_http_server("x", url="http://x:1")
|
||||
srv_y = self._make_http_server("y", url="http://y:2")
|
||||
|
||||
proxy_x = create_mcp_http_proxy_tool_class(
|
||||
url="http://x:1", remote=RemoteTool(name="tx"), alias="x"
|
||||
)
|
||||
proxy_y = create_mcp_http_proxy_tool_class(
|
||||
url="http://y:2", remote=RemoteTool(name="ty"), alias="y"
|
||||
)
|
||||
|
||||
registry._cache[registry._server_key(srv_x)] = {proxy_x.get_name(): proxy_x}
|
||||
registry._cache[registry._server_key(srv_y)] = {proxy_y.get_name(): proxy_y}
|
||||
|
||||
agent_a_tools = registry.get_tools([srv_x])
|
||||
agent_b_tools = registry.get_tools([srv_y])
|
||||
|
||||
assert "x_tx" in agent_a_tools
|
||||
assert "y_ty" not in agent_a_tools
|
||||
assert "y_ty" in agent_b_tools
|
||||
assert "x_tx" not in agent_b_tools
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_http_success(self):
|
||||
registry = MCPRegistry()
|
||||
srv = self._make_http_server("demo", url="http://demo:9090")
|
||||
remote = RemoteTool(name="hello", description="Hi")
|
||||
|
||||
with patch(
|
||||
"vibe.core.tools.mcp.registry.list_tools_http", return_value=[remote]
|
||||
):
|
||||
tools = await registry._discover_http(srv)
|
||||
|
||||
assert tools is not None
|
||||
assert len(tools) == 1
|
||||
name = next(iter(tools))
|
||||
assert name == "demo_hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_http_failure_returns_none(self):
|
||||
registry = MCPRegistry()
|
||||
srv = self._make_http_server("fail", url="http://fail:1")
|
||||
|
||||
with patch(
|
||||
"vibe.core.tools.mcp.registry.list_tools_http",
|
||||
side_effect=ConnectionError("down"),
|
||||
):
|
||||
tools = await registry._discover_http(srv)
|
||||
|
||||
assert tools is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_stdio_success(self):
|
||||
registry = MCPRegistry()
|
||||
srv = self._make_stdio_server("local", command="python -m local_srv")
|
||||
remote = RemoteTool(name="run", description="Run it")
|
||||
|
||||
with patch(
|
||||
"vibe.core.tools.mcp.registry.list_tools_stdio", return_value=[remote]
|
||||
):
|
||||
tools = await registry._discover_stdio(srv)
|
||||
|
||||
assert tools is not None
|
||||
assert len(tools) == 1
|
||||
name = next(iter(tools))
|
||||
assert name == "local_run"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_stdio_failure_returns_none(self):
|
||||
registry = MCPRegistry()
|
||||
srv = self._make_stdio_server("broken")
|
||||
|
||||
with patch(
|
||||
"vibe.core.tools.mcp.registry.list_tools_stdio",
|
||||
side_effect=OSError("no binary"),
|
||||
):
|
||||
tools = await registry._discover_stdio(srv)
|
||||
|
||||
assert tools is None
|
||||
|
||||
def test_get_tools_discovers_only_uncached(self):
|
||||
registry = MCPRegistry()
|
||||
|
||||
cached_srv = self._make_http_server("cached", url="http://c:1")
|
||||
new_srv = self._make_http_server("new", url="http://n:2")
|
||||
|
||||
cached_proxy = create_mcp_http_proxy_tool_class(
|
||||
url="http://c:1", remote=RemoteTool(name="ct"), alias="cached"
|
||||
)
|
||||
registry._cache[registry._server_key(cached_srv)] = {
|
||||
cached_proxy.get_name(): cached_proxy
|
||||
}
|
||||
|
||||
new_remote = RemoteTool(name="nt")
|
||||
with patch(
|
||||
"vibe.core.tools.mcp.registry.list_tools_http", return_value=[new_remote]
|
||||
):
|
||||
tools = registry.get_tools([cached_srv, new_srv])
|
||||
|
||||
assert "cached_ct" in tools
|
||||
assert "new_nt" in tools
|
||||
assert len(registry._cache) == 2
|
||||
|
|
|
|||
171
tests/tools/test_mcp_sampling.py
Normal 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
|
||||
|
|
@ -8,7 +8,7 @@ from tests.conftest import build_test_vibe_config
|
|||
from tests.mock.utils import collect_result
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.agents.models import BUILTIN_AGENTS, AgentType
|
||||
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
|
||||
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError, ToolPermission
|
||||
from vibe.core.tools.builtins.task import Task, TaskArgs, TaskResult, TaskToolConfig
|
||||
from vibe.core.types import AssistantEvent, LLMMessage, Role
|
||||
|
||||
|
|
@ -76,6 +76,50 @@ class TestTaskToolValidation:
|
|||
assert agent.agent_type == AgentType.SUBAGENT
|
||||
|
||||
|
||||
class TestTaskToolResolvePermission:
|
||||
def test_explore_allowed_by_default(self, task_tool: Task) -> None:
|
||||
args = TaskArgs(task="do something", agent="explore")
|
||||
result = task_tool.resolve_permission(args)
|
||||
assert result == ToolPermission.ALWAYS
|
||||
|
||||
def test_unknown_agent_returns_none(self, task_tool: Task) -> None:
|
||||
args = TaskArgs(task="do something", agent="custom_agent")
|
||||
result = task_tool.resolve_permission(args)
|
||||
assert result is None
|
||||
|
||||
def test_denylist_takes_precedence(self) -> None:
|
||||
config = TaskToolConfig(allowlist=["explore"], denylist=["explore"])
|
||||
tool = Task(config=config, state=BaseToolState())
|
||||
args = TaskArgs(task="do something", agent="explore")
|
||||
result = tool.resolve_permission(args)
|
||||
assert result == ToolPermission.NEVER
|
||||
|
||||
def test_glob_pattern_in_allowlist(self) -> None:
|
||||
config = TaskToolConfig(allowlist=["exp*"])
|
||||
tool = Task(config=config, state=BaseToolState())
|
||||
args = TaskArgs(task="do something", agent="explore")
|
||||
result = tool.resolve_permission(args)
|
||||
assert result == ToolPermission.ALWAYS
|
||||
|
||||
def test_glob_pattern_in_denylist(self) -> None:
|
||||
config = TaskToolConfig(denylist=["danger*"])
|
||||
tool = Task(config=config, state=BaseToolState())
|
||||
args = TaskArgs(task="do something", agent="dangerous_agent")
|
||||
result = tool.resolve_permission(args)
|
||||
assert result == ToolPermission.NEVER
|
||||
|
||||
def test_empty_lists_returns_none(self) -> None:
|
||||
config = TaskToolConfig(allowlist=[], denylist=[])
|
||||
tool = Task(config=config, state=BaseToolState())
|
||||
args = TaskArgs(task="do something", agent="explore")
|
||||
result = tool.resolve_permission(args)
|
||||
assert result is None
|
||||
|
||||
def test_default_config_has_explore_in_allowlist(self) -> None:
|
||||
config = TaskToolConfig()
|
||||
assert "explore" in config.allowlist
|
||||
|
||||
|
||||
class TestTaskToolExecution:
|
||||
@pytest.fixture
|
||||
def ctx(self) -> InvokeContext:
|
||||
|
|
|
|||
253
tests/tools/test_webfetch.py
Normal 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"
|
||||
165
tests/tools/test_websearch.py
Normal 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
|
|
@ -1,21 +1,17 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.13'",
|
||||
"python_full_version < '3.13'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/a4/26698e0186933b4ab6e814626c99ee52b5522d039b5c94c983ecb3a66eed/agent_client_protocol-0.8.0.tar.gz", hash = "sha256:f9eade29167ff72a10fae7a0a0c1f27436909a790e159fb10265c2874e58d922", size = 68577, upload-time = "2026-02-07T17:08:46.513Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7b/7cdac86db388809d9e3bc58cac88cc7dfa49b7615b98fab304a828cd7f8a/agent_client_protocol-0.8.1.tar.gz", hash = "sha256:1bbf15663bf51f64942597f638e32a6284c5da918055d9672d3510e965143dbd", size = 68866, upload-time = "2026-02-13T15:34:54.567Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/04/e55a3c549c09c0023cb92c696c7b98d97bb657088f940e34f4bc47d1a49a/agent_client_protocol-0.8.0-py3-none-any.whl", hash = "sha256:2d5712b88b3249dbd6148b24d32c6eb8992e5663f224db6291524ac80cca8037", size = 54362, upload-time = "2026-02-07T17:08:45.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/f3/219eeca0ad4a20843d4b9eaac5532f87018b9d25730a62a16f54f6c52d1a/agent_client_protocol-0.8.1-py3-none-any.whl", hash = "sha256:9421a11fd435b4831660272d169c3812d553bb7247049c138c3ca127e4b8af8e", size = 54529, upload-time = "2026-02-13T15:34:53.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -38,15 +34,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.12.0"
|
||||
version = "4.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -59,12 +55,34 @@ wheels = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.11.12"
|
||||
name = "beautifulsoup4"
|
||||
version = "4.14.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
|
||||
dependencies = [
|
||||
{ name = "soupsieve" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
version = "7.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/07/56595285564e90777d758ebd383d6b0b971b87729bbe2184a849932a3736/cachetools-7.0.1.tar.gz", hash = "sha256:e31e579d2c5b6e2944177a0397150d312888ddf4e16e12f1016068f0c03b8341", size = 36126, upload-time = "2026-02-10T22:24:05.03Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484, upload-time = "2026-02-10T22:24:03.741Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -269,23 +287,23 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "debugpy"
|
||||
version = "1.8.19"
|
||||
version = "1.8.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/75/9e12d4d42349b817cd545b89247696c67917aab907012ae5b64bbfea3199/debugpy-1.8.19.tar.gz", hash = "sha256:eea7e5987445ab0b5ed258093722d5ecb8bb72217c5c9b1e21f64efe23ddebdb", size = 1644590, upload-time = "2025-12-15T21:53:28.044Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/15/d762e5263d9e25b763b78be72dc084c7a32113a0bac119e2f7acae7700ed/debugpy-1.8.19-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:bccb1540a49cde77edc7ce7d9d075c1dbeb2414751bc0048c7a11e1b597a4c2e", size = 2549995, upload-time = "2025-12-15T21:53:43.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/88/f7d25c68b18873b7c53d7c156ca7a7ffd8e77073aa0eac170a9b679cf786/debugpy-1.8.19-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:e9c68d9a382ec754dc05ed1d1b4ed5bd824b9f7c1a8cd1083adb84b3c93501de", size = 4309891, upload-time = "2025-12-15T21:53:45.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/4f/a65e973aba3865794da65f71971dca01ae66666132c7b2647182d5be0c5f/debugpy-1.8.19-cp312-cp312-win32.whl", hash = "sha256:6599cab8a783d1496ae9984c52cb13b7c4a3bd06a8e6c33446832a5d97ce0bee", size = 5286355, upload-time = "2025-12-15T21:53:46.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/3a/d3d8b48fec96e3d824e404bf428276fb8419dfa766f78f10b08da1cb2986/debugpy-1.8.19-cp312-cp312-win_amd64.whl", hash = "sha256:66e3d2fd8f2035a8f111eb127fa508469dfa40928a89b460b41fd988684dc83d", size = 5328239, upload-time = "2025-12-15T21:53:48.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/3d/388035a31a59c26f1ecc8d86af607d0c42e20ef80074147cd07b180c4349/debugpy-1.8.19-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:91e35db2672a0abaf325f4868fcac9c1674a0d9ad9bb8a8c849c03a5ebba3e6d", size = 2538859, upload-time = "2025-12-15T21:53:50.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/19/c93a0772d0962294f083dbdb113af1a7427bb632d36e5314297068f55db7/debugpy-1.8.19-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:85016a73ab84dea1c1f1dcd88ec692993bcbe4532d1b49ecb5f3c688ae50c606", size = 4292575, upload-time = "2025-12-15T21:53:51.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/56/09e48ab796b0a77e3d7dc250f95251832b8bf6838c9632f6100c98bdf426/debugpy-1.8.19-cp313-cp313-win32.whl", hash = "sha256:b605f17e89ba0ecee994391194285fada89cee111cfcd29d6f2ee11cbdc40976", size = 5286209, upload-time = "2025-12-15T21:53:53.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/4e/931480b9552c7d0feebe40c73725dd7703dcc578ba9efc14fe0e6d31cfd1/debugpy-1.8.19-cp313-cp313-win_amd64.whl", hash = "sha256:c30639998a9f9cd9699b4b621942c0179a6527f083c72351f95c6ab1728d5b73", size = 5328206, upload-time = "2025-12-15T21:53:55.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b9/cbec520c3a00508327476c7fce26fbafef98f412707e511eb9d19a2ef467/debugpy-1.8.19-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:1e8c4d1bd230067bf1bbcdbd6032e5a57068638eb28b9153d008ecde288152af", size = 2537372, upload-time = "2025-12-15T21:53:57.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/5e/cf4e4dc712a141e10d58405c58c8268554aec3c35c09cdcda7535ff13f76/debugpy-1.8.19-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d40c016c1f538dbf1762936e3aeb43a89b965069d9f60f9e39d35d9d25e6b809", size = 4268729, upload-time = "2025-12-15T21:53:58.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/a3/c91a087ab21f1047db328c1d3eb5d1ff0e52de9e74f9f6f6fa14cdd93d58/debugpy-1.8.19-cp314-cp314-win32.whl", hash = "sha256:0601708223fe1cd0e27c6cce67a899d92c7d68e73690211e6788a4b0e1903f5b", size = 5286388, upload-time = "2025-12-15T21:54:00.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/b8/bfdc30b6e94f1eff09f2dc9cc1f9cd1c6cde3d996bcbd36ce2d9a4956e99/debugpy-1.8.19-cp314-cp314-win_amd64.whl", hash = "sha256:8e19a725f5d486f20e53a1dde2ab8bb2c9607c40c00a42ab646def962b41125f", size = 5327741, upload-time = "2025-12-15T21:54:02.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/3e/e27078370414ef35fafad2c06d182110073daaeb5d3bf734b0b1eeefe452/debugpy-1.8.19-py2.py3-none-any.whl", hash = "sha256:360ffd231a780abbc414ba0f005dad409e71c78637efe8f2bd75837132a41d38", size = 5292321, upload-time = "2025-12-15T21:54:16.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/57/7f34f4736bfb6e00f2e4c96351b07805d83c9a7b33d28580ae01374430f7/debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d", size = 2550686, upload-time = "2026-01-29T23:03:42.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/78/b193a3975ca34458f6f0e24aaf5c3e3da72f5401f6054c0dfd004b41726f/debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b", size = 4310588, upload-time = "2026-01-29T23:03:43.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/55/f14deb95eaf4f30f07ef4b90a8590fc05d9e04df85ee379712f6fb6736d7/debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390", size = 5331372, upload-time = "2026-01-29T23:03:45.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/39/2bef246368bd42f9bd7cba99844542b74b84dacbdbea0833e610f384fee8/debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3", size = 5372835, upload-time = "2026-01-29T23:03:47.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/e2/fc500524cc6f104a9d049abc85a0a8b3f0d14c0a39b9c140511c61e5b40b/debugpy-1.8.20-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:5dff4bb27027821fdfcc9e8f87309a28988231165147c31730128b1c983e282a", size = 2539560, upload-time = "2026-01-29T23:03:48.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/83/fb33dcea789ed6018f8da20c5a9bc9d82adc65c0c990faed43f7c955da46/debugpy-1.8.20-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:84562982dd7cf5ebebfdea667ca20a064e096099997b175fe204e86817f64eaf", size = 4293272, upload-time = "2026-01-29T23:03:50.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/25/b1e4a01bfb824d79a6af24b99ef291e24189080c93576dfd9b1a2815cd0f/debugpy-1.8.20-cp313-cp313-win32.whl", hash = "sha256:da11dea6447b2cadbf8ce2bec59ecea87cc18d2c574980f643f2d2dfe4862393", size = 5331208, upload-time = "2026-01-29T23:03:51.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/f7/a0b368ce54ffff9e9028c098bd2d28cfc5b54f9f6c186929083d4c60ba58/debugpy-1.8.20-cp313-cp313-win_amd64.whl", hash = "sha256:eb506e45943cab2efb7c6eafdd65b842f3ae779f020c82221f55aca9de135ed7", size = 5372930, upload-time = "2026-01-29T23:03:53.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/2e/f6cb9a8a13f5058f0a20fe09711a7b726232cd5a78c6a7c05b2ec726cff9/debugpy-1.8.20-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9c74df62fc064cd5e5eaca1353a3ef5a5d50da5eb8058fcef63106f7bebe6173", size = 2538066, upload-time = "2026-01-29T23:03:54.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/56/6ddca50b53624e1ca3ce1d1e49ff22db46c47ea5fb4c0cc5c9b90a616364/debugpy-1.8.20-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:077a7447589ee9bc1ff0cdf443566d0ecf540ac8aa7333b775ebcb8ce9f4ecad", size = 4269425, upload-time = "2026-01-29T23:03:56.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/d9/d64199c14a0d4c476df46c82470a3ce45c8d183a6796cfb5e66533b3663c/debugpy-1.8.20-cp314-cp314-win32.whl", hash = "sha256:352036a99dd35053b37b7803f748efc456076f929c6a895556932eaf2d23b07f", size = 5331407, upload-time = "2026-01-29T23:03:58.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/d9/1f07395b54413432624d61524dfd98c1a7c7827d2abfdb8829ac92638205/debugpy-1.8.20-cp314-cp314-win_amd64.whl", hash = "sha256:a98eec61135465b062846112e5ecf2eebb855305acc1dfbae43b72903b8ab5be", size = 5372521, upload-time = "2026-01-29T23:03:59.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -299,11 +317,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "docutils"
|
||||
version = "0.22.3"
|
||||
version = "0.22.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/02/111134bfeb6e6c7ac4c74594e39a59f6c0195dc4846afbeac3cba60f1927/docutils-0.22.3.tar.gz", hash = "sha256:21486ae730e4ca9f622677b1412b879af1791efcfba517e4c6f60be543fc8cdd", size = 2290153, upload-time = "2025-11-06T02:35:55.655Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/11/a8/c6a4b901d17399c77cd81fb001ce8961e9f5e04d3daf27e8925cb012e163/docutils-0.22.3-py3-none-any.whl", hash = "sha256:bd772e4aca73aff037958d44f2be5229ded4c09927fcf8690c577b66234d6ceb", size = 633032, upload-time = "2025-11-06T02:35:52.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -326,11 +344,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.20.0"
|
||||
version = "3.24.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -380,6 +398,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "googleapis-common-protos"
|
||||
version = "1.72.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
|
|
@ -428,23 +458,23 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "id"
|
||||
version = "1.5.0"
|
||||
version = "1.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237, upload-time = "2024-12-04T19:53:05.575Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088, upload-time = "2026-02-04T16:19:41.26Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611, upload-time = "2024-12-04T19:53:03.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689, upload-time = "2026-02-04T16:19:40.051Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.6.15"
|
||||
version = "2.6.16"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -456,6 +486,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "importlib-metadata"
|
||||
version = "8.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "zipp" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
|
|
@ -488,23 +530,23 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "jaraco-context"
|
||||
version = "6.0.1"
|
||||
version = "6.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jaraco-functools"
|
||||
version = "4.3.0"
|
||||
version = "4.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "more-itertools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294", size = 19755, upload-time = "2025-08-18T20:05:09.91Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", size = 10408, upload-time = "2025-08-18T20:05:08.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -530,7 +572,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.25.1"
|
||||
version = "4.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
|
|
@ -538,9 +580,9 @@ dependencies = [
|
|||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -613,6 +655,19 @@ linkify = [
|
|||
{ name = "linkify-it-py" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdownify"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
|
|
@ -678,7 +733,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.23.0"
|
||||
version = "1.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
|
|
@ -696,9 +751,9 @@ dependencies = [
|
|||
{ name = "typing-inspection" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/1a/9c8a5362e3448d585081d6c7aa95898a64e0ac59d3e26169ae6c3ca5feaf/mcp-1.23.0.tar.gz", hash = "sha256:84e0c29316d0a8cf0affd196fd000487ac512aa3f771b63b2ea864e22961772b", size = 596506, upload-time = "2025-12-02T13:40:02.558Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/b2/28739ce409f98159c0121eab56e69ad71546c4f34ac8b42e58c03f57dccc/mcp-1.23.0-py3-none-any.whl", hash = "sha256:5a645cf111ed329f4619f2629a3f15d9aabd7adc2ea09d600d31467b51ecb64f", size = 231427, upload-time = "2025-12-02T13:40:00.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -724,17 +779,19 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "mistral-vibe"
|
||||
version = "2.2.1"
|
||||
version = "2.3.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "agent-client-protocol" },
|
||||
{ name = "anyio" },
|
||||
{ name = "cachetools" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "gitpython" },
|
||||
{ name = "giturlparse" },
|
||||
{ name = "google-auth" },
|
||||
{ name = "httpx" },
|
||||
{ name = "keyring" },
|
||||
{ name = "markdownify" },
|
||||
{ name = "mcp" },
|
||||
{ name = "mistralai" },
|
||||
{ name = "packaging" },
|
||||
|
|
@ -777,16 +834,18 @@ dev = [
|
|||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-client-protocol", specifier = "==0.8.0" },
|
||||
{ name = "agent-client-protocol", specifier = "==0.8.1" },
|
||||
{ name = "anyio", specifier = ">=4.12.0" },
|
||||
{ name = "cachetools", specifier = ">=5.5.0" },
|
||||
{ name = "cryptography", specifier = ">=44.0.0,<=46.0.3" },
|
||||
{ name = "gitpython", specifier = ">=3.1.46" },
|
||||
{ name = "giturlparse", specifier = ">=0.14.0" },
|
||||
{ name = "google-auth", specifier = ">=2.0.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "keyring", specifier = ">=25.6.0" },
|
||||
{ name = "markdownify", specifier = ">=1.2.2" },
|
||||
{ name = "mcp", specifier = ">=1.14.0" },
|
||||
{ name = "mistralai", specifier = "==1.9.11" },
|
||||
{ name = "mistralai", specifier = "==1.12.4" },
|
||||
{ name = "packaging", specifier = ">=24.1" },
|
||||
{ name = "pexpect", specifier = ">=4.9.0" },
|
||||
{ name = "pydantic", specifier = ">=2.12.4" },
|
||||
|
|
@ -825,20 +884,23 @@ dev = [
|
|||
|
||||
[[package]]
|
||||
name = "mistralai"
|
||||
version = "1.9.11"
|
||||
version = "1.12.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "eval-type-backport" },
|
||||
{ name = "httpx" },
|
||||
{ name = "invoke" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-http" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/8d/d8b7af67a966b6f227024e1cb7287fc19901a434f87a5a391dcfe635d338/mistralai-1.9.11.tar.gz", hash = "sha256:3df9e403c31a756ec79e78df25ee73cea3eb15f86693773e16b16adaf59c9b8a", size = 208051, upload-time = "2025-10-02T15:53:40.473Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/12/c3476c53e907255b5f485f085ba50dd9a84b40fe662e9a888d6ded26fa7b/mistralai-1.12.4.tar.gz", hash = "sha256:e52b53bab58025dcd208eeac13e3c3df5778d4112eeca1f08124096c7738929f", size = 243129, upload-time = "2026-02-20T17:55:13.73Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/76/4ce12563aea5a76016f8643eff30ab731e6656c845e9e4d090ef10c7b925/mistralai-1.9.11-py3-none-any.whl", hash = "sha256:7a3dc2b8ef3fceaa3582220234261b5c4e3e03a972563b07afa150e44a25a6d3", size = 442796, upload-time = "2025-10-02T15:53:39.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/f9/98d825105c450b9c67c27026caa374112b7e466c18331601d02ca278a01b/mistralai-1.12.4-py3-none-any.whl", hash = "sha256:7b69fcbc306436491ad3377fbdead527c9f3a0ce145ec029bf04c6308ff2cca6", size = 509321, upload-time = "2026-02-20T17:55:15.27Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -852,53 +914,136 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "nh3"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/a5/34c26015d3a434409f4d2a1cd8821a06c05238703f49283ffeb937bef093/nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376", size = 19288, upload-time = "2025-10-30T11:17:45.948Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/37/ab55eb2b05e334ff9a1ad52c556ace1f9c20a3f63613a165d384d5387657/nh3-0.3.3.tar.gz", hash = "sha256:185ed41b88c910b9ca8edc89ca3b4be688a12cb9de129d84befa2f74a0039fee", size = 18968, upload-time = "2026-02-14T09:35:15.664Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/01/a1eda067c0ba823e5e2bb033864ae4854549e49fb6f3407d2da949106bfb/nh3-0.3.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d18957a90806d943d141cc5e4a0fefa1d77cf0d7a156878bf9a66eed52c9cc7d", size = 1419839, upload-time = "2025-10-30T11:17:09.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/57/07826ff65d59e7e9cc789ef1dc405f660cabd7458a1864ab58aefa17411b/nh3-0.3.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45c953e57028c31d473d6b648552d9cab1efe20a42ad139d78e11d8f42a36130", size = 791183, upload-time = "2025-10-30T11:17:11.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/2f/e8a86f861ad83f3bb5455f596d5c802e34fcdb8c53a489083a70fd301333/nh3-0.3.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c9850041b77a9147d6bbd6dbbf13eeec7009eb60b44e83f07fcb2910075bf9b", size = 829127, upload-time = "2025-10-30T11:17:13.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/97/77aef4daf0479754e8e90c7f8f48f3b7b8725a3b8c0df45f2258017a6895/nh3-0.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:403c11563e50b915d0efdb622866d1d9e4506bce590ef7da57789bf71dd148b5", size = 997131, upload-time = "2025-10-30T11:17:14.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/ee/fd8140e4df9d52143e89951dd0d797f5546004c6043285289fbbe3112293/nh3-0.3.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0dca4365db62b2d71ff1620ee4f800c4729849906c5dd504ee1a7b2389558e31", size = 1068783, upload-time = "2025-10-30T11:17:15.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/64/bdd9631779e2d588b08391f7555828f352e7f6427889daf2fa424bfc90c9/nh3-0.3.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0fe7ee035dd7b2290715baf29cb27167dddd2ff70ea7d052c958dbd80d323c99", size = 994732, upload-time = "2025-10-30T11:17:17.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/66/90190033654f1f28ca98e3d76b8be1194505583f9426b0dcde782a3970a2/nh3-0.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a40202fd58e49129764f025bbaae77028e420f1d5b3c8e6f6fd3a6490d513868", size = 975997, upload-time = "2025-10-30T11:17:18.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/30/ebf8e2e8d71fdb5a5d5d8836207177aed1682df819cbde7f42f16898946c/nh3-0.3.2-cp314-cp314t-win32.whl", hash = "sha256:1f9ba555a797dbdcd844b89523f29cdc90973d8bd2e836ea6b962cf567cadd93", size = 583364, upload-time = "2025-10-30T11:17:20.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/ae/95c52b5a75da429f11ca8902c2128f64daafdc77758d370e4cc310ecda55/nh3-0.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:dce4248edc427c9b79261f3e6e2b3ecbdd9b88c267012168b4a7b3fc6fd41d13", size = 589982, upload-time = "2025-10-30T11:17:21.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/bd/c7d862a4381b95f2469704de32c0ad419def0f4a84b7a138a79532238114/nh3-0.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:019ecbd007536b67fdf76fab411b648fb64e2257ca3262ec80c3425c24028c80", size = 577126, upload-time = "2025-10-30T11:17:22.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/3e/f5a5cc2885c24be13e9b937441bd16a012ac34a657fe05e58927e8af8b7a/nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e", size = 1431980, upload-time = "2025-10-30T11:17:25.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/f7/529a99324d7ef055de88b690858f4189379708abae92ace799365a797b7f/nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8", size = 820805, upload-time = "2025-10-30T11:17:26.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/62/19b7c50ccd1fa7d0764822d2cea8f2a320f2fd77474c7a1805cb22cf69b0/nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866", size = 803527, upload-time = "2025-10-30T11:17:28.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/ca/f022273bab5440abff6302731a49410c5ef66b1a9502ba3fbb2df998d9ff/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131", size = 1051674, upload-time = "2025-10-30T11:17:29.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f7/5728e3b32a11daf5bd21cf71d91c463f74305938bc3eb9e0ac1ce141646e/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5", size = 1004737, upload-time = "2025-10-30T11:17:31.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/7f/f17e0dba0a99cee29e6cee6d4d52340ef9cb1f8a06946d3a01eb7ec2fb01/nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07", size = 911745, upload-time = "2025-10-30T11:17:32.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/0f/c76bf3dba22c73c38e9b1113b017cf163f7696f50e003404ec5ecdb1e8a6/nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7", size = 797184, upload-time = "2025-10-30T11:17:34.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/a1/73d8250f888fb0ddf1b119b139c382f8903d8bb0c5bd1f64afc7e38dad1d/nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87", size = 838556, upload-time = "2025-10-30T11:17:35.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/09/deb57f1fb656a7a5192497f4a287b0ade5a2ff6b5d5de4736d13ef6d2c1f/nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a", size = 1006695, upload-time = "2025-10-30T11:17:37.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/61/8f4d41c4ccdac30e4b1a4fa7be4b0f9914d8314a5058472f84c8e101a418/nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", size = 1075471, upload-time = "2025-10-30T11:17:38.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/c6/966aec0cb4705e69f6c3580422c239205d5d4d0e50fac380b21e87b6cf1b/nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", size = 1002439, upload-time = "2025-10-30T11:17:39.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/c8/97a2d5f7a314cce2c5c49f30c6f161b7f3617960ade4bfc2fd1ee092cb20/nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", size = 987439, upload-time = "2025-10-30T11:17:40.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/95/2d6fc6461687d7a171f087995247dec33e8749a562bfadd85fb5dbf37a11/nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", size = 589826, upload-time = "2025-10-30T11:17:42.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/9a/1a1c154f10a575d20dd634e5697805e589bbdb7673a0ad00e8da90044ba7/nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", size = 596406, upload-time = "2025-10-30T11:17:43.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/7e/a96255f63b7aef032cbee8fc4d6e37def72e3aaedc1f72759235e8f13cb1/nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", size = 584162, upload-time = "2025-10-30T11:17:44.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a4/834f0ebd80844ce67e1bdb011d6f844f61cdb4c1d7cdc56a982bc054cc00/nh3-0.3.3-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:21b058cd20d9f0919421a820a2843fdb5e1749c0bf57a6247ab8f4ba6723c9fc", size = 1428680, upload-time = "2026-02-14T09:34:33.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/1a/a7d72e750f74c6b71befbeebc4489579fe783466889d41f32e34acde0b6b/nh3-0.3.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4400a73c2a62859e769f9d36d1b5a7a5c65c4179d1dddd2f6f3095b2db0cbfc", size = 799003, upload-time = "2026-02-14T09:34:35.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/d5/089eb6d65da139dc2223b83b2627e00872eccb5e1afdf5b1d76eb6ad3fcc/nh3-0.3.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ef87f8e916321a88b45f2d597f29bd56e560ed4568a50f0f1305afab86b7189", size = 846818, upload-time = "2026-02-14T09:34:37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/c6/44a0b65fc7b213a3a725f041ef986534b100e58cd1a2e00f0fd3c9603893/nh3-0.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a446eae598987f49ee97ac2f18eafcce4e62e7574bd1eb23782e4702e54e217d", size = 1012537, upload-time = "2026-02-14T09:34:38.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/3a/91bcfcc0a61b286b8b25d39e288b9c0ba91c3290d402867d1cd705169844/nh3-0.3.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0d5eb734a78ac364af1797fef718340a373f626a9ff6b4fb0b4badf7927e7b81", size = 1095435, upload-time = "2026-02-14T09:34:40.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/fd/4617a19d80cf9f958e65724ff5e97bc2f76f2f4c5194c740016606c87bd1/nh3-0.3.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:92a958e6f6d0100e025a5686aafd67e3c98eac67495728f8bb64fbeb3e474493", size = 1056344, upload-time = "2026-02-14T09:34:41.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/7d/5bcbbc56e71b7dda7ef1d6008098da9c5426d6334137ef32bb2b9c496984/nh3-0.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9ed40cf8449a59a03aa465114fedce1ff7ac52561688811d047917cc878b19ca", size = 1034533, upload-time = "2026-02-14T09:34:43.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/9c/054eff8a59a8b23b37f0f4ac84cdd688ee84cf5251664c0e14e5d30a8a67/nh3-0.3.3-cp314-cp314t-win32.whl", hash = "sha256:b50c3770299fb2a7c1113751501e8878d525d15160a4c05194d7fe62b758aad8", size = 608305, upload-time = "2026-02-14T09:34:44.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/b0/64667b8d522c7b859717a02b1a66ba03b529ca1df623964e598af8db1ed5/nh3-0.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:21a63ccb18ddad3f784bb775955839b8b80e347e597726f01e43ca1abcc5c808", size = 620633, upload-time = "2026-02-14T09:34:46.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/b5/ae9909e4ddfd86ee076c4d6d62ba69e9b31061da9d2f722936c52df8d556/nh3-0.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f508ddd4e2433fdcb78c790fc2d24e3a349ba775e5fa904af89891321d4844a3", size = 607027, upload-time = "2026-02-14T09:34:47.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/3e/aef8cf8e0419b530c95e96ae93a5078e9b36c1e6613eeb1df03a80d5194e/nh3-0.3.3-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e8ee96156f7dfc6e30ecda650e480c5ae0a7d38f0c6fafc3c1c655e2500421d9", size = 1448640, upload-time = "2026-02-14T09:34:49.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/43/d2011a4f6c0272cb122eeff40062ee06bb2b6e57eabc3a5e057df0d582df/nh3-0.3.3-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45fe0d6a607264910daec30360c8a3b5b1500fd832d21b2da608256287bcb92d", size = 839405, upload-time = "2026-02-14T09:34:50.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/f3/965048510c1caf2a34ed04411a46a04a06eb05563cd06f1aa57b71eb2bc8/nh3-0.3.3-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bc1d4b30ba1ba896669d944b6003630592665974bd11a3dc2f661bde92798a7", size = 825849, upload-time = "2026-02-14T09:34:52.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/99/b4bbc6ad16329d8db2c2c320423f00b549ca3b129c2b2f9136be2606dbb0/nh3-0.3.3-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f433a2dd66545aad4a720ad1b2150edcdca75bfff6f4e6f378ade1ec138d5e77", size = 1068303, upload-time = "2026-02-14T09:34:54.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/34/3420d97065aab1b35f3e93ce9c96c8ebd423ce86fe84dee3126790421a2a/nh3-0.3.3-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52e973cb742e95b9ae1b35822ce23992428750f4b46b619fe86eba4205255b30", size = 1029316, upload-time = "2026-02-14T09:34:56.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/9a/99eda757b14e596fdb2ca5f599a849d9554181aa899274d0d183faef4493/nh3-0.3.3-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c730617bdc15d7092dcc0469dc2826b914c8f874996d105b4bc3842a41c1cd9", size = 919944, upload-time = "2026-02-14T09:34:57.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/84/c0dc75c7fb596135f999e59a410d9f45bdabb989f1cb911f0016d22b747b/nh3-0.3.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e98fa3dbfd54e25487e36ba500bc29bca3a4cab4ffba18cfb1a35a2d02624297", size = 811461, upload-time = "2026-02-14T09:34:59.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/ec/b1bf57cab6230eec910e4863528dc51dcf21b57aaf7c88ee9190d62c9185/nh3-0.3.3-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:3a62b8ae7c235481715055222e54c682422d0495a5c73326807d4e44c5d14691", size = 840360, upload-time = "2026-02-14T09:35:01.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/5e/326ae34e904dde09af1de51219a611ae914111f0970f2f111f4f0188f57e/nh3-0.3.3-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc305a2264868ec8fa16548296f803d8fd9c1fa66cd28b88b605b1bd06667c0b", size = 859872, upload-time = "2026-02-14T09:35:03.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/38/7eba529ce17ab4d3790205da37deabb4cb6edcba15f27b8562e467f2fc97/nh3-0.3.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:90126a834c18af03bfd6ff9a027bfa6bbf0e238527bc780a24de6bd7cc1041e2", size = 1023550, upload-time = "2026-02-14T09:35:04.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/a2/556fdecd37c3681b1edee2cf795a6799c6ed0a5551b2822636960d7e7651/nh3-0.3.3-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:24769a428e9e971e4ccfb24628f83aaa7dc3c8b41b130c8ddc1835fa1c924489", size = 1105212, upload-time = "2026-02-14T09:35:06.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/e3/5db0b0ad663234967d83702277094687baf7c498831a2d3ad3451c11770f/nh3-0.3.3-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:b7a18ee057761e455d58b9d31445c3e4b2594cff4ddb84d2e331c011ef46f462", size = 1069970, upload-time = "2026-02-14T09:35:08.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/b2/2ea21b79c6e869581ce5f51549b6e185c4762233591455bf2a326fb07f3b/nh3-0.3.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a4b2c1f3e6f3cbe7048e17f4fefad3f8d3e14cc0fd08fb8599e0d5653f6b181", size = 1047588, upload-time = "2026-02-14T09:35:09.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/92/2e434619e658c806d9c096eed2cdff9a883084299b7b19a3f0824eb8e63d/nh3-0.3.3-cp38-abi3-win32.whl", hash = "sha256:e974850b131fdffa75e7ad8e0d9c7a855b96227b093417fdf1bd61656e530f37", size = 616179, upload-time = "2026-02-14T09:35:11.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/88/1ce287ef8649dc51365b5094bd3713b76454838140a32ab4f8349973883c/nh3-0.3.3-cp38-abi3-win_amd64.whl", hash = "sha256:2efd17c0355d04d39e6d79122b42662277ac10a17ea48831d90b46e5ef7e4fc0", size = 631159, upload-time = "2026-02-14T09:35:12.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/f1/b4835dbde4fb06f29db89db027576d6014081cd278d9b6751facc3e69e43/nh3-0.3.3-cp38-abi3-win_arm64.whl", hash = "sha256:b838e619f483531483d26d889438e53a880510e832d2aafe73f93b7b1ac2bce2", size = 616645, upload-time = "2026-02-14T09:35:14.062Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.9.1"
|
||||
version = "1.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.39.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "importlib-metadata" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-exporter-otlp-proto-common"
|
||||
version = "1.39.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-proto" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-exporter-otlp-proto-http"
|
||||
version = "1.39.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "googleapis-common-protos" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-common" },
|
||||
{ name = "opentelemetry-proto" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-proto"
|
||||
version = "1.39.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-sdk"
|
||||
version = "1.39.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-semantic-conventions"
|
||||
version = "0.60b1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -924,11 +1069,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.5.0"
|
||||
version = "4.9.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -942,7 +1087,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "4.5.0"
|
||||
version = "4.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cfgv" },
|
||||
|
|
@ -951,9 +1096,24 @@ dependencies = [
|
|||
{ name = "pyyaml" },
|
||||
{ name = "virtualenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/9b/6a4ffb4ed980519da959e1cf3122fc6cb41211daa58dbae1c73c0e519a37/pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b", size = 198428, upload-time = "2025-11-22T21:02:42.304Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429, upload-time = "2025-11-22T21:02:40.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "6.33.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -988,11 +1148,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.23"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1083,16 +1243,16 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.12.0"
|
||||
version = "2.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1106,7 +1266,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pyinstaller"
|
||||
version = "6.17.0"
|
||||
version = "6.19.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "altgraph" },
|
||||
|
|
@ -1117,41 +1277,41 @@ dependencies = [
|
|||
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/80/9e0dad9c69a7cfd4b5aaede8c6225d762bab7247a2a6b7651e1995522001/pyinstaller-6.17.0.tar.gz", hash = "sha256:be372bd911392b88277e510940ac32a5c2a6ce4b8d00a311c78fa443f4f27313", size = 4014147, upload-time = "2025-11-24T19:43:32.109Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/63/fd62472b6371d89dc138d40c36d87a50dc2de18a035803bbdc376b4ffac4/pyinstaller-6.19.0.tar.gz", hash = "sha256:ec73aeb8bd9b7f2f1240d328a4542e90b3c6e6fbc106014778431c616592a865", size = 4036072, upload-time = "2026-02-14T18:06:28.718Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/35/f5/37e419d84d5284ecab11ef8b61306a3b978fe6f0fd69a9541e16bfd72e65/pyinstaller-6.17.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4e446b8030c6e5a2f712e3f82011ecf6c7ead86008357b0d23a0ec4bcde31dac", size = 1031880, upload-time = "2025-11-24T19:42:30.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/b6/2e184879ab9cf90a1d2867fdd34d507c4d246b3cc52ca05aad00bfc70ee7/pyinstaller-6.17.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:aa9fd87aaa28239c6f0d0210114029bd03f8cac316a90bab071a5092d7c85ad7", size = 731968, upload-time = "2025-11-24T19:42:35.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/76/f529de98f7e5cce7904c19b224990003fc2267eda2ee5fdd8452acb420a9/pyinstaller-6.17.0-py3-none-manylinux2014_i686.whl", hash = "sha256:060b122e43e7c0b23e759a4153be34bd70914135ab955bb18a67181e0dca85a2", size = 743217, upload-time = "2025-11-24T19:42:39.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/10/c02bfbb050cafc4c353cf69baf95407e211e1372bd286ab5ce5cbc13a30a/pyinstaller-6.17.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:cd213d1a545c97dfe4a3c40e8213ff7c5127fc115c49229f27a3fa541503444b", size = 741119, upload-time = "2025-11-24T19:42:43.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/9d/69fdacfd9335695f5900a376cfe3e4aed28f0720ffc15fee81fdb9d920bc/pyinstaller-6.17.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:89c0d18ba8b62c6607abd8cf2299ae5ffa5c36d8c47f39608ce8c3f357f6099f", size = 738111, upload-time = "2025-11-24T19:42:46.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/1e/e8e36e1568f6865ac706c6e1f875c1a346ddaa9f9a8f923d66545d2240ed/pyinstaller-6.17.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:2a147b83cdebb07855bd5a663600891550062373a2ca375c58eacead33741a27", size = 737795, upload-time = "2025-11-24T19:42:50.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/15/9dc0f81ccb746c27bfa6ee53164422fe47ee079c7a717d9c4791aba78797/pyinstaller-6.17.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:f8cfbbfa6708e54fb936df6dd6eafaf133e84efb0d2fe25b91cfeefa793c4ca4", size = 736891, upload-time = "2025-11-24T19:42:54.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/e6/bed54821c1ebe1275c559661d3e7bfa23c406673b515252dfbf89db56c65/pyinstaller-6.17.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:97f4c1942f7b4cd73f9e38b49cc8f5f8a6fbb44922cb60dd3073a189b77ee1ae", size = 736752, upload-time = "2025-11-24T19:42:58.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/84/897d759198676b910d69d42640b6d25d50b449f2209e18127a974cf59dbe/pyinstaller-6.17.0-py3-none-win32.whl", hash = "sha256:ce0be227a037fd4be672226db709088565484f597d6b230bceec19850fdd4c85", size = 1317851, upload-time = "2025-11-24T19:43:04.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/f5/6a122efe024433ecc34aab6f499e0bd2bbe059c639b77b0045aa2421b0bf/pyinstaller-6.17.0-py3-none-win_amd64.whl", hash = "sha256:b019940dbf7a01489d6b26f9fb97db74b504e0a757010f7ad078675befc85a82", size = 1378685, upload-time = "2025-11-24T19:43:10.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/96/14991773c9e599707a53594429ccf372f9ee638df3b7d26b65fd1a7433f0/pyinstaller-6.17.0-py3-none-win_arm64.whl", hash = "sha256:3c92a335e338170df7e615f75279cfeea97ade89e6dd7694943c8c185460f7b7", size = 1320032, upload-time = "2025-11-24T19:43:16.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/eb/23374721fecfa72677e79800921cb6aceefa6ba48574dc404f3f6c6c3be7/pyinstaller-6.19.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4190e76b74f0c4b5c5f11ac360928cd2e36ec8e3194d437bf6b8648c7bc0c134", size = 1040563, upload-time = "2026-02-14T18:05:22.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/7e/dfd724b0b533f5aaec0ee5df406fe2319987ed6964480a706f85478b12ea/pyinstaller-6.19.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8bd68abd812d8a6ba33b9f1810e91fee0f325969733721b78151f0065319ca11", size = 735477, upload-time = "2026-02-14T18:05:27.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/c9/ee3a4101c31f26344e66896c73c1fd6ed8282bf871473365b7f8674af406/pyinstaller-6.19.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1ec54ef967996ca61dacba676227e2b23219878ccce5ee9d6f3aada7b8ed8abf", size = 747143, upload-time = "2026-02-14T18:05:31.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0a/fc77e9f861be8cf300ac37155f59cc92aff99b29f2ddd78546f563a5b5a6/pyinstaller-6.19.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4ab2bb52e58448e14ddf9450601bdedd66800465043501c1d8f1cab87b60b122", size = 744849, upload-time = "2026-02-14T18:05:35.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/e3/6872e020ee758afe0b821663858492c10745608b07150e5e2c824a5b3e1c/pyinstaller-6.19.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:da6d5c6391ccefe73554b9fa29b86001c8e378e0f20c2a4004f836ba537eff63", size = 741590, upload-time = "2026-02-14T18:05:39.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/60/b8db5f1a4b0fb228175f2ea0aa33f949adcc097fbe981cc524f9faf85777/pyinstaller-6.19.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a0fc5f6b3c55aa54353f0c74ffa59b1115433c1850c6f655d62b461a2ed6cbbe", size = 741448, upload-time = "2026-02-14T18:05:45.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/4d/63b0600f2694e9141b83129fbc1c488ec84d5a0770b1448ec154dcd0fee9/pyinstaller-6.19.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:e649ba6bd1b0b89b210ad92adb5fbdc8a42dd2c5ca4f72ef3a0bfec83a424b83", size = 740613, upload-time = "2026-02-14T18:05:49.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/d4/e812ad36178093a0e9fd4b8127577748dd85b0cb71de912229dca21fd741/pyinstaller-6.19.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:481a909c8e60c8692fc60fcb1344d984b44b943f8bc9682f2fcdae305ad297e6", size = 740350, upload-time = "2026-02-14T18:05:54.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/03/b2c2ee41fb8e10fd2a45d21f5ec2ef25852cfb978dbf762972eed59e3d63/pyinstaller-6.19.0-py3-none-win32.whl", hash = "sha256:3c5c251054fe4cfaa04c34a363dcfbf811545438cb7198304cd444756bc2edd2", size = 1324317, upload-time = "2026-02-14T18:06:00.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/d3/6d5e62b8270e2b53a6065e281b3a7785079b00e9019c8019952828dd1669/pyinstaller-6.19.0-py3-none-win_amd64.whl", hash = "sha256:b5bb6536c6560330d364d91522250f254b107cf69129d9cbcd0e6727c570be33", size = 1384894, upload-time = "2026-02-14T18:06:06.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/65/458cd523308a101a22fd2742893405030cc24994cc74b1b767cecf137160/pyinstaller-6.19.0-py3-none-win_arm64.whl", hash = "sha256:c2d5a539b0bfe6159d5522c8c70e1c0e487f22c2badae0f97d45246223b798ea", size = 1325374, upload-time = "2026-02-14T18:06:12.804Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyinstaller-hooks-contrib"
|
||||
version = "2025.10"
|
||||
version = "2026.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/4f/e33132acdb8f732978e577b8a0130a412cbfe7a3414605e3fd380a975522/pyinstaller_hooks_contrib-2025.10.tar.gz", hash = "sha256:a1a737e5c0dccf1cf6f19a25e2efd109b9fec9ddd625f97f553dac16ee884881", size = 168155, upload-time = "2025-11-22T09:34:36.138Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/eb/e1dd9a5348e4cf348471c0e5fd617d948779bc3199cf4edb134d8fceca91/pyinstaller_hooks_contrib-2026.1.tar.gz", hash = "sha256:a5f0891a1e81e92406ab917d9e76adfd7a2b68415ee2e35c950a7b3910bc361b", size = 171504, upload-time = "2026-02-18T13:01:15.711Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/86/de/a7688eed49a1d3df337cdaa4c0d64e231309a52f269850a72051975e3c4a/pyinstaller_hooks_contrib-2025.10-py3-none-any.whl", hash = "sha256:aa7a378518772846221f63a84d6306d9827299323243db890851474dfd1231a9", size = 447760, upload-time = "2025-11-22T09:34:34.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/69/12bafee3cc485d977f596e0d803d7c6fb147430fc35dfe505730aa3a28dd/pyinstaller_hooks_contrib-2026.1-py3-none-any.whl", hash = "sha256:66ad4888ba67de6f3cfd7ef554f9dd1a4389e2eb19f84d7129a5a6818e3f2180", size = 452841, upload-time = "2026-02-18T13:01:14.471Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
version = "2.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -1170,15 +1330,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pyright"
|
||||
version = "1.1.407"
|
||||
version = "1.1.408"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nodeenv" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1274,11 +1434,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.20"
|
||||
version = "0.0.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1430,15 +1590,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.2.0"
|
||||
version = "14.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1536,28 +1696,27 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.14.7"
|
||||
version = "0.15.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b7/5b/dd7406afa6c95e3d8fa9d652b6d6dd17dd4a6bf63cb477014e8ccd3dcd46/ruff-0.14.7.tar.gz", hash = "sha256:3417deb75d23bd14a722b57b0a1435561db65f0ad97435b4cf9f85ffcef34ae5", size = 5727324, upload-time = "2025-11-28T20:55:10.525Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/b1/7ea5647aaf90106f6d102230e5df874613da43d1089864da1553b899ba5e/ruff-0.14.7-py3-none-linux_armv6l.whl", hash = "sha256:b9d5cb5a176c7236892ad7224bc1e63902e4842c460a0b5210701b13e3de4fca", size = 13414475, upload-time = "2025-11-28T20:54:54.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/19/fddb4cd532299db9cdaf0efdc20f5c573ce9952a11cb532d3b859d6d9871/ruff-0.14.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3f64fe375aefaf36ca7d7250292141e39b4cea8250427482ae779a2aa5d90015", size = 13634613, upload-time = "2025-11-28T20:55:17.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/2b/469a66e821d4f3de0440676ed3e04b8e2a1dc7575cf6fa3ba6d55e3c8557/ruff-0.14.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93e83bd3a9e1a3bda64cb771c0d47cda0e0d148165013ae2d3554d718632d554", size = 12765458, upload-time = "2025-11-28T20:55:26.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/05/0b001f734fe550bcfde4ce845948ac620ff908ab7241a39a1b39bb3c5f49/ruff-0.14.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3838948e3facc59a6070795de2ae16e5786861850f78d5914a03f12659e88f94", size = 13236412, upload-time = "2025-11-28T20:55:28.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/36/8ed15d243f011b4e5da75cd56d6131c6766f55334d14ba31cce5461f28aa/ruff-0.14.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24c8487194d38b6d71cd0fd17a5b6715cda29f59baca1defe1e3a03240f851d1", size = 13182949, upload-time = "2025-11-28T20:55:33.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/cf/fcb0b5a195455729834f2a6eadfe2e4519d8ca08c74f6d2b564a4f18f553/ruff-0.14.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79c73db6833f058a4be8ffe4a0913b6d4ad41f6324745179bd2aa09275b01d0b", size = 13816470, upload-time = "2025-11-28T20:55:08.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/5d/34a4748577ff7a5ed2f2471456740f02e86d1568a18c9faccfc73bd9ca3f/ruff-0.14.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:12eb7014fccff10fc62d15c79d8a6be4d0c2d60fe3f8e4d169a0d2def75f5dad", size = 15289621, upload-time = "2025-11-28T20:55:30.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/53/0a9385f047a858ba133d96f3f8e3c9c66a31cc7c4b445368ef88ebeac209/ruff-0.14.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c623bbdc902de7ff715a93fa3bb377a4e42dd696937bf95669118773dbf0c50", size = 14975817, upload-time = "2025-11-28T20:55:24.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/d7/2f1c32af54c3b46e7fadbf8006d8b9bcfbea535c316b0bd8813d6fb25e5d/ruff-0.14.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f53accc02ed2d200fa621593cdb3c1ae06aa9b2c3cae70bc96f72f0000ae97a9", size = 14284549, upload-time = "2025-11-28T20:55:06.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/05/434ddd86becd64629c25fb6b4ce7637dd52a45cc4a4415a3008fe61c27b9/ruff-0.14.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:281f0e61a23fcdcffca210591f0f53aafaa15f9025b5b3f9706879aaa8683bc4", size = 14071389, upload-time = "2025-11-28T20:55:35.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/50/fdf89d4d80f7f9d4f420d26089a79b3bb1538fe44586b148451bc2ba8d9c/ruff-0.14.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dbbaa5e14148965b91cb090236931182ee522a5fac9bc5575bafc5c07b9f9682", size = 14202679, upload-time = "2025-11-28T20:55:01.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/54/87b34988984555425ce967f08a36df0ebd339bb5d9d0e92a47e41151eafc/ruff-0.14.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1464b6e54880c0fe2f2d6eaefb6db15373331414eddf89d6b903767ae2458143", size = 13147677, upload-time = "2025-11-28T20:55:19.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/29/f55e4d44edfe053918a16a3299e758e1c18eef216b7a7092550d7a9ec51c/ruff-0.14.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f217ed871e4621ea6128460df57b19ce0580606c23aeab50f5de425d05226784", size = 13151392, upload-time = "2025-11-28T20:55:21.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/69/47aae6dbd4f1d9b4f7085f4d9dcc84e04561ee7ad067bf52e0f9b02e3209/ruff-0.14.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6be02e849440ed3602d2eb478ff7ff07d53e3758f7948a2a598829660988619e", size = 13412230, upload-time = "2025-11-28T20:55:12.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/4b/6e96cb6ba297f2ba502a231cd732ed7c3de98b1a896671b932a5eefa3804/ruff-0.14.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19a0f116ee5e2b468dfe80c41c84e2bbd6b74f7b719bee86c2ecde0a34563bcc", size = 14195397, upload-time = "2025-11-28T20:54:56.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/82/251d5f1aa4dcad30aed491b4657cecd9fb4274214da6960ffec144c260f7/ruff-0.14.7-py3-none-win32.whl", hash = "sha256:e33052c9199b347c8937937163b9b149ef6ab2e4bb37b042e593da2e6f6cccfa", size = 13126751, upload-time = "2025-11-28T20:55:03.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/b5/d0b7d145963136b564806f6584647af45ab98946660d399ec4da79cae036/ruff-0.14.7-py3-none-win_amd64.whl", hash = "sha256:e17a20ad0d3fad47a326d773a042b924d3ac31c6ca6deb6c72e9e6b5f661a7c6", size = 14531726, upload-time = "2025-11-28T20:54:59.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/d2/1637f4360ada6a368d3265bf39f2cf737a0aaab15ab520fc005903e883f8/ruff-0.14.7-py3-none-win_arm64.whl", hash = "sha256:be4d653d3bea1b19742fcc6502354e32f65cd61ff2fbdb365803ef2c2aec6228", size = 13609215, upload-time = "2025-11-28T20:55:15.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1575,11 +1734,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "80.9.0"
|
||||
version = "82.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1600,28 +1759,39 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.0.3"
|
||||
version = "3.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943, upload-time = "2025-10-30T18:44:20.117Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253, upload-time = "2026-01-17T13:11:05.62Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765, upload-time = "2025-10-30T18:44:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763, upload-time = "2026-01-17T13:11:03.775Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.46.2"
|
||||
version = "0.52.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1638,7 +1808,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "textual"
|
||||
version = "7.4.0"
|
||||
version = "8.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py", extra = ["linkify"] },
|
||||
|
|
@ -1648,9 +1818,9 @@ dependencies = [
|
|||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/64/8d/2fbd6b8652f4cabf9cb0852d7af1aa45b6cad32d0f50735856e8f9e41719/textual-7.4.0.tar.gz", hash = "sha256:1a9598e485492f9a8f033c7ec5e59528df3ab0742fda925681acf78b0fb210de", size = 1592252, upload-time = "2026-01-25T19:57:04.624Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/08/1e1f705825359590ddfaeda57653bd518c4ff7a96bb2c3239ba1b6fc4c51/textual-8.0.0.tar.gz", hash = "sha256:ce48f83a3d686c0fac0e80bf9136e1f8851c653aa6a4502e43293a151df18809", size = 1595895, upload-time = "2026-02-16T17:12:14.215Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/9c/4169ccffed6d53f78e3175eae0cd649990071c6e24b6ad8830812ebab726/textual-7.4.0-py3-none-any.whl", hash = "sha256:41a066cae649654d4ecfe53b8316f5737c0042d1693ce50690b769a7840780ac", size = 717985, upload-time = "2026-01-25T19:57:02.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/be/e191c2a15da20530fde03564564e3e4b4220eb9d687d4014957e5c6a5e85/textual-8.0.0-py3-none-any.whl", hash = "sha256:8908f4ebe93a6b4f77ca7262197784a52162bc88b05f4ecf50ac93a92d49bb8f", size = 718904, upload-time = "2026-02-16T17:12:11.962Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1813,19 +1983,19 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "typos"
|
||||
version = "1.40.0"
|
||||
version = "1.43.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/f0/8d988732b10ef72ed82900b055590a210a5ae423b4088d17fa961305ed6b/typos-1.40.0.tar.gz", hash = "sha256:5cb1a04a6291fa1fa358ce6d8cd5b50e396d0a306466b792ac6c246066b1780f", size = 1765534, upload-time = "2025-11-26T20:54:53.792Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/10/f3e13aece5b053745242aecbcdfa05110d65d5fb8d3ea25b05540ea97540/typos-1.43.5.tar.gz", hash = "sha256:aa445c5eaf0e32095c3183dda96e0fa8ffcabbfd796721c75d7a8a68e9cd0d75", size = 1806801, upload-time = "2026-02-16T15:34:17.41Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/dd/64ceee60d4d1d7d0c90dac8d3bb5ebfcc2e1d1e5b5166f3284abc4052e45/typos-1.40.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:71441cb06044baba29911e4b6500a85b2e915736d1fc0a54d5f575addb12a307", size = 3507274, upload-time = "2025-11-26T20:54:39.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/db/64f7146b86e912041aafe275f627081e4bd005f71932f5280cf0c3944f2b/typos-1.40.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:269e411f342126b06f38936eba9d391a41442c17425e57068797c9e6997e3fca", size = 3391108, upload-time = "2025-11-26T20:54:41.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f1/1eead106cc0c025319d23ccff78aa7b9c86a8a918f62359180f119deb96b/typos-1.40.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78d4d7be7e6f61c1bbec01abd9ee2e08254f633b845a9d2c5786051832c3e0c1", size = 8215390, upload-time = "2025-11-26T20:54:43.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/c9/dc027ec8819d1c652d80ac2c3b6216dcc4c6d198907e2c2ed29cd4710685/typos-1.40.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4dbc419aed7cd4b9e8ec71a28045a3b6262fa5a41170734a3fc4dfdf1e7d7a51", size = 7192543, upload-time = "2025-11-26T20:54:44.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/db/f6fef0f4d173f501b469a90ed3d462bf7e4301a28507b7914cefa1d78ca1/typos-1.40.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0701400559effc6806a043dac55e1b77fc09e540661bf4315eaf55a628138214", size = 7729297, upload-time = "2025-11-26T20:54:46.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/a4/bb5b415cd352168550170ba5bb7c6b1c53fe457084df5ff07488c525dca6/typos-1.40.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:41ed67ad7cba724841f72d5c7c69de20f79dbee52917fb1fb5f3efa327d44cd3", size = 7107127, upload-time = "2025-11-26T20:54:47.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/92/1a39cea9ba7369555ed3f540b48ed5fd6f059ec89e24fb87dd21df69bf2a/typos-1.40.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:47764e89fca194b77ff65741b1527210096e39984b2c460ba5bc4868ea05ea88", size = 8141765, upload-time = "2025-11-26T20:54:49.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/64/7d28b539b6d09b59ed3ea13f54e74e0cb8409fff3174928ee2f98ca349fb/typos-1.40.0-py3-none-win32.whl", hash = "sha256:9cd19efd5a3abcc788770ffb9a070f39da0d97c4aadd7eaf471e744a02002464", size = 3065525, upload-time = "2025-11-26T20:54:51.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/0a/e324e17a0407dfe2459ecd8c467b0b3953ec5c553bd552949fdc238bec91/typos-1.40.0-py3-none-win_amd64.whl", hash = "sha256:69c47f0b899bc62d87d6fc431824348782e76dca1867115976915a197b0a1fd2", size = 3254935, upload-time = "2025-11-26T20:54:52.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3d/698e7b760f68558048407718d4abb0bcc9baf9cfb7d1b9d68bcf60417031/typos-1.43.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:733221c09d6914c001d2ac16f630773dc36ff384df9ab128c9e93b019eeedf2f", size = 3474242, upload-time = "2026-02-16T15:34:03.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/08/3b09c26a9e476399f9b2c4ab42df4d079b900d3818d5f47a7e9c04d81192/typos-1.43.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1d2f86b458917fdc91b89f47101498adc7338361e1ed0bed6e4325d0e674aca", size = 3370863, upload-time = "2026-02-16T15:34:04.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/c6/64e75469439f78c75ed23a1c3712ce68e10293402da912c453f5b432ee9f/typos-1.43.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d75091c9519224f2f74964b6ca5133abfba9be44ce49f12a981e27c29bcef97", size = 8176174, upload-time = "2026-02-16T15:34:06.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/0f/60a3cc327cd5d5ab49f615c451ac24e59dedea2e33d69b3d54a6e9749c76/typos-1.43.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:839dfa2bc802b02460097b21f7d93d0fa87d5f76adb83049072211a7279f5ebe", size = 7326066, upload-time = "2026-02-16T15:34:08.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/6e/54ee65eb9a00f398d0767a10f73ea430ec072ff3a1fea40a3f3573ce7a61/typos-1.43.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0825a4d77d66726e86fac03c6cf2efc11b0a5c112b2156dcaeb61a24a807166", size = 7701673, upload-time = "2026-02-16T15:34:09.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/9b/b5a6dc3fadece24dbf6e9a528ed0b9d19d22e55681d5330c1c693cb8bcfe/typos-1.43.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7ee0a71ac21820868686ad52cb26a3e08197b09ceedf04d1736cb8472061665b", size = 7053904, upload-time = "2026-02-16T15:34:11.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/e1/9369b16d0797d4601a874e454825a3742b9df43941aca90d89448750ce2f/typos-1.43.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:09615c8f64e656940533aa025a9e8dc5c93e5cf6bfbedb059cdcafde75cb6d72", size = 8126680, upload-time = "2026-02-16T15:34:12.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/6f/dd5c786e860954a31fb5d2831a4d95e5f5b019cc6c558225fb6d38edc6c1/typos-1.43.5-py3-none-win32.whl", hash = "sha256:4c865b1b6149acbdaed9d548282ccfad526e1380de19d189e7ac675cd4869156", size = 3129847, upload-time = "2026-02-16T15:34:14.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/35/d496189b38ec92c0946e13e63fa630699fd785f6d765132dad0c3efe3b55/typos-1.43.5-py3-none-win_amd64.whl", hash = "sha256:10009e9a3702da037803b075efc94fa5328bd93af357fb6a2f284c0dbcc77f30", size = 3310650, upload-time = "2026-02-16T15:34:15.617Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1839,38 +2009,38 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.5.0"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.38.0"
|
||||
version = "0.41.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "20.35.4"
|
||||
version = "20.38.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "distlib" },
|
||||
{ name = "filelock" },
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d2/03/a94d404ca09a89a7301a7008467aed525d4cdeb9186d262154dd23208709/virtualenv-20.38.0.tar.gz", hash = "sha256:94f39b1abaea5185bf7ea5a46702b56f1d0c9aa2f41a6c2b8b0af4ddc74c10a7", size = 5864558, upload-time = "2026-02-19T07:48:02.385Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/d7/394801755d4c8684b655d35c665aea7836ec68320304f62ab3c94395b442/virtualenv-20.38.0-py3-none-any.whl", hash = "sha256:d6e78e5889de3a4742df2d3d44e779366325a90cf356f15621fddace82431794", size = 5837778, upload-time = "2026-02-19T07:47:59.778Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1952,6 +2122,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zipp"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
|
|
|
|||
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.2.1"
|
||||
__version__ = "2.3.0"
|
||||
|
|
|
|||
|
|
@ -37,14 +37,12 @@ from acp.schema import (
|
|||
Implementation,
|
||||
ListSessionsResponse,
|
||||
McpServerStdio,
|
||||
ModelInfo,
|
||||
PromptCapabilities,
|
||||
ResumeSessionResponse,
|
||||
SessionCapabilities,
|
||||
SessionInfo,
|
||||
SessionListCapabilities,
|
||||
SessionModelState,
|
||||
SessionModeState,
|
||||
SetSessionConfigOptionResponse,
|
||||
SseMcpServer,
|
||||
TextContentBlock,
|
||||
TextResourceContents,
|
||||
|
|
@ -56,6 +54,7 @@ from acp.schema import (
|
|||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from vibe import VIBE_ROOT, __version__
|
||||
from vibe.acp.acp_logger import acp_message_observer
|
||||
from vibe.acp.tools.base import BaseAcpTool
|
||||
from vibe.acp.tools.session_update import (
|
||||
tool_call_session_update,
|
||||
|
|
@ -71,12 +70,13 @@ from vibe.acp.utils import (
|
|||
create_tool_call_replay,
|
||||
create_tool_result_replay,
|
||||
create_user_message_replay,
|
||||
get_all_acp_session_modes,
|
||||
get_proxy_help_text,
|
||||
is_valid_acp_agent,
|
||||
is_valid_acp_mode,
|
||||
make_mode_response,
|
||||
make_model_response,
|
||||
)
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.agents.models import CHAT as CHAT_AGENT, BuiltinAgentName
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import (
|
||||
MissingAPIKeyError,
|
||||
|
|
@ -98,6 +98,7 @@ from vibe.core.types import (
|
|||
AsyncApprovalCallback,
|
||||
CompactEndEvent,
|
||||
CompactStartEvent,
|
||||
EntrypointMetadata,
|
||||
LLMMessage,
|
||||
ReasoningEvent,
|
||||
Role,
|
||||
|
|
@ -121,7 +122,8 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
def __init__(self) -> None:
|
||||
self.sessions: dict[str, AcpSessionLoop] = {}
|
||||
self.client_capabilities = None
|
||||
self.client_capabilities: ClientCapabilities | None = None
|
||||
self.client_info: Implementation | None = None
|
||||
|
||||
@override
|
||||
async def initialize(
|
||||
|
|
@ -132,6 +134,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
**kwargs: Any,
|
||||
) -> InitializeResponse:
|
||||
self.client_capabilities = client_capabilities
|
||||
self.client_info = client_info
|
||||
|
||||
# The ACP Agent process can be launched in 3 different ways, depending on installation
|
||||
# - dev mode: `uv run vibe-acp`, ran from the project root
|
||||
|
|
@ -200,6 +203,14 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
) -> AuthenticateResponse | None:
|
||||
raise NotImplementedError("Not implemented yet")
|
||||
|
||||
def _build_entrypoint_metadata(self) -> EntrypointMetadata:
|
||||
return EntrypointMetadata(
|
||||
agent_entrypoint="acp",
|
||||
agent_version=__version__,
|
||||
client_name=self.client_info.name if self.client_info else "",
|
||||
client_version=self.client_info.version if self.client_info else "",
|
||||
)
|
||||
|
||||
def _load_config(self) -> VibeConfig:
|
||||
try:
|
||||
config = VibeConfig.load(disabled_tools=["ask_user_question"])
|
||||
|
|
@ -223,21 +234,6 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
return session
|
||||
|
||||
def _build_session_model_state(self, agent_loop: AgentLoop) -> SessionModelState:
|
||||
return SessionModelState(
|
||||
current_model_id=agent_loop.config.active_model,
|
||||
available_models=[
|
||||
ModelInfo(model_id=model.alias, name=model.alias)
|
||||
for model in agent_loop.config.models
|
||||
],
|
||||
)
|
||||
|
||||
def _build_session_mode_state(self, session: AcpSessionLoop) -> SessionModeState:
|
||||
return SessionModeState(
|
||||
current_mode_id=session.agent_loop.agent_profile.name,
|
||||
available_modes=get_all_acp_session_modes(session.agent_loop.agent_manager),
|
||||
)
|
||||
|
||||
@override
|
||||
async def new_session(
|
||||
self,
|
||||
|
|
@ -251,19 +247,32 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
config = self._load_config()
|
||||
|
||||
agent_loop = AgentLoop(
|
||||
config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
|
||||
config=config,
|
||||
agent_name=BuiltinAgentName.DEFAULT,
|
||||
enable_streaming=True,
|
||||
entrypoint_metadata=self._build_entrypoint_metadata(),
|
||||
)
|
||||
agent_loop.agent_manager.register_agent(CHAT_AGENT)
|
||||
# NOTE: For now, we pin session.id to agent_loop.session_id right after init time.
|
||||
# We should just use agent_loop.session_id everywhere, but it can still change during
|
||||
# session lifetime (e.g. agent_loop.compact is called).
|
||||
# We should refactor agent_loop.session_id to make it immutable in ACP context.
|
||||
session = await self._create_acp_session(agent_loop.session_id, agent_loop)
|
||||
agent_loop.emit_new_session_telemetry("acp")
|
||||
agent_loop.emit_new_session_telemetry()
|
||||
|
||||
modes_state, modes_config = make_mode_response(
|
||||
list(agent_loop.agent_manager.available_agents.values()),
|
||||
session.agent_loop.agent_profile.name,
|
||||
)
|
||||
models_state, models_config = make_model_response(
|
||||
agent_loop.config.models, agent_loop.config.active_model
|
||||
)
|
||||
|
||||
return NewSessionResponse(
|
||||
session_id=session.id,
|
||||
models=self._build_session_model_state(agent_loop),
|
||||
modes=self._build_session_mode_state(session),
|
||||
models=models_state,
|
||||
modes=modes_state,
|
||||
config_options=[modes_config, models_config],
|
||||
)
|
||||
|
||||
def _get_acp_tool_overrides(self) -> list[Path]:
|
||||
|
|
@ -447,8 +456,12 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
}) from e
|
||||
|
||||
agent_loop = AgentLoop(
|
||||
config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
|
||||
config=config,
|
||||
agent_name=BuiltinAgentName.DEFAULT,
|
||||
enable_streaming=True,
|
||||
entrypoint_metadata=self._build_entrypoint_metadata(),
|
||||
)
|
||||
agent_loop.agent_manager.register_agent(CHAT_AGENT)
|
||||
|
||||
non_system_messages = [
|
||||
msg for msg in loaded_messages if msg.role != Role.system
|
||||
|
|
@ -460,19 +473,24 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
await self._replay_conversation_history(session_id, non_system_messages)
|
||||
|
||||
return LoadSessionResponse(
|
||||
models=self._build_session_model_state(agent_loop),
|
||||
modes=self._build_session_mode_state(session),
|
||||
modes_state, modes_config = make_mode_response(
|
||||
list(agent_loop.agent_manager.available_agents.values()),
|
||||
session.agent_loop.agent_profile.name,
|
||||
)
|
||||
models_state, models_config = make_model_response(
|
||||
agent_loop.config.models, agent_loop.config.active_model
|
||||
)
|
||||
|
||||
@override
|
||||
async def set_session_mode(
|
||||
self, mode_id: str, session_id: str, **kwargs: Any
|
||||
) -> SetSessionModeResponse | None:
|
||||
session = self._get_session(session_id)
|
||||
return LoadSessionResponse(
|
||||
models=models_state,
|
||||
modes=modes_state,
|
||||
config_options=[modes_config, models_config],
|
||||
)
|
||||
|
||||
if not is_valid_acp_agent(session.agent_loop.agent_manager, mode_id):
|
||||
return None
|
||||
async def _apply_mode_change(self, session: AcpSessionLoop, mode_id: str) -> bool:
|
||||
profiles = list(session.agent_loop.agent_manager.available_agents.values())
|
||||
if not is_valid_acp_mode(profiles, mode_id):
|
||||
return False
|
||||
|
||||
await session.agent_loop.switch_agent(mode_id)
|
||||
|
||||
|
|
@ -483,17 +501,12 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
self._create_approval_callback(session.id)
|
||||
)
|
||||
|
||||
return SetSessionModeResponse()
|
||||
|
||||
@override
|
||||
async def set_session_model(
|
||||
self, model_id: str, session_id: str, **kwargs: Any
|
||||
) -> SetSessionModelResponse | None:
|
||||
session = self._get_session(session_id)
|
||||
return True
|
||||
|
||||
async def _apply_model_change(self, session: AcpSessionLoop, model_id: str) -> bool:
|
||||
model_aliases = [model.alias for model in session.agent_loop.config.models]
|
||||
if model_id not in model_aliases:
|
||||
return None
|
||||
return False
|
||||
|
||||
VibeConfig.save_updates({"active_model": model_id})
|
||||
|
||||
|
|
@ -504,8 +517,59 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
|
||||
|
||||
return True
|
||||
|
||||
@override
|
||||
async def set_session_mode(
|
||||
self, mode_id: str, session_id: str, **kwargs: Any
|
||||
) -> SetSessionModeResponse | None:
|
||||
session = self._get_session(session_id)
|
||||
|
||||
if not await self._apply_mode_change(session, mode_id):
|
||||
return None
|
||||
|
||||
return SetSessionModeResponse()
|
||||
|
||||
@override
|
||||
async def set_session_model(
|
||||
self, model_id: str, session_id: str, **kwargs: Any
|
||||
) -> SetSessionModelResponse | None:
|
||||
session = self._get_session(session_id)
|
||||
|
||||
if not await self._apply_model_change(session, model_id):
|
||||
return None
|
||||
|
||||
return SetSessionModelResponse()
|
||||
|
||||
@override
|
||||
async def set_config_option(
|
||||
self, config_id: str, session_id: str, value: str, **kwargs: Any
|
||||
) -> SetSessionConfigOptionResponse | None:
|
||||
session = self._get_session(session_id)
|
||||
|
||||
match config_id:
|
||||
case "mode":
|
||||
success = await self._apply_mode_change(session, value)
|
||||
case "model":
|
||||
success = await self._apply_model_change(session, value)
|
||||
case _:
|
||||
success = False
|
||||
|
||||
if not success:
|
||||
return None
|
||||
|
||||
profiles = list(session.agent_loop.agent_manager.available_agents.values())
|
||||
_, modes_config = make_mode_response(
|
||||
profiles, session.agent_loop.agent_profile.name
|
||||
)
|
||||
_, models_config = make_model_response(
|
||||
session.agent_loop.config.models, session.agent_loop.config.active_model
|
||||
)
|
||||
|
||||
return SetSessionConfigOptionResponse(
|
||||
config_options=[modes_config, models_config]
|
||||
)
|
||||
|
||||
@override
|
||||
async def list_sessions(
|
||||
self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any
|
||||
|
|
@ -736,7 +800,13 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
def run_acp_server() -> None:
|
||||
try:
|
||||
asyncio.run(run_agent(agent=VibeAcpAgentLoop(), use_unstable_protocol=True))
|
||||
asyncio.run(
|
||||
run_agent(
|
||||
agent=VibeAcpAgentLoop(),
|
||||
use_unstable_protocol=True,
|
||||
observers=[acp_message_observer],
|
||||
)
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
# This is expected when the server is terminated
|
||||
pass
|
||||
|
|
|
|||
97
vibe/acp/acp_logger.py
Normal 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
|
||||
|
|
@ -7,8 +7,8 @@ import sys
|
|||
|
||||
from vibe import __version__
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE, unlock_config_paths
|
||||
from vibe.core.utils import logger
|
||||
|
||||
# Configure line buffering for subprocess communication
|
||||
sys.stdout.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ from acp.helpers import SessionUpdate, ToolCallContentVariant
|
|||
from acp.schema import ToolCallProgress
|
||||
from pydantic import BaseModel, ConfigDict, Field, SkipValidation
|
||||
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.tools.base import BaseTool, ToolError
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
from vibe.core.utils import logger
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ from acp.schema import (
|
|||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
|
||||
from vibe.core.tools.builtins.bash import Bash as CoreBashTool, BashArgs, BashResult
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
|
||||
from vibe.core.utils import logger
|
||||
|
||||
|
||||
class AcpBashState(BaseToolState, AcpToolState):
|
||||
|
|
@ -110,7 +110,16 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
|
|||
raise self._build_timeout_error(command, timeout)
|
||||
|
||||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> ToolCallStart:
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> ToolCallStart | None:
|
||||
if event.args is None:
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title="bash",
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind="execute",
|
||||
content=None,
|
||||
raw_input=None,
|
||||
)
|
||||
if not isinstance(event.args, BashArgs):
|
||||
raise ValueError(f"Unexpected tool args: {event.args}")
|
||||
|
||||
|
|
|
|||
|
|
@ -4,19 +4,18 @@ from pathlib import Path
|
|||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.base import BaseToolState, ToolError
|
||||
from vibe.core.tools.builtins.read_file import (
|
||||
ReadFile as CoreReadFileTool,
|
||||
ReadFileArgs,
|
||||
ReadFileResult,
|
||||
ReadFileState,
|
||||
_ReadResult,
|
||||
)
|
||||
|
||||
ReadFileResult = ReadFileResult
|
||||
|
||||
|
||||
class AcpReadFileState(ReadFileState, AcpToolState):
|
||||
class AcpReadFileState(BaseToolState, AcpToolState):
|
||||
pass
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,17 +12,16 @@ from acp.schema import (
|
|||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.base import BaseToolState, ToolError
|
||||
from vibe.core.tools.builtins.search_replace import (
|
||||
SearchReplace as CoreSearchReplaceTool,
|
||||
SearchReplaceArgs,
|
||||
SearchReplaceResult,
|
||||
SearchReplaceState,
|
||||
)
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class AcpSearchReplaceState(SearchReplaceState, AcpToolState):
|
||||
class AcpSearchReplaceState(BaseToolState, AcpToolState):
|
||||
file_backup_content: str | None = None
|
||||
|
||||
|
||||
|
|
@ -73,6 +72,15 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
|||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
|
||||
args = event.args
|
||||
if args is None:
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title="search_replace",
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind="edit",
|
||||
content=None,
|
||||
raw_input=None,
|
||||
)
|
||||
if not isinstance(args, SearchReplaceArgs):
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -12,17 +12,16 @@ from acp.schema import (
|
|||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.base import BaseToolState, ToolError
|
||||
from vibe.core.tools.builtins.write_file import (
|
||||
WriteFile as CoreWriteFileTool,
|
||||
WriteFileArgs,
|
||||
WriteFileResult,
|
||||
WriteFileState,
|
||||
)
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class AcpWriteFileState(WriteFileState, AcpToolState):
|
||||
class AcpWriteFileState(BaseToolState, AcpToolState):
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -51,6 +50,15 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
|
|||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
|
||||
args = event.args
|
||||
if args is None:
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title="write_file",
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind="edit",
|
||||
content=None,
|
||||
raw_input=None,
|
||||
)
|
||||
if not isinstance(args, WriteFileArgs):
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
|
|||
content=content,
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind=TOOL_KIND.get(event.tool_name, "other"),
|
||||
raw_input=event.args.model_dump_json(),
|
||||
raw_input=event.args.model_dump_json() if event.args else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,14 @@ from acp.schema import (
|
|||
AgentMessageChunk,
|
||||
AgentThoughtChunk,
|
||||
ContentToolCallContent,
|
||||
ModelInfo,
|
||||
PermissionOption,
|
||||
SessionConfigOption,
|
||||
SessionConfigOptionSelect,
|
||||
SessionConfigSelectOption,
|
||||
SessionMode,
|
||||
SessionModelState,
|
||||
SessionModeState,
|
||||
TextContentBlock,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
|
|
@ -21,7 +27,7 @@ from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
|
|||
from vibe.core.utils import compact_reduction_display
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.config import ModelConfig
|
||||
|
||||
|
||||
class ToolOption(StrEnum):
|
||||
|
|
@ -50,22 +56,80 @@ TOOL_OPTIONS = [
|
|||
]
|
||||
|
||||
|
||||
def agent_profile_to_acp(profile: AgentProfile) -> SessionMode:
|
||||
return SessionMode(
|
||||
id=profile.name, name=profile.display_name, description=profile.description
|
||||
def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
|
||||
return any(
|
||||
p.name == mode_name and p.agent_type == AgentType.AGENT for p in profiles
|
||||
)
|
||||
|
||||
|
||||
def is_valid_acp_agent(agent_manager: AgentManager, agent_name: str) -> bool:
|
||||
return agent_name in agent_manager.available_agents
|
||||
def make_mode_response(
|
||||
profiles: list[AgentProfile], current_mode_id: str
|
||||
) -> tuple[SessionModeState, SessionConfigOption]:
|
||||
session_modes: list[SessionMode] = []
|
||||
config_options: list[SessionConfigSelectOption] = []
|
||||
|
||||
for profile in profiles:
|
||||
if profile.agent_type != AgentType.AGENT:
|
||||
continue
|
||||
session_modes.append(
|
||||
SessionMode(
|
||||
id=profile.name,
|
||||
name=profile.display_name,
|
||||
description=profile.description,
|
||||
)
|
||||
)
|
||||
config_options.append(
|
||||
SessionConfigSelectOption(
|
||||
value=profile.name,
|
||||
name=profile.display_name,
|
||||
description=profile.description,
|
||||
)
|
||||
)
|
||||
|
||||
state = SessionModeState(
|
||||
current_mode_id=current_mode_id, available_modes=session_modes
|
||||
)
|
||||
config = SessionConfigOption(
|
||||
root=SessionConfigOptionSelect(
|
||||
id="mode",
|
||||
name="Session Mode",
|
||||
current_value=current_mode_id,
|
||||
category="mode",
|
||||
type="select",
|
||||
options=config_options,
|
||||
)
|
||||
)
|
||||
return state, config
|
||||
|
||||
|
||||
def get_all_acp_session_modes(agent_manager: AgentManager) -> list[SessionMode]:
|
||||
return [
|
||||
agent_profile_to_acp(profile)
|
||||
for profile in agent_manager.available_agents.values()
|
||||
if profile.agent_type == AgentType.AGENT
|
||||
]
|
||||
def make_model_response(
|
||||
models: list[ModelConfig], current_model_id: str
|
||||
) -> tuple[SessionModelState, SessionConfigOption]:
|
||||
model_infos: list[ModelInfo] = []
|
||||
config_options: list[SessionConfigSelectOption] = []
|
||||
|
||||
for model in models:
|
||||
model_infos.append(ModelInfo(model_id=model.alias, name=model.alias))
|
||||
config_options.append(
|
||||
SessionConfigSelectOption(
|
||||
value=model.alias, name=model.alias, description=model.name
|
||||
)
|
||||
)
|
||||
|
||||
state = SessionModelState(
|
||||
current_model_id=current_model_id, available_models=model_infos
|
||||
)
|
||||
config_option = SessionConfigOption(
|
||||
root=SessionConfigOptionSelect(
|
||||
id="model",
|
||||
name="Model",
|
||||
current_value=current_model_id,
|
||||
category="model",
|
||||
type="select",
|
||||
options=config_options,
|
||||
)
|
||||
)
|
||||
return state, config_option
|
||||
|
||||
|
||||
def create_compact_start_session_update(event: CompactStartEvent) -> ToolCallStart:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from rich import print as rprint
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.cli.textual_ui.app import run_textual_ui
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
|
|
@ -14,11 +16,12 @@ from vibe.core.config import (
|
|||
VibeConfig,
|
||||
load_dotenv_values,
|
||||
)
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE
|
||||
from vibe.core.programmatic import run_programmatic
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.types import LLMMessage, OutputFormat, Role
|
||||
from vibe.core.utils import ConversationLimitException, logger
|
||||
from vibe.core.types import EntrypointMetadata, LLMMessage, OutputFormat, Role
|
||||
from vibe.core.utils import ConversationLimitException
|
||||
from vibe.setup.onboarding import run_onboarding
|
||||
|
||||
|
||||
|
|
@ -74,7 +77,7 @@ def bootstrap_config_files() -> None:
|
|||
|
||||
def load_session(
|
||||
args: argparse.Namespace, config: VibeConfig
|
||||
) -> list[LLMMessage] | None:
|
||||
) -> tuple[list[LLMMessage], Path] | None:
|
||||
if not args.continue_session and not args.resume:
|
||||
return None
|
||||
|
||||
|
|
@ -107,18 +110,26 @@ def load_session(
|
|||
|
||||
try:
|
||||
loaded_messages, _ = SessionLoader.load_session(session_to_load)
|
||||
return loaded_messages
|
||||
return loaded_messages, session_to_load
|
||||
except Exception as e:
|
||||
rprint(f"[red]Failed to load session: {e}[/]")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _load_messages_from_previous_session(
|
||||
agent_loop: AgentLoop, loaded_messages: list[LLMMessage]
|
||||
def _resume_previous_session(
|
||||
agent_loop: AgentLoop, loaded_messages: list[LLMMessage], session_path: Path
|
||||
) -> None:
|
||||
non_system_messages = [msg for msg in loaded_messages if msg.role != Role.system]
|
||||
agent_loop.messages.extend(non_system_messages)
|
||||
logger.info("Loaded %d messages from previous session", len(non_system_messages))
|
||||
|
||||
_, metadata = SessionLoader.load_session(session_path)
|
||||
session_id = metadata.get("session_id", agent_loop.session_id)
|
||||
agent_loop.session_id = session_id
|
||||
agent_loop.session_logger.resume_existing_session(session_id, session_path)
|
||||
|
||||
logger.info(
|
||||
"Resumed session %s with %d messages", session_id, len(non_system_messages)
|
||||
)
|
||||
|
||||
|
||||
def run_cli(args: argparse.Namespace) -> None:
|
||||
|
|
@ -136,7 +147,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
if args.enabled_tools:
|
||||
config.enabled_tools = args.enabled_tools
|
||||
|
||||
loaded_messages = load_session(args, config)
|
||||
loaded_session = load_session(args, config)
|
||||
|
||||
stdin_prompt = get_prompt_from_stdin()
|
||||
if args.prompt is not None:
|
||||
|
|
@ -157,7 +168,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
max_turns=args.max_turns,
|
||||
max_price=args.max_price,
|
||||
output_format=output_format,
|
||||
previous_messages=loaded_messages,
|
||||
previous_messages=loaded_session[0] if loaded_session else None,
|
||||
agent_name=initial_agent_name,
|
||||
)
|
||||
if final_response:
|
||||
|
|
@ -171,11 +182,19 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
sys.exit(1)
|
||||
else:
|
||||
agent_loop = AgentLoop(
|
||||
config, agent_name=initial_agent_name, enable_streaming=True
|
||||
config,
|
||||
agent_name=initial_agent_name,
|
||||
enable_streaming=True,
|
||||
entrypoint_metadata=EntrypointMetadata(
|
||||
agent_entrypoint="cli",
|
||||
agent_version=__version__,
|
||||
client_name="vibe_cli",
|
||||
client_version=__version__,
|
||||
),
|
||||
)
|
||||
|
||||
if loaded_messages:
|
||||
_load_messages_from_previous_session(agent_loop, loaded_messages)
|
||||
if loaded_session:
|
||||
_resume_previous_session(agent_loop, *loaded_session)
|
||||
|
||||
run_textual_ui(
|
||||
agent_loop=agent_loop,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,11 @@ class CommandRegistry:
|
|||
description="Configure proxy and SSL certificate settings",
|
||||
handler="_show_proxy_setup",
|
||||
),
|
||||
"resume": Command(
|
||||
aliases=frozenset(["/resume", "/continue"]),
|
||||
description="Browse and resume past sessions",
|
||||
handler="_show_session_picker",
|
||||
),
|
||||
}
|
||||
|
||||
for command in excluded_commands:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class Terminal(Enum):
|
|||
VSCODE = "vscode"
|
||||
VSCODE_INSIDERS = "vscode_insiders"
|
||||
CURSOR = "cursor"
|
||||
JETBRAINS = "jetbrains"
|
||||
ITERM2 = "iterm2"
|
||||
WEZTERM = "wezterm"
|
||||
GHOSTTY = "ghostty"
|
||||
|
|
@ -50,6 +51,16 @@ def _detect_vscode_terminal() -> Literal[Terminal.VSCODE, Terminal.VSCODE_INSIDE
|
|||
return Terminal.VSCODE
|
||||
|
||||
|
||||
def _detect_terminal_from_env() -> Terminal | None:
|
||||
if os.environ.get("WEZTERM_PANE"):
|
||||
return Terminal.WEZTERM
|
||||
if os.environ.get("GHOSTTY_RESOURCES_DIR"):
|
||||
return Terminal.GHOSTTY
|
||||
if "jetbrains" in os.environ.get("TERMINAL_EMULATOR", "").lower():
|
||||
return Terminal.JETBRAINS
|
||||
return None
|
||||
|
||||
|
||||
def detect_terminal() -> Terminal:
|
||||
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
||||
|
||||
|
|
@ -66,12 +77,7 @@ def detect_terminal() -> Terminal:
|
|||
if term_program in term_map:
|
||||
return term_map[term_program]
|
||||
|
||||
if os.environ.get("WEZTERM_PANE"):
|
||||
return Terminal.WEZTERM
|
||||
if os.environ.get("GHOSTTY_RESOURCES_DIR"):
|
||||
return Terminal.GHOSTTY
|
||||
|
||||
return Terminal.UNKNOWN
|
||||
return _detect_terminal_from_env() or Terminal.UNKNOWN
|
||||
|
||||
|
||||
def _get_vscode_keybindings_path(is_stable: bool) -> Path | None:
|
||||
|
|
@ -303,6 +309,13 @@ def setup_terminal() -> SetupResult:
|
|||
match terminal:
|
||||
case Terminal.VSCODE | Terminal.VSCODE_INSIDERS | Terminal.CURSOR:
|
||||
return _setup_vscode_like_terminal(terminal)
|
||||
case Terminal.JETBRAINS:
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=Terminal.JETBRAINS,
|
||||
message="Jetbrains terminal is not supported.\n"
|
||||
"You can manually configure Shift+Enter to send: \\x1b[13;2u",
|
||||
)
|
||||
case Terminal.ITERM2:
|
||||
return _setup_iterm2()
|
||||
case Terminal.WEZTERM:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ from vibe.cli.plan_offer.decide_plan_offer import (
|
|||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway
|
||||
from vibe.cli.terminal_setup import setup_terminal
|
||||
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
|
||||
from vibe.cli.textual_ui.notifications import (
|
||||
NotificationContext,
|
||||
NotificationPort,
|
||||
TextualNotificationAdapter,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
|
||||
from vibe.cli.textual_ui.widgets.banner.banner import Banner
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
||||
|
|
@ -55,6 +60,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
|||
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
|
||||
from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
|
||||
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
|
||||
from vibe.cli.textual_ui.windowing import (
|
||||
|
|
@ -84,6 +90,7 @@ from vibe.core.agent_loop import AgentLoop, TeleportError
|
|||
from vibe.core.agents import AgentProfile
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths.config_paths import HISTORY_FILE
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.teleport.types import (
|
||||
|
|
@ -115,7 +122,6 @@ from vibe.core.utils import (
|
|||
CancellationReason,
|
||||
get_user_cancellation_message,
|
||||
is_dangerous_directory,
|
||||
logger,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -132,11 +138,16 @@ class BottomApp(StrEnum):
|
|||
Input = auto()
|
||||
ProxySetup = auto()
|
||||
Question = auto()
|
||||
SessionPicker = auto()
|
||||
|
||||
|
||||
class ChatScroll(VerticalScroll):
|
||||
"""Optimized scroll container that skips cascading style recalculations."""
|
||||
|
||||
@property
|
||||
def is_at_bottom(self) -> bool:
|
||||
return self.scroll_offset.y >= (self.max_scroll_y - 3)
|
||||
|
||||
def update_node_styles(self, animate: bool = True) -> None:
|
||||
pass
|
||||
|
||||
|
|
@ -145,34 +156,40 @@ PRUNE_LOW_MARK = 1000
|
|||
PRUNE_HIGH_MARK = 1500
|
||||
|
||||
|
||||
async def prune_by_height(messages_area: Widget, low_mark: int, high_mark: int) -> bool:
|
||||
"""Remove older children to keep virtual height within bounds.
|
||||
Implementation from https://github.com/batrachianai/toad/blob/a335b56c9015514d5f38654e3909aaa78850c510/src/toad/widgets/conversation.py#L1495
|
||||
async def prune_oldest_children(
|
||||
messages_area: Widget, low_mark: int, high_mark: int
|
||||
) -> bool:
|
||||
"""Remove the oldest children so the virtual height stays within bounds.
|
||||
|
||||
Walks children back-to-front to find how much to keep (up to *low_mark*
|
||||
of visible height), then removes everything before that point.
|
||||
"""
|
||||
height = messages_area.virtual_size.height
|
||||
if height <= high_mark:
|
||||
total_height = messages_area.virtual_size.height
|
||||
if total_height <= high_mark:
|
||||
return False
|
||||
prune_children: list[Widget] = []
|
||||
bottom_margin = 0
|
||||
prune_height = 0
|
||||
for child in messages_area.children:
|
||||
|
||||
children = messages_area.children
|
||||
if not children:
|
||||
return False
|
||||
|
||||
accumulated = 0
|
||||
cut = len(children)
|
||||
|
||||
for child in reversed(children):
|
||||
if not child.display:
|
||||
prune_children.append(child)
|
||||
cut -= 1
|
||||
continue
|
||||
top, _, bottom, _ = child.styles.margin
|
||||
child_height = child.outer_size.height
|
||||
prune_height = (
|
||||
(prune_height - bottom_margin + max(bottom_margin, top))
|
||||
+ bottom
|
||||
+ child_height
|
||||
)
|
||||
bottom_margin = bottom
|
||||
if height - prune_height <= low_mark:
|
||||
accumulated += child.outer_size.height
|
||||
cut -= 1
|
||||
if accumulated >= low_mark:
|
||||
break
|
||||
prune_children.append(child)
|
||||
if prune_children:
|
||||
await messages_area.remove_children(prune_children)
|
||||
return bool(prune_children)
|
||||
|
||||
to_remove = list(children[:cut])
|
||||
if not to_remove:
|
||||
return False
|
||||
|
||||
await messages_area.remove_children(to_remove)
|
||||
return True
|
||||
|
||||
|
||||
class VibeApp(App): # noqa: PLR0904
|
||||
|
|
@ -203,10 +220,16 @@ class VibeApp(App): # noqa: PLR0904
|
|||
update_cache_repository: UpdateCacheRepository | None = None,
|
||||
current_version: str = CORE_VERSION,
|
||||
plan_offer_gateway: WhoAmIGateway | None = None,
|
||||
terminal_notifier: NotificationPort | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.agent_loop = agent_loop
|
||||
self._terminal_notifier = terminal_notifier or TextualNotificationAdapter(
|
||||
self,
|
||||
get_enabled=lambda: self.config.enable_notifications,
|
||||
default_title="Vibe",
|
||||
)
|
||||
self._agent_running = False
|
||||
self._interrupt_requested = False
|
||||
self._agent_task: asyncio.Task | None = None
|
||||
|
|
@ -246,6 +269,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._cached_messages_area: Widget | None = None
|
||||
self._cached_chat: ChatScroll | None = None
|
||||
self._cached_loading_area: Widget | None = None
|
||||
self._switch_agent_generation = 0
|
||||
|
||||
@property
|
||||
def config(self) -> VibeConfig:
|
||||
|
|
@ -279,6 +303,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
async def on_mount(self) -> None:
|
||||
self.theme = "textual-ansi"
|
||||
self._terminal_notifier.restore()
|
||||
|
||||
self._cached_messages_area = self.query_one("#messages")
|
||||
self._cached_chat = self.query_one("#chat", ChatScroll)
|
||||
|
|
@ -311,7 +336,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._resume_history_from_messages()
|
||||
await self._check_and_show_whats_new()
|
||||
self._schedule_update_notification()
|
||||
self.agent_loop.emit_new_session_telemetry("cli")
|
||||
self.agent_loop.emit_new_session_telemetry()
|
||||
|
||||
self.call_after_refresh(self._refresh_banner)
|
||||
|
||||
|
|
@ -575,12 +600,16 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_agent_loop_turn(message)
|
||||
)
|
||||
|
||||
def _reset_ui_state(self) -> None:
|
||||
self._windowing.reset()
|
||||
self._tool_call_map = None
|
||||
self._history_widget_indices = WeakKeyDictionary()
|
||||
|
||||
async def _resume_history_from_messages(self) -> None:
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
if not should_resume_history(list(messages_area.children)):
|
||||
return
|
||||
|
||||
self._windowing.reset()
|
||||
history_messages = non_system_history_messages(self.agent_loop.messages)
|
||||
if (
|
||||
plan := create_resume_plan(history_messages, HISTORY_RESUME_TAIL_MESSAGES)
|
||||
|
|
@ -592,7 +621,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
plan.tool_call_map,
|
||||
start_index=plan.tail_start_index,
|
||||
)
|
||||
self.call_after_refresh(self._scroll_chat_to_end)
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
self.call_after_refresh(chat.anchor)
|
||||
self._tool_call_map = plan.tool_call_map
|
||||
self._windowing.set_backfill(plan.backfill_messages)
|
||||
await self._load_more.set_visible(
|
||||
|
|
@ -643,6 +673,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return (ApprovalResponse.YES, None)
|
||||
|
||||
self._pending_approval = asyncio.Future()
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_approval_app(tool, args)
|
||||
result = await self._pending_approval
|
||||
|
|
@ -654,6 +685,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
question_args = cast(AskUserQuestionArgs, args)
|
||||
|
||||
self._pending_question = asyncio.Future()
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_question_app(question_args)
|
||||
result = await self._pending_question
|
||||
|
|
@ -714,6 +746,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self.event_handler:
|
||||
await self.event_handler.finalize_streaming()
|
||||
await self._refresh_windowing_from_history()
|
||||
self._terminal_notifier.notify(NotificationContext.COMPLETE)
|
||||
|
||||
async def _teleport_command(self) -> None:
|
||||
await self._handle_teleport_command(show_message=False)
|
||||
|
|
@ -864,11 +897,108 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return
|
||||
await self._switch_to_proxy_setup_app()
|
||||
|
||||
async def _show_session_picker(self) -> None:
|
||||
session_config = self.config.session_logging
|
||||
|
||||
if not session_config.enabled:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
"Session logging is disabled in configuration.",
|
||||
collapsed=self._tools_collapsed,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
cwd = str(Path.cwd())
|
||||
raw_sessions = SessionLoader.list_sessions(session_config, cwd=cwd)
|
||||
|
||||
if not raw_sessions:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("No sessions found for this directory.")
|
||||
)
|
||||
return
|
||||
|
||||
sessions = sorted(
|
||||
raw_sessions, key=lambda s: s.get("end_time") or "", reverse=True
|
||||
)
|
||||
|
||||
latest_messages = {
|
||||
s["session_id"]: SessionLoader.get_first_user_message(
|
||||
s["session_id"], session_config
|
||||
)
|
||||
for s in sessions
|
||||
}
|
||||
|
||||
picker = SessionPickerApp(sessions=sessions, latest_messages=latest_messages)
|
||||
await self._switch_from_input(picker)
|
||||
|
||||
async def on_session_picker_app_session_selected(
|
||||
self, event: SessionPickerApp.SessionSelected
|
||||
) -> None:
|
||||
await self._switch_to_input_app()
|
||||
|
||||
session_config = self.config.session_logging
|
||||
session_path = SessionLoader.find_session_by_id(
|
||||
event.session_id, session_config
|
||||
)
|
||||
|
||||
if not session_path:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
f"Session `{event.session_id[:8]}` not found.",
|
||||
collapsed=self._tools_collapsed,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
loaded_messages, _ = SessionLoader.load_session(session_path)
|
||||
|
||||
current_system_messages = [
|
||||
msg for msg in self.agent_loop.messages if msg.role == Role.system
|
||||
]
|
||||
non_system_messages = [
|
||||
msg for msg in loaded_messages if msg.role != Role.system
|
||||
]
|
||||
|
||||
self.agent_loop.session_id = event.session_id
|
||||
self.agent_loop.session_logger.resume_existing_session(
|
||||
event.session_id, session_path
|
||||
)
|
||||
|
||||
self.agent_loop.messages.reset(
|
||||
current_system_messages + non_system_messages
|
||||
)
|
||||
|
||||
self._reset_ui_state()
|
||||
await self._load_more.hide()
|
||||
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
await messages_area.remove_children()
|
||||
|
||||
await self._resume_history_from_messages()
|
||||
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage(f"Resumed session `{event.session_id[:8]}`")
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
f"Failed to load session: {e}", collapsed=self._tools_collapsed
|
||||
)
|
||||
)
|
||||
|
||||
async def on_session_picker_app_cancelled(
|
||||
self, event: SessionPickerApp.Cancelled
|
||||
) -> None:
|
||||
await self._switch_to_input_app()
|
||||
|
||||
await self._mount_and_scroll(UserCommandMessage("Resume cancelled."))
|
||||
|
||||
async def _reload_config(self) -> None:
|
||||
try:
|
||||
self._windowing.reset()
|
||||
self._tool_call_map = None
|
||||
self._history_widget_indices = WeakKeyDictionary()
|
||||
self._reset_ui_state()
|
||||
await self._load_more.hide()
|
||||
base_config = VibeConfig.load()
|
||||
|
||||
|
|
@ -886,9 +1016,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
async def _clear_history(self) -> None:
|
||||
try:
|
||||
self._windowing.reset()
|
||||
self._tool_call_map = None
|
||||
self._history_widget_indices = WeakKeyDictionary()
|
||||
self._reset_ui_state()
|
||||
await self.agent_loop.clear_history()
|
||||
if self.event_handler:
|
||||
await self.event_handler.finalize_streaming()
|
||||
|
|
@ -1019,6 +1147,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
|
||||
bottom_container = self.query_one("#bottom-app-container")
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
should_scroll = scroll and chat.is_at_bottom
|
||||
|
||||
if self._chat_input_container:
|
||||
self._chat_input_container.display = False
|
||||
|
|
@ -1028,8 +1158,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await bottom_container.mount(widget)
|
||||
|
||||
self.call_after_refresh(widget.focus)
|
||||
if scroll:
|
||||
self.call_after_refresh(self._scroll_chat_to_end)
|
||||
if should_scroll:
|
||||
self.call_after_refresh(chat.anchor)
|
||||
|
||||
async def _switch_to_config_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.Config:
|
||||
|
|
@ -1069,7 +1199,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._chat_input_container.display = True
|
||||
self._current_bottom_app = BottomApp.Input
|
||||
self.call_after_refresh(self._chat_input_container.focus_input)
|
||||
self.call_after_refresh(self._scroll_chat_to_end)
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
if chat.is_at_bottom:
|
||||
self.call_after_refresh(chat.anchor)
|
||||
|
||||
def _focus_current_bottom_app(self) -> None:
|
||||
try:
|
||||
|
|
@ -1084,6 +1216,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.query_one(ApprovalApp).focus()
|
||||
case BottomApp.Question:
|
||||
self.query_one(QuestionApp).focus()
|
||||
case BottomApp.SessionPicker:
|
||||
self.query_one(SessionPickerApp).focus()
|
||||
case app:
|
||||
assert_never(app)
|
||||
except Exception:
|
||||
|
|
@ -1115,6 +1249,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_session_picker_app_escape(self) -> None:
|
||||
try:
|
||||
session_picker = self.query_one(SessionPickerApp)
|
||||
session_picker.post_message(SessionPickerApp.Cancelled())
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_input_app_escape(self) -> None:
|
||||
try:
|
||||
input_widget = self.query_one(ChatInputContainer)
|
||||
|
|
@ -1151,6 +1293,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_question_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.SessionPicker:
|
||||
self._handle_session_picker_app_escape()
|
||||
return
|
||||
|
||||
if (
|
||||
self._current_bottom_app == BottomApp.Input
|
||||
and self._last_escape_time is not None
|
||||
|
|
@ -1163,7 +1309,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_agent_running_escape()
|
||||
|
||||
self._last_escape_time = current_time
|
||||
self.call_after_refresh(self._scroll_chat_to_end)
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
if chat.is_at_bottom:
|
||||
self.call_after_refresh(chat.anchor)
|
||||
self._focus_current_bottom_app()
|
||||
|
||||
async def on_history_load_more_requested(self, _: HistoryLoadMoreRequested) -> None:
|
||||
|
|
@ -1235,9 +1383,32 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.agent_profile
|
||||
)
|
||||
self._update_profile_widgets(new_profile)
|
||||
await self.agent_loop.switch_agent(new_profile.name)
|
||||
if self._chat_input_container:
|
||||
self._chat_input_container.switching_mode = True
|
||||
|
||||
def schedule_switch() -> None:
|
||||
self._switch_agent_generation += 1
|
||||
my_gen = self._switch_agent_generation
|
||||
|
||||
def switch_agent_sync() -> None:
|
||||
try:
|
||||
asyncio.run(self.agent_loop.switch_agent(new_profile.name))
|
||||
self.agent_loop.set_approval_callback(self._approval_callback)
|
||||
self.agent_loop.set_user_input_callback(self._user_input_callback)
|
||||
finally:
|
||||
if (
|
||||
self._chat_input_container
|
||||
and self._switch_agent_generation == my_gen
|
||||
):
|
||||
self.call_from_thread(
|
||||
setattr, self._chat_input_container, "switching_mode", False
|
||||
)
|
||||
|
||||
self.run_worker(
|
||||
switch_agent_sync, group="switch_agent", exclusive=True, thread=True
|
||||
)
|
||||
|
||||
self.call_after_refresh(schedule_switch)
|
||||
|
||||
def action_clear_quit(self) -> None:
|
||||
input_widgets = self.query(ChatInputContainer)
|
||||
|
|
@ -1296,8 +1467,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
whats_new_message.add_class("after-history")
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
should_anchor = chat.is_at_bottom
|
||||
await chat.mount(whats_new_message, after=messages_area)
|
||||
self._whats_new_message = whats_new_message
|
||||
if should_anchor:
|
||||
chat.anchor()
|
||||
await mark_version_as_seen(self._current_version, self._update_cache_repository)
|
||||
|
||||
|
|
@ -1324,28 +1497,36 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
return
|
||||
|
||||
def _scroll_chat_to_end(self) -> None:
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
chat.scroll_end(animate=False)
|
||||
|
||||
async def _mount_and_scroll(self, widget: Widget) -> None:
|
||||
async def _mount_and_scroll(
|
||||
self, widget: Widget, after: Widget | None = None
|
||||
) -> None:
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
|
||||
is_user_initiated = isinstance(widget, (UserMessage, UserCommandMessage))
|
||||
should_anchor = is_user_initiated or chat.is_at_bottom
|
||||
|
||||
if after is not None and after.parent is messages_area:
|
||||
await messages_area.mount(widget, after=after)
|
||||
else:
|
||||
await messages_area.mount(widget)
|
||||
if isinstance(widget, StreamingMessageBase):
|
||||
await widget.write_initial_content()
|
||||
|
||||
self.call_after_refresh(self._try_prune)
|
||||
if should_anchor:
|
||||
chat.anchor()
|
||||
|
||||
async def _try_prune(self) -> None:
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
pruned = await prune_by_height(messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK)
|
||||
pruned = await prune_oldest_children(
|
||||
messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK
|
||||
)
|
||||
if self._load_more.widget and not self._load_more.widget.parent:
|
||||
self._load_more.widget = None
|
||||
if pruned:
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
if chat.is_at_bottom:
|
||||
self.call_later(chat.anchor)
|
||||
|
||||
async def _refresh_windowing_from_history(self) -> None:
|
||||
|
|
@ -1425,10 +1606,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
|
||||
|
||||
def on_app_blur(self, event: AppBlur) -> None:
|
||||
self._terminal_notifier.on_blur()
|
||||
if self._chat_input_container and self._chat_input_container.input_widget:
|
||||
self._chat_input_container.input_widget.set_app_focus(False)
|
||||
|
||||
def on_app_focus(self, event: AppFocus) -> None:
|
||||
self._terminal_notifier.on_focus()
|
||||
if self._chat_input_container and self._chat_input_container.input_widget:
|
||||
self._chat_input_container.input_widget.set_app_focus(True)
|
||||
|
||||
|
|
|
|||
|
|
@ -118,7 +118,8 @@ TextArea > .text-area--cursor {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
#prompt {
|
||||
#prompt,
|
||||
#prompt-spinner {
|
||||
width: auto;
|
||||
background: transparent;
|
||||
color: $mistral_orange;
|
||||
|
|
@ -719,17 +720,6 @@ StatusMessage {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
.proxy-label {
|
||||
height: auto;
|
||||
color: ansi_blue;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.proxy-description {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
.proxy-label-line {
|
||||
height: auto;
|
||||
}
|
||||
|
|
@ -739,7 +729,6 @@ StatusMessage {
|
|||
height: auto;
|
||||
border: none;
|
||||
border-left: wide ansi_bright_black;
|
||||
margin-top: 1;
|
||||
padding: 0 0 0 1;
|
||||
}
|
||||
|
||||
|
|
@ -1020,3 +1009,36 @@ ContextProgress {
|
|||
.whats-new-message.after-history {
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
#sessionpicker-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#sessionpicker-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#sessionpicker-options {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#sessionpicker-options:focus {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.sessionpicker-help {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class EventHandler:
|
|||
) -> None:
|
||||
self.mount_callback = mount_callback
|
||||
self.get_tools_collapsed = get_tools_collapsed
|
||||
self.current_tool_call: ToolCallMessage | None = None
|
||||
self.tool_calls: dict[str, ToolCallMessage] = {}
|
||||
self.current_compact: CompactMessage | None = None
|
||||
self.current_streaming_message: AssistantMessage | None = None
|
||||
self.current_streaming_reasoning: ReasoningMessage | None = None
|
||||
|
|
@ -90,30 +90,40 @@ class EventHandler:
|
|||
async def _handle_tool_call(
|
||||
self, event: ToolCallEvent, loading_widget: LoadingWidget | None = None
|
||||
) -> ToolCallMessage | None:
|
||||
tool_call_id = event.tool_call_id
|
||||
existing_tool_call = self.tool_calls.get(tool_call_id) if tool_call_id else None
|
||||
if existing_tool_call:
|
||||
existing_tool_call.update_event(event)
|
||||
tool_call = existing_tool_call
|
||||
else:
|
||||
tool_call = ToolCallMessage(event)
|
||||
if tool_call_id:
|
||||
self.tool_calls[tool_call_id] = tool_call
|
||||
await self.mount_callback(tool_call)
|
||||
|
||||
if loading_widget and event.tool_class:
|
||||
adapter = ToolUIDataAdapter(event.tool_class)
|
||||
status_text = adapter.get_status_text()
|
||||
loading_widget.set_status(status_text)
|
||||
|
||||
self.current_tool_call = tool_call
|
||||
await self.mount_callback(tool_call)
|
||||
loading_widget.set_status(adapter.get_status_text())
|
||||
|
||||
return tool_call
|
||||
|
||||
async def _handle_tool_result(self, event: ToolResultEvent) -> None:
|
||||
tools_collapsed = self.get_tools_collapsed()
|
||||
tool_result = ToolResultMessage(
|
||||
event, self.current_tool_call, collapsed=tools_collapsed
|
||||
)
|
||||
await self.mount_callback(tool_result)
|
||||
|
||||
self.current_tool_call = None
|
||||
call_widget = (
|
||||
self.tool_calls.get(event.tool_call_id) if event.tool_call_id else None
|
||||
)
|
||||
|
||||
tool_result = ToolResultMessage(event, call_widget, collapsed=tools_collapsed)
|
||||
await self.mount_callback(tool_result, after=call_widget)
|
||||
|
||||
if event.tool_call_id and event.tool_call_id in self.tool_calls:
|
||||
del self.tool_calls[event.tool_call_id]
|
||||
|
||||
async def _handle_tool_stream(self, event: ToolStreamEvent) -> None:
|
||||
if self.current_tool_call:
|
||||
self.current_tool_call.set_stream_message(event.message)
|
||||
tool_call = self.tool_calls.get(event.tool_call_id)
|
||||
if tool_call:
|
||||
tool_call.set_stream_message(event.message)
|
||||
|
||||
async def _handle_assistant_message(self, event: AssistantEvent) -> None:
|
||||
if self.current_streaming_reasoning is not None:
|
||||
|
|
@ -131,6 +141,8 @@ class EventHandler:
|
|||
async def _handle_reasoning_message(self, event: ReasoningEvent) -> None:
|
||||
if self.current_streaming_message is not None:
|
||||
await self.current_streaming_message.stop_stream()
|
||||
if self.current_streaming_message.is_stripped_content_empty():
|
||||
await self.current_streaming_message.remove()
|
||||
self.current_streaming_message = None
|
||||
|
||||
if self.current_streaming_reasoning is None:
|
||||
|
|
@ -166,9 +178,9 @@ class EventHandler:
|
|||
self.current_streaming_message = None
|
||||
|
||||
def stop_current_tool_call(self, success: bool = True) -> None:
|
||||
if self.current_tool_call:
|
||||
self.current_tool_call.stop_spinning(success=success)
|
||||
self.current_tool_call = None
|
||||
for tool_call in self.tool_calls.values():
|
||||
tool_call.stop_spinning(success=success)
|
||||
self.tool_calls.clear()
|
||||
|
||||
def stop_current_compact(self) -> None:
|
||||
if self.current_compact:
|
||||
|
|
|
|||
11
vibe/cli/textual_ui/notifications/__init__.py
Normal 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"]
|
||||
0
vibe/cli/textual_ui/notifications/adapters/__init__.py
Normal 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")
|
||||
0
vibe/cli/textual_ui/notifications/ports/__init__.py
Normal file
16
vibe/cli/textual_ui/notifications/ports/notification_port.py
Normal 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: ...
|
||||
|
|
@ -2,16 +2,31 @@ from __future__ import annotations
|
|||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.message import Message
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.history_manager import HistoryManager
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea, InputMode
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
||||
|
||||
|
||||
class _PromptSpinner(SpinnerMixin, Static):
|
||||
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.BRAILLE
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._indicator_widget: Static | None = None
|
||||
self.init_spinner()
|
||||
super().__init__(self._spinner.current_frame(), id="prompt-spinner")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._indicator_widget = self
|
||||
self.start_spinner_timer()
|
||||
|
||||
|
||||
class ChatInputBody(Widget):
|
||||
|
|
@ -30,6 +45,7 @@ class ChatInputBody(Widget):
|
|||
self.input_widget: ChatTextArea | None = None
|
||||
self.prompt_widget: NoMarkupStatic | None = None
|
||||
self._nuage_enabled = nuage_enabled
|
||||
self._switching_mode = False
|
||||
|
||||
if history_file:
|
||||
self.history = HistoryManager(history_file)
|
||||
|
|
@ -159,6 +175,9 @@ class ChatInputBody(Widget):
|
|||
def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None:
|
||||
event.stop()
|
||||
|
||||
if self._switching_mode:
|
||||
return
|
||||
|
||||
if not self.input_widget:
|
||||
return
|
||||
|
||||
|
|
@ -175,6 +194,25 @@ class ChatInputBody(Widget):
|
|||
|
||||
self.post_message(self.Submitted(value))
|
||||
|
||||
@property
|
||||
def switching_mode(self) -> bool:
|
||||
return self._switching_mode
|
||||
|
||||
@switching_mode.setter
|
||||
def switching_mode(self, value: bool) -> None:
|
||||
self._switching_mode = value
|
||||
if value:
|
||||
if self.prompt_widget:
|
||||
self.prompt_widget.display = False
|
||||
if not self.query(_PromptSpinner):
|
||||
self.query_one(Horizontal).mount(_PromptSpinner(), before=0)
|
||||
else:
|
||||
for spinner in self.query(_PromptSpinner):
|
||||
spinner.remove()
|
||||
if self.prompt_widget:
|
||||
self.prompt_widget.display = True
|
||||
self._update_prompt()
|
||||
|
||||
@property
|
||||
def value(self) -> str:
|
||||
if not self.input_widget:
|
||||
|
|
|
|||
|
|
@ -180,6 +180,15 @@ class ChatInputContainer(Vertical):
|
|||
event.stop()
|
||||
self.post_message(self.Submitted(event.value))
|
||||
|
||||
@property
|
||||
def switching_mode(self) -> bool:
|
||||
return self._body.switching_mode if self._body else False
|
||||
|
||||
@switching_mode.setter
|
||||
def switching_mode(self, value: bool) -> None:
|
||||
if self._body:
|
||||
self._body.switching_mode = value
|
||||
|
||||
def set_safety(self, safety: AgentSafety) -> None:
|
||||
self._safety = safety
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
self.init_spinner()
|
||||
self.status = status or self._get_default_status()
|
||||
self.current_color_index = 0
|
||||
self._color_direction = 1
|
||||
self.transition_progress = 0
|
||||
self._status_widget: Static | None = None
|
||||
self.hint_widget: Static | None = None
|
||||
|
|
@ -141,11 +142,12 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
return
|
||||
self._update_animation()
|
||||
|
||||
def _next_color_index(self) -> int:
|
||||
return self.current_color_index + self._color_direction
|
||||
|
||||
def _get_color_for_position(self, position: int) -> str:
|
||||
current_color = self.TARGET_COLORS[self.current_color_index]
|
||||
next_color = self.TARGET_COLORS[
|
||||
(self.current_color_index + 1) % len(self.TARGET_COLORS)
|
||||
]
|
||||
next_color = self.TARGET_COLORS[self._next_color_index()]
|
||||
if position < self.transition_progress:
|
||||
return next_color
|
||||
return current_color
|
||||
|
|
@ -173,9 +175,9 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
|
||||
self.transition_progress += 1
|
||||
if self.transition_progress > total_elements:
|
||||
self.current_color_index = (self.current_color_index + 1) % len(
|
||||
self.TARGET_COLORS
|
||||
)
|
||||
self.current_color_index = self._next_color_index()
|
||||
if not 0 < self.current_color_index < len(self.TARGET_COLORS) - 1:
|
||||
self._color_direction *= -1
|
||||
self.transition_progress = 0
|
||||
|
||||
if self.hint_widget and self.start_time is not None:
|
||||
|
|
|
|||
|
|
@ -106,6 +106,9 @@ class StreamingMessageBase(Static):
|
|||
def _should_write_content(self) -> bool:
|
||||
return True
|
||||
|
||||
def is_stripped_content_empty(self) -> bool:
|
||||
return self._content.strip() == ""
|
||||
|
||||
|
||||
class AssistantMessage(StreamingMessageBase):
|
||||
def __init__(self, content: str) -> None:
|
||||
|
|
|
|||
|
|
@ -43,26 +43,20 @@ class ProxySetupApp(Container):
|
|||
|
||||
with Vertical(id="proxysetup-content"):
|
||||
yield NoMarkupStatic("Proxy Configuration", classes="settings-title")
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
for key, description in SUPPORTED_PROXY_VARS.items():
|
||||
yield Static(
|
||||
f"[bold ansi_blue]{key}[/] [dim]{description}[/dim]",
|
||||
classes="proxy-label-line",
|
||||
)
|
||||
yield Static(f"[bold ansi_blue]{key}[/]", classes="proxy-label-line")
|
||||
|
||||
initial_value = self.initial_values.get(key) or ""
|
||||
input_widget = Input(
|
||||
value=initial_value,
|
||||
placeholder="NOT SET",
|
||||
placeholder=description,
|
||||
id=f"proxy-input-{key}",
|
||||
classes="proxy-input",
|
||||
)
|
||||
self.inputs[key] = input_widget
|
||||
yield input_widget
|
||||
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
yield NoMarkupStatic(
|
||||
"↑↓ navigate Enter save & exit ESC cancel", classes="settings-help"
|
||||
)
|
||||
|
|
@ -92,7 +86,7 @@ class ProxySetupApp(Container):
|
|||
prev_idx = (idx - 1) % len(inputs)
|
||||
inputs[prev_idx].focus()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
def on_input_submitted(self, _event: Input.Submitted) -> None:
|
||||
self._save_and_close()
|
||||
|
||||
def on_blur(self, _event: events.Blur) -> None:
|
||||
|
|
|
|||
113
vibe/cli/textual_ui/widgets/session_picker.py
Normal 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())
|
||||
|
|
@ -42,8 +42,8 @@ def parse_search_replace_to_diff(content: str) -> list[str]:
|
|||
for i, (search_text, replace_text) in enumerate(matches):
|
||||
if i > 0:
|
||||
all_diff_lines.append("") # Separator between blocks
|
||||
search_lines = search_text.strip().split("\n")
|
||||
replace_lines = replace_text.strip().split("\n")
|
||||
search_lines = search_text.strip("\n").split("\n")
|
||||
replace_lines = replace_text.strip("\n").split("\n")
|
||||
diff = difflib.unified_diff(search_lines, replace_lines, lineterm="", n=2)
|
||||
all_diff_lines.extend(list(diff)[2:]) # Skip file headers
|
||||
|
||||
|
|
@ -74,8 +74,10 @@ class ToolApprovalWidget[TArgs: BaseModel](Vertical):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
MAX_MSG_SIZE = 150
|
||||
for field_name in type(self.args).model_fields:
|
||||
value = getattr(self.args, field_name)
|
||||
model_cls = type(self.args)
|
||||
field_names = model_cls.model_fields or self.args.model_extra or {}
|
||||
for field_name in field_names:
|
||||
value = getattr(self.args, field_name, None)
|
||||
if value is None or value in ("", []):
|
||||
continue
|
||||
value_str = str(value)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class ToolCallMessage(StatusMessage):
|
|||
raise ValueError("Either event or tool_name must be provided")
|
||||
|
||||
self._event = event
|
||||
self._tool_name = tool_name or (event.tool_name if event else "unknown")
|
||||
self._tool_name = tool_name or (event.tool_name if event else None) or "unknown"
|
||||
self._is_history = event is None
|
||||
self._stream_widget: NoMarkupStatic | None = None
|
||||
|
||||
|
|
@ -44,6 +44,7 @@ class ToolCallMessage(StatusMessage):
|
|||
yield self._stream_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
super().on_mount()
|
||||
siblings = list(self.parent.children) if self.parent else []
|
||||
idx = siblings.index(self) if self in siblings else -1
|
||||
if idx > 0 and isinstance(
|
||||
|
|
@ -51,13 +52,23 @@ class ToolCallMessage(StatusMessage):
|
|||
):
|
||||
self.add_class("no-gap")
|
||||
|
||||
@property
|
||||
def tool_call_id(self) -> str | None:
|
||||
return self._event.tool_call_id if self._event else None
|
||||
|
||||
def get_content(self) -> str:
|
||||
if self._event and self._event.tool_class:
|
||||
if self._event:
|
||||
adapter = ToolUIDataAdapter(self._event.tool_class)
|
||||
display = adapter.get_call_display(self._event)
|
||||
return display.summary
|
||||
return self._tool_name
|
||||
|
||||
def update_event(self, event: ToolCallEvent) -> None:
|
||||
self._event = event
|
||||
self._tool_name = event.tool_name
|
||||
if self._text_widget:
|
||||
self._text_widget.update(self.get_content())
|
||||
|
||||
def set_stream_message(self, message: str) -> None:
|
||||
"""Update the stream message displayed below the tool call indicator."""
|
||||
if self._stream_widget:
|
||||
|
|
@ -151,6 +162,12 @@ class ToolResultMessage(Static):
|
|||
await self._content_container.remove_children()
|
||||
|
||||
if self._event is None:
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from textual.widget import Widget
|
||||
|
|
@ -13,11 +14,11 @@ from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
|||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
|
||||
def non_system_history_messages(messages: list[LLMMessage]) -> list[LLMMessage]:
|
||||
def non_system_history_messages(messages: Sequence[LLMMessage]) -> list[LLMMessage]:
|
||||
return [msg for msg in messages if msg.role != Role.system]
|
||||
|
||||
|
||||
def build_tool_call_map(messages: list[LLMMessage]) -> dict[str, str]:
|
||||
def build_tool_call_map(messages: Sequence[LLMMessage]) -> dict[str, str]:
|
||||
tool_call_map: dict[str, str] = {}
|
||||
for msg in messages:
|
||||
if msg.role != Role.assistant or not msg.tool_calls:
|
||||
|
|
@ -29,7 +30,7 @@ def build_tool_call_map(messages: list[LLMMessage]) -> dict[str, str]:
|
|||
|
||||
|
||||
def build_history_widgets(
|
||||
batch: list[LLMMessage],
|
||||
batch: Sequence[LLMMessage],
|
||||
tool_call_map: dict[str, str],
|
||||
*,
|
||||
start_index: int,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from collections.abc import AsyncGenerator, Callable, Generator
|
||||
from enum import StrEnum, auto
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
|
|
@ -13,6 +13,7 @@ from uuid import uuid4
|
|||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from vibe.cli.terminal_setup import detect_terminal
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
|
||||
from vibe.core.config import Backend, ProviderConfig, VibeConfig
|
||||
|
|
@ -26,14 +27,18 @@ from vibe.core.llm.format import (
|
|||
)
|
||||
from vibe.core.llm.types import BackendLike
|
||||
from vibe.core.middleware import (
|
||||
CHAT_AGENT_EXIT,
|
||||
CHAT_AGENT_REMINDER,
|
||||
PLAN_AGENT_EXIT,
|
||||
PLAN_AGENT_REMINDER,
|
||||
AutoCompactMiddleware,
|
||||
ContextWarningMiddleware,
|
||||
ConversationContext,
|
||||
MiddlewareAction,
|
||||
MiddlewarePipeline,
|
||||
MiddlewareResult,
|
||||
PlanAgentMiddleware,
|
||||
PriceLimitMiddleware,
|
||||
ReadOnlyAgentMiddleware,
|
||||
ResetReason,
|
||||
TurnLimitMiddleware,
|
||||
)
|
||||
|
|
@ -52,6 +57,8 @@ from vibe.core.tools.base import (
|
|||
ToolPermissionError,
|
||||
)
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
from vibe.core.tools.mcp import MCPRegistry
|
||||
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
|
||||
from vibe.core.trusted_folders import has_agents_md_file
|
||||
from vibe.core.types import (
|
||||
AgentStats,
|
||||
|
|
@ -62,13 +69,16 @@ from vibe.core.types import (
|
|||
BaseEvent,
|
||||
CompactEndEvent,
|
||||
CompactStartEvent,
|
||||
EntrypointMetadata,
|
||||
LLMChunk,
|
||||
LLMMessage,
|
||||
LLMUsage,
|
||||
MessageList,
|
||||
RateLimitError,
|
||||
ReasoningEvent,
|
||||
Role,
|
||||
SyncApprovalCallback,
|
||||
ToolCall,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
ToolStreamEvent,
|
||||
|
|
@ -139,6 +149,7 @@ class AgentLoop:
|
|||
max_price: float | None = None,
|
||||
backend: BackendLike | None = None,
|
||||
enable_streaming: bool = False,
|
||||
entrypoint_metadata: EntrypointMetadata | None = None,
|
||||
) -> None:
|
||||
self._base_config = config
|
||||
self._max_turns = max_turns
|
||||
|
|
@ -147,15 +158,20 @@ class AgentLoop:
|
|||
self.agent_manager = AgentManager(
|
||||
lambda: self._base_config, initial_agent=agent_name
|
||||
)
|
||||
self.tool_manager = ToolManager(lambda: self.config)
|
||||
self._mcp_registry = MCPRegistry()
|
||||
self.tool_manager = ToolManager(
|
||||
lambda: self.config, mcp_registry=self._mcp_registry
|
||||
)
|
||||
self.skill_manager = SkillManager(lambda: self.config)
|
||||
self.format_handler = APIToolFormatHandler()
|
||||
|
||||
self.backend_factory = lambda: backend or self._select_backend()
|
||||
self.backend = self.backend_factory()
|
||||
self._sampling_handler = MCPSamplingHandler(
|
||||
backend_getter=lambda: self.backend, config_getter=lambda: self.config
|
||||
)
|
||||
|
||||
self.message_observer = message_observer
|
||||
self._last_observed_message_index: int = 0
|
||||
self.enable_streaming = enable_streaming
|
||||
self.middleware_pipeline = MiddlewarePipeline()
|
||||
self._setup_middleware()
|
||||
|
|
@ -163,11 +179,8 @@ class AgentLoop:
|
|||
system_prompt = get_universal_system_prompt(
|
||||
self.tool_manager, self.config, self.skill_manager, self.agent_manager
|
||||
)
|
||||
self.messages = [LLMMessage(role=Role.system, content=system_prompt)]
|
||||
|
||||
if self.message_observer:
|
||||
self.message_observer(self.messages[0])
|
||||
self._last_observed_message_index = 1
|
||||
system_message = LLMMessage(role=Role.system, content=system_prompt)
|
||||
self.messages = MessageList(initial=[system_message], observer=message_observer)
|
||||
|
||||
self.stats = AgentStats()
|
||||
try:
|
||||
|
|
@ -180,6 +193,7 @@ class AgentLoop:
|
|||
self.approval_callback: ApprovalCallback | None = None
|
||||
self.user_input_callback: UserInputCallback | None = None
|
||||
|
||||
self.entrypoint_metadata = entrypoint_metadata
|
||||
self.session_id = str(uuid4())
|
||||
self._current_user_message_id: str | None = None
|
||||
|
||||
|
|
@ -221,19 +235,28 @@ class AgentLoop:
|
|||
self.config.tools[tool_name].permission = permission
|
||||
self.tool_manager.invalidate_tool(tool_name)
|
||||
|
||||
def emit_new_session_telemetry(
|
||||
self, entrypoint: Literal["cli", "acp", "programmatic"]
|
||||
) -> None:
|
||||
def emit_new_session_telemetry(self) -> None:
|
||||
entrypoint = (
|
||||
self.entrypoint_metadata.agent_entrypoint
|
||||
if self.entrypoint_metadata
|
||||
else "unknown"
|
||||
)
|
||||
has_agents_md = has_agents_md_file(Path.cwd())
|
||||
nb_skills = len(self.skill_manager.available_skills)
|
||||
nb_mcp_servers = len(self.config.mcp_servers)
|
||||
nb_models = len(self.config.models)
|
||||
|
||||
terminal_emulator = None
|
||||
if entrypoint == "cli":
|
||||
terminal_emulator = detect_terminal().value
|
||||
|
||||
self.telemetry_client.send_new_session(
|
||||
has_agents_md=has_agents_md,
|
||||
nb_skills=nb_skills,
|
||||
nb_mcp_servers=nb_mcp_servers,
|
||||
nb_models=nb_models,
|
||||
entrypoint=entrypoint,
|
||||
terminal_emulator=terminal_emulator,
|
||||
)
|
||||
|
||||
def _select_backend(self) -> BackendLike:
|
||||
|
|
@ -242,9 +265,6 @@ class AgentLoop:
|
|||
timeout = self.config.api_timeout
|
||||
return BACKEND_FACTORY[provider.backend](provider=provider, timeout=timeout)
|
||||
|
||||
def add_message(self, message: LLMMessage) -> None:
|
||||
self.messages.append(message)
|
||||
|
||||
async def _save_messages(self) -> None:
|
||||
await self.session_logger.save_interaction(
|
||||
self.messages,
|
||||
|
|
@ -254,19 +274,6 @@ class AgentLoop:
|
|||
self.agent_profile,
|
||||
)
|
||||
|
||||
async def _flush_new_messages(self) -> None:
|
||||
await self._save_messages()
|
||||
|
||||
if not self.message_observer:
|
||||
return
|
||||
|
||||
if self._last_observed_message_index >= len(self.messages):
|
||||
return
|
||||
|
||||
for msg in self.messages[self._last_observed_message_index :]:
|
||||
self.message_observer(msg)
|
||||
self._last_observed_message_index = len(self.messages)
|
||||
|
||||
async def act(self, msg: str) -> AsyncGenerator[BaseEvent]:
|
||||
self._clean_message_history()
|
||||
async for event in self._conversation_loop(msg):
|
||||
|
|
@ -345,7 +352,22 @@ class AgentLoop:
|
|||
ContextWarningMiddleware(0.5, self.config.auto_compact_threshold)
|
||||
)
|
||||
|
||||
self.middleware_pipeline.add(PlanAgentMiddleware(lambda: self.agent_profile))
|
||||
self.middleware_pipeline.add(
|
||||
ReadOnlyAgentMiddleware(
|
||||
lambda: self.agent_profile,
|
||||
BuiltinAgentName.PLAN,
|
||||
PLAN_AGENT_REMINDER,
|
||||
PLAN_AGENT_EXIT,
|
||||
)
|
||||
)
|
||||
self.middleware_pipeline.add(
|
||||
ReadOnlyAgentMiddleware(
|
||||
lambda: self.agent_profile,
|
||||
BuiltinAgentName.CHAT,
|
||||
CHAT_AGENT_REMINDER,
|
||||
CHAT_AGENT_EXIT,
|
||||
)
|
||||
)
|
||||
|
||||
async def _handle_middleware_result(
|
||||
self, result: MiddlewareResult
|
||||
|
|
@ -440,7 +462,7 @@ class AgentLoop:
|
|||
if is_user_cancellation_event(event):
|
||||
user_cancelled = True
|
||||
yield event
|
||||
await self._flush_new_messages()
|
||||
await self._save_messages()
|
||||
|
||||
last_message = self.messages[-1]
|
||||
should_break_loop = last_message.role != Role.tool
|
||||
|
|
@ -449,7 +471,7 @@ class AgentLoop:
|
|||
return
|
||||
|
||||
finally:
|
||||
await self._flush_new_messages()
|
||||
await self._save_messages()
|
||||
|
||||
async def _perform_llm_turn(self) -> AsyncGenerator[BaseEvent, None]:
|
||||
if self.enable_streaming:
|
||||
|
|
@ -471,15 +493,42 @@ class AgentLoop:
|
|||
async for event in self._handle_tool_calls(resolved):
|
||||
yield event
|
||||
|
||||
def _build_tool_call_events(
|
||||
self, tool_calls: list[ToolCall] | None, emitted_ids: set[str]
|
||||
) -> Generator[ToolCallEvent, None, None]:
|
||||
for tc in tool_calls or []:
|
||||
if tc.id is None or not tc.function.name:
|
||||
continue
|
||||
if tc.id in emitted_ids:
|
||||
continue
|
||||
|
||||
tool_class = self.tool_manager.available_tools.get(tc.function.name)
|
||||
if tool_class is None:
|
||||
continue
|
||||
|
||||
yield ToolCallEvent(
|
||||
tool_call_id=tc.id,
|
||||
tool_call_index=tc.index,
|
||||
tool_name=tc.function.name,
|
||||
tool_class=tool_class,
|
||||
)
|
||||
|
||||
async def _stream_assistant_events(
|
||||
self,
|
||||
) -> AsyncGenerator[AssistantEvent | ReasoningEvent]:
|
||||
) -> AsyncGenerator[AssistantEvent | ReasoningEvent | ToolCallEvent]:
|
||||
message_id: str | None = None
|
||||
emitted_tool_call_ids = set[str]()
|
||||
|
||||
async for chunk in self._chat_streaming():
|
||||
if message_id is None:
|
||||
message_id = chunk.message.message_id
|
||||
|
||||
for event in self._build_tool_call_events(
|
||||
chunk.message.tool_calls, emitted_tool_call_ids
|
||||
):
|
||||
emitted_tool_call_ids.add(event.tool_call_id)
|
||||
yield event
|
||||
|
||||
if chunk.message.reasoning_content:
|
||||
yield ReasoningEvent(
|
||||
content=chunk.message.reasoning_content, message_id=message_id
|
||||
|
|
@ -560,9 +609,12 @@ class AgentLoop:
|
|||
async for item in tool_instance.invoke(
|
||||
ctx=InvokeContext(
|
||||
tool_call_id=tool_call.call_id,
|
||||
approval_callback=self.approval_callback,
|
||||
agent_manager=self.agent_manager,
|
||||
session_dir=self.session_logger.session_dir,
|
||||
entrypoint_metadata=self.entrypoint_metadata,
|
||||
approval_callback=self.approval_callback,
|
||||
user_input_callback=self.user_input_callback,
|
||||
sampling_callback=self._sampling_handler,
|
||||
),
|
||||
**tool_call.args_dict,
|
||||
):
|
||||
|
|
@ -671,6 +723,9 @@ class AgentLoop:
|
|||
tool_choice=tool_choice,
|
||||
extra_headers=self._get_extra_headers(provider),
|
||||
max_tokens=max_tokens,
|
||||
metadata=self.entrypoint_metadata.model_dump()
|
||||
if self.entrypoint_metadata
|
||||
else None,
|
||||
)
|
||||
end_time = time.perf_counter()
|
||||
|
||||
|
|
@ -714,6 +769,9 @@ class AgentLoop:
|
|||
tool_choice=tool_choice,
|
||||
extra_headers=self._get_extra_headers(provider),
|
||||
max_tokens=max_tokens,
|
||||
metadata=self.entrypoint_metadata.model_dump()
|
||||
if self.entrypoint_metadata
|
||||
else None,
|
||||
):
|
||||
processed_message = self.format_handler.process_api_response_message(
|
||||
chunk.message
|
||||
|
|
@ -759,36 +817,25 @@ class AgentLoop:
|
|||
approval_type=ToolPermission.ALWAYS,
|
||||
)
|
||||
|
||||
allowlist_denylist_result = tool.check_allowlist_denylist(args)
|
||||
if allowlist_denylist_result == ToolPermission.ALWAYS:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE,
|
||||
approval_type=ToolPermission.ALWAYS,
|
||||
)
|
||||
elif allowlist_denylist_result == ToolPermission.NEVER:
|
||||
denylist_patterns = tool.config.denylist
|
||||
denylist_str = ", ".join(repr(pattern) for pattern in denylist_patterns)
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.SKIP,
|
||||
approval_type=ToolPermission.NEVER,
|
||||
feedback=f"Tool '{tool.get_name()}' blocked by denylist: [{denylist_str}]",
|
||||
)
|
||||
|
||||
tool_name = tool.get_name()
|
||||
perm = self.tool_manager.get_tool_config(tool_name).permission
|
||||
effective = (
|
||||
tool.resolve_permission(args)
|
||||
or self.tool_manager.get_tool_config(tool_name).permission
|
||||
)
|
||||
|
||||
if perm is ToolPermission.ALWAYS:
|
||||
match effective:
|
||||
case ToolPermission.ALWAYS:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE,
|
||||
approval_type=ToolPermission.ALWAYS,
|
||||
)
|
||||
if perm is ToolPermission.NEVER:
|
||||
case ToolPermission.NEVER:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.SKIP,
|
||||
approval_type=ToolPermission.NEVER,
|
||||
feedback=f"Tool '{tool_name}' is permanently disabled",
|
||||
)
|
||||
|
||||
case _:
|
||||
return await self._ask_approval(tool_name, args, tool_call_id)
|
||||
|
||||
async def _ask_approval(
|
||||
|
|
@ -852,9 +899,11 @@ class AgentLoop:
|
|||
empty_response = LLMMessage(
|
||||
role=Role.tool,
|
||||
tool_call_id=tool_call_data.id or "",
|
||||
name=(tool_call_data.function.name or "")
|
||||
name=(
|
||||
(tool_call_data.function.name or "")
|
||||
if tool_call_data.function
|
||||
else "",
|
||||
else ""
|
||||
),
|
||||
content=str(
|
||||
get_user_cancellation_message(
|
||||
CancellationReason.TOOL_NO_RESPONSE
|
||||
|
|
@ -898,7 +947,7 @@ class AgentLoop:
|
|||
self.tool_manager,
|
||||
self.agent_profile,
|
||||
)
|
||||
self.messages = self.messages[:1]
|
||||
self.messages.reset(self.messages[:1])
|
||||
|
||||
self.stats = AgentStats.create_fresh(self.stats)
|
||||
self.stats.trigger_listeners()
|
||||
|
|
@ -927,10 +976,14 @@ class AgentLoop:
|
|||
)
|
||||
|
||||
summary_request = UtilityPrompt.COMPACT.read()
|
||||
self.messages.append(LLMMessage(role=Role.user, content=summary_request))
|
||||
self.stats.steps += 1
|
||||
|
||||
with self.messages.silent():
|
||||
self.messages.append(
|
||||
LLMMessage(role=Role.user, content=summary_request)
|
||||
)
|
||||
summary_result = await self._chat()
|
||||
|
||||
if summary_result.usage is None:
|
||||
raise AgentLoopLLMResponseError(
|
||||
"Usage data missing in compaction summary response"
|
||||
|
|
@ -939,8 +992,7 @@ class AgentLoop:
|
|||
|
||||
system_message = self.messages[0]
|
||||
summary_message = LLMMessage(role=Role.user, content=summary_content)
|
||||
self.messages = [system_message, summary_message]
|
||||
self._last_observed_message_index = 1
|
||||
self.messages.reset([system_message, summary_message])
|
||||
|
||||
active_model = self.config.get_active_model()
|
||||
provider = self.config.get_provider_for_model(active_model)
|
||||
|
|
@ -950,6 +1002,9 @@ class AgentLoop:
|
|||
messages=self.messages,
|
||||
tools=self.format_handler.get_available_tools(self.tool_manager),
|
||||
extra_headers={"user-agent": get_user_agent(provider.backend)},
|
||||
metadata=self.entrypoint_metadata.model_dump()
|
||||
if self.entrypoint_metadata
|
||||
else None,
|
||||
)
|
||||
|
||||
self.stats.context_tokens = actual_context_tokens
|
||||
|
|
@ -1015,17 +1070,19 @@ class AgentLoop:
|
|||
if max_price is not None:
|
||||
self._max_price = max_price
|
||||
|
||||
self.tool_manager = ToolManager(lambda: self.config)
|
||||
self.tool_manager = ToolManager(
|
||||
lambda: self.config, mcp_registry=self._mcp_registry
|
||||
)
|
||||
self.skill_manager = SkillManager(lambda: self.config)
|
||||
|
||||
new_system_prompt = get_universal_system_prompt(
|
||||
self.tool_manager, self.config, self.skill_manager, self.agent_manager
|
||||
)
|
||||
|
||||
self.messages = [
|
||||
self.messages.reset([
|
||||
LLMMessage(role=Role.system, content=new_system_prompt),
|
||||
*[msg for msg in self.messages if msg.role != Role.system],
|
||||
]
|
||||
])
|
||||
|
||||
if len(self.messages) == 1:
|
||||
self.stats.reset_context_state()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from logging import getLogger
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
|
@ -11,15 +10,14 @@ from vibe.core.agents.models import (
|
|||
AgentType,
|
||||
BuiltinAgentName,
|
||||
)
|
||||
from vibe.core.paths.config_paths import resolve_local_agents_dir
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths.config_paths import discover_local_agents_dirs
|
||||
from vibe.core.paths.global_paths import GLOBAL_AGENTS_DIR
|
||||
from vibe.core.utils import name_matches
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import VibeConfig
|
||||
|
||||
logger = getLogger("vibe")
|
||||
|
||||
|
||||
class AgentManager:
|
||||
def __init__(
|
||||
|
|
@ -77,6 +75,10 @@ class AgentManager:
|
|||
self.active_profile = self.get_agent(name)
|
||||
self._cached_config = None
|
||||
|
||||
def register_agent(self, profile: AgentProfile) -> None:
|
||||
self._available[profile.name] = profile
|
||||
self._cached_config = None
|
||||
|
||||
def invalidate_config(self) -> None:
|
||||
self._cached_config = None
|
||||
|
||||
|
|
@ -86,8 +88,7 @@ class AgentManager:
|
|||
for path in config.agent_paths:
|
||||
if path.is_dir():
|
||||
paths.append(path)
|
||||
if (agents_dir := resolve_local_agents_dir(Path.cwd())) is not None:
|
||||
paths.append(agents_dir)
|
||||
paths.extend(discover_local_agents_dirs(Path.cwd()))
|
||||
if GLOBAL_AGENTS_DIR.path.is_dir():
|
||||
paths.append(GLOBAL_AGENTS_DIR.path)
|
||||
unique: list[Path] = []
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ class AgentType(StrEnum):
|
|||
|
||||
class BuiltinAgentName(StrEnum):
|
||||
DEFAULT = "default"
|
||||
CHAT = "chat"
|
||||
PLAN = "plan"
|
||||
ACCEPT_EDITS = "accept-edits"
|
||||
AUTO_APPROVE = "auto-approve"
|
||||
|
|
@ -69,6 +70,7 @@ class AgentProfile:
|
|||
)
|
||||
|
||||
|
||||
CHAT_AGENT_TOOLS = ["grep", "read_file", "ask_user_question", "task"]
|
||||
PLAN_AGENT_TOOLS = ["grep", "read_file", "todo", "ask_user_question", "task"]
|
||||
|
||||
DEFAULT = AgentProfile(
|
||||
|
|
@ -84,6 +86,13 @@ PLAN = AgentProfile(
|
|||
AgentSafety.SAFE,
|
||||
overrides={"auto_approve": True, "enabled_tools": PLAN_AGENT_TOOLS},
|
||||
)
|
||||
CHAT = AgentProfile(
|
||||
BuiltinAgentName.CHAT,
|
||||
"Chat",
|
||||
"Read-only conversational mode for questions and discussions",
|
||||
AgentSafety.SAFE,
|
||||
overrides={"auto_approve": True, "enabled_tools": CHAT_AGENT_TOOLS},
|
||||
)
|
||||
ACCEPT_EDITS = AgentProfile(
|
||||
BuiltinAgentName.ACCEPT_EDITS,
|
||||
"Accept Edits",
|
||||
|
|
|
|||
|
|
@ -60,6 +60,32 @@ class IgnoreRules:
|
|||
self._patterns: list[CompiledPattern] | None = None
|
||||
self._root: Path | None = None
|
||||
|
||||
def _compile_default_patterns(self) -> list[CompiledPattern]:
|
||||
patterns: list[CompiledPattern] = []
|
||||
for raw, is_exclude in self._defaults:
|
||||
anchor_root = raw.startswith("/")
|
||||
if anchor_root:
|
||||
raw = raw[1:]
|
||||
stripped = raw.rstrip("/")
|
||||
patterns.append(
|
||||
CompiledPattern(
|
||||
raw=raw,
|
||||
stripped=stripped,
|
||||
is_exclude=is_exclude,
|
||||
dir_only=raw.endswith("/"),
|
||||
name_only="/" not in stripped,
|
||||
anchor_root=anchor_root,
|
||||
)
|
||||
)
|
||||
return patterns
|
||||
|
||||
def get_walk_skip_dir_names(self) -> frozenset[str]:
|
||||
return frozenset(
|
||||
p.stripped
|
||||
for p in self._compile_default_patterns()
|
||||
if p.dir_only and p.name_only and not p.anchor_root
|
||||
)
|
||||
|
||||
def ensure_for_root(self, root: Path) -> None:
|
||||
resolved_root = root.resolve()
|
||||
if self._patterns is None or self._root != resolved_root:
|
||||
|
|
@ -81,24 +107,7 @@ class IgnoreRules:
|
|||
self._root = None
|
||||
|
||||
def _build_patterns(self, root: Path) -> list[CompiledPattern]:
|
||||
patterns: list[CompiledPattern] = []
|
||||
for raw, is_exclude in self._defaults:
|
||||
anchor_root = raw.startswith("/")
|
||||
if anchor_root:
|
||||
raw = raw[1:]
|
||||
|
||||
stripped = raw.rstrip("/")
|
||||
patterns.append(
|
||||
CompiledPattern(
|
||||
raw=raw,
|
||||
stripped=stripped,
|
||||
is_exclude=is_exclude,
|
||||
dir_only=raw.endswith("/"),
|
||||
name_only="/" not in stripped,
|
||||
anchor_root=anchor_root,
|
||||
)
|
||||
)
|
||||
|
||||
patterns = self._compile_default_patterns()
|
||||
gitignore_path = root / ".gitignore"
|
||||
if gitignore_path.exists():
|
||||
try:
|
||||
|
|
@ -154,3 +163,6 @@ class IgnoreRules:
|
|||
return False
|
||||
|
||||
return not pattern.dir_only or is_dir
|
||||
|
||||
|
||||
WALK_SKIP_DIR_NAMES: frozenset[str] = IgnoreRules().get_walk_skip_dir_names()
|
||||
|
|
|
|||
|
|
@ -165,6 +165,10 @@ class _MCPBase(BaseModel):
|
|||
tool_timeout_sec: float = Field(
|
||||
default=60.0, gt=0, description="Timeout in seconds for tool execution."
|
||||
)
|
||||
sampling_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Allow this MCP server to request LLM completions via sampling/createMessage.",
|
||||
)
|
||||
|
||||
@field_validator("name", mode="after")
|
||||
@classmethod
|
||||
|
|
@ -324,6 +328,7 @@ class VibeConfig(BaseSettings):
|
|||
include_prompt_detail: bool = True
|
||||
enable_update_checks: bool = True
|
||||
enable_auto_update: bool = True
|
||||
enable_notifications: bool = True
|
||||
api_timeout: float = 720.0
|
||||
|
||||
# TODO(vibe-nuage): remove exclude=True once the feature is publicly available
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
import json
|
||||
import re
|
||||
from typing import Any, ClassVar
|
||||
|
|
@ -22,7 +23,7 @@ class AnthropicMapper:
|
|||
"""Shared mapper for converting messages to/from Anthropic API format."""
|
||||
|
||||
def prepare_messages(
|
||||
self, messages: list[LLMMessage]
|
||||
self, messages: Sequence[LLMMessage]
|
||||
) -> tuple[str | None, list[dict[str, Any]]]:
|
||||
system_prompt: str | None = None
|
||||
converted: list[dict[str, Any]] = []
|
||||
|
|
@ -457,7 +458,7 @@ class AnthropicAdapter(APIAdapter):
|
|||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol
|
||||
|
||||
from vibe.core.types import AvailableTool, LLMChunk, LLMMessage, StrToolChoice
|
||||
|
|
@ -22,7 +23,7 @@ class APIAdapter(Protocol):
|
|||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Sequence
|
||||
import json
|
||||
import os
|
||||
import types
|
||||
|
|
@ -82,7 +82,7 @@ class OpenAIAdapter(APIAdapter):
|
|||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
|
|
@ -211,12 +211,13 @@ class GenericBackend:
|
|||
self,
|
||||
*,
|
||||
model: ModelConfig,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float = 0.2,
|
||||
tools: list[AvailableTool] | None = None,
|
||||
max_tokens: int | None = None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> LLMChunk:
|
||||
api_key = (
|
||||
os.getenv(self._provider.api_key_env_var)
|
||||
|
|
@ -279,12 +280,13 @@ class GenericBackend:
|
|||
self,
|
||||
*,
|
||||
model: ModelConfig,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float = 0.2,
|
||||
tools: list[AvailableTool] | None = None,
|
||||
max_tokens: int | None = None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> AsyncGenerator[LLMChunk, None]:
|
||||
api_key = (
|
||||
os.getenv(self._provider.api_key_env_var)
|
||||
|
|
@ -395,11 +397,12 @@ class GenericBackend:
|
|||
self,
|
||||
*,
|
||||
model: ModelConfig,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float = 0.0,
|
||||
tools: list[AvailableTool] | None = None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> int:
|
||||
probe_messages = list(messages)
|
||||
if not probe_messages or probe_messages[-1].role != Role.user:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Sequence
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
|
@ -226,12 +226,13 @@ class MistralBackend:
|
|||
self,
|
||||
*,
|
||||
model: ModelConfig,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
extra_headers: dict[str, str] | None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> LLMChunk:
|
||||
try:
|
||||
merged_messages = merge_consecutive_user_messages(messages)
|
||||
|
|
@ -247,6 +248,7 @@ class MistralBackend:
|
|||
if tool_choice
|
||||
else None,
|
||||
http_headers=extra_headers,
|
||||
metadata=metadata,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
|
|
@ -300,12 +302,13 @@ class MistralBackend:
|
|||
self,
|
||||
*,
|
||||
model: ModelConfig,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
extra_headers: dict[str, str] | None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> AsyncGenerator[LLMChunk, None]:
|
||||
try:
|
||||
merged_messages = merge_consecutive_user_messages(messages)
|
||||
|
|
@ -321,6 +324,7 @@ class MistralBackend:
|
|||
if tool_choice
|
||||
else None,
|
||||
http_headers=extra_headers,
|
||||
metadata=metadata,
|
||||
):
|
||||
parsed = (
|
||||
self._mapper.parse_content(chunk.data.choices[0].delta.content)
|
||||
|
|
@ -376,11 +380,12 @@ class MistralBackend:
|
|||
self,
|
||||
*,
|
||||
model: ModelConfig,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float = 0.0,
|
||||
tools: list[AvailableTool] | None = None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> int:
|
||||
result = await self.complete(
|
||||
model=model,
|
||||
|
|
@ -390,6 +395,7 @@ class MistralBackend:
|
|||
max_tokens=1,
|
||||
tool_choice=tool_choice,
|
||||
extra_headers=extra_headers,
|
||||
metadata=metadata,
|
||||
)
|
||||
if result.usage is None:
|
||||
raise ValueError("Missing usage in non streaming completion")
|
||||
|
|
|
|||