v2.2.0 (#395)
Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai> Co-authored-by: Clément Siriex <clement.sirieix@mistral.ai> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai> Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
@@ -32,3 +32,4 @@ repos:
|
||||
hooks:
|
||||
- id: typos
|
||||
args: [--write-changes]
|
||||
exclude: __snapshots__/.*\.svg$
|
||||
|
||||
2
.vscode/launch.json
vendored
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ACP Server",
|
||||
|
||||
@@ -133,3 +133,10 @@ guidelines:
|
||||
- uv sync to install dependencies declared in pyproject.toml and uv.lock
|
||||
- uv run script.py to run a script within the uv environment
|
||||
- uv run pytest (or any other python tool) to run the tool within the uv environment
|
||||
|
||||
- title: "Imports in Cursor (no Pylance)"
|
||||
description: >
|
||||
Cursor's built-in Pyright does not offer the "Add import" quick fix (Ctrl+.). To add a missing import:
|
||||
- Use the workspace snippets: type the prefix (e.g. acpschema, acphelpers, vibetypes, vibeconfig) and accept the suggestion to insert the import line, then change the symbol name.
|
||||
- Or ask Cursor: select the undefined symbol, then Cmd+K and request "Add the missing import for <symbol>".
|
||||
- Or copy the import from an existing file in the repo (e.g. acp.schema, acp.helpers, vibe.core.*).
|
||||
|
||||
29
CHANGELOG.md
@@ -5,6 +5,35 @@ 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.2.0] - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- Google Vertex AI support
|
||||
- Telemetry: user interaction and tool usage events sent to datalake (configurable via `disable_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)
|
||||
- Proxy Wizard (PoC) for CLI and for ACP
|
||||
- Proxy setup documentation
|
||||
- Documentation for JetBrains ACP registry
|
||||
|
||||
### Changed
|
||||
|
||||
- Trusted folders: presence of `.agents` is now considered trustable content
|
||||
- Logging handling updated
|
||||
- Pin `cryptography` to >=44.0.0,<=46.0.3; uv sync for cryptography
|
||||
|
||||
### Fixed
|
||||
|
||||
- Auto scroll when switching to input
|
||||
- MCP stdio: redirect stderr to logger to avoid unwanted console output
|
||||
- Align `pyproject.toml` minimum versions with `uv.lock` for pip installs
|
||||
- Middleware injection: use standalone user messages instead of mutating flushed messages
|
||||
- Revert cryptography 46.0.5 bump for compatibility
|
||||
- Pin banner version in UI snapshot tests for stability
|
||||
|
||||
|
||||
## [2.1.0] - 2026-02-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -72,6 +72,22 @@ This section is for developers who want to set up the repository for local devel
|
||||
|
||||
Pre-commit hooks will automatically run checks before each commit.
|
||||
|
||||
### Logging Configuration
|
||||
|
||||
Logs are written to `~/.vibe/logs/vibe.log` by default. Control logging via environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `LOG_LEVEL` | Logging level (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`) | `WARNING` |
|
||||
| `LOG_MAX_BYTES` | Max log file size in bytes before rotation | `10485760` (10 MB) |
|
||||
| `DEBUG_MODE` | When `true`, forces `DEBUG` level | - |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
LOG_LEVEL=DEBUG uv run vibe
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
Run all tests:
|
||||
|
||||
12
README.md
@@ -84,6 +84,7 @@ pip install mistral-vibe
|
||||
- [Custom Vibe Home Directory](#custom-vibe-home-directory)
|
||||
- [Editors/IDEs](#editorsides)
|
||||
- [Resources](#resources)
|
||||
- [Data collection & usage](#data-collection--usage)
|
||||
- [License](#license)
|
||||
|
||||
## Features
|
||||
@@ -328,9 +329,10 @@ This skill helps analyze code quality and suggest improvements.
|
||||
|
||||
Vibe discovers skills from multiple locations:
|
||||
|
||||
1. **Global skills directory**: `~/.vibe/skills/`
|
||||
2. **Local project skills**: `.vibe/skills/` in your project
|
||||
3. **Custom paths**: Configured in `config.toml`
|
||||
1. **Custom paths**: Configured in `config.toml` via `skill_paths`
|
||||
2. **Standard Agent Skills path** (project root, trusted folders only): `.agents/skills/` — [Agent Skills](https://agentskills.io) standard
|
||||
3. **Local project skills** (project root, trusted folders only): `.vibe/skills/` in your project
|
||||
4. **Global skills directory**: `~/.vibe/skills/`
|
||||
|
||||
```toml
|
||||
skill_paths = ["/path/to/custom/skills"]
|
||||
@@ -595,6 +597,10 @@ Mistral Vibe can be used in text editors and IDEs that support [Agent Client Pro
|
||||
- [CHANGELOG](CHANGELOG.md) - See what's new in each version
|
||||
- [CONTRIBUTING](CONTRIBUTING.md) - Guidelines for feature requests, feedback and bug reports
|
||||
|
||||
## Data collection & usage
|
||||
|
||||
Use of Vibe is subject to our [Privacy Policy](https://legal.mistral.ai/terms/privacy-policy) and may include the collection and processing of data related to your use of the service, such as usage data, to operate, maintain, and improve Vibe. You can disable telemetry in your `config.toml` by setting `enable_telemetry = false`.
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2025 Mistral AI
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "mistral-vibe"
|
||||
name = "Mistral Vibe"
|
||||
description = "Mistral's open-source coding assistant"
|
||||
version = "2.1.0"
|
||||
version = "2.2.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.1.0/vibe-acp-darwin-aarch64-2.1.0.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-darwin-aarch64-2.2.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-darwin-x86_64-2.1.0.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-darwin-x86_64-2.2.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-linux-aarch64-2.1.0.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-linux-aarch64-2.2.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-linux-x86_64-2.1.0.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-linux-x86_64-2.2.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.windows-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-windows-aarch64-2.1.0.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-windows-aarch64-2.2.0.zip"
|
||||
cmd = "./vibe-acp.exe"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-windows-x86_64-2.1.0.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-windows-x86_64-2.2.0.zip"
|
||||
cmd = "./vibe-acp.exe"
|
||||
|
||||
@@ -5,3 +5,4 @@ Welcome to the Mistral Vibe documentation! For basic setup, see the [main README
|
||||
## Available Documentation
|
||||
|
||||
- **[ACP Setup](acp-setup.md)** - Setup instructions for using Mistral Vibe with various editors and IDEs that support the Agent Client Protocol.
|
||||
- **[Proxy Setup](proxy-setup.md)** - Configure proxy and SSL certificate settings for corporate networks or firewalls.
|
||||
|
||||
@@ -22,10 +22,20 @@ For usage in Zed, we recommend using the [Mistral Vibe Zed's extension](https://
|
||||
}
|
||||
```
|
||||
|
||||
2. In the `New Thread` pane on the right, select the `vibe` agent and start the conversation.
|
||||
1. In the `New Thread` pane on the right, select the `vibe` agent and start the conversation.
|
||||
|
||||
## JetBrains IDEs
|
||||
|
||||
For using Mistral Vibe in JetBrains IDEs, you'll need to have the [Jetbrains AI Assistant extension](https://plugins.jetbrains.com/plugin/22282-jetbrains-ai-assistant) installed
|
||||
|
||||
### Version 2025.3 or later
|
||||
|
||||
1. Open settings, then go to `Tools > AI Assistant > Agents`. Search for `Mistral Vibe`, click install
|
||||
|
||||
2. Open AI Assistant. You should be able to select Mistral Vibe from the agent selector (if you're not authenticated yet, you will be prompted to do so).
|
||||
|
||||
### Legacy method
|
||||
|
||||
1. Add the following snippet to your JetBrains IDE acp.json ([documentation](https://www.jetbrains.com/help/ai-assistant/acp.html)):
|
||||
|
||||
```json
|
||||
@@ -38,7 +48,7 @@ For usage in Zed, we recommend using the [Mistral Vibe Zed's extension](https://
|
||||
}
|
||||
```
|
||||
|
||||
2. In the AI Chat agent selector, select the new Mistral Vibe agent and start the conversation.
|
||||
1. In the AI Chat agent selector, select the new Mistral Vibe agent and start the conversation.
|
||||
|
||||
## Neovim (using avante.nvim)
|
||||
|
||||
|
||||
36
docs/proxy-setup.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Proxy Setup
|
||||
|
||||
Mistral Vibe supports proxy configuration for environments that require network traffic to go through a proxy server. Proxy settings are shared between the CLI and ACP — configuring them in one will apply to both.
|
||||
|
||||
## Using Mistral Vibe CLI
|
||||
|
||||
Configure proxy settings through the interactive form:
|
||||
|
||||
1. Type `/proxy-setup` and press Enter
|
||||
2. Fill in the variables you need, then press **Enter** to save or **Escape** to cancel
|
||||
3. **Restart the CLI** for changes to take effect
|
||||
|
||||
## Through an ACP Client
|
||||
|
||||
In ACP, variables must be set one at a time using the `/proxy-setup` command:
|
||||
|
||||
```bash
|
||||
/proxy-setup HTTP_PROXY http://proxy.example.com:8080
|
||||
```
|
||||
|
||||
Once all variables are configured, **restart the conversation** for changes to take effect.
|
||||
|
||||
## Supported Environment Variables
|
||||
|
||||
Mistral Vibe uses [httpx](https://www.python-httpx.org/environment_variables/) for HTTP requests and supports the same environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `HTTP_PROXY` | Proxy URL for HTTP requests |
|
||||
| `HTTPS_PROXY` | Proxy URL for HTTPS requests |
|
||||
| `ALL_PROXY` | Proxy URL for all requests (fallback when HTTP_PROXY/HTTPS_PROXY are not set) |
|
||||
| `NO_PROXY` | Comma-separated list of hosts that should bypass the proxy |
|
||||
| `SSL_CERT_FILE` | Path to a custom SSL certificate file |
|
||||
| `SSL_CERT_DIR` | Path to a directory containing SSL certificates |
|
||||
|
||||
These variables can also be set directly in your shell environment before launching the CLI (but will be overridden if set through the interactive form).
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mistral-vibe"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
description = "Minimal CLI coding agent by Mistral"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -29,28 +29,30 @@ classifiers = [
|
||||
dependencies = [
|
||||
"agent-client-protocol==0.8.0",
|
||||
"anyio>=4.12.0",
|
||||
"httpx>=0.28.1",
|
||||
"mcp>=1.14.0",
|
||||
"mistralai==1.9.11",
|
||||
"pexpect>=4.9.0",
|
||||
"packaging>=24.1",
|
||||
"pydantic>=2.12.4",
|
||||
"pydantic-settings>=2.12.0",
|
||||
"pyyaml>=6.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"rich>=14.0.0",
|
||||
"textual>=1.0.0",
|
||||
"tomli-w>=1.2.0",
|
||||
"watchfiles>=1.1.1",
|
||||
"pyperclip>=1.11.0",
|
||||
"textual-speedups>=0.2.1",
|
||||
"tree-sitter>=0.25.2",
|
||||
"tree-sitter-bash>=0.25.1",
|
||||
"keyring>=25.6.0",
|
||||
"cryptography>=44.0.0",
|
||||
"zstandard>=0.25.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",
|
||||
"mcp>=1.14.0",
|
||||
"mistralai==1.9.11",
|
||||
"packaging>=24.1",
|
||||
"pexpect>=4.9.0",
|
||||
"pydantic>=2.12.4",
|
||||
"pydantic-settings>=2.12.0",
|
||||
"pyperclip>=1.11.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"pyyaml>=6.0.0",
|
||||
"requests>=2.20.0",
|
||||
"rich>=14.0.0",
|
||||
"textual>=1.0.0",
|
||||
"textual-speedups>=0.2.1",
|
||||
"tomli-w>=1.2.0",
|
||||
"tree-sitter>=0.25.2",
|
||||
"tree-sitter-bash>=0.25.1",
|
||||
"watchfiles>=1.1.1",
|
||||
"zstandard>=0.25.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -59,7 +61,6 @@ Repository = "https://github.com/mistralai/mistral-vibe"
|
||||
Issues = "https://github.com/mistralai/mistral-vibe/issues"
|
||||
Documentation = "https://github.com/mistralai/mistral-vibe#readme"
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling", "hatch-vcs", "editables"]
|
||||
build-backend = "hatchling.build"
|
||||
@@ -83,19 +84,19 @@ required-version = ">=0.8.0"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"debugpy>=1.8.19",
|
||||
"pre-commit>=4.2.0",
|
||||
"pyright>=1.1.403",
|
||||
"pytest>=8.3.5",
|
||||
"pytest-asyncio>=1.2.0",
|
||||
"pytest-timeout>=2.4.0",
|
||||
"pytest-textual-snapshot>=1.1.0",
|
||||
"pytest-timeout>=2.4.0",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"respx>=0.22.0",
|
||||
"ruff>=0.14.5",
|
||||
"twine>=5.0.0",
|
||||
"typos>=1.34.0",
|
||||
"vulture>=2.14",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"debugpy>=1.8.19",
|
||||
]
|
||||
|
||||
build = ["pyinstaller>=6.17.0"]
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -40,3 +43,55 @@ def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop:
|
||||
|
||||
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgent).start()
|
||||
return _create_acp_agent()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_session_dir(tmp_path: Path) -> Path:
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
return session_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_test_session():
|
||||
"""Create a test session with configurable messages and metadata.
|
||||
|
||||
Supports both messages parameter (for load_session tests) and
|
||||
end_time parameter (for list_sessions tests).
|
||||
"""
|
||||
|
||||
def _create_session(
|
||||
session_dir: Path,
|
||||
session_id: str,
|
||||
cwd: str,
|
||||
messages: list[dict] | None = None,
|
||||
title: str | None = None,
|
||||
end_time: str | None = None,
|
||||
) -> Path:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
session_folder = session_dir / f"session_{timestamp}_{session_id[:8]}"
|
||||
session_folder.mkdir(exist_ok=True)
|
||||
|
||||
if messages is None:
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
|
||||
messages_file = session_folder / "messages.jsonl"
|
||||
with messages_file.open("w", encoding="utf-8") as f:
|
||||
for msg in messages:
|
||||
f.write(json.dumps(msg) + "\n")
|
||||
|
||||
metadata = {
|
||||
"session_id": session_id,
|
||||
"start_time": "2024-01-01T12:00:00Z",
|
||||
"end_time": end_time or "2024-01-01T12:05:00Z",
|
||||
"environment": {"working_directory": cwd},
|
||||
"title": title,
|
||||
}
|
||||
|
||||
metadata_file = session_folder / "meta.json"
|
||||
with metadata_file.open("w", encoding="utf-8") as f:
|
||||
json.dump(metadata, f)
|
||||
|
||||
return session_folder
|
||||
|
||||
return _create_session
|
||||
|
||||
@@ -451,6 +451,18 @@ class TestSessionUpdates:
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
commands_response_text = await read_response(process)
|
||||
assert commands_response_text is not None
|
||||
commands_response = UpdateJsonRpcNotification.model_validate(
|
||||
json.loads(commands_response_text)
|
||||
)
|
||||
assert commands_response.params is not None
|
||||
assert (
|
||||
commands_response.params.update.session_update
|
||||
== "available_commands_update"
|
||||
)
|
||||
|
||||
user_response_text = await read_response(process)
|
||||
assert user_response_text is not None
|
||||
user_response = UpdateJsonRpcNotification.model_validate(
|
||||
|
||||
@@ -6,6 +6,8 @@ from acp.schema import (
|
||||
ClientCapabilities,
|
||||
Implementation,
|
||||
PromptCapabilities,
|
||||
SessionCapabilities,
|
||||
SessionListCapabilities,
|
||||
)
|
||||
import pytest
|
||||
|
||||
@@ -19,13 +21,14 @@ class TestACPInitialize:
|
||||
|
||||
assert response.protocol_version == PROTOCOL_VERSION
|
||||
assert response.agent_capabilities == AgentCapabilities(
|
||||
load_session=False,
|
||||
load_session=True,
|
||||
prompt_capabilities=PromptCapabilities(
|
||||
audio=False, embedded_context=True, image=False
|
||||
),
|
||||
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.1.0"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.2.0"
|
||||
)
|
||||
|
||||
assert response.auth_methods == []
|
||||
@@ -42,13 +45,14 @@ class TestACPInitialize:
|
||||
|
||||
assert response.protocol_version == PROTOCOL_VERSION
|
||||
assert response.agent_capabilities == AgentCapabilities(
|
||||
load_session=False,
|
||||
load_session=True,
|
||||
prompt_capabilities=PromptCapabilities(
|
||||
audio=False, embedded_context=True, image=False
|
||||
),
|
||||
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.1.0"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.2.0"
|
||||
)
|
||||
|
||||
assert response.auth_methods is not None
|
||||
|
||||
242
tests/acp/test_list_sessions.py
Normal file
@@ -0,0 +1,242 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.acp.conftest import _create_acp_agent
|
||||
from vibe.core.config import MissingAPIKeyError, SessionLoggingConfig
|
||||
|
||||
|
||||
class TestListSessions:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_empty(self, temp_session_dir: Path) -> None:
|
||||
acp_agent = _create_acp_agent()
|
||||
|
||||
session_config = SessionLoggingConfig(
|
||||
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
|
||||
)
|
||||
|
||||
with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load:
|
||||
mock_config = mock_load.return_value
|
||||
mock_config.session_logging = session_config
|
||||
|
||||
response = await acp_agent.list_sessions()
|
||||
|
||||
assert response.sessions == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_returns_all_sessions(
|
||||
self, temp_session_dir: Path, create_test_session
|
||||
) -> None:
|
||||
acp_agent = _create_acp_agent()
|
||||
|
||||
create_test_session(
|
||||
temp_session_dir,
|
||||
"aaaaaaaa-1111",
|
||||
"/home/user/project1",
|
||||
title="First session",
|
||||
end_time="2024-01-01T12:00:00Z",
|
||||
)
|
||||
create_test_session(
|
||||
temp_session_dir,
|
||||
"bbbbbbbb-2222",
|
||||
"/home/user/project2",
|
||||
title="Second session",
|
||||
end_time="2024-01-01T13:00:00Z",
|
||||
)
|
||||
|
||||
session_config = SessionLoggingConfig(
|
||||
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
|
||||
)
|
||||
|
||||
with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load:
|
||||
mock_config = mock_load.return_value
|
||||
mock_config.session_logging = session_config
|
||||
|
||||
response = await acp_agent.list_sessions()
|
||||
|
||||
assert len(response.sessions) == 2
|
||||
session_ids = {s.session_id for s in response.sessions}
|
||||
assert "aaaaaaaa-1111" in session_ids
|
||||
assert "bbbbbbbb-2222" in session_ids
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_filters_by_cwd(
|
||||
self, temp_session_dir: Path, create_test_session
|
||||
) -> None:
|
||||
acp_agent = _create_acp_agent()
|
||||
|
||||
create_test_session(
|
||||
temp_session_dir,
|
||||
"aaaaaaaa-proj1",
|
||||
"/home/user/project1",
|
||||
title="Project 1 session",
|
||||
)
|
||||
create_test_session(
|
||||
temp_session_dir,
|
||||
"bbbbbbbb-proj2",
|
||||
"/home/user/project2",
|
||||
title="Project 2 session",
|
||||
)
|
||||
create_test_session(
|
||||
temp_session_dir,
|
||||
"cccccccc-proj1",
|
||||
"/home/user/project1",
|
||||
title="Another Project 1 session",
|
||||
)
|
||||
|
||||
session_config = SessionLoggingConfig(
|
||||
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
|
||||
)
|
||||
|
||||
with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load:
|
||||
mock_config = mock_load.return_value
|
||||
mock_config.session_logging = session_config
|
||||
|
||||
response = await acp_agent.list_sessions(cwd="/home/user/project1")
|
||||
|
||||
assert len(response.sessions) == 2
|
||||
for session in response.sessions:
|
||||
assert session.cwd == "/home/user/project1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_sorted_by_updated_at(
|
||||
self, temp_session_dir: Path, create_test_session
|
||||
) -> None:
|
||||
acp_agent = _create_acp_agent()
|
||||
|
||||
create_test_session(
|
||||
temp_session_dir,
|
||||
"oldest-s",
|
||||
"/home/user/project",
|
||||
title="Oldest",
|
||||
end_time="2024-01-01T10:00:00Z",
|
||||
)
|
||||
create_test_session(
|
||||
temp_session_dir,
|
||||
"newest-s",
|
||||
"/home/user/project",
|
||||
title="Newest",
|
||||
end_time="2024-01-01T14:00:00Z",
|
||||
)
|
||||
create_test_session(
|
||||
temp_session_dir,
|
||||
"middle-s",
|
||||
"/home/user/project",
|
||||
title="Middle",
|
||||
end_time="2024-01-01T12:00:00Z",
|
||||
)
|
||||
|
||||
session_config = SessionLoggingConfig(
|
||||
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
|
||||
)
|
||||
|
||||
with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load:
|
||||
mock_config = mock_load.return_value
|
||||
mock_config.session_logging = session_config
|
||||
|
||||
response = await acp_agent.list_sessions()
|
||||
|
||||
assert len(response.sessions) == 3
|
||||
|
||||
assert response.sessions[0].title == "Newest"
|
||||
assert response.sessions[1].title == "Middle"
|
||||
assert response.sessions[2].title == "Oldest"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_includes_session_info_fields(
|
||||
self, temp_session_dir: Path, create_test_session
|
||||
) -> None:
|
||||
acp_agent = _create_acp_agent()
|
||||
|
||||
create_test_session(
|
||||
temp_session_dir,
|
||||
"test-session-123",
|
||||
"/home/user/project",
|
||||
title="Test Session Title",
|
||||
end_time="2024-01-15T10:30:00Z",
|
||||
)
|
||||
|
||||
session_config = SessionLoggingConfig(
|
||||
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
|
||||
)
|
||||
|
||||
with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load:
|
||||
mock_config = mock_load.return_value
|
||||
mock_config.session_logging = session_config
|
||||
|
||||
response = await acp_agent.list_sessions()
|
||||
|
||||
assert len(response.sessions) == 1
|
||||
session = response.sessions[0]
|
||||
assert session.session_id == "test-session-123"
|
||||
assert session.cwd == "/home/user/project"
|
||||
assert session.title == "Test Session Title"
|
||||
# updated_at is normalized to UTC
|
||||
assert session.updated_at is not None
|
||||
assert session.updated_at.endswith("+00:00")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_skips_invalid_sessions(
|
||||
self, temp_session_dir: Path, create_test_session
|
||||
) -> None:
|
||||
acp_agent = _create_acp_agent()
|
||||
|
||||
create_test_session(
|
||||
temp_session_dir, "valid-se", "/home/user/project", title="Valid Session"
|
||||
)
|
||||
|
||||
invalid_session = temp_session_dir / "session_20240101_120000_invalid1"
|
||||
invalid_session.mkdir()
|
||||
(invalid_session / "meta.json").write_text('{"session_id": "invalid"}')
|
||||
|
||||
no_id_session = temp_session_dir / "session_20240101_120001_noid0000"
|
||||
no_id_session.mkdir()
|
||||
(no_id_session / "messages.jsonl").write_text(
|
||||
'{"role": "user", "content": "Hello"}\n'
|
||||
)
|
||||
(no_id_session / "meta.json").write_text(
|
||||
'{"environment": {"working_directory": "/test"}}'
|
||||
)
|
||||
|
||||
session_config = SessionLoggingConfig(
|
||||
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
|
||||
)
|
||||
|
||||
with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load:
|
||||
mock_config = mock_load.return_value
|
||||
mock_config.session_logging = session_config
|
||||
|
||||
response = await acp_agent.list_sessions()
|
||||
|
||||
assert len(response.sessions) == 1
|
||||
assert response.sessions[0].session_id == "valid-se"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_nonexistent_save_dir(self) -> None:
|
||||
acp_agent = _create_acp_agent()
|
||||
|
||||
session_config = SessionLoggingConfig(
|
||||
save_dir="/nonexistent/path", session_prefix="session", enabled=True
|
||||
)
|
||||
|
||||
with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load:
|
||||
mock_config = mock_load.return_value
|
||||
mock_config.session_logging = session_config
|
||||
|
||||
response = await acp_agent.list_sessions()
|
||||
|
||||
assert response.sessions == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_without_api_key(self) -> None:
|
||||
acp_agent = _create_acp_agent()
|
||||
|
||||
with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load:
|
||||
mock_load.side_effect = MissingAPIKeyError("api_key", "mistral")
|
||||
|
||||
response = await acp_agent.list_sessions()
|
||||
|
||||
assert response.sessions == []
|
||||
301
tests/acp/test_load_session.py
Normal file
@@ -0,0 +1,301 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from acp import RequestError
|
||||
from acp.schema import (
|
||||
AgentMessageChunk,
|
||||
AgentThoughtChunk,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
UserMessageChunk,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_client import FakeClient
|
||||
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, SessionLoggingConfig
|
||||
from vibe.core.types import Role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent_with_session_config(
|
||||
backend: FakeBackend, temp_session_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> tuple[VibeAcpAgentLoop, FakeClient]:
|
||||
session_config = SessionLoggingConfig(
|
||||
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
|
||||
)
|
||||
config = build_test_vibe_config(
|
||||
active_model="devstral-latest",
|
||||
models=[
|
||||
ModelConfig(
|
||||
name="devstral-latest", provider="mistral", alias="devstral-latest"
|
||||
)
|
||||
],
|
||||
session_logging=session_config,
|
||||
)
|
||||
|
||||
class PatchedAgentLoop(AgentLoop):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **{**kwargs, "backend": backend})
|
||||
self._base_config = config
|
||||
self.agent_manager.invalidate_config()
|
||||
|
||||
monkeypatch.setattr("vibe.acp.acp_agent_loop.AgentLoop", PatchedAgentLoop)
|
||||
monkeypatch.setattr(VibeAcpAgentLoop, "_load_config", lambda self: config)
|
||||
|
||||
vibe_acp_agent = VibeAcpAgentLoop()
|
||||
client = FakeClient()
|
||||
vibe_acp_agent.on_connect(client)
|
||||
client.on_connect(vibe_acp_agent)
|
||||
|
||||
return vibe_acp_agent, client
|
||||
|
||||
|
||||
class TestLoadSession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_returns_response_with_models_and_modes(
|
||||
self,
|
||||
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||
temp_session_dir: Path,
|
||||
create_test_session,
|
||||
) -> None:
|
||||
acp_agent, _client = acp_agent_with_session_config
|
||||
|
||||
session_id = "test-sess-12345678"
|
||||
cwd = str(Path.cwd())
|
||||
create_test_session(temp_session_dir, session_id, cwd)
|
||||
|
||||
response = await acp_agent.load_session(
|
||||
cwd=cwd, mcp_servers=[], session_id=session_id
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.models is not None
|
||||
assert response.models.current_model_id == "devstral-latest"
|
||||
assert response.modes is not None
|
||||
assert response.modes.current_mode_id == BuiltinAgentName.DEFAULT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_registers_session_with_original_id(
|
||||
self,
|
||||
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||
temp_session_dir: Path,
|
||||
create_test_session,
|
||||
) -> None:
|
||||
acp_agent, _client = acp_agent_with_session_config
|
||||
|
||||
session_id = "orig-id-12345678"
|
||||
cwd = str(Path.cwd())
|
||||
create_test_session(temp_session_dir, session_id, cwd)
|
||||
|
||||
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
|
||||
|
||||
assert session_id in acp_agent.sessions
|
||||
assert acp_agent.sessions[session_id].id == session_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_injects_messages_into_agent_loop(
|
||||
self,
|
||||
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||
temp_session_dir: Path,
|
||||
create_test_session,
|
||||
) -> None:
|
||||
acp_agent, _client = acp_agent_with_session_config
|
||||
|
||||
session_id = "msg-test-12345678"
|
||||
cwd = str(Path.cwd())
|
||||
messages = [
|
||||
{"role": "system", "content": "You are helpful"},
|
||||
{"role": "user", "content": "First question"},
|
||||
{"role": "assistant", "content": "First answer"},
|
||||
{"role": "user", "content": "Second question"},
|
||||
{"role": "assistant", "content": "Second answer"},
|
||||
]
|
||||
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
|
||||
|
||||
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
|
||||
|
||||
session = acp_agent.sessions[session_id]
|
||||
|
||||
non_system = [m for m in session.agent_loop.messages if m.role != Role.system]
|
||||
assert len(non_system) == 4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_replays_user_messages(
|
||||
self,
|
||||
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||
temp_session_dir: Path,
|
||||
create_test_session,
|
||||
) -> None:
|
||||
acp_agent, client = acp_agent_with_session_config
|
||||
|
||||
session_id = "replay-usr-123456"
|
||||
cwd = str(Path.cwd())
|
||||
messages = [{"role": "user", "content": "Hello world"}]
|
||||
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
|
||||
|
||||
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
|
||||
|
||||
user_updates = [
|
||||
u for u in client._session_updates if isinstance(u.update, UserMessageChunk)
|
||||
]
|
||||
assert len(user_updates) == 1
|
||||
assert user_updates[0].update.content.text == "Hello world"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_replays_assistant_messages(
|
||||
self,
|
||||
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||
temp_session_dir: Path,
|
||||
create_test_session,
|
||||
) -> None:
|
||||
acp_agent, client = acp_agent_with_session_config
|
||||
|
||||
session_id = "replay-ast-123456"
|
||||
cwd = str(Path.cwd())
|
||||
messages = [
|
||||
{"role": "user", "content": "Hi"},
|
||||
{"role": "assistant", "content": "Hello! How can I help?"},
|
||||
]
|
||||
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
|
||||
|
||||
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
|
||||
|
||||
agent_updates = [
|
||||
u
|
||||
for u in client._session_updates
|
||||
if isinstance(u.update, AgentMessageChunk)
|
||||
]
|
||||
assert len(agent_updates) == 1
|
||||
assert agent_updates[0].update.content.text == "Hello! How can I help?"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_replays_tool_calls(
|
||||
self,
|
||||
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||
temp_session_dir: Path,
|
||||
create_test_session,
|
||||
) -> None:
|
||||
acp_agent, client = acp_agent_with_session_config
|
||||
|
||||
session_id = "replay-tool-12345"
|
||||
cwd = str(Path.cwd())
|
||||
messages = [
|
||||
{"role": "user", "content": "Read the file"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": '{"path": "/tmp/test.txt"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_123", "content": "file contents"},
|
||||
]
|
||||
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
|
||||
|
||||
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
|
||||
|
||||
tool_call_starts = [
|
||||
u for u in client._session_updates if isinstance(u.update, ToolCallStart)
|
||||
]
|
||||
assert len(tool_call_starts) == 1
|
||||
assert tool_call_starts[0].update.title == "read_file"
|
||||
assert tool_call_starts[0].update.tool_call_id == "call_123"
|
||||
|
||||
tool_results = [
|
||||
u for u in client._session_updates if isinstance(u.update, ToolCallProgress)
|
||||
]
|
||||
assert len(tool_results) == 1
|
||||
assert tool_results[0].update.tool_call_id == "call_123"
|
||||
assert tool_results[0].update.status == "completed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_replays_reasoning_content(
|
||||
self,
|
||||
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||
temp_session_dir: Path,
|
||||
create_test_session,
|
||||
) -> None:
|
||||
acp_agent, client = acp_agent_with_session_config
|
||||
|
||||
session_id = "replay-reason-123"
|
||||
cwd = str(Path.cwd())
|
||||
messages = [
|
||||
{"role": "user", "content": "Think about this"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Here is my answer",
|
||||
"reasoning_content": "Let me think step by step...",
|
||||
},
|
||||
]
|
||||
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
|
||||
|
||||
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
|
||||
|
||||
thought_updates = [
|
||||
u
|
||||
for u in client._session_updates
|
||||
if isinstance(u.update, AgentThoughtChunk)
|
||||
]
|
||||
assert len(thought_updates) == 1
|
||||
assert thought_updates[0].update.content.text == "Let me think step by step..."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_not_found_raises_error(
|
||||
self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient]
|
||||
) -> None:
|
||||
acp_agent, _client = acp_agent_with_session_config
|
||||
|
||||
with pytest.raises(RequestError):
|
||||
await acp_agent.load_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[], session_id="nonexistent-session"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_replays_full_conversation(
|
||||
self,
|
||||
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||
temp_session_dir: Path,
|
||||
create_test_session,
|
||||
) -> None:
|
||||
acp_agent, client = acp_agent_with_session_config
|
||||
|
||||
session_id = "full-conv-1234567"
|
||||
cwd = str(Path.cwd())
|
||||
messages = [
|
||||
{"role": "user", "content": "First message"},
|
||||
{"role": "assistant", "content": "First response"},
|
||||
{"role": "user", "content": "Second message"},
|
||||
{"role": "assistant", "content": "Second response"},
|
||||
]
|
||||
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
|
||||
|
||||
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
|
||||
|
||||
user_updates = [
|
||||
u for u in client._session_updates if isinstance(u.update, UserMessageChunk)
|
||||
]
|
||||
agent_updates = [
|
||||
u
|
||||
for u in client._session_updates
|
||||
if isinstance(u.update, AgentMessageChunk)
|
||||
]
|
||||
|
||||
assert len(user_updates) == 2
|
||||
assert len(agent_updates) == 2
|
||||
assert user_updates[0].update.content.text == "First message"
|
||||
assert user_updates[1].update.content.text == "Second message"
|
||||
assert agent_updates[0].update.content.text == "First response"
|
||||
assert agent_updates[1].update.content.text == "Second response"
|
||||
@@ -41,12 +41,18 @@ def acp_agent_loop(backend) -> VibeAcpAgentLoop:
|
||||
class TestACPNewSession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_response_structure(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, telemetry_events: list[dict]
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
|
||||
new_session_events = [
|
||||
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"
|
||||
|
||||
assert session_response.session_id is not None
|
||||
acp_session = next(
|
||||
(
|
||||
|
||||
271
tests/acp/test_proxy_setup_acp.py
Normal file
@@ -0,0 +1,271 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from acp.schema import AgentMessageChunk, AvailableCommandsUpdate, TextContentBlock
|
||||
import pytest
|
||||
|
||||
from tests.acp.conftest import _create_acp_agent
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from tests.stubs.fake_client import FakeClient
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent_loop(backend) -> VibeAcpAgentLoop:
|
||||
config = build_test_vibe_config()
|
||||
|
||||
class PatchedAgentLoop(AgentLoop):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **{**kwargs, "backend": backend})
|
||||
self._base_config = config
|
||||
self.agent_manager.invalidate_config()
|
||||
|
||||
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start()
|
||||
|
||||
return _create_acp_agent()
|
||||
|
||||
|
||||
def _get_fake_client(acp_agent_loop: VibeAcpAgentLoop) -> FakeClient:
|
||||
assert isinstance(acp_agent_loop.client, FakeClient)
|
||||
return acp_agent_loop.client
|
||||
|
||||
|
||||
class TestAvailableCommandsUpdate:
|
||||
@pytest.mark.asyncio
|
||||
async def test_available_commands_sent_on_new_session(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
import asyncio
|
||||
|
||||
await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
updates = _get_fake_client(acp_agent_loop)._session_updates
|
||||
available_commands_updates = [
|
||||
u for u in updates if isinstance(u.update, AvailableCommandsUpdate)
|
||||
]
|
||||
|
||||
assert len(available_commands_updates) == 1
|
||||
update = available_commands_updates[0].update
|
||||
assert len(update.available_commands) == 1
|
||||
assert update.available_commands[0].name == "proxy-setup"
|
||||
assert "proxy" in update.available_commands[0].description.lower()
|
||||
|
||||
|
||||
class TestProxySetupCommand:
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_setup_shows_help_when_no_args(
|
||||
self,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
env_file = tmp_path / ".env"
|
||||
|
||||
class FakeGlobalEnvFile:
|
||||
path = env_file
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile()
|
||||
)
|
||||
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
|
||||
_get_fake_client(acp_agent_loop)._session_updates.clear()
|
||||
|
||||
response = await acp_agent_loop.prompt(
|
||||
prompt=[TextContentBlock(type="text", text="/proxy-setup")],
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
assert response.stop_reason == "end_turn"
|
||||
|
||||
updates = _get_fake_client(acp_agent_loop)._session_updates
|
||||
message_updates = [
|
||||
u for u in updates if isinstance(u.update, AgentMessageChunk)
|
||||
]
|
||||
|
||||
assert len(message_updates) == 1
|
||||
content = message_updates[0].update.content.text
|
||||
assert "## Proxy Configuration" in content
|
||||
assert "HTTP_PROXY" in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_setup_sets_value(
|
||||
self,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
env_file = tmp_path / ".env"
|
||||
|
||||
class FakeGlobalEnvFile:
|
||||
path = env_file
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile()
|
||||
)
|
||||
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
|
||||
_get_fake_client(acp_agent_loop)._session_updates.clear()
|
||||
|
||||
response = await acp_agent_loop.prompt(
|
||||
prompt=[
|
||||
TextContentBlock(
|
||||
type="text", text="/proxy-setup HTTP_PROXY http://localhost:8080"
|
||||
)
|
||||
],
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
assert response.stop_reason == "end_turn"
|
||||
|
||||
updates = _get_fake_client(acp_agent_loop)._session_updates
|
||||
message_updates = [
|
||||
u for u in updates if isinstance(u.update, AgentMessageChunk)
|
||||
]
|
||||
|
||||
assert len(message_updates) == 1
|
||||
content = message_updates[0].update.content.text
|
||||
assert "HTTP_PROXY" in content
|
||||
assert "http://localhost:8080" in content
|
||||
|
||||
assert env_file.exists()
|
||||
env_content = env_file.read_text()
|
||||
assert "HTTP_PROXY" in env_content
|
||||
assert "http://localhost:8080" in env_content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_setup_unsets_value(
|
||||
self,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("HTTP_PROXY=http://old-proxy.com\n")
|
||||
|
||||
class FakeGlobalEnvFile:
|
||||
path = env_file
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile()
|
||||
)
|
||||
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
|
||||
_get_fake_client(acp_agent_loop)._session_updates.clear()
|
||||
|
||||
response = await acp_agent_loop.prompt(
|
||||
prompt=[TextContentBlock(type="text", text="/proxy-setup HTTP_PROXY")],
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
assert response.stop_reason == "end_turn"
|
||||
|
||||
updates = _get_fake_client(acp_agent_loop)._session_updates
|
||||
message_updates = [
|
||||
u for u in updates if isinstance(u.update, AgentMessageChunk)
|
||||
]
|
||||
|
||||
assert len(message_updates) == 1
|
||||
content = message_updates[0].update.content.text
|
||||
assert "Removed" in content
|
||||
assert "HTTP_PROXY" in content
|
||||
|
||||
env_content = env_file.read_text()
|
||||
assert "HTTP_PROXY" not in env_content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_setup_invalid_key_returns_error(
|
||||
self,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
env_file = tmp_path / ".env"
|
||||
|
||||
class FakeGlobalEnvFile:
|
||||
path = env_file
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile()
|
||||
)
|
||||
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
|
||||
_get_fake_client(acp_agent_loop)._session_updates.clear()
|
||||
|
||||
response = await acp_agent_loop.prompt(
|
||||
prompt=[
|
||||
TextContentBlock(type="text", text="/proxy-setup INVALID_KEY value")
|
||||
],
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
assert response.stop_reason == "end_turn"
|
||||
|
||||
updates = _get_fake_client(acp_agent_loop)._session_updates
|
||||
message_updates = [
|
||||
u for u in updates if isinstance(u.update, AgentMessageChunk)
|
||||
]
|
||||
|
||||
assert len(message_updates) == 1
|
||||
content = message_updates[0].update.content.text
|
||||
assert "Error" in content
|
||||
assert "Unknown key" in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_setup_case_insensitive(
|
||||
self,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
env_file = tmp_path / ".env"
|
||||
|
||||
class FakeGlobalEnvFile:
|
||||
path = env_file
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile()
|
||||
)
|
||||
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
|
||||
_get_fake_client(acp_agent_loop)._session_updates.clear()
|
||||
|
||||
response = await acp_agent_loop.prompt(
|
||||
prompt=[
|
||||
TextContentBlock(
|
||||
type="text", text="/PROXY-SETUP http_proxy http://localhost:8080"
|
||||
)
|
||||
],
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
assert response.stop_reason == "end_turn"
|
||||
|
||||
assert env_file.exists()
|
||||
env_content = env_file.read_text()
|
||||
assert "HTTP_PROXY" in env_content
|
||||
55
tests/acp/test_utils.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from vibe.acp.utils import get_proxy_help_text
|
||||
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
|
||||
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS
|
||||
|
||||
|
||||
def _write_env_file(content: str) -> None:
|
||||
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
GLOBAL_ENV_FILE.path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
class TestGetProxyHelpText:
|
||||
def test_returns_string(self) -> None:
|
||||
result = get_proxy_help_text()
|
||||
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_includes_proxy_configuration_header(self) -> None:
|
||||
result = get_proxy_help_text()
|
||||
|
||||
assert "## Proxy Configuration" in result
|
||||
|
||||
def test_includes_usage_section(self) -> None:
|
||||
result = get_proxy_help_text()
|
||||
|
||||
assert "### Usage:" in result
|
||||
assert "/proxy-setup" in result
|
||||
|
||||
def test_includes_all_supported_variables(self) -> None:
|
||||
result = get_proxy_help_text()
|
||||
|
||||
for key in SUPPORTED_PROXY_VARS:
|
||||
assert f"`{key}`" in result
|
||||
|
||||
def test_shows_none_configured_when_no_settings(self) -> None:
|
||||
result = get_proxy_help_text()
|
||||
|
||||
assert "(none configured)" in result
|
||||
|
||||
def test_shows_current_settings_when_configured(self) -> None:
|
||||
_write_env_file("HTTP_PROXY=http://proxy:8080\n")
|
||||
|
||||
result = get_proxy_help_text()
|
||||
|
||||
assert "HTTP_PROXY=http://proxy:8080" in result
|
||||
assert "(none configured)" not in result
|
||||
|
||||
def test_shows_only_set_values(self) -> None:
|
||||
_write_env_file("HTTP_PROXY=http://proxy:8080\n")
|
||||
|
||||
result = get_proxy_help_text()
|
||||
|
||||
assert "HTTP_PROXY=http://proxy:8080" in result
|
||||
assert "HTTPS_PROXY=" not in result
|
||||
@@ -100,7 +100,7 @@ async def test_arrow_navigation_cycles_through_suggestions(vibe_app: VibeApp) ->
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pressing_enter_submits_selected_command_and_hides_popup(
|
||||
vibe_app: VibeApp,
|
||||
vibe_app: VibeApp, telemetry_events: list[dict]
|
||||
) -> None:
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
@@ -115,6 +115,17 @@ async def test_pressing_enter_submits_selected_command_and_hides_popup(
|
||||
message_content = message.query_one(Markdown)
|
||||
assert "Show help message" in message_content.source
|
||||
|
||||
slash_used = [
|
||||
e
|
||||
for e in telemetry_events
|
||||
if e.get("event_name") == "vibe/slash_command_used"
|
||||
]
|
||||
assert any(
|
||||
e.get("properties", {}).get("command") == "help"
|
||||
and e.get("properties", {}).get("command_type") == "builtin"
|
||||
for e in slash_used
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def file_tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
|
||||
586
tests/backend/test_anthropic_adapter.py
Normal file
@@ -0,0 +1,586 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.llm.backend.anthropic import AnthropicAdapter, AnthropicMapper
|
||||
from vibe.core.types import (
|
||||
AvailableFunction,
|
||||
AvailableTool,
|
||||
FunctionCall,
|
||||
LLMMessage,
|
||||
Role,
|
||||
ToolCall,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mapper():
|
||||
return AnthropicMapper()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter():
|
||||
return AnthropicAdapter()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider():
|
||||
return ProviderConfig(
|
||||
name="anthropic",
|
||||
api_base="https://api.anthropic.com",
|
||||
api_key_env_var="ANTHROPIC_API_KEY",
|
||||
api_style="anthropic",
|
||||
)
|
||||
|
||||
|
||||
class TestMapperPrepareMessages:
|
||||
def test_system_extracted(self, mapper):
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="You are helpful."),
|
||||
LLMMessage(role=Role.user, content="Hi"),
|
||||
]
|
||||
system, converted = mapper.prepare_messages(messages)
|
||||
assert system == "You are helpful."
|
||||
assert len(converted) == 1
|
||||
assert converted[0]["role"] == "user"
|
||||
|
||||
def test_user_message(self, mapper):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
_, converted = mapper.prepare_messages(messages)
|
||||
assert converted[0]["content"] == [{"type": "text", "text": "Hello"}]
|
||||
|
||||
def test_assistant_text(self, mapper):
|
||||
messages = [LLMMessage(role=Role.assistant, content="Sure")]
|
||||
_, converted = mapper.prepare_messages(messages)
|
||||
assert converted[0]["role"] == "assistant"
|
||||
content = converted[0]["content"]
|
||||
assert any(b.get("type") == "text" and b.get("text") == "Sure" for b in content)
|
||||
|
||||
def test_assistant_with_reasoning_content_and_signature(self, mapper):
|
||||
messages = [
|
||||
LLMMessage(
|
||||
role=Role.assistant,
|
||||
content="Answer",
|
||||
reasoning_content="hmm",
|
||||
reasoning_signature="sig",
|
||||
)
|
||||
]
|
||||
_, converted = mapper.prepare_messages(messages)
|
||||
content = converted[0]["content"]
|
||||
assert content[0] == {"type": "thinking", "thinking": "hmm", "signature": "sig"}
|
||||
assert content[1]["type"] == "text"
|
||||
|
||||
def test_assistant_with_reasoning_content(self, mapper):
|
||||
messages = [
|
||||
LLMMessage(
|
||||
role=Role.assistant, content="Answer", reasoning_content="thinking..."
|
||||
)
|
||||
]
|
||||
_, converted = mapper.prepare_messages(messages)
|
||||
content = converted[0]["content"]
|
||||
assert content[0] == {"type": "thinking", "thinking": "thinking..."}
|
||||
|
||||
def test_assistant_with_tool_calls(self, mapper):
|
||||
messages = [
|
||||
LLMMessage(
|
||||
role=Role.assistant,
|
||||
content="Let me search.",
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id="tc_1",
|
||||
index=0,
|
||||
function=FunctionCall(name="search", arguments='{"q": "test"}'),
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
_, converted = mapper.prepare_messages(messages)
|
||||
content = converted[0]["content"]
|
||||
tool_block = [b for b in content if b["type"] == "tool_use"][0]
|
||||
assert tool_block["name"] == "search"
|
||||
assert tool_block["input"] == {"q": "test"}
|
||||
|
||||
def test_tool_result_appended_to_user(self, mapper):
|
||||
messages = [
|
||||
LLMMessage(role=Role.user, content="Do it"),
|
||||
LLMMessage(role=Role.tool, content="result", tool_call_id="tc_1"),
|
||||
]
|
||||
_, converted = mapper.prepare_messages(messages)
|
||||
# tool_result is merged into the preceding user message
|
||||
assert len(converted) == 1
|
||||
assert converted[0]["role"] == "user"
|
||||
blocks = converted[0]["content"]
|
||||
assert any(b.get("type") == "tool_result" for b in blocks)
|
||||
|
||||
def test_tool_result_new_user_when_no_prior(self, mapper):
|
||||
messages = [LLMMessage(role=Role.tool, content="result", tool_call_id="tc_1")]
|
||||
_, converted = mapper.prepare_messages(messages)
|
||||
assert converted[0]["role"] == "user"
|
||||
assert converted[0]["content"][0]["type"] == "tool_result"
|
||||
|
||||
|
||||
class TestMapperPrepareTools:
|
||||
def test_none_returns_none(self, mapper):
|
||||
assert mapper.prepare_tools(None) is None
|
||||
|
||||
def test_empty_returns_none(self, mapper):
|
||||
assert mapper.prepare_tools([]) is None
|
||||
|
||||
def test_converts_tools(self, mapper):
|
||||
tools = [
|
||||
AvailableTool(
|
||||
function=AvailableFunction(
|
||||
name="search",
|
||||
description="Search things",
|
||||
parameters={"type": "object"},
|
||||
)
|
||||
)
|
||||
]
|
||||
result = mapper.prepare_tools(tools)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "search"
|
||||
assert result[0]["input_schema"] == {"type": "object"}
|
||||
|
||||
|
||||
class TestMapperToolChoice:
|
||||
def test_none(self, mapper):
|
||||
assert mapper.prepare_tool_choice(None) is None
|
||||
|
||||
def test_auto(self, mapper):
|
||||
assert mapper.prepare_tool_choice("auto") == {"type": "auto"}
|
||||
|
||||
def test_none_str(self, mapper):
|
||||
assert mapper.prepare_tool_choice("none") == {"type": "none"}
|
||||
|
||||
def test_any(self, mapper):
|
||||
assert mapper.prepare_tool_choice("any") == {"type": "any"}
|
||||
|
||||
def test_required(self, mapper):
|
||||
assert mapper.prepare_tool_choice("required") == {"type": "any"}
|
||||
|
||||
def test_specific_tool(self, mapper):
|
||||
tool = AvailableTool(
|
||||
function=AvailableFunction(name="search", description="", parameters={})
|
||||
)
|
||||
assert mapper.prepare_tool_choice(tool) == {"type": "tool", "name": "search"}
|
||||
|
||||
|
||||
class TestMapperParseResponse:
|
||||
def test_text(self, mapper):
|
||||
data = {
|
||||
"content": [{"type": "text", "text": "Hello"}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||||
}
|
||||
chunk = mapper.parse_response(data)
|
||||
assert chunk.message.content == "Hello"
|
||||
assert chunk.usage.prompt_tokens == 10
|
||||
|
||||
def test_thinking(self, mapper):
|
||||
data = {
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "hmm", "signature": "sig"},
|
||||
{"type": "text", "text": "Answer"},
|
||||
],
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1},
|
||||
}
|
||||
chunk = mapper.parse_response(data)
|
||||
assert chunk.message.content == "Answer"
|
||||
assert chunk.message.reasoning_content == "hmm"
|
||||
assert chunk.message.reasoning_signature == "sig"
|
||||
|
||||
def test_redacted_thinking(self, mapper):
|
||||
data = {
|
||||
"content": [
|
||||
{"type": "redacted_thinking", "data": "xyz"},
|
||||
{"type": "text", "text": "Answer"},
|
||||
],
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1},
|
||||
}
|
||||
chunk = mapper.parse_response(data)
|
||||
assert chunk.message.content == "Answer"
|
||||
assert chunk.message.reasoning_content is None
|
||||
|
||||
def test_tool_use(self, mapper):
|
||||
data = {
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "t1", "name": "search", "input": {"q": "hi"}}
|
||||
],
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1},
|
||||
}
|
||||
chunk = mapper.parse_response(data)
|
||||
assert chunk.message.tool_calls[0].function.name == "search"
|
||||
assert json.loads(chunk.message.tool_calls[0].function.arguments) == {"q": "hi"}
|
||||
|
||||
def test_cache_tokens(self, mapper):
|
||||
data = {
|
||||
"content": [{"type": "text", "text": "x"}],
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"cache_creation_input_tokens": 5,
|
||||
"cache_read_input_tokens": 3,
|
||||
"output_tokens": 7,
|
||||
},
|
||||
}
|
||||
chunk = mapper.parse_response(data)
|
||||
assert chunk.usage.prompt_tokens == 18
|
||||
assert chunk.usage.completion_tokens == 7
|
||||
|
||||
|
||||
class TestMapperStreamingEvents:
|
||||
def test_text_delta(self, mapper):
|
||||
chunk, idx = mapper.parse_streaming_event(
|
||||
"content_block_delta",
|
||||
{"delta": {"type": "text_delta", "text": "hi"}, "index": 0},
|
||||
0,
|
||||
)
|
||||
assert chunk.message.content == "hi"
|
||||
|
||||
def test_thinking_delta(self, mapper):
|
||||
chunk, _ = mapper.parse_streaming_event(
|
||||
"content_block_delta",
|
||||
{"delta": {"type": "thinking_delta", "thinking": "hmm"}, "index": 0},
|
||||
0,
|
||||
)
|
||||
assert chunk.message.reasoning_content == "hmm"
|
||||
|
||||
def test_tool_use_start(self, mapper):
|
||||
chunk, idx = mapper.parse_streaming_event(
|
||||
"content_block_start",
|
||||
{
|
||||
"content_block": {"type": "tool_use", "id": "t1", "name": "search"},
|
||||
"index": 2,
|
||||
},
|
||||
0,
|
||||
)
|
||||
assert chunk.message.tool_calls[0].id == "t1"
|
||||
assert idx == 2
|
||||
|
||||
def test_input_json_delta(self, mapper):
|
||||
chunk, _ = mapper.parse_streaming_event(
|
||||
"content_block_delta",
|
||||
{
|
||||
"delta": {"type": "input_json_delta", "partial_json": '{"q":'},
|
||||
"index": 1,
|
||||
},
|
||||
0,
|
||||
)
|
||||
assert chunk.message.tool_calls[0].function.arguments == '{"q":'
|
||||
|
||||
def test_message_start_usage(self, mapper):
|
||||
chunk, _ = mapper.parse_streaming_event(
|
||||
"message_start",
|
||||
{"message": {"usage": {"input_tokens": 50, "cache_read_input_tokens": 10}}},
|
||||
0,
|
||||
)
|
||||
assert chunk.usage.prompt_tokens == 60
|
||||
|
||||
def test_message_delta_usage(self, mapper):
|
||||
chunk, _ = mapper.parse_streaming_event(
|
||||
"message_delta", {"usage": {"output_tokens": 42}}, 0
|
||||
)
|
||||
assert chunk.usage.completion_tokens == 42
|
||||
|
||||
def test_unknown_event(self, mapper):
|
||||
chunk, idx = mapper.parse_streaming_event("ping", {}, 5)
|
||||
assert chunk is None
|
||||
assert idx == 5
|
||||
|
||||
def test_signature_delta(self, mapper):
|
||||
chunk, _ = mapper.parse_streaming_event(
|
||||
"content_block_delta",
|
||||
{"delta": {"type": "signature_delta", "signature": "sig"}, "index": 0},
|
||||
0,
|
||||
)
|
||||
assert chunk is not None
|
||||
assert chunk.message.reasoning_signature == "sig"
|
||||
|
||||
|
||||
class TestAdapterPrepareRequest:
|
||||
def test_basic(self, adapter, provider):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-sonnet-4-20250514",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
payload = json.loads(req.body)
|
||||
assert payload["model"] == "claude-sonnet-4-20250514"
|
||||
assert payload["max_tokens"] == 1024
|
||||
assert payload["temperature"] == 0.5
|
||||
assert req.endpoint == "/v1/messages"
|
||||
assert req.headers["anthropic-version"] == "2023-06-01"
|
||||
|
||||
def test_beta_features(self, adapter, provider):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-sonnet-4-20250514",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
)
|
||||
assert "prompt-caching-2024-07-31" in req.headers["anthropic-beta"]
|
||||
assert "interleaved-thinking-2025-05-14" in req.headers["anthropic-beta"]
|
||||
assert "fine-grained-tool-streaming-2025-05-14" in req.headers["anthropic-beta"]
|
||||
|
||||
def test_api_key_header(self, adapter, provider):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-sonnet-4-20250514",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
api_key="sk-test-key",
|
||||
)
|
||||
assert req.headers["x-api-key"] == "sk-test-key"
|
||||
|
||||
def test_streaming(self, adapter, provider):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-sonnet-4-20250514",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
tool_choice=None,
|
||||
enable_streaming=True,
|
||||
provider=provider,
|
||||
)
|
||||
assert json.loads(req.body)["stream"] is True
|
||||
|
||||
def test_default_max_tokens(self, adapter, provider):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-sonnet-4-20250514",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
)
|
||||
assert json.loads(req.body)["max_tokens"] == AnthropicAdapter.DEFAULT_MAX_TOKENS
|
||||
|
||||
def test_with_thinking(self, adapter, provider):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-sonnet-4-20250514",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
thinking="medium",
|
||||
)
|
||||
payload = json.loads(req.body)
|
||||
assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10000}
|
||||
assert payload["max_tokens"] == 1024
|
||||
assert payload["temperature"] == 1
|
||||
|
||||
def test_system_cached(self, adapter, provider):
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="Be helpful."),
|
||||
LLMMessage(role=Role.user, content="Hello"),
|
||||
]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-sonnet-4-20250514",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
)
|
||||
payload = json.loads(req.body)
|
||||
assert payload["system"][0]["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
def test_with_tools(self, adapter, provider):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
tools = [
|
||||
AvailableTool(
|
||||
function=AvailableFunction(
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
)
|
||||
]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-sonnet-4-20250514",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=tools,
|
||||
max_tokens=1024,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
)
|
||||
payload = json.loads(req.body)
|
||||
assert len(payload["tools"]) == 1
|
||||
assert payload["tools"][0]["name"] == "test_tool"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"level,expected_budget", [("low", 1024), ("medium", 10_000), ("high", 32_000)]
|
||||
)
|
||||
def test_thinking_levels_budget_model(
|
||||
self, adapter, provider, level, expected_budget
|
||||
):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-sonnet-4-20250514",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
thinking=level,
|
||||
)
|
||||
payload = json.loads(req.body)
|
||||
assert payload["thinking"] == {
|
||||
"type": "enabled",
|
||||
"budget_tokens": expected_budget,
|
||||
}
|
||||
assert payload["temperature"] == 1
|
||||
assert payload["max_tokens"] == expected_budget + 8192
|
||||
|
||||
@pytest.mark.parametrize("level", ["low", "medium", "high"])
|
||||
def test_thinking_levels_adaptive_model(self, adapter, provider, level):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-opus-4-6-20260101",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
thinking=level,
|
||||
)
|
||||
payload = json.loads(req.body)
|
||||
assert payload["thinking"] == {"type": "adaptive"}
|
||||
assert payload["output_config"] == {"effort": level}
|
||||
assert payload["temperature"] == 1
|
||||
assert payload["max_tokens"] == 32_768
|
||||
|
||||
def test_history_forced_thinking_budget_model(self, adapter, provider):
|
||||
messages = [
|
||||
LLMMessage(role=Role.user, content="Hello"),
|
||||
LLMMessage(
|
||||
role=Role.assistant,
|
||||
content="Answer",
|
||||
reasoning_content="thinking...",
|
||||
reasoning_signature="sig",
|
||||
),
|
||||
LLMMessage(role=Role.user, content="Follow up"),
|
||||
]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-sonnet-4-20250514",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
)
|
||||
payload = json.loads(req.body)
|
||||
assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10_000}
|
||||
assert payload["temperature"] == 1
|
||||
assert payload["max_tokens"] == 18_192
|
||||
|
||||
def test_history_forced_thinking_adaptive_model(self, adapter, provider):
|
||||
messages = [
|
||||
LLMMessage(role=Role.user, content="Hello"),
|
||||
LLMMessage(
|
||||
role=Role.assistant,
|
||||
content="Answer",
|
||||
reasoning_content="thinking...",
|
||||
reasoning_signature="sig",
|
||||
),
|
||||
LLMMessage(role=Role.user, content="Follow up"),
|
||||
]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-opus-4-6-20260101",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
)
|
||||
payload = json.loads(req.body)
|
||||
assert payload["thinking"] == {"type": "adaptive"}
|
||||
assert payload["output_config"] == {"effort": "medium"}
|
||||
assert payload["max_tokens"] == 32_768
|
||||
|
||||
|
||||
class TestAdapterParseResponse:
|
||||
def test_non_streaming(self, adapter, provider):
|
||||
data = {
|
||||
"content": [{"type": "text", "text": "Hello!"}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||||
}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.content == "Hello!"
|
||||
assert chunk.usage.prompt_tokens == 10
|
||||
|
||||
def test_streaming_text_delta(self, adapter, provider):
|
||||
data = {
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "Hi"},
|
||||
}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.content == "Hi"
|
||||
|
||||
def test_streaming_message_start(self, adapter, provider):
|
||||
data = {"type": "message_start", "message": {"usage": {"input_tokens": 100}}}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.usage.prompt_tokens == 100
|
||||
|
||||
def test_streaming_unknown_returns_empty(self, adapter, provider):
|
||||
data = {"type": "ping"}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.role == Role.assistant
|
||||
assert chunk.message.content is None
|
||||
|
||||
def test_cache_control_last_user_message(self, adapter):
|
||||
messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]
|
||||
adapter._add_cache_control_to_last_user_message(messages)
|
||||
assert messages[0]["content"][0]["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
def test_cache_control_skips_non_user(self, adapter):
|
||||
messages = [
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "Hello"}]}
|
||||
]
|
||||
adapter._add_cache_control_to_last_user_message(messages)
|
||||
assert "cache_control" not in messages[0]["content"][0]
|
||||
|
||||
def test_cache_control_empty(self, adapter):
|
||||
messages: list[dict] = []
|
||||
adapter._add_cache_control_to_last_user_message(messages)
|
||||
assert messages == []
|
||||
591
tests/backend/test_vertex_anthropic_adapter.py
Normal file
@@ -0,0 +1,591 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.llm.backend.vertex import (
|
||||
VertexAnthropicAdapter,
|
||||
build_vertex_base_url,
|
||||
build_vertex_endpoint,
|
||||
)
|
||||
from vibe.core.types import AvailableFunction, AvailableTool, LLMMessage, Role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter():
|
||||
return VertexAnthropicAdapter()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider():
|
||||
return ProviderConfig(
|
||||
name="vertex",
|
||||
api_base="",
|
||||
project_id="test-project",
|
||||
region="us-central1",
|
||||
api_style="vertex-anthropic",
|
||||
)
|
||||
|
||||
|
||||
class TestBuildVertexEndpoint:
|
||||
def test_non_streaming(self):
|
||||
endpoint = build_vertex_endpoint(
|
||||
"us-central1", "my-project", "claude-3-5-sonnet"
|
||||
)
|
||||
assert endpoint == (
|
||||
"/v1/projects/my-project/locations/us-central1/"
|
||||
"publishers/anthropic/models/claude-3-5-sonnet:rawPredict"
|
||||
)
|
||||
|
||||
def test_streaming(self):
|
||||
endpoint = build_vertex_endpoint(
|
||||
"us-central1", "my-project", "claude-3-5-sonnet", streaming=True
|
||||
)
|
||||
assert endpoint == (
|
||||
"/v1/projects/my-project/locations/us-central1/"
|
||||
"publishers/anthropic/models/claude-3-5-sonnet:streamRawPredict"
|
||||
)
|
||||
|
||||
def test_base_url(self):
|
||||
base = build_vertex_base_url("us-central1")
|
||||
assert base == "https://us-central1-aiplatform.googleapis.com"
|
||||
|
||||
def test_global_endpoint(self):
|
||||
endpoint = build_vertex_endpoint("global", "my-project", "claude-3-5-sonnet")
|
||||
assert endpoint == (
|
||||
"/v1/projects/my-project/locations/global/"
|
||||
"publishers/anthropic/models/claude-3-5-sonnet:rawPredict"
|
||||
)
|
||||
|
||||
def test_global_base_url(self):
|
||||
base = build_vertex_base_url("global")
|
||||
assert base == "https://aiplatform.googleapis.com"
|
||||
|
||||
|
||||
class TestPrepareRequest:
|
||||
@patch(
|
||||
"vibe.core.llm.backend.vertex.get_vertex_access_token",
|
||||
return_value="fake-token",
|
||||
)
|
||||
def test_basic_request(self, mock_token, adapter, provider):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-3-5-sonnet",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
payload = json.loads(req.body)
|
||||
assert payload["anthropic_version"] == "vertex-2023-10-16"
|
||||
assert "model" not in payload
|
||||
assert payload["max_tokens"] == 1024
|
||||
assert payload["temperature"] == 0.5
|
||||
assert req.headers["Authorization"] == "Bearer fake-token"
|
||||
assert req.headers["anthropic-beta"] == adapter.BETA_FEATURES
|
||||
assert "rawPredict" in req.endpoint
|
||||
assert "streamRawPredict" not in req.endpoint
|
||||
assert req.base_url == "https://us-central1-aiplatform.googleapis.com"
|
||||
|
||||
@patch(
|
||||
"vibe.core.llm.backend.vertex.get_vertex_access_token",
|
||||
return_value="fake-token",
|
||||
)
|
||||
def test_streaming_request(self, mock_token, adapter, provider):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-3-5-sonnet",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
tool_choice=None,
|
||||
enable_streaming=True,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
payload = json.loads(req.body)
|
||||
assert payload.get("stream") is True
|
||||
assert "streamRawPredict" in req.endpoint
|
||||
|
||||
@patch(
|
||||
"vibe.core.llm.backend.vertex.get_vertex_access_token",
|
||||
return_value="fake-token",
|
||||
)
|
||||
def test_no_beta_features_for_vertex(self, mock_token, adapter, provider):
|
||||
"""Vertex AI doesn't support the same beta features as direct Anthropic API."""
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-3-5-sonnet",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
# Vertex AI doesn't support prompt-caching or other beta features
|
||||
assert req.headers.get("anthropic-beta", "") == ""
|
||||
|
||||
@patch(
|
||||
"vibe.core.llm.backend.vertex.get_vertex_access_token",
|
||||
return_value="fake-token",
|
||||
)
|
||||
def test_with_extended_thinking(self, mock_token, adapter, provider):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-3-5-sonnet",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
thinking="medium",
|
||||
)
|
||||
|
||||
payload = json.loads(req.body)
|
||||
assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10000}
|
||||
assert payload["max_tokens"] == 1024
|
||||
assert payload["temperature"] == 1
|
||||
|
||||
@patch(
|
||||
"vibe.core.llm.backend.vertex.get_vertex_access_token",
|
||||
return_value="fake-token",
|
||||
)
|
||||
def test_with_tools(self, mock_token, adapter, provider):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
tools = [
|
||||
AvailableTool(
|
||||
function=AvailableFunction(
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
)
|
||||
]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-3-5-sonnet",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=tools,
|
||||
max_tokens=1024,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
payload = json.loads(req.body)
|
||||
assert len(payload["tools"]) == 1
|
||||
assert payload["tools"][0]["name"] == "test_tool"
|
||||
|
||||
def test_missing_project_id(self, adapter):
|
||||
provider = ProviderConfig(
|
||||
name="vertex",
|
||||
api_base="",
|
||||
region="us-central1",
|
||||
api_style="vertex-anthropic",
|
||||
)
|
||||
with pytest.raises(ValueError, match="project_id"):
|
||||
adapter.prepare_request(
|
||||
model_name="claude-3-5-sonnet",
|
||||
messages=[LLMMessage(role=Role.user, content="Hello")],
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
def test_missing_region(self, adapter):
|
||||
provider = ProviderConfig(
|
||||
name="vertex",
|
||||
api_base="",
|
||||
project_id="test-project",
|
||||
api_style="vertex-anthropic",
|
||||
)
|
||||
with pytest.raises(ValueError, match="region"):
|
||||
adapter.prepare_request(
|
||||
model_name="claude-3-5-sonnet",
|
||||
messages=[LLMMessage(role=Role.user, content="Hello")],
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
@patch(
|
||||
"vibe.core.llm.backend.vertex.get_vertex_access_token",
|
||||
return_value="fake-token",
|
||||
)
|
||||
def test_default_max_tokens(self, mock_token, adapter, provider):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
req = adapter.prepare_request(
|
||||
model_name="claude-3-5-sonnet",
|
||||
messages=messages,
|
||||
temperature=0.5,
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
tool_choice=None,
|
||||
enable_streaming=False,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
payload = json.loads(req.body)
|
||||
assert payload["max_tokens"] == adapter.DEFAULT_MAX_TOKENS
|
||||
|
||||
|
||||
class TestParseFullResponse:
|
||||
def test_simple_text_response(self, adapter, provider):
|
||||
data = {
|
||||
"content": [{"type": "text", "text": "Hello there!"}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||||
}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.content == "Hello there!"
|
||||
assert chunk.usage.prompt_tokens == 10
|
||||
assert chunk.usage.completion_tokens == 5
|
||||
|
||||
def test_response_with_tool_calls(self, adapter, provider):
|
||||
data = {
|
||||
"content": [
|
||||
{"type": "text", "text": "Let me help."},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tool_123",
|
||||
"name": "search",
|
||||
"input": {"query": "test"},
|
||||
},
|
||||
],
|
||||
"usage": {"input_tokens": 20, "output_tokens": 15},
|
||||
}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.content == "Let me help."
|
||||
assert len(chunk.message.tool_calls) == 1
|
||||
assert chunk.message.tool_calls[0].id == "tool_123"
|
||||
assert chunk.message.tool_calls[0].function.name == "search"
|
||||
assert json.loads(chunk.message.tool_calls[0].function.arguments) == {
|
||||
"query": "test"
|
||||
}
|
||||
|
||||
def test_response_with_thinking(self, adapter, provider):
|
||||
data = {
|
||||
"content": [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "Let me think...",
|
||||
"signature": "sig123",
|
||||
},
|
||||
{"type": "text", "text": "Here's my answer."},
|
||||
],
|
||||
"usage": {"input_tokens": 30, "output_tokens": 20},
|
||||
}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.content == "Here's my answer."
|
||||
assert chunk.message.reasoning_content == "Let me think..."
|
||||
assert chunk.message.reasoning_signature == "sig123"
|
||||
|
||||
def test_response_with_cache_tokens(self, adapter, provider):
|
||||
data = {
|
||||
"content": [{"type": "text", "text": "Hello"}],
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"cache_creation_input_tokens": 5,
|
||||
"cache_read_input_tokens": 3,
|
||||
"output_tokens": 7,
|
||||
},
|
||||
}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.usage.prompt_tokens == 18
|
||||
assert chunk.usage.completion_tokens == 7
|
||||
|
||||
def test_response_with_redacted_thinking(self, adapter, provider):
|
||||
data = {
|
||||
"content": [
|
||||
{"type": "redacted_thinking", "data": "redacted_data_here"},
|
||||
{"type": "text", "text": "Answer."},
|
||||
],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||||
}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.content == "Answer."
|
||||
assert chunk.message.reasoning_content is None
|
||||
|
||||
def test_response_empty_usage(self, adapter, provider):
|
||||
data = {"content": [{"type": "text", "text": "Hello"}], "usage": {}}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.usage.prompt_tokens == 0
|
||||
assert chunk.usage.completion_tokens == 0
|
||||
|
||||
|
||||
class TestStreamingEvents:
|
||||
def test_message_start(self, adapter, provider):
|
||||
data = {
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"cache_creation_input_tokens": 20,
|
||||
"cache_read_input_tokens": 10,
|
||||
}
|
||||
},
|
||||
}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.usage is not None
|
||||
assert chunk.usage.prompt_tokens == 130
|
||||
assert chunk.usage.completion_tokens == 0
|
||||
|
||||
def test_message_start_without_usage(self, adapter, provider):
|
||||
data = {"type": "message_start", "message": {}}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.role == Role.assistant
|
||||
|
||||
def test_content_block_start_tool_use(self, adapter, provider):
|
||||
data = {
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "tool_use", "id": "tool_abc", "name": "search"},
|
||||
}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.tool_calls is not None
|
||||
assert len(chunk.message.tool_calls) == 1
|
||||
assert chunk.message.tool_calls[0].id == "tool_abc"
|
||||
assert chunk.message.tool_calls[0].function.name == "search"
|
||||
assert chunk.message.tool_calls[0].index == 0
|
||||
|
||||
def test_content_block_start_thinking(self, adapter, provider):
|
||||
data = {
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "thinking", "thinking": ""},
|
||||
}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.reasoning_content is not None
|
||||
|
||||
def test_content_block_start_redacted_thinking(self, adapter, provider):
|
||||
data = {
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "redacted_thinking", "data": "abc"},
|
||||
}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.content is None
|
||||
assert chunk.message.reasoning_content is None
|
||||
|
||||
def test_content_block_delta_text(self, adapter, provider):
|
||||
data = {
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "Hello"},
|
||||
}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.content == "Hello"
|
||||
|
||||
def test_content_block_delta_thinking(self, adapter, provider):
|
||||
data = {
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "thinking_delta", "thinking": "I think..."},
|
||||
}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.reasoning_content == "I think..."
|
||||
|
||||
def test_content_block_delta_input_json(self, adapter, provider):
|
||||
data = {
|
||||
"type": "content_block_delta",
|
||||
"index": 1,
|
||||
"delta": {"type": "input_json_delta", "partial_json": '{"key":'},
|
||||
}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.tool_calls is not None
|
||||
assert chunk.message.tool_calls[0].function.arguments == '{"key":'
|
||||
|
||||
def test_content_block_stop(self, adapter, provider):
|
||||
data = {"type": "content_block_stop", "index": 0}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.content is None
|
||||
assert chunk.message.reasoning_content is None
|
||||
|
||||
def test_message_delta_with_usage(self, adapter, provider):
|
||||
data = {"type": "message_delta", "usage": {"output_tokens": 42}}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.usage is not None
|
||||
assert chunk.usage.completion_tokens == 42
|
||||
assert chunk.usage.prompt_tokens == 0
|
||||
|
||||
def test_message_delta_without_usage(self, adapter, provider):
|
||||
data = {"type": "message_delta", "usage": {}}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.role == Role.assistant
|
||||
|
||||
def test_unknown_event_returns_empty_chunk(self, adapter, provider):
|
||||
data = {"type": "ping"}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.role == Role.assistant
|
||||
assert chunk.message.content is None
|
||||
|
||||
def test_signature_delta(self, adapter, provider):
|
||||
data = {
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "signature_delta", "signature": "sig_abc"},
|
||||
}
|
||||
chunk = adapter.parse_response(data, provider)
|
||||
assert chunk.message.reasoning_signature == "sig_abc"
|
||||
|
||||
def test_message_start_resets_state(self, adapter, provider):
|
||||
adapter._current_index = 5
|
||||
|
||||
data = {"type": "message_start", "message": {"usage": {"input_tokens": 10}}}
|
||||
adapter.parse_response(data, provider)
|
||||
assert adapter._current_index == 0
|
||||
|
||||
def test_full_streaming_sequence(self, adapter, provider):
|
||||
chunks = []
|
||||
|
||||
# message_start
|
||||
chunks.append(
|
||||
adapter.parse_response(
|
||||
{"type": "message_start", "message": {"usage": {"input_tokens": 50}}},
|
||||
provider,
|
||||
)
|
||||
)
|
||||
assert chunks[-1].usage.prompt_tokens == 50
|
||||
|
||||
# thinking block
|
||||
adapter.parse_response(
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "thinking", "thinking": ""},
|
||||
},
|
||||
provider,
|
||||
)
|
||||
chunks.append(
|
||||
adapter.parse_response(
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "thinking_delta", "thinking": "Analyzing..."},
|
||||
},
|
||||
provider,
|
||||
)
|
||||
)
|
||||
assert chunks[-1].message.reasoning_content == "Analyzing..."
|
||||
adapter.parse_response({"type": "content_block_stop", "index": 0}, provider)
|
||||
|
||||
# text block
|
||||
chunks.append(
|
||||
adapter.parse_response(
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 1,
|
||||
"delta": {"type": "text_delta", "text": "Here's the result."},
|
||||
},
|
||||
provider,
|
||||
)
|
||||
)
|
||||
assert chunks[-1].message.content == "Here's the result."
|
||||
|
||||
# tool use
|
||||
chunks.append(
|
||||
adapter.parse_response(
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 2,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": "tool_1",
|
||||
"name": "search",
|
||||
},
|
||||
},
|
||||
provider,
|
||||
)
|
||||
)
|
||||
assert chunks[-1].message.tool_calls[0].function.name == "search"
|
||||
|
||||
# message_delta with final usage
|
||||
chunks.append(
|
||||
adapter.parse_response(
|
||||
{"type": "message_delta", "usage": {"output_tokens": 100}}, provider
|
||||
)
|
||||
)
|
||||
assert chunks[-1].usage.completion_tokens == 100
|
||||
|
||||
|
||||
class TestHelperMethods:
|
||||
def test_has_thinking_content_true(self, adapter):
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Let me think..."},
|
||||
{"type": "text", "text": "Answer"},
|
||||
],
|
||||
},
|
||||
]
|
||||
assert adapter._has_thinking_content(messages) is True
|
||||
|
||||
def test_has_thinking_content_false(self, adapter):
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Just text"},
|
||||
]
|
||||
assert adapter._has_thinking_content(messages) is False
|
||||
|
||||
def test_has_thinking_content_empty(self, adapter):
|
||||
assert adapter._has_thinking_content([]) is False
|
||||
|
||||
def test_has_thinking_content_non_list_content(self, adapter):
|
||||
messages = [
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "no thinking"}]}
|
||||
]
|
||||
assert adapter._has_thinking_content(messages) is False
|
||||
|
||||
def test_add_cache_control_to_last_user_message(self, adapter):
|
||||
messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]
|
||||
adapter._add_cache_control_to_last_user_message(messages)
|
||||
assert messages[0]["content"][0]["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
def test_add_cache_control_skips_non_user(self, adapter):
|
||||
messages = [
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "Hello"}]}
|
||||
]
|
||||
adapter._add_cache_control_to_last_user_message(messages)
|
||||
assert "cache_control" not in messages[0]["content"][0]
|
||||
|
||||
def test_add_cache_control_skips_string_content(self, adapter):
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
adapter._add_cache_control_to_last_user_message(messages)
|
||||
assert messages[0]["content"] == "Hello"
|
||||
|
||||
def test_add_cache_control_tool_result(self, adapter):
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "123", "content": "result"}
|
||||
],
|
||||
}
|
||||
]
|
||||
adapter._add_cache_control_to_last_user_message(messages)
|
||||
assert messages[0]["content"][0]["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
def test_add_cache_control_empty_messages(self, adapter):
|
||||
messages: list[dict] = []
|
||||
adapter._add_cache_control_to_last_user_message(messages)
|
||||
assert messages == []
|
||||
@@ -74,7 +74,8 @@ def test_copy_selection_to_clipboard_no_notification(
|
||||
del widgets[0].text_selection
|
||||
mock_app.query.return_value = widgets
|
||||
|
||||
copy_selection_to_clipboard(mock_app)
|
||||
result = copy_selection_to_clipboard(mock_app)
|
||||
assert result is None
|
||||
mock_app.notify.assert_not_called()
|
||||
|
||||
|
||||
@@ -87,8 +88,9 @@ def test_copy_selection_to_clipboard_success(
|
||||
)
|
||||
mock_app.query.return_value = [widget]
|
||||
|
||||
copy_selection_to_clipboard(mock_app)
|
||||
result = copy_selection_to_clipboard(mock_app)
|
||||
|
||||
assert result == "selected text"
|
||||
mock_copy_osc52.assert_called_once_with("selected text")
|
||||
mock_app.notify.assert_called_once_with(
|
||||
'"selected text" copied to clipboard',
|
||||
@@ -109,8 +111,9 @@ def test_copy_selection_to_clipboard_failure(
|
||||
|
||||
mock_copy_osc52.side_effect = Exception("OSC52 failed")
|
||||
|
||||
copy_selection_to_clipboard(mock_app)
|
||||
result = copy_selection_to_clipboard(mock_app)
|
||||
|
||||
assert result is None
|
||||
mock_copy_osc52.assert_called_once_with("selected text")
|
||||
mock_app.notify.assert_called_once_with(
|
||||
"Failed to copy - clipboard not available", severity="warning", timeout=3
|
||||
@@ -129,8 +132,9 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No
|
||||
mock_app.query.return_value = [widget1, widget2, widget3]
|
||||
|
||||
with patch("vibe.cli.clipboard._copy_osc52") as mock_copy_osc52:
|
||||
copy_selection_to_clipboard(mock_app)
|
||||
result = copy_selection_to_clipboard(mock_app)
|
||||
|
||||
assert result == "first selection\nsecond selection"
|
||||
mock_copy_osc52.assert_called_once_with("first selection\nsecond selection")
|
||||
mock_app.notify.assert_called_once_with(
|
||||
'"first selection\u23cesecond selection" copied to clipboard',
|
||||
@@ -148,8 +152,9 @@ def test_copy_selection_to_clipboard_preview_shortening(mock_app: MagicMock) ->
|
||||
mock_app.query.return_value = [widget]
|
||||
|
||||
with patch("vibe.cli.clipboard._copy_osc52") as mock_copy_osc52:
|
||||
copy_selection_to_clipboard(mock_app)
|
||||
result = copy_selection_to_clipboard(mock_app)
|
||||
|
||||
assert result == long_text
|
||||
mock_copy_osc52.assert_called_once_with(long_text)
|
||||
notification_call = mock_app.notify.call_args
|
||||
assert notification_call is not None
|
||||
|
||||
54
tests/cli/test_commands.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.commands import Command, CommandRegistry
|
||||
|
||||
|
||||
class TestCommandRegistry:
|
||||
def test_get_command_name_returns_canonical_name_for_alias(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
assert registry.get_command_name("/help") == "help"
|
||||
assert registry.get_command_name("/config") == "config"
|
||||
assert registry.get_command_name("/model") == "config"
|
||||
assert registry.get_command_name("/clear") == "clear"
|
||||
assert registry.get_command_name("/exit") == "exit"
|
||||
|
||||
def test_get_command_name_normalizes_input(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
assert registry.get_command_name(" /help ") == "help"
|
||||
assert registry.get_command_name("/HELP") == "help"
|
||||
|
||||
def test_get_command_name_returns_none_for_unknown(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
assert registry.get_command_name("/unknown") is None
|
||||
assert registry.get_command_name("hello") is None
|
||||
assert registry.get_command_name("") is None
|
||||
|
||||
def test_find_command_returns_command_when_alias_matches(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
cmd = registry.find_command("/help")
|
||||
assert cmd is not None
|
||||
assert cmd.handler == "_show_help"
|
||||
assert isinstance(cmd, Command)
|
||||
|
||||
def test_find_command_returns_none_when_no_match(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
assert registry.find_command("/nonexistent") is None
|
||||
|
||||
def test_find_command_uses_get_command_name(self) -> None:
|
||||
"""find_command and get_command_name stay in sync for same input."""
|
||||
registry = CommandRegistry()
|
||||
for alias in ["/help", "/config", "/clear", "/exit"]:
|
||||
cmd_name = registry.get_command_name(alias)
|
||||
cmd = registry.find_command(alias)
|
||||
if cmd_name is None:
|
||||
assert cmd is None
|
||||
else:
|
||||
assert cmd is not None
|
||||
assert cmd_name in registry.commands
|
||||
assert registry.commands[cmd_name] is cmd
|
||||
|
||||
def test_excluded_commands_not_in_registry(self) -> None:
|
||||
registry = CommandRegistry(excluded_commands=["exit"])
|
||||
assert registry.get_command_name("/exit") is None
|
||||
assert registry.find_command("/exit") is None
|
||||
assert registry.get_command_name("/help") == "help"
|
||||
@@ -94,6 +94,22 @@ def _mock_update_commands(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["true"])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def telemetry_events(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
|
||||
events: list[dict[str, Any]] = []
|
||||
|
||||
def record_telemetry(
|
||||
self: Any, event_name: str, properties: dict[str, Any]
|
||||
) -> None:
|
||||
events.append({"event_name": event_name, "properties": properties})
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vibe.core.telemetry.send.TelemetryClient.send_telemetry_event",
|
||||
record_telemetry,
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vibe_app() -> VibeApp:
|
||||
return build_test_vibe_app()
|
||||
|
||||
57
tests/core/test_config_paths.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from vibe.core.paths.config_paths import resolve_local_skills_dirs
|
||||
|
||||
|
||||
class TestResolveLocalSkillsDirs:
|
||||
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) == []
|
||||
|
||||
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) == []
|
||||
|
||||
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)
|
||||
assert result == [vibe_skills]
|
||||
|
||||
def test_returns_agents_skills_only_when_only_it_exists(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
agents_skills = tmp_path / ".agents" / "skills"
|
||||
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)
|
||||
assert result == [agents_skills]
|
||||
|
||||
def test_returns_both_in_order_when_both_exist(self, tmp_path: Path) -> None:
|
||||
vibe_skills = tmp_path / ".vibe" / "skills"
|
||||
agents_skills = tmp_path / ".agents" / "skills"
|
||||
vibe_skills.mkdir(parents=True)
|
||||
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)
|
||||
assert result == [vibe_skills, agents_skills]
|
||||
|
||||
def test_ignores_vibe_skills_when_file_not_dir(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe").mkdir()
|
||||
(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)
|
||||
assert result == []
|
||||
280
tests/core/test_file_logging.py
Normal file
@@ -0,0 +1,280 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.utils import StructuredLogFormatter, apply_logging_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_log_dir(tmp_path: Path):
|
||||
"""Mock LOG_DIR and LOG_FILE to use tmp_path for testing."""
|
||||
mock_dir = MagicMock()
|
||||
mock_dir.path = tmp_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),
|
||||
):
|
||||
yield tmp_path
|
||||
|
||||
|
||||
class TestStructuredFormatter:
|
||||
def test_format_contains_required_fields(self) -> None:
|
||||
formatter = StructuredLogFormatter()
|
||||
record = logging.LogRecord(
|
||||
name="test_logger",
|
||||
level=logging.INFO,
|
||||
pathname="test.py",
|
||||
lineno=1,
|
||||
msg="Test message",
|
||||
args=(),
|
||||
exc_info=None,
|
||||
)
|
||||
|
||||
output = formatter.format(record)
|
||||
|
||||
parts = output.split(" ", 4)
|
||||
assert len(parts) == 5
|
||||
assert "T" in parts[0]
|
||||
assert parts[1].isdigit()
|
||||
assert parts[2].isdigit()
|
||||
assert parts[3] == "INFO"
|
||||
assert parts[4] == "Test message"
|
||||
|
||||
def test_format_includes_exception(self) -> None:
|
||||
formatter = StructuredLogFormatter()
|
||||
try:
|
||||
raise ValueError("test error")
|
||||
except ValueError:
|
||||
import sys
|
||||
|
||||
exc_info = sys.exc_info()
|
||||
|
||||
record = logging.LogRecord(
|
||||
name="test_logger",
|
||||
level=logging.ERROR,
|
||||
pathname="test.py",
|
||||
lineno=1,
|
||||
msg="Error occurred",
|
||||
args=(),
|
||||
exc_info=exc_info,
|
||||
)
|
||||
|
||||
output = formatter.format(record)
|
||||
|
||||
assert "Error occurred" in output
|
||||
assert "ValueError" in output
|
||||
assert "test error" in output
|
||||
|
||||
def test_format_escapes_newlines_in_message(self) -> None:
|
||||
formatter = StructuredLogFormatter()
|
||||
multiline_msg = dedent("""
|
||||
Line one
|
||||
Line two
|
||||
Line three""")
|
||||
record = logging.LogRecord(
|
||||
name="test_logger",
|
||||
level=logging.INFO,
|
||||
pathname="test.py",
|
||||
lineno=1,
|
||||
msg=multiline_msg,
|
||||
args=(),
|
||||
exc_info=None,
|
||||
)
|
||||
|
||||
output = formatter.format(record)
|
||||
|
||||
assert "\n" not in output
|
||||
assert "Line one\\nLine two\\nLine three" in output
|
||||
|
||||
def test_format_escapes_newlines_in_exception(self) -> None:
|
||||
formatter = StructuredLogFormatter()
|
||||
try:
|
||||
raise ValueError("test error")
|
||||
except ValueError:
|
||||
import sys
|
||||
|
||||
exc_info = sys.exc_info()
|
||||
|
||||
record = logging.LogRecord(
|
||||
name="test_logger",
|
||||
level=logging.ERROR,
|
||||
pathname="test.py",
|
||||
lineno=1,
|
||||
msg="Error occurred",
|
||||
args=(),
|
||||
exc_info=exc_info,
|
||||
)
|
||||
|
||||
output = formatter.format(record)
|
||||
|
||||
assert "\n" not in output
|
||||
assert "ValueError" in output
|
||||
assert "test error" in output
|
||||
assert "\\n" in output
|
||||
|
||||
def test_format_output_is_single_line(self) -> None:
|
||||
formatter = StructuredLogFormatter()
|
||||
try:
|
||||
error_msg = dedent("""
|
||||
multi
|
||||
line
|
||||
error""")
|
||||
raise RuntimeError(error_msg)
|
||||
except RuntimeError:
|
||||
import sys
|
||||
|
||||
exc_info = sys.exc_info()
|
||||
|
||||
multiline_msg = dedent("""
|
||||
Something
|
||||
went
|
||||
wrong""")
|
||||
record = logging.LogRecord(
|
||||
name="test_logger",
|
||||
level=logging.ERROR,
|
||||
pathname="test.py",
|
||||
lineno=1,
|
||||
msg=multiline_msg,
|
||||
args=(),
|
||||
exc_info=exc_info,
|
||||
)
|
||||
|
||||
output = formatter.format(record)
|
||||
lines = output.split("\n")
|
||||
|
||||
assert len(lines) == 1
|
||||
|
||||
|
||||
class TestApplyLoggingConfig:
|
||||
def test_adds_handler_to_logger(
|
||||
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
||||
test_logger = logging.getLogger("test_apply_logging")
|
||||
initial_handler_count = len(test_logger.handlers)
|
||||
|
||||
apply_logging_config(test_logger)
|
||||
|
||||
assert len(test_logger.handlers) == initial_handler_count + 1
|
||||
|
||||
def test_creates_log_file(
|
||||
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
||||
test_logger = logging.getLogger("test_log_file")
|
||||
test_logger.setLevel(logging.DEBUG)
|
||||
|
||||
apply_logging_config(test_logger)
|
||||
test_logger.info("Test log entry")
|
||||
|
||||
log_file = mock_log_dir / "vibe.log"
|
||||
assert log_file.exists()
|
||||
|
||||
def test_log_entry_format(
|
||||
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
||||
test_logger = logging.getLogger("test_format")
|
||||
test_logger.setLevel(logging.DEBUG)
|
||||
|
||||
apply_logging_config(test_logger)
|
||||
test_logger.warning("Test warning message")
|
||||
|
||||
log_file = mock_log_dir / "vibe.log"
|
||||
content = log_file.read_text()
|
||||
|
||||
assert "WARNING" in content
|
||||
assert "Test warning message" in content
|
||||
|
||||
def test_respects_log_level(
|
||||
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("LOG_LEVEL", "WARNING")
|
||||
test_logger = logging.getLogger("test_level_filter")
|
||||
test_logger.setLevel(logging.DEBUG)
|
||||
|
||||
apply_logging_config(test_logger)
|
||||
test_logger.debug("Debug message")
|
||||
test_logger.info("Info message")
|
||||
test_logger.warning("Warning message")
|
||||
|
||||
log_file = mock_log_dir / "vibe.log"
|
||||
content = log_file.read_text()
|
||||
|
||||
assert "Debug message" not in content
|
||||
assert "Info message" not in content
|
||||
assert "Warning message" in content
|
||||
|
||||
def test_creates_log_directory_if_missing(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
log_dir = tmp_path / "nested" / "logs"
|
||||
mock_dir = MagicMock()
|
||||
mock_dir.path = log_dir
|
||||
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),
|
||||
):
|
||||
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
||||
test_logger = logging.getLogger("test_mkdir")
|
||||
|
||||
apply_logging_config(test_logger)
|
||||
|
||||
assert log_dir.exists()
|
||||
|
||||
def test_debug_mode_overrides_log_level(
|
||||
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("LOG_LEVEL", "WARNING")
|
||||
monkeypatch.setenv("DEBUG_MODE", "true")
|
||||
test_logger = logging.getLogger("test_debug_mode")
|
||||
test_logger.setLevel(logging.DEBUG)
|
||||
|
||||
apply_logging_config(test_logger)
|
||||
test_logger.debug("Debug message")
|
||||
|
||||
log_file = mock_log_dir / "vibe.log"
|
||||
content = log_file.read_text()
|
||||
|
||||
assert "Debug message" in content
|
||||
|
||||
def test_invalid_log_level_defaults_to_warning(
|
||||
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("LOG_LEVEL", "INVALID")
|
||||
test_logger = logging.getLogger("test_invalid_level")
|
||||
test_logger.setLevel(logging.DEBUG)
|
||||
|
||||
apply_logging_config(test_logger)
|
||||
test_logger.info("Info message")
|
||||
test_logger.warning("Warning message")
|
||||
|
||||
log_file = mock_log_dir / "vibe.log"
|
||||
content = log_file.read_text()
|
||||
|
||||
assert "Info message" not in content
|
||||
assert "Warning message" in content
|
||||
|
||||
def test_log_max_bytes_from_env(
|
||||
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("LOG_MAX_BYTES", "5242880") # 5 MB
|
||||
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
||||
test_logger = logging.getLogger("test_max_bytes")
|
||||
|
||||
apply_logging_config(test_logger)
|
||||
|
||||
# Verify handler was added with correct maxBytes
|
||||
handler = test_logger.handlers[-1]
|
||||
assert isinstance(handler, RotatingFileHandler)
|
||||
assert handler.maxBytes == 5242880
|
||||
304
tests/core/test_proxy_setup.py
Normal file
@@ -0,0 +1,304 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
|
||||
from vibe.core.proxy_setup import (
|
||||
SUPPORTED_PROXY_VARS,
|
||||
ProxySetupError,
|
||||
get_current_proxy_settings,
|
||||
parse_proxy_command,
|
||||
set_proxy_var,
|
||||
unset_proxy_var,
|
||||
)
|
||||
|
||||
|
||||
def _write_env_file(content: str) -> None:
|
||||
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
GLOBAL_ENV_FILE.path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
class TestProxySetupError:
|
||||
def test_inherits_from_exception(self) -> None:
|
||||
assert issubclass(ProxySetupError, Exception)
|
||||
|
||||
def test_preserves_message(self) -> None:
|
||||
error = ProxySetupError("test message")
|
||||
assert str(error) == "test message"
|
||||
|
||||
|
||||
class TestSupportedProxyVars:
|
||||
def test_contains_all_expected_keys(self) -> None:
|
||||
expected_keys = {
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"NO_PROXY",
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
}
|
||||
assert set(SUPPORTED_PROXY_VARS.keys()) == expected_keys
|
||||
|
||||
def test_all_keys_are_uppercase(self) -> None:
|
||||
for key in SUPPORTED_PROXY_VARS:
|
||||
assert key == key.upper()
|
||||
|
||||
def test_all_values_are_non_empty_strings(self) -> None:
|
||||
for value in SUPPORTED_PROXY_VARS.values():
|
||||
assert isinstance(value, str)
|
||||
assert len(value) > 0
|
||||
|
||||
|
||||
class TestGetCurrentProxySettings:
|
||||
def test_returns_all_none_when_env_file_does_not_exist(self) -> None:
|
||||
result = get_current_proxy_settings()
|
||||
|
||||
assert all(value is None for value in result.values())
|
||||
|
||||
def test_returns_dict_with_all_supported_keys(self) -> None:
|
||||
result = get_current_proxy_settings()
|
||||
|
||||
assert set(result.keys()) == set(SUPPORTED_PROXY_VARS.keys())
|
||||
|
||||
def test_returns_values_from_env_file(self) -> None:
|
||||
_write_env_file(
|
||||
"HTTP_PROXY=http://proxy:8080\nHTTPS_PROXY=https://proxy:8443\n"
|
||||
)
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
|
||||
assert result["HTTP_PROXY"] == "http://proxy:8080"
|
||||
assert result["HTTPS_PROXY"] == "https://proxy:8443"
|
||||
|
||||
def test_returns_none_for_unset_keys(self) -> None:
|
||||
_write_env_file("HTTP_PROXY=http://proxy:8080\n")
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
|
||||
assert result["HTTP_PROXY"] == "http://proxy:8080"
|
||||
assert result["HTTPS_PROXY"] is None
|
||||
assert result["ALL_PROXY"] is None
|
||||
|
||||
def test_ignores_non_proxy_vars_in_env_file(self) -> None:
|
||||
_write_env_file("HTTP_PROXY=http://proxy:8080\nOTHER_VAR=ignored\n")
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
|
||||
assert "OTHER_VAR" not in result
|
||||
assert result["HTTP_PROXY"] == "http://proxy:8080"
|
||||
|
||||
def test_handles_values_with_special_characters(self) -> None:
|
||||
_write_env_file("HTTP_PROXY=http://user:p@ss@proxy:8080\n")
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
|
||||
assert result["HTTP_PROXY"] == "http://user:p@ss@proxy:8080"
|
||||
|
||||
def test_returns_all_none_when_env_file_read_fails(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_write_env_file("HTTP_PROXY=http://proxy:8080\n")
|
||||
|
||||
def raise_error(*args, **kwargs):
|
||||
raise OSError("Permission denied")
|
||||
|
||||
monkeypatch.setattr("vibe.core.proxy_setup.dotenv_values", raise_error)
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
|
||||
assert all(value is None for value in result.values())
|
||||
|
||||
|
||||
class TestSetProxyVar:
|
||||
def test_sets_valid_proxy_var(self) -> None:
|
||||
set_proxy_var("HTTP_PROXY", "http://proxy:8080")
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
assert result["HTTP_PROXY"] == "http://proxy:8080"
|
||||
|
||||
@pytest.mark.parametrize("key", SUPPORTED_PROXY_VARS.keys())
|
||||
def test_sets_all_supported_vars(self, key: str) -> None:
|
||||
set_proxy_var(key, "test-value")
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
assert result[key] == "test-value"
|
||||
|
||||
def test_uppercases_key_before_validation(self) -> None:
|
||||
set_proxy_var("http_proxy", "http://proxy:8080")
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
assert result["HTTP_PROXY"] == "http://proxy:8080"
|
||||
|
||||
def test_raises_error_for_unknown_key(self) -> None:
|
||||
with pytest.raises(ProxySetupError) as exc_info:
|
||||
set_proxy_var("UNKNOWN_KEY", "value")
|
||||
|
||||
assert "Unknown key 'UNKNOWN_KEY'" in str(exc_info.value)
|
||||
|
||||
def test_error_message_contains_supported_keys(self) -> None:
|
||||
with pytest.raises(ProxySetupError) as exc_info:
|
||||
set_proxy_var("UNKNOWN_KEY", "value")
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "HTTP_PROXY" in error_msg
|
||||
assert "HTTPS_PROXY" in error_msg
|
||||
|
||||
def test_creates_env_file_if_missing(self) -> None:
|
||||
assert not GLOBAL_ENV_FILE.path.exists()
|
||||
|
||||
set_proxy_var("HTTP_PROXY", "http://proxy:8080")
|
||||
|
||||
assert GLOBAL_ENV_FILE.path.exists()
|
||||
|
||||
def test_overwrites_existing_value(self) -> None:
|
||||
set_proxy_var("HTTP_PROXY", "http://old:8080")
|
||||
set_proxy_var("HTTP_PROXY", "http://new:8080")
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
assert result["HTTP_PROXY"] == "http://new:8080"
|
||||
|
||||
def test_preserves_other_values(self) -> None:
|
||||
set_proxy_var("HTTP_PROXY", "http://proxy:8080")
|
||||
set_proxy_var("HTTPS_PROXY", "https://proxy:8443")
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
assert result["HTTP_PROXY"] == "http://proxy:8080"
|
||||
assert result["HTTPS_PROXY"] == "https://proxy:8443"
|
||||
|
||||
def test_handles_value_with_spaces(self) -> None:
|
||||
set_proxy_var("NO_PROXY", "localhost, 127.0.0.1, .local")
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
assert result["NO_PROXY"] == "localhost, 127.0.0.1, .local"
|
||||
|
||||
def test_handles_url_with_credentials(self) -> None:
|
||||
set_proxy_var("HTTP_PROXY", "http://user:password@proxy:8080")
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
assert result["HTTP_PROXY"] == "http://user:password@proxy:8080"
|
||||
|
||||
|
||||
class TestUnsetProxyVar:
|
||||
def test_removes_existing_var(self) -> None:
|
||||
set_proxy_var("HTTP_PROXY", "http://proxy:8080")
|
||||
unset_proxy_var("HTTP_PROXY")
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
assert result["HTTP_PROXY"] is None
|
||||
|
||||
def test_uppercases_key_before_validation(self) -> None:
|
||||
set_proxy_var("HTTP_PROXY", "http://proxy:8080")
|
||||
unset_proxy_var("http_proxy")
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
assert result["HTTP_PROXY"] is None
|
||||
|
||||
def test_raises_error_for_unknown_key(self) -> None:
|
||||
with pytest.raises(ProxySetupError) as exc_info:
|
||||
unset_proxy_var("UNKNOWN_KEY")
|
||||
|
||||
assert "Unknown key 'UNKNOWN_KEY'" in str(exc_info.value)
|
||||
|
||||
def test_error_message_contains_supported_keys(self) -> None:
|
||||
with pytest.raises(ProxySetupError) as exc_info:
|
||||
unset_proxy_var("UNKNOWN_KEY")
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "HTTP_PROXY" in error_msg
|
||||
|
||||
def test_no_op_when_env_file_does_not_exist(self) -> None:
|
||||
assert not GLOBAL_ENV_FILE.path.exists()
|
||||
|
||||
unset_proxy_var("HTTP_PROXY")
|
||||
|
||||
assert not GLOBAL_ENV_FILE.path.exists()
|
||||
|
||||
def test_no_op_when_key_not_in_file(self) -> None:
|
||||
set_proxy_var("HTTPS_PROXY", "https://proxy:8443")
|
||||
unset_proxy_var("HTTP_PROXY")
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
assert result["HTTP_PROXY"] is None
|
||||
assert result["HTTPS_PROXY"] == "https://proxy:8443"
|
||||
|
||||
def test_preserves_other_values(self) -> None:
|
||||
set_proxy_var("HTTP_PROXY", "http://proxy:8080")
|
||||
set_proxy_var("HTTPS_PROXY", "https://proxy:8443")
|
||||
unset_proxy_var("HTTP_PROXY")
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
assert result["HTTP_PROXY"] is None
|
||||
assert result["HTTPS_PROXY"] == "https://proxy:8443"
|
||||
|
||||
@pytest.mark.parametrize("key", SUPPORTED_PROXY_VARS.keys())
|
||||
def test_all_supported_vars_can_be_unset(self, key: str) -> None:
|
||||
set_proxy_var(key, "test-value")
|
||||
unset_proxy_var(key)
|
||||
|
||||
result = get_current_proxy_settings()
|
||||
assert result[key] is None
|
||||
|
||||
|
||||
class TestParseProxyCommand:
|
||||
def test_parses_key_only(self) -> None:
|
||||
key, value = parse_proxy_command("HTTP_PROXY")
|
||||
|
||||
assert key == "HTTP_PROXY"
|
||||
assert value is None
|
||||
|
||||
def test_parses_key_and_value(self) -> None:
|
||||
key, value = parse_proxy_command("HTTP_PROXY http://proxy:8080")
|
||||
|
||||
assert key == "HTTP_PROXY"
|
||||
assert value == "http://proxy:8080"
|
||||
|
||||
def test_uppercases_key(self) -> None:
|
||||
key, value = parse_proxy_command("http_proxy")
|
||||
|
||||
assert key == "HTTP_PROXY"
|
||||
|
||||
def test_preserves_value_case(self) -> None:
|
||||
key, value = parse_proxy_command("HTTP_PROXY http://Proxy:8080")
|
||||
|
||||
assert value == "http://Proxy:8080"
|
||||
|
||||
def test_strips_leading_whitespace(self) -> None:
|
||||
key, value = parse_proxy_command(" HTTP_PROXY")
|
||||
|
||||
assert key == "HTTP_PROXY"
|
||||
|
||||
def test_strips_trailing_whitespace(self) -> None:
|
||||
key, value = parse_proxy_command("HTTP_PROXY ")
|
||||
|
||||
assert key == "HTTP_PROXY"
|
||||
assert value is None
|
||||
|
||||
def test_splits_on_first_space_only(self) -> None:
|
||||
key, value = parse_proxy_command("NO_PROXY localhost, 127.0.0.1, .local")
|
||||
|
||||
assert key == "NO_PROXY"
|
||||
assert value == "localhost, 127.0.0.1, .local"
|
||||
|
||||
def test_raises_error_for_empty_string(self) -> None:
|
||||
with pytest.raises(ProxySetupError) as exc_info:
|
||||
parse_proxy_command("")
|
||||
|
||||
assert "No key provided" in str(exc_info.value)
|
||||
|
||||
def test_raises_error_for_whitespace_only(self) -> None:
|
||||
with pytest.raises(ProxySetupError) as exc_info:
|
||||
parse_proxy_command(" ")
|
||||
|
||||
assert "No key provided" in str(exc_info.value)
|
||||
|
||||
def test_handles_tab_as_separator(self) -> None:
|
||||
key, value = parse_proxy_command("HTTP_PROXY\thttp://proxy:8080")
|
||||
|
||||
assert key == "HTTP_PROXY"
|
||||
assert value == "http://proxy:8080"
|
||||
|
||||
def test_handles_multiple_spaces_as_separator(self) -> None:
|
||||
key, value = parse_proxy_command("HTTP_PROXY http://proxy:8080")
|
||||
|
||||
assert key == "HTTP_PROXY"
|
||||
assert value == "http://proxy:8080"
|
||||
269
tests/core/test_telemetry_send.py
Normal file
@@ -0,0 +1,269 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from tests.stubs.fake_tool import FakeTool, FakeToolArgs
|
||||
from vibe.core.agent_loop import ToolDecision, ToolExecutionResponse
|
||||
from vibe.core.config import Backend
|
||||
from vibe.core.llm.format import ResolvedToolCall
|
||||
from vibe.core.telemetry.send import DATALAKE_EVENTS_URL, TelemetryClient
|
||||
from vibe.core.tools.base import BaseTool, ToolPermission
|
||||
from vibe.core.utils import get_user_agent
|
||||
|
||||
_original_send_telemetry_event = TelemetryClient.send_telemetry_event
|
||||
from vibe.core.tools.builtins.write_file import WriteFile, WriteFileArgs
|
||||
|
||||
|
||||
def _make_resolved_tool_call(
|
||||
tool_name: str, args_dict: dict[str, Any]
|
||||
) -> ResolvedToolCall:
|
||||
if tool_name == "write_file":
|
||||
validated = WriteFileArgs(
|
||||
path="foo.txt", content="x", overwrite=args_dict.get("overwrite", False)
|
||||
)
|
||||
cls: type[BaseTool] = WriteFile
|
||||
else:
|
||||
validated = FakeToolArgs()
|
||||
cls = FakeTool
|
||||
return ResolvedToolCall(
|
||||
tool_name=tool_name, tool_class=cls, validated_args=validated, call_id="call_1"
|
||||
)
|
||||
|
||||
|
||||
def _run_telemetry_tasks() -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
loop.run_until_complete(asyncio.sleep(0))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
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)
|
||||
env_key = config.get_provider_for_model(
|
||||
config.get_active_model()
|
||||
).api_key_env_var
|
||||
monkeypatch.delenv(env_key, raising=False)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
assert client._get_mistral_api_key() is None
|
||||
client._client = MagicMock()
|
||||
client._client.post = AsyncMock()
|
||||
|
||||
client.send_telemetry_event("vibe/test", {})
|
||||
_run_telemetry_tasks()
|
||||
|
||||
client._client.post.assert_not_called()
|
||||
|
||||
def test_send_telemetry_event_does_nothing_when_disabled(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=True)
|
||||
env_key = config.get_provider_for_model(
|
||||
config.get_active_model()
|
||||
).api_key_env_var
|
||||
monkeypatch.setenv(env_key, "sk-test")
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
client._client = MagicMock()
|
||||
client._client.post = AsyncMock()
|
||||
|
||||
client.send_telemetry_event("vibe/test", {})
|
||||
_run_telemetry_tasks()
|
||||
|
||||
client._client.post.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_telemetry_event_posts_when_enabled(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
TelemetryClient, "send_telemetry_event", _original_send_telemetry_event
|
||||
)
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
env_key = config.get_provider_for_model(
|
||||
config.get_active_model()
|
||||
).api_key_env_var
|
||||
monkeypatch.setenv(env_key, "sk-test")
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
mock_post = AsyncMock(return_value=MagicMock(status_code=204))
|
||||
client._client = MagicMock()
|
||||
client._client.post = mock_post
|
||||
client._client.aclose = AsyncMock()
|
||||
|
||||
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"}},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer sk-test",
|
||||
"User-Agent": get_user_agent(Backend.MISTRAL),
|
||||
},
|
||||
)
|
||||
|
||||
def test_send_tool_call_finished_payload_shape(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
tool_call = _make_resolved_tool_call("todo", {})
|
||||
decision = ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE, approval_type=ToolPermission.ALWAYS
|
||||
)
|
||||
|
||||
client.send_tool_call_finished(
|
||||
tool_call=tool_call,
|
||||
status="success",
|
||||
decision=decision,
|
||||
agent_profile_name="default",
|
||||
)
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
event_name = telemetry_events[0]["event_name"]
|
||||
assert event_name == "vibe/tool_call_finished"
|
||||
properties = telemetry_events[0]["properties"]
|
||||
assert properties["tool_name"] == "todo"
|
||||
assert properties["status"] == "success"
|
||||
assert properties["decision"] == "execute"
|
||||
assert properties["approval_type"] == "always"
|
||||
assert properties["agent_profile_name"] == "default"
|
||||
assert properties["nb_files_created"] == 0
|
||||
assert properties["nb_files_modified"] == 0
|
||||
|
||||
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)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
tool_call = _make_resolved_tool_call("write_file", {"overwrite": False})
|
||||
|
||||
client.send_tool_call_finished(
|
||||
tool_call=tool_call,
|
||||
status="success",
|
||||
decision=None,
|
||||
agent_profile_name="default",
|
||||
result={"file_existed": False},
|
||||
)
|
||||
|
||||
assert telemetry_events[0]["properties"]["nb_files_created"] == 1
|
||||
assert telemetry_events[0]["properties"]["nb_files_modified"] == 0
|
||||
|
||||
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)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
tool_call = _make_resolved_tool_call("write_file", {"overwrite": True})
|
||||
|
||||
client.send_tool_call_finished(
|
||||
tool_call=tool_call,
|
||||
status="success",
|
||||
decision=None,
|
||||
agent_profile_name="default",
|
||||
result={"file_existed": True},
|
||||
)
|
||||
|
||||
assert telemetry_events[0]["properties"]["nb_files_created"] == 0
|
||||
assert telemetry_events[0]["properties"]["nb_files_modified"] == 1
|
||||
|
||||
def test_send_tool_call_finished_decision_none(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
tool_call = _make_resolved_tool_call("todo", {})
|
||||
|
||||
client.send_tool_call_finished(
|
||||
tool_call=tool_call,
|
||||
status="skipped",
|
||||
decision=None,
|
||||
agent_profile_name="default",
|
||||
)
|
||||
|
||||
assert telemetry_events[0]["properties"]["decision"] is None
|
||||
assert telemetry_events[0]["properties"]["approval_type"] is None
|
||||
|
||||
def test_send_user_copied_text_payload(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
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]["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)
|
||||
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]["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)
|
||||
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"
|
||||
|
||||
def test_send_slash_command_used_payload(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
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]["properties"]["command"] == "help"
|
||||
assert telemetry_events[0]["properties"]["command_type"] == "builtin"
|
||||
assert telemetry_events[1]["properties"]["command"] == "my_skill"
|
||||
assert telemetry_events[1]["properties"]["command_type"] == "skill"
|
||||
|
||||
def test_send_new_session_payload(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(disable_telemetry=False)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_new_session(
|
||||
has_agents_md=True,
|
||||
nb_skills=2,
|
||||
nb_mcp_servers=1,
|
||||
nb_models=3,
|
||||
entrypoint="cli",
|
||||
)
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
event_name = telemetry_events[0]["event_name"]
|
||||
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 "version" in properties
|
||||
@@ -8,7 +8,12 @@ import pytest
|
||||
import tomli_w
|
||||
|
||||
from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE
|
||||
from vibe.core.trusted_folders import TrustedFoldersManager
|
||||
from vibe.core.trusted_folders import (
|
||||
AGENTS_MD_FILENAMES,
|
||||
TrustedFoldersManager,
|
||||
has_agents_md_file,
|
||||
has_trustable_content,
|
||||
)
|
||||
|
||||
|
||||
class TestTrustedFoldersManager:
|
||||
@@ -203,3 +208,48 @@ class TestTrustedFoldersManager:
|
||||
manager.add_trusted(tmp_path)
|
||||
|
||||
assert manager.is_trusted(tmp_path) is True
|
||||
|
||||
|
||||
class TestHasAgentsMdFile:
|
||||
def test_returns_false_for_empty_directory(self, tmp_path: Path) -> None:
|
||||
assert has_agents_md_file(tmp_path) is False
|
||||
|
||||
def test_returns_true_when_agents_md_exists(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "AGENTS.md").write_text("# Agents", encoding="utf-8")
|
||||
assert has_agents_md_file(tmp_path) is True
|
||||
|
||||
def test_returns_true_when_vibe_md_exists(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "VIBE.md").write_text("# Vibe", encoding="utf-8")
|
||||
assert has_agents_md_file(tmp_path) is True
|
||||
|
||||
def test_returns_true_when_dot_vibe_md_exists(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe.md").write_text("# Vibe", encoding="utf-8")
|
||||
assert has_agents_md_file(tmp_path) is True
|
||||
|
||||
def test_returns_false_when_only_other_files_exist(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("", encoding="utf-8")
|
||||
(tmp_path / ".vibe").mkdir()
|
||||
assert has_agents_md_file(tmp_path) is False
|
||||
|
||||
def test_agents_md_filenames_constant(self) -> None:
|
||||
assert AGENTS_MD_FILENAMES == ["AGENTS.md", "VIBE.md", ".vibe.md"]
|
||||
|
||||
|
||||
class TestHasTrustableContent:
|
||||
def test_returns_true_when_vibe_dir_exists(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe").mkdir()
|
||||
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()
|
||||
assert has_trustable_content(tmp_path) is True
|
||||
|
||||
def test_returns_true_when_agents_md_filename_exists(self, tmp_path: Path) -> None:
|
||||
for name in AGENTS_MD_FILENAMES:
|
||||
(tmp_path / name).write_text("", encoding="utf-8")
|
||||
assert has_trustable_content(tmp_path) is True
|
||||
(tmp_path / name).unlink()
|
||||
|
||||
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
|
||||
|
||||
@@ -41,7 +41,6 @@ async def test_ui_gets_through_the_onboarding_successfully() -> None:
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pass_welcome_screen(pilot)
|
||||
|
||||
api_screen = app.screen
|
||||
input_widget = api_screen.query_one("#key", Input)
|
||||
await pilot.press(*api_key_value)
|
||||
|
||||
@@ -67,8 +67,8 @@ def create_test_session():
|
||||
if metadata is None:
|
||||
metadata = {
|
||||
"session_id": session_id,
|
||||
"start_time": "2023-01-01T12:00:00",
|
||||
"end_time": "2023-01-01T12:05:00",
|
||||
"start_time": "2023-01-01T12:00:00Z",
|
||||
"end_time": "2023-01-01T12:05:00Z",
|
||||
"total_messages": 2,
|
||||
"stats": {
|
||||
"steps": 1,
|
||||
@@ -635,3 +635,190 @@ class TestSessionLoaderEdgeCases:
|
||||
assert messages[0].content == "Hello"
|
||||
assert messages[1].role == Role.assistant
|
||||
assert messages[1].content == "Hi there!"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_test_session_with_cwd():
|
||||
def _create_session(
|
||||
session_dir: Path,
|
||||
session_id: str,
|
||||
cwd: str,
|
||||
title: str | None = None,
|
||||
end_time: str | None = None,
|
||||
) -> Path:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
session_folder = session_dir / f"test_{timestamp}_{session_id[:8]}"
|
||||
session_folder.mkdir(exist_ok=True)
|
||||
|
||||
messages_file = session_folder / "messages.jsonl"
|
||||
messages_file.write_text('{"role": "user", "content": "Hello"}\n')
|
||||
|
||||
metadata = {
|
||||
"session_id": session_id,
|
||||
"start_time": "2024-01-01T12:00:00Z",
|
||||
"end_time": end_time or "2024-01-01T12:05:00Z",
|
||||
"environment": {"working_directory": cwd},
|
||||
"title": title,
|
||||
}
|
||||
|
||||
metadata_file = session_folder / "meta.json"
|
||||
with metadata_file.open("w", encoding="utf-8") as f:
|
||||
json.dump(metadata, f)
|
||||
|
||||
return session_folder
|
||||
|
||||
return _create_session
|
||||
|
||||
|
||||
class TestSessionLoaderListSessions:
|
||||
def test_list_sessions_empty(self, session_config: SessionLoggingConfig) -> None:
|
||||
result = SessionLoader.list_sessions(session_config)
|
||||
assert result == []
|
||||
|
||||
def test_list_sessions_returns_all_sessions(
|
||||
self, session_config: SessionLoggingConfig, create_test_session_with_cwd
|
||||
) -> None:
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
create_test_session_with_cwd(
|
||||
session_dir,
|
||||
"aaaaaaaa-1111",
|
||||
"/home/user/project1",
|
||||
title="First session",
|
||||
end_time="2024-01-01T12:00:00Z",
|
||||
)
|
||||
create_test_session_with_cwd(
|
||||
session_dir,
|
||||
"bbbbbbbb-2222",
|
||||
"/home/user/project2",
|
||||
title="Second session",
|
||||
end_time="2024-01-01T13:00:00Z",
|
||||
)
|
||||
|
||||
result = SessionLoader.list_sessions(session_config)
|
||||
|
||||
assert len(result) == 2
|
||||
session_ids = {s["session_id"] for s in result}
|
||||
assert "aaaaaaaa-1111" in session_ids
|
||||
assert "bbbbbbbb-2222" in session_ids
|
||||
|
||||
def test_list_sessions_filters_by_cwd(
|
||||
self, session_config: SessionLoggingConfig, create_test_session_with_cwd
|
||||
) -> None:
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
create_test_session_with_cwd(
|
||||
session_dir,
|
||||
"aaaaaaaa-proj1",
|
||||
"/home/user/project1",
|
||||
title="Project 1 session",
|
||||
)
|
||||
create_test_session_with_cwd(
|
||||
session_dir,
|
||||
"bbbbbbbb-proj2",
|
||||
"/home/user/project2",
|
||||
title="Project 2 session",
|
||||
)
|
||||
create_test_session_with_cwd(
|
||||
session_dir,
|
||||
"cccccccc-proj1",
|
||||
"/home/user/project1",
|
||||
title="Another Project 1 session",
|
||||
)
|
||||
|
||||
result = SessionLoader.list_sessions(session_config, cwd="/home/user/project1")
|
||||
|
||||
assert len(result) == 2
|
||||
for session in result:
|
||||
assert session["cwd"] == "/home/user/project1"
|
||||
|
||||
def test_list_sessions_includes_all_fields(
|
||||
self, session_config: SessionLoggingConfig, create_test_session_with_cwd
|
||||
) -> None:
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
create_test_session_with_cwd(
|
||||
session_dir,
|
||||
"test-session-123",
|
||||
"/home/user/project",
|
||||
title="Test Session Title",
|
||||
end_time="2024-01-15T10:30:00Z",
|
||||
)
|
||||
|
||||
result = SessionLoader.list_sessions(session_config)
|
||||
|
||||
assert len(result) == 1
|
||||
session = result[0]
|
||||
assert session["session_id"] == "test-session-123"
|
||||
assert session["cwd"] == "/home/user/project"
|
||||
assert session["title"] == "Test Session Title"
|
||||
|
||||
def test_list_sessions_skips_invalid_sessions(
|
||||
self, session_config: SessionLoggingConfig, create_test_session_with_cwd
|
||||
) -> None:
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
create_test_session_with_cwd(
|
||||
session_dir, "valid-se", "/home/user/project", title="Valid Session"
|
||||
)
|
||||
|
||||
invalid_session = session_dir / "test_20240101_120000_invalid1"
|
||||
invalid_session.mkdir()
|
||||
(invalid_session / "meta.json").write_text('{"session_id": "invalid"}')
|
||||
|
||||
no_id_session = session_dir / "test_20240101_120001_noid0000"
|
||||
no_id_session.mkdir()
|
||||
(no_id_session / "messages.jsonl").write_text(
|
||||
'{"role": "user", "content": "Hello"}\n'
|
||||
)
|
||||
(no_id_session / "meta.json").write_text(
|
||||
'{"environment": {"working_directory": "/test"}}'
|
||||
)
|
||||
|
||||
result = SessionLoader.list_sessions(session_config)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["session_id"] == "valid-se"
|
||||
|
||||
def test_list_sessions_nonexistent_save_dir(self) -> None:
|
||||
bad_config = SessionLoggingConfig(
|
||||
save_dir="/nonexistent/path", session_prefix="test", enabled=True
|
||||
)
|
||||
|
||||
result = SessionLoader.list_sessions(bad_config)
|
||||
assert result == []
|
||||
|
||||
def test_list_sessions_handles_missing_environment(
|
||||
self, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
session_folder = session_dir / "test_20240101_120000_noenv000"
|
||||
session_folder.mkdir()
|
||||
(session_folder / "messages.jsonl").write_text(
|
||||
'{"role": "user", "content": "Hello"}\n'
|
||||
)
|
||||
(session_folder / "meta.json").write_text(
|
||||
'{"session_id": "noenv000", "end_time": "2024-01-01T12:00:00Z"}'
|
||||
)
|
||||
|
||||
result = SessionLoader.list_sessions(session_config)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["session_id"] == "noenv000"
|
||||
assert result[0]["cwd"] == "" # Empty string when no working_directory
|
||||
|
||||
def test_list_sessions_handles_none_title(
|
||||
self, session_config: SessionLoggingConfig, create_test_session_with_cwd
|
||||
) -> None:
|
||||
session_dir = Path(session_config.save_dir)
|
||||
|
||||
create_test_session_with_cwd(
|
||||
session_dir, "notitle0", "/home/user/project", title=None
|
||||
)
|
||||
|
||||
result = SessionLoader.list_sessions(session_config)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["session_id"] == "notitle0"
|
||||
assert result[0]["title"] is None
|
||||
|
||||
@@ -8,6 +8,7 @@ from tests.conftest import build_test_vibe_config
|
||||
from tests.skills.conftest import create_skill
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.skills.manager import SkillManager
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -184,6 +185,85 @@ class TestSkillManagerParsing:
|
||||
|
||||
|
||||
class TestSkillManagerSearchPaths:
|
||||
def test_discovers_from_vibe_skills_when_cwd_trusted(
|
||||
self, tmp_working_directory: Path
|
||||
) -> None:
|
||||
trusted_folders_manager.add_trusted(tmp_working_directory)
|
||||
vibe_skills = tmp_working_directory / ".vibe" / "skills"
|
||||
vibe_skills.mkdir(parents=True)
|
||||
create_skill(vibe_skills, "vibe-skill", "Skill from .vibe/skills")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests", include_project_context=False, skill_paths=[]
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
assert "vibe-skill" in manager.available_skills
|
||||
assert (
|
||||
manager.available_skills["vibe-skill"].description
|
||||
== "Skill from .vibe/skills"
|
||||
)
|
||||
|
||||
def test_discovers_from_agents_skills_when_cwd_trusted(
|
||||
self, tmp_working_directory: Path
|
||||
) -> None:
|
||||
trusted_folders_manager.add_trusted(tmp_working_directory)
|
||||
agents_skills = tmp_working_directory / ".agents" / "skills"
|
||||
agents_skills.mkdir(parents=True)
|
||||
create_skill(agents_skills, "agents-skill", "Skill from .agents/skills")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests", include_project_context=False, skill_paths=[]
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
assert "agents-skill" in manager.available_skills
|
||||
assert (
|
||||
manager.available_skills["agents-skill"].description
|
||||
== "Skill from .agents/skills"
|
||||
)
|
||||
|
||||
def test_discovers_from_both_vibe_and_agents_skills_when_cwd_trusted(
|
||||
self, tmp_working_directory: Path
|
||||
) -> None:
|
||||
trusted_folders_manager.add_trusted(tmp_working_directory)
|
||||
vibe_skills = tmp_working_directory / ".vibe" / "skills"
|
||||
agents_skills = tmp_working_directory / ".agents" / "skills"
|
||||
vibe_skills.mkdir(parents=True)
|
||||
agents_skills.mkdir(parents=True)
|
||||
create_skill(vibe_skills, "vibe-only", "From .vibe")
|
||||
create_skill(agents_skills, "agents-only", "From .agents")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests", include_project_context=False, skill_paths=[]
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
assert len(manager.available_skills) == 2
|
||||
assert manager.available_skills["vibe-only"].description == "From .vibe"
|
||||
assert manager.available_skills["agents-only"].description == "From .agents"
|
||||
|
||||
def test_first_discovered_wins_when_same_skill_in_vibe_and_agents(
|
||||
self, tmp_working_directory: Path
|
||||
) -> None:
|
||||
trusted_folders_manager.add_trusted(tmp_working_directory)
|
||||
vibe_skills = tmp_working_directory / ".vibe" / "skills"
|
||||
agents_skills = tmp_working_directory / ".agents" / "skills"
|
||||
vibe_skills.mkdir(parents=True)
|
||||
agents_skills.mkdir(parents=True)
|
||||
create_skill(vibe_skills, "shared-skill", "First from .vibe")
|
||||
create_skill(agents_skills, "shared-skill", "Second from .agents")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests", include_project_context=False, skill_paths=[]
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
assert len(manager.available_skills) == 1
|
||||
assert (
|
||||
manager.available_skills["shared-skill"].description == "First from .vibe"
|
||||
)
|
||||
|
||||
def test_discovers_from_multiple_skill_paths(self, tmp_path: Path) -> None:
|
||||
# Create two separate skill directories
|
||||
skills_dir_1 = tmp_path / "skills1"
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
@@ -141,7 +141,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -160,7 +160,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -164,7 +164,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
<rect fill="#4b4e55" x="12.2" y="245.5" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#9a9b99" x="48.8" y="294.3" width="61" 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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -160,7 +160,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -160,7 +160,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -160,7 +160,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -159,7 +159,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -159,7 +159,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,200 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1238 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: #9a9b99 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1219.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ProxySetupTestApp</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="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-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-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="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</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="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-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-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="556.8" textLength="146.4" clip-path="url(#terminal-line-22)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="556.8" textLength="122" clip-path="url(#terminal-line-22)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="556.8" textLength="183" clip-path="url(#terminal-line-22)">devstral-latest</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="0" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="581.2" textLength="414.8" clip-path="url(#terminal-line-23)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="0" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">Type </text><text class="terminal-r3" x="231.8" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">/help</text><text class="terminal-r1" x="292.8" y="605.6" textLength="256.2" clip-path="url(#terminal-line-24)"> for more information</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r4" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">⎣</text><text class="terminal-r1" x="48.8" y="654.4" textLength="256.2" clip-path="url(#terminal-line-26)">Proxy setup opened...</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="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">⎣</text><text class="terminal-r1" x="48.8" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Proxy setup cancelled.</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</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="1220" clip-path="url(#terminal-line-30)">┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐</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-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">></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="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="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>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,200 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1238 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: #9a9b99 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1219.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">PrePopulatedProxySetupTestApp</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="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-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-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="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</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="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-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-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="556.8" textLength="146.4" clip-path="url(#terminal-line-22)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="556.8" textLength="122" clip-path="url(#terminal-line-22)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="556.8" textLength="183" clip-path="url(#terminal-line-22)">devstral-latest</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="0" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="581.2" textLength="414.8" clip-path="url(#terminal-line-23)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="0" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">Type </text><text class="terminal-r3" x="231.8" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">/help</text><text class="terminal-r1" x="292.8" y="605.6" textLength="256.2" clip-path="url(#terminal-line-24)"> for more information</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r4" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">⎣</text><text class="terminal-r1" x="48.8" y="654.4" textLength="256.2" clip-path="url(#terminal-line-26)">Proxy setup opened...</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="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">⎣</text><text class="terminal-r1" x="48.8" y="678.8" textLength="793" clip-path="url(#terminal-line-27)">Proxy settings saved. Restart the CLI for changes to take effect.</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</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="1220" clip-path="url(#terminal-line-30)">┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐</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-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">></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="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="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>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,204 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1238 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: #4b4e55 }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #292929 }
|
||||
.terminal-r5 { fill: #9a9b99 }
|
||||
.terminal-r6 { fill: #608ab1;font-weight: bold }
|
||||
.terminal-r7 { fill: #868887 }
|
||||
.terminal-r8 { fill: #949798 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1219.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ProxySetupTestApp</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="#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"/>
|
||||
<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><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="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>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,203 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1238 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: #4b4e55 }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #292929 }
|
||||
.terminal-r5 { fill: #9a9b99 }
|
||||
.terminal-r6 { fill: #608ab1;font-weight: bold }
|
||||
.terminal-r7 { fill: #868887 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1219.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">PrePopulatedProxySetupTestApp</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="#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"/>
|
||||
<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><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="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>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,201 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1238 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: #9a9b99 }
|
||||
.terminal-r5 { fill: #cc555a;font-weight: bold }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1219.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ProxySetupTestApp</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="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-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-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="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</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="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-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-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="556.8" textLength="146.4" clip-path="url(#terminal-line-22)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="556.8" textLength="122" clip-path="url(#terminal-line-22)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="556.8" textLength="183" clip-path="url(#terminal-line-22)">devstral-latest</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="0" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="581.2" textLength="414.8" clip-path="url(#terminal-line-23)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="0" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">Type </text><text class="terminal-r3" x="231.8" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">/help</text><text class="terminal-r1" x="292.8" y="605.6" textLength="256.2" clip-path="url(#terminal-line-24)"> for more information</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r4" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">⎣</text><text class="terminal-r1" x="48.8" y="654.4" textLength="256.2" clip-path="url(#terminal-line-26)">Proxy setup opened...</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="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">⎣</text><text class="terminal-r5" x="48.8" y="678.8" textLength="671" clip-path="url(#terminal-line-27)">Error: Failed to save proxy settings: Permission denied</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</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="1220" clip-path="url(#terminal-line-30)">┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐</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-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">></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="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="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>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,200 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1238 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: #9a9b99 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1219.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ProxySetupTestApp</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="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-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-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="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</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="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-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-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="556.8" textLength="146.4" clip-path="url(#terminal-line-22)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="556.8" textLength="122" clip-path="url(#terminal-line-22)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="556.8" textLength="183" clip-path="url(#terminal-line-22)">devstral-latest</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="0" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="581.2" textLength="414.8" clip-path="url(#terminal-line-23)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="0" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">Type </text><text class="terminal-r3" x="231.8" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">/help</text><text class="terminal-r1" x="292.8" y="605.6" textLength="256.2" clip-path="url(#terminal-line-24)"> for more information</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r4" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">⎣</text><text class="terminal-r1" x="48.8" y="654.4" textLength="256.2" clip-path="url(#terminal-line-26)">Proxy setup opened...</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="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">⎣</text><text class="terminal-r1" x="48.8" y="678.8" textLength="793" clip-path="url(#terminal-line-27)">Proxy settings saved. Restart the CLI for changes to take effect.</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</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="1220" clip-path="url(#terminal-line-30)">┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐</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-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">></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="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="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>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -162,7 +162,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -162,7 +162,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -162,7 +162,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -162,7 +162,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -163,7 +163,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -161,7 +161,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -161,7 +161,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -162,7 +162,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -162,7 +162,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -161,7 +161,7 @@
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<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-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v2.1.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" 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="414.8" clip-path="url(#terminal-line-1)">1 model · 0 MCP servers · 0 skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type </text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> for more information</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)">
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
10
tests/snapshots/conftest.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pin_banner_version(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"vibe.cli.textual_ui.widgets.banner.banner.__version__", "0.0.0"
|
||||
)
|
||||
129
tests/snapshots/test_ui_snapshot_proxy_setup.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
from vibe.core.proxy_setup import get_current_proxy_settings, set_proxy_var
|
||||
|
||||
|
||||
class ProxySetupTestApp(BaseSnapshotTestApp):
|
||||
async def on_mount(self) -> None:
|
||||
await super().on_mount()
|
||||
await self._switch_to_proxy_setup_app()
|
||||
|
||||
|
||||
class PrePopulatedProxySetupTestApp(BaseSnapshotTestApp):
|
||||
async def on_mount(self) -> None:
|
||||
set_proxy_var("HTTP_PROXY", "http://old-proxy:8080")
|
||||
set_proxy_var("HTTPS_PROXY", "https://old-proxy:8443")
|
||||
await super().on_mount()
|
||||
await self._switch_to_proxy_setup_app()
|
||||
|
||||
|
||||
def test_snapshot_proxy_setup_initial_empty(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_proxy_setup.py:ProxySetupTestApp",
|
||||
terminal_size=(100, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_proxy_setup_initial_with_values(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_proxy_setup.py:PrePopulatedProxySetupTestApp",
|
||||
terminal_size=(100, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_proxy_setup_save_new_values(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
await pilot.press(*"http://proxy.example.com:8080")
|
||||
await pilot.press("down")
|
||||
await pilot.press(*"https://proxy.example.com:8443")
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_proxy_setup.py:ProxySetupTestApp",
|
||||
terminal_size=(100, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
settings = get_current_proxy_settings()
|
||||
assert settings["HTTP_PROXY"] == "http://proxy.example.com:8080"
|
||||
assert settings["HTTPS_PROXY"] == "https://proxy.example.com:8443"
|
||||
|
||||
|
||||
def test_snapshot_proxy_setup_edit_existing_values(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
await pilot.press("ctrl+u")
|
||||
await pilot.press(*"http://new-proxy:9090")
|
||||
await pilot.press("down")
|
||||
await pilot.press("ctrl+u")
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_proxy_setup.py:PrePopulatedProxySetupTestApp",
|
||||
terminal_size=(100, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
settings = get_current_proxy_settings()
|
||||
assert settings["HTTP_PROXY"] == "http://new-proxy:9090"
|
||||
assert settings["HTTPS_PROXY"] is None
|
||||
|
||||
|
||||
def test_snapshot_proxy_setup_cancel_discards_changes(
|
||||
snap_compare: SnapCompare,
|
||||
) -> None:
|
||||
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
await pilot.press(*"http://should-not-save:8080")
|
||||
await pilot.pause(0.1)
|
||||
await pilot.press("escape")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_proxy_setup.py:ProxySetupTestApp",
|
||||
terminal_size=(100, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
settings = get_current_proxy_settings()
|
||||
assert settings["HTTP_PROXY"] is None
|
||||
|
||||
|
||||
def test_snapshot_proxy_setup_save_error(
|
||||
snap_compare: SnapCompare, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def raise_error(*args, **kwargs):
|
||||
raise OSError("Permission denied")
|
||||
|
||||
monkeypatch.setattr("vibe.core.proxy_setup.set_key", raise_error)
|
||||
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
await pilot.press(*"http://proxy:8080")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_proxy_setup.py:ProxySetupTestApp",
|
||||
terminal_size=(100, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
@@ -16,7 +16,9 @@ from vibe.core.types import (
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_triggers_and_batches_observer() -> None:
|
||||
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:
|
||||
@@ -52,3 +54,10 @@ async def test_auto_compact_triggers_and_batches_observer() -> None:
|
||||
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"
|
||||
]
|
||||
assert len(auto_compact) == 1
|
||||
|
||||
@@ -17,7 +17,6 @@ from vibe.core.llm.exceptions import BackendErrorBuilder
|
||||
from vibe.core.middleware import (
|
||||
ConversationContext,
|
||||
MiddlewareAction,
|
||||
MiddlewarePipeline,
|
||||
MiddlewareResult,
|
||||
ResetReason,
|
||||
)
|
||||
@@ -48,9 +47,6 @@ class InjectBeforeMiddleware:
|
||||
action=MiddlewareAction.INJECT_MESSAGE, message=self.injected_message
|
||||
)
|
||||
|
||||
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
return MiddlewareResult()
|
||||
|
||||
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
|
||||
return None
|
||||
|
||||
@@ -99,15 +95,17 @@ async def test_act_flushes_batched_messages_with_injection_middleware(
|
||||
async for _ in agent.act("How can you help?"):
|
||||
pass
|
||||
|
||||
assert len(observed) == 3
|
||||
assert [r for r, _ in observed] == [Role.system, Role.user, Role.assistant]
|
||||
assert len(observed) == 4
|
||||
assert [r for r, _ in observed] == [
|
||||
Role.system,
|
||||
Role.user,
|
||||
Role.user,
|
||||
Role.assistant,
|
||||
]
|
||||
assert observed[0][1] == "You are Vibe, a super useful programming assistant."
|
||||
# injected content should be appended to the user's message before emission
|
||||
assert (
|
||||
observed[1][1]
|
||||
== f"How can you help?\n\n{InjectBeforeMiddleware.injected_message}"
|
||||
)
|
||||
assert observed[2][1] == "I can write very efficient code."
|
||||
assert observed[1][1] == "How can you help?"
|
||||
assert observed[2][1] == InjectBeforeMiddleware.injected_message
|
||||
assert observed[3][1] == "I can write very efficient code."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -318,19 +316,14 @@ async def test_act_merges_streamed_tool_call_arguments() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_act_handles_user_cancellation_during_streaming() -> None:
|
||||
class CountingMiddleware(MiddlewarePipeline):
|
||||
class CountingMiddleware:
|
||||
def __init__(self) -> None:
|
||||
self.before_calls = 0
|
||||
self.after_calls = 0
|
||||
|
||||
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
self.before_calls += 1
|
||||
return MiddlewareResult()
|
||||
|
||||
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
self.after_calls += 1
|
||||
return MiddlewareResult()
|
||||
|
||||
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
|
||||
return None
|
||||
|
||||
@@ -371,7 +364,6 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
|
||||
ToolResultEvent,
|
||||
]
|
||||
assert middleware.before_calls == 1
|
||||
assert middleware.after_calls == 0
|
||||
assert isinstance(events[-1], ToolResultEvent)
|
||||
assert events[-1].skipped is True
|
||||
assert events[-1].skip_reason is not None
|
||||
|
||||
@@ -378,9 +378,7 @@ class TestReloadPreservesMessages:
|
||||
assert agent.messages[0].role == Role.system
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_notifies_observer_with_all_messages(
|
||||
self, observer_capture
|
||||
) -> None:
|
||||
async def test_reload_does_not_reemit_to_observer(self, observer_capture) -> None:
|
||||
observed, observer = observer_capture
|
||||
backend = FakeBackend(mock_llm_chunk(content="Response"))
|
||||
agent = build_test_agent_loop(
|
||||
@@ -394,10 +392,7 @@ class TestReloadPreservesMessages:
|
||||
|
||||
await agent.reload_with_initial_messages()
|
||||
|
||||
assert len(observed) == 3
|
||||
assert observed[0].role == Role.system
|
||||
assert observed[1].role == Role.user
|
||||
assert observed[2].role == Role.assistant
|
||||
assert len(observed) == 0
|
||||
|
||||
|
||||
class TestCompactStatsHandling:
|
||||
|
||||
@@ -75,7 +75,9 @@ def make_agent_loop(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_tool_call_executes_under_auto_approve() -> None:
|
||||
async def test_single_tool_call_executes_under_auto_approve(
|
||||
telemetry_events: list[dict],
|
||||
) -> None:
|
||||
mocked_tool_call_id = "call_1"
|
||||
tool_call = make_todo_tool_call(mocked_tool_call_id)
|
||||
backend = FakeBackend([
|
||||
@@ -110,9 +112,19 @@ async def test_single_tool_call_executes_under_auto_approve() -> None:
|
||||
assert tool_msgs[-1].tool_call_id == mocked_tool_call_id
|
||||
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"
|
||||
]
|
||||
assert len(tool_finished) == 1
|
||||
assert tool_finished[0]["properties"]["tool_name"] == "todo"
|
||||
assert tool_finished[0]["properties"]["status"] == "success"
|
||||
assert tool_finished[0]["properties"]["approval_type"] == "always"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_requires_approval_if_not_auto_approved() -> None:
|
||||
async def test_tool_call_requires_approval_if_not_auto_approved(
|
||||
telemetry_events: list[dict],
|
||||
) -> None:
|
||||
agent_loop = make_agent_loop(
|
||||
auto_approve=False,
|
||||
todo_permission=ToolPermission.ASK,
|
||||
@@ -145,9 +157,15 @@ async def test_tool_call_requires_approval_if_not_auto_approved() -> None:
|
||||
assert agent_loop.stats.tool_calls_agreed == 0
|
||||
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"
|
||||
]
|
||||
assert len(tool_finished) == 1
|
||||
assert tool_finished[0]["properties"]["approval_type"] == "ask"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_approved_by_callback() -> None:
|
||||
async def test_tool_call_approved_by_callback(telemetry_events: list[dict]) -> None:
|
||||
def approval_callback(
|
||||
_tool_name: str, _args: BaseModel, _tool_call_id: str
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
@@ -179,11 +197,17 @@ async def test_tool_call_approved_by_callback() -> None:
|
||||
assert agent_loop.stats.tool_calls_rejected == 0
|
||||
assert agent_loop.stats.tool_calls_succeeded == 1
|
||||
|
||||
tool_finished = [
|
||||
e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
|
||||
]
|
||||
assert len(tool_finished) == 1
|
||||
assert tool_finished[0]["properties"]["approval_type"] == "ask"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_callback() -> (
|
||||
None
|
||||
):
|
||||
async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_callback(
|
||||
telemetry_events: list[dict],
|
||||
) -> None:
|
||||
custom_feedback = "User declined tool execution"
|
||||
|
||||
def approval_callback(
|
||||
@@ -218,9 +242,17 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
|
||||
assert agent_loop.stats.tool_calls_agreed == 0
|
||||
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"
|
||||
]
|
||||
assert len(tool_finished) == 1
|
||||
assert tool_finished[0]["properties"]["approval_type"] == "ask"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_skipped_when_permission_is_never() -> None:
|
||||
async def test_tool_call_skipped_when_permission_is_never(
|
||||
telemetry_events: list[dict],
|
||||
) -> None:
|
||||
agent_loop = make_agent_loop(
|
||||
auto_approve=False,
|
||||
todo_permission=ToolPermission.NEVER,
|
||||
@@ -254,6 +286,12 @@ async def test_tool_call_skipped_when_permission_is_never() -> None:
|
||||
assert agent_loop.stats.tool_calls_agreed == 0
|
||||
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"
|
||||
]
|
||||
assert len(tool_finished) == 1
|
||||
assert tool_finished[0]["properties"]["approval_type"] == "never"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> None:
|
||||
|
||||
@@ -26,7 +26,7 @@ class SpyStreamingFormatter:
|
||||
|
||||
|
||||
def test_run_programmatic_preload_streaming_is_batched(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
monkeypatch: pytest.MonkeyPatch, telemetry_events: list[dict]
|
||||
) -> None:
|
||||
spy = SpyStreamingFormatter()
|
||||
monkeypatch.setattr(
|
||||
@@ -77,6 +77,14 @@ def test_run_programmatic_preload_streaming_is_batched(
|
||||
Role.user,
|
||||
Role.assistant,
|
||||
]
|
||||
|
||||
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"
|
||||
assert "version" in new_session[0]["properties"]
|
||||
|
||||
assert (
|
||||
spy.emitted[0][1] == "You are Vibe, a super useful programming assistant."
|
||||
)
|
||||
|
||||
47
tests/test_message_merging.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from vibe.core.llm.message_utils import merge_consecutive_user_messages
|
||||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
|
||||
def test_merge_consecutive_user_messages() -> None:
|
||||
messages = [
|
||||
LLMMessage(role=Role.system, content="System"),
|
||||
LLMMessage(role=Role.user, content="User 1"),
|
||||
LLMMessage(role=Role.user, content="User 2"),
|
||||
LLMMessage(role=Role.assistant, content="Assistant"),
|
||||
]
|
||||
result = merge_consecutive_user_messages(messages)
|
||||
assert len(result) == 3
|
||||
assert result[1].content == "User 1\n\nUser 2"
|
||||
|
||||
|
||||
def test_preserves_non_consecutive_user_messages() -> None:
|
||||
messages = [
|
||||
LLMMessage(role=Role.user, content="User 1"),
|
||||
LLMMessage(role=Role.assistant, content="Assistant"),
|
||||
LLMMessage(role=Role.user, content="User 2"),
|
||||
]
|
||||
result = merge_consecutive_user_messages(messages)
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
def test_empty_messages() -> None:
|
||||
assert merge_consecutive_user_messages([]) == []
|
||||
|
||||
|
||||
def test_single_message() -> None:
|
||||
messages = [LLMMessage(role=Role.user, content="Only one")]
|
||||
result = merge_consecutive_user_messages(messages)
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_three_consecutive_user_messages() -> None:
|
||||
messages = [
|
||||
LLMMessage(role=Role.user, content="A"),
|
||||
LLMMessage(role=Role.user, content="B"),
|
||||
LLMMessage(role=Role.user, content="C"),
|
||||
]
|
||||
result = merge_consecutive_user_messages(messages)
|
||||
assert len(result) == 1
|
||||
assert result[0].content == "A\n\nB\n\nC"
|
||||
@@ -2,14 +2,17 @@ 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.config import VibeConfig
|
||||
from vibe.core.middleware import (
|
||||
PLAN_AGENT_EXIT,
|
||||
PLAN_AGENT_REMINDER,
|
||||
ConversationContext,
|
||||
MiddlewareAction,
|
||||
MiddlewarePipeline,
|
||||
PlanAgentMiddleware,
|
||||
ResetReason,
|
||||
)
|
||||
from vibe.core.types import AgentStats
|
||||
|
||||
@@ -71,28 +74,58 @@ class TestPlanAgentMiddleware:
|
||||
assert result.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_after_turn_always_continues(self, ctx: ConversationContext) -> None:
|
||||
async def test_injects_reminder_only_once_while_in_plan_mode(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
|
||||
|
||||
result = await middleware.after_turn(ctx)
|
||||
result1 = await middleware.before_turn(ctx)
|
||||
assert result1.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result1.message == PLAN_AGENT_REMINDER
|
||||
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
result2 = await middleware.before_turn(ctx)
|
||||
assert result2.action == MiddlewareAction.CONTINUE
|
||||
assert result2.message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dynamically_checks_agent(self, ctx: ConversationContext) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
|
||||
async def test_injects_exit_message_when_leaving_plan_mode(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(lambda: current_profile)
|
||||
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
# Enter plan mode
|
||||
await middleware.before_turn(ctx)
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
# 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
|
||||
|
||||
current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
@pytest.mark.asyncio
|
||||
async def test_reinjects_reminder_when_reentering_plan_mode(
|
||||
self, ctx: ConversationContext
|
||||
) -> None:
|
||||
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
|
||||
middleware = PlanAgentMiddleware(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
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_reminder(self, ctx: ConversationContext) -> None:
|
||||
@@ -105,10 +138,238 @@ class TestPlanAgentMiddleware:
|
||||
|
||||
assert result.message == custom_reminder
|
||||
|
||||
def test_reset_does_nothing(self) -> None:
|
||||
@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(
|
||||
lambda: current_profile, exit_message=custom_exit
|
||||
)
|
||||
|
||||
await middleware.before_turn(ctx) # enter plan
|
||||
|
||||
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)
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# Fourth call (still default): no injection
|
||||
result = await middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
|
||||
class TestMiddlewarePipelineWithPlanAgent:
|
||||
@pytest.mark.asyncio
|
||||
@@ -135,3 +396,222 @@ class TestMiddlewarePipelineWithPlanAgent:
|
||||
result = await pipeline.run_before_turn(ctx)
|
||||
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
|
||||
class TestPlanAgentMiddlewareIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_agent_preserves_middleware_state_for_exit_message(
|
||||
self,
|
||||
) -> None:
|
||||
config = build_test_vibe_config(
|
||||
auto_compact_threshold=0,
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
enabled_tools=[],
|
||||
)
|
||||
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)
|
||||
)
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
)
|
||||
result = await plan_middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_REMINDER
|
||||
|
||||
await agent.switch_agent(BuiltinAgentName.DEFAULT)
|
||||
|
||||
plan_middleware_after = next(
|
||||
mw
|
||||
for mw in agent.middleware_pipeline.middlewares
|
||||
if isinstance(mw, PlanAgentMiddleware)
|
||||
)
|
||||
assert plan_middleware is plan_middleware_after
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
)
|
||||
result = await plan_middleware_after.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_EXIT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_agent_allows_reinjection_on_reentry(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
auto_compact_threshold=0,
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
enabled_tools=[],
|
||||
)
|
||||
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)
|
||||
)
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
)
|
||||
await plan_middleware.before_turn(ctx)
|
||||
|
||||
await agent.switch_agent(BuiltinAgentName.DEFAULT)
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
)
|
||||
result = await plan_middleware.before_turn(ctx)
|
||||
assert result.message == PLAN_AGENT_EXIT
|
||||
|
||||
await agent.switch_agent(BuiltinAgentName.PLAN)
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
)
|
||||
result = await plan_middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_REMINDER
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_plan_to_auto_approve_fires_exit(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
auto_compact_threshold=0,
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
enabled_tools=[],
|
||||
)
|
||||
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)
|
||||
)
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
)
|
||||
await plan_middleware.before_turn(ctx) # enter plan
|
||||
|
||||
await agent.switch_agent(BuiltinAgentName.AUTO_APPROVE)
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
)
|
||||
result = await plan_middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert result.message == PLAN_AGENT_EXIT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_between_non_plan_agents_no_injection(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
auto_compact_threshold=0,
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
enabled_tools=[],
|
||||
)
|
||||
agent = build_test_agent_loop(
|
||||
config=config, agent_name=BuiltinAgentName.DEFAULT
|
||||
)
|
||||
|
||||
plan_middleware = next(
|
||||
mw
|
||||
for mw in agent.middleware_pipeline.middlewares
|
||||
if isinstance(mw, PlanAgentMiddleware)
|
||||
)
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
)
|
||||
result = await plan_middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
await agent.switch_agent(BuiltinAgentName.AUTO_APPROVE)
|
||||
|
||||
ctx = ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
)
|
||||
result = await plan_middleware.before_turn(ctx)
|
||||
assert result.action == MiddlewareAction.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_lifecycle_plan_default_plan_default(self) -> None:
|
||||
"""Integration test for a full plan -> default -> plan -> default cycle."""
|
||||
config = build_test_vibe_config(
|
||||
auto_compact_threshold=0,
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
enabled_tools=[],
|
||||
)
|
||||
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)
|
||||
)
|
||||
|
||||
def _ctx():
|
||||
return ConversationContext(
|
||||
messages=agent.messages, stats=agent.stats, config=agent.config
|
||||
)
|
||||
|
||||
# 1. Enter plan: inject reminder
|
||||
r = await plan_middleware.before_turn(_ctx())
|
||||
assert r.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert r.message == PLAN_AGENT_REMINDER
|
||||
|
||||
# 2. Stay in plan: no injection
|
||||
r = await plan_middleware.before_turn(_ctx())
|
||||
assert r.action == MiddlewareAction.CONTINUE
|
||||
|
||||
# 3. Switch to default: inject exit
|
||||
await agent.switch_agent(BuiltinAgentName.DEFAULT)
|
||||
r = await plan_middleware.before_turn(_ctx())
|
||||
assert r.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert r.message == PLAN_AGENT_EXIT
|
||||
|
||||
# 4. Stay in default: no injection
|
||||
r = await plan_middleware.before_turn(_ctx())
|
||||
assert r.action == MiddlewareAction.CONTINUE
|
||||
|
||||
# 5. Switch back to plan: inject reminder again
|
||||
await agent.switch_agent(BuiltinAgentName.PLAN)
|
||||
r = await plan_middleware.before_turn(_ctx())
|
||||
assert r.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert r.message == PLAN_AGENT_REMINDER
|
||||
|
||||
# 6. Stay in plan: no injection
|
||||
r = await plan_middleware.before_turn(_ctx())
|
||||
assert r.action == MiddlewareAction.CONTINUE
|
||||
|
||||
# 7. Switch to default again: inject exit
|
||||
await agent.switch_agent(BuiltinAgentName.DEFAULT)
|
||||
r = await plan_middleware.before_turn(_ctx())
|
||||
assert r.action == MiddlewareAction.INJECT_MESSAGE
|
||||
assert r.message == PLAN_AGENT_EXIT
|
||||
|
||||
# 8. Stay in default: no injection
|
||||
r = await plan_middleware.before_turn(_ctx())
|
||||
assert r.action == MiddlewareAction.CONTINUE
|
||||
|
||||
@@ -267,6 +267,7 @@ class TestAPIToolFormatHandlerReasoningContent:
|
||||
mock_message.role = "assistant"
|
||||
mock_message.content = "The answer is 42."
|
||||
mock_message.reasoning_content = "Let me think..."
|
||||
mock_message.reasoning_signature = None
|
||||
mock_message.tool_calls = None
|
||||
|
||||
result = handler.process_api_response_message(mock_message)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pydantic import ValidationError
|
||||
import pytest
|
||||
@@ -9,7 +13,9 @@ from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp
|
||||
from vibe.core.tools.mcp import (
|
||||
MCPToolResult,
|
||||
RemoteTool,
|
||||
_mcp_stderr_capture,
|
||||
_parse_call_result,
|
||||
_stderr_logger_thread,
|
||||
create_mcp_http_proxy_tool_class,
|
||||
create_mcp_stdio_proxy_tool_class,
|
||||
)
|
||||
@@ -121,6 +127,66 @@ class TestParseCallResult:
|
||||
assert result.text == "line1\nline2"
|
||||
|
||||
|
||||
class TestMCPStderrCapture:
|
||||
"""Tests for _mcp_stderr_capture and _stderr_logger_thread."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_stderr_capture_returns_writable_stream(self):
|
||||
async with _mcp_stderr_capture() as stream:
|
||||
assert stream is not None
|
||||
assert callable(getattr(stream, "write", None))
|
||||
stream.write("test\n")
|
||||
|
||||
def test_stderr_logger_thread_logs_decoded_lines(self):
|
||||
r_fd, w_fd = os.pipe()
|
||||
try:
|
||||
vibe_logger = logging.getLogger("vibe")
|
||||
with patch.object(vibe_logger, "debug") as debug_mock:
|
||||
thread = threading.Thread(
|
||||
target=_stderr_logger_thread, args=(r_fd,), daemon=True
|
||||
)
|
||||
thread.start()
|
||||
try:
|
||||
w = os.fdopen(w_fd, "wb")
|
||||
w_fd = -1
|
||||
w.write(b"hello stderr\n")
|
||||
w.write(b"second line\n")
|
||||
w.close()
|
||||
w = None
|
||||
finally:
|
||||
time.sleep(0.05)
|
||||
debug_mock.assert_any_call("[MCP stderr] hello stderr")
|
||||
debug_mock.assert_any_call("[MCP stderr] second line")
|
||||
finally:
|
||||
if w_fd >= 0:
|
||||
try:
|
||||
os.close(w_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.close(r_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_stderr_capture_logs_written_data(self):
|
||||
vibe_logger = logging.getLogger("vibe")
|
||||
with patch.object(vibe_logger, "debug") as debug_mock:
|
||||
async with _mcp_stderr_capture() as stream:
|
||||
stream.write("captured line\n")
|
||||
time.sleep(0.05)
|
||||
debug_mock.assert_called_with("[MCP stderr] captured line")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_stderr_capture_ignores_empty_lines(self):
|
||||
vibe_logger = logging.getLogger("vibe")
|
||||
with patch.object(vibe_logger, "debug") as debug_mock:
|
||||
async with _mcp_stderr_capture() as stream:
|
||||
stream.write("\n\n")
|
||||
time.sleep(0.05)
|
||||
debug_mock.assert_not_called()
|
||||
|
||||
|
||||
class TestCreateMCPHttpProxyToolClass:
|
||||
def test_creates_tool_class_with_correct_name(self):
|
||||
remote = RemoteTool(name="my_tool", description="Test tool")
|
||||
|
||||
55
uv.lock
generated
@@ -366,6 +366,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f9/9ff5a301459f804a885f237453ba81564bc6ee54740e9f2676c2642043f6/giturlparse-0.14.0-py2.py3-none-any.whl", hash = "sha256:04fd9c262ca9a4db86043d2ef32b2b90bfcbcdefc4f6a260fd9402127880931d", size = 16299, upload-time = "2025-10-22T09:21:10.818Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-auth"
|
||||
version = "2.48.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "pyasn1-modules" },
|
||||
{ name = "rsa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" }
|
||||
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 = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -710,7 +724,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mistral-vibe"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "agent-client-protocol" },
|
||||
@@ -718,6 +732,7 @@ dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "gitpython" },
|
||||
{ name = "giturlparse" },
|
||||
{ name = "google-auth" },
|
||||
{ name = "httpx" },
|
||||
{ name = "keyring" },
|
||||
{ name = "mcp" },
|
||||
@@ -729,6 +744,7 @@ dependencies = [
|
||||
{ name = "pyperclip" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "rich" },
|
||||
{ name = "textual" },
|
||||
{ name = "textual-speedups" },
|
||||
@@ -763,9 +779,10 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "agent-client-protocol", specifier = "==0.8.0" },
|
||||
{ name = "anyio", specifier = ">=4.12.0" },
|
||||
{ name = "cryptography", specifier = ">=44.0.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 = "mcp", specifier = ">=1.14.0" },
|
||||
@@ -777,6 +794,7 @@ requires-dist = [
|
||||
{ name = "pyperclip", specifier = ">=1.11.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.0" },
|
||||
{ name = "requests", specifier = ">=2.20.0" },
|
||||
{ name = "rich", specifier = ">=14.0.0" },
|
||||
{ name = "textual", specifier = ">=1.0.0" },
|
||||
{ name = "textual-speedups", specifier = ">=0.2.1" },
|
||||
@@ -947,6 +965,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1-modules"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.23"
|
||||
@@ -1483,6 +1522,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.14.7"
|
||||
|
||||
@@ -3,4 +3,4 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.1.0"
|
||||
__version__ = "2.2.0"
|
||||
|
||||
@@ -20,7 +20,7 @@ from acp import (
|
||||
SetSessionModeResponse,
|
||||
run_agent,
|
||||
)
|
||||
from acp.helpers import ContentBlock, SessionUpdate
|
||||
from acp.helpers import ContentBlock, SessionUpdate, update_available_commands
|
||||
from acp.schema import (
|
||||
AgentCapabilities,
|
||||
AgentMessageChunk,
|
||||
@@ -28,6 +28,8 @@ from acp.schema import (
|
||||
AllowedOutcome,
|
||||
AuthenticateResponse,
|
||||
AuthMethod,
|
||||
AvailableCommand,
|
||||
AvailableCommandInput,
|
||||
ClientCapabilities,
|
||||
ContentToolCallContent,
|
||||
ForkSessionResponse,
|
||||
@@ -38,6 +40,9 @@ from acp.schema import (
|
||||
ModelInfo,
|
||||
PromptCapabilities,
|
||||
ResumeSessionResponse,
|
||||
SessionCapabilities,
|
||||
SessionInfo,
|
||||
SessionListCapabilities,
|
||||
SessionModelState,
|
||||
SessionModeState,
|
||||
SseMcpServer,
|
||||
@@ -45,6 +50,7 @@ from acp.schema import (
|
||||
TextResourceContents,
|
||||
ToolCallProgress,
|
||||
ToolCallUpdate,
|
||||
UnstructuredCommandInput,
|
||||
UserMessageChunk,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
@@ -58,15 +64,33 @@ from vibe.acp.tools.session_update import (
|
||||
from vibe.acp.utils import (
|
||||
TOOL_OPTIONS,
|
||||
ToolOption,
|
||||
create_assistant_message_replay,
|
||||
create_compact_end_session_update,
|
||||
create_compact_start_session_update,
|
||||
create_reasoning_replay,
|
||||
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,
|
||||
)
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_dotenv_values
|
||||
from vibe.core.config import (
|
||||
MissingAPIKeyError,
|
||||
SessionLoggingConfig,
|
||||
VibeConfig,
|
||||
load_dotenv_values,
|
||||
)
|
||||
from vibe.core.proxy_setup import (
|
||||
ProxySetupError,
|
||||
parse_proxy_command,
|
||||
set_proxy_var,
|
||||
unset_proxy_var,
|
||||
)
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.types import (
|
||||
ApprovalResponse,
|
||||
@@ -74,7 +98,9 @@ from vibe.core.types import (
|
||||
AsyncApprovalCallback,
|
||||
CompactEndEvent,
|
||||
CompactStartEvent,
|
||||
LLMMessage,
|
||||
ReasoningEvent,
|
||||
Role,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
ToolStreamEvent,
|
||||
@@ -150,10 +176,13 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
|
||||
response = InitializeResponse(
|
||||
agent_capabilities=AgentCapabilities(
|
||||
load_session=False,
|
||||
load_session=True,
|
||||
prompt_capabilities=PromptCapabilities(
|
||||
audio=False, embedded_context=True, image=False
|
||||
),
|
||||
session_capabilities=SessionCapabilities(
|
||||
list=SessionListCapabilities()
|
||||
),
|
||||
),
|
||||
protocol_version=PROTOCOL_VERSION,
|
||||
agent_info=Implementation(
|
||||
@@ -171,6 +200,44 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
) -> AuthenticateResponse | None:
|
||||
raise NotImplementedError("Not implemented yet")
|
||||
|
||||
def _load_config(self) -> VibeConfig:
|
||||
try:
|
||||
config = VibeConfig.load(disabled_tools=["ask_user_question"])
|
||||
config.tool_paths.extend(self._get_acp_tool_overrides())
|
||||
return config
|
||||
except MissingAPIKeyError as e:
|
||||
raise RequestError.auth_required({
|
||||
"message": "You must be authenticated before creating a session"
|
||||
}) from e
|
||||
|
||||
async def _create_acp_session(
|
||||
self, session_id: str, agent_loop: AgentLoop
|
||||
) -> AcpSessionLoop:
|
||||
session = AcpSessionLoop(id=session_id, agent_loop=agent_loop)
|
||||
self.sessions[session.id] = session
|
||||
|
||||
if not agent_loop.auto_approve:
|
||||
agent_loop.set_approval_callback(self._create_approval_callback(session.id))
|
||||
|
||||
asyncio.create_task(self._send_available_commands(session.id))
|
||||
|
||||
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,
|
||||
@@ -181,13 +248,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
load_dotenv_values()
|
||||
os.chdir(cwd)
|
||||
|
||||
try:
|
||||
config = VibeConfig.load(disabled_tools=["ask_user_question"])
|
||||
config.tool_paths.extend(self._get_acp_tool_overrides())
|
||||
except MissingAPIKeyError as e:
|
||||
raise RequestError.auth_required({
|
||||
"message": "You must be authenticated before creating a new session"
|
||||
}) from e
|
||||
config = self._load_config()
|
||||
|
||||
agent_loop = AgentLoop(
|
||||
config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
|
||||
@@ -196,30 +257,15 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
# 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 = AcpSessionLoop(id=agent_loop.session_id, agent_loop=agent_loop)
|
||||
self.sessions[session.id] = session
|
||||
session = await self._create_acp_session(agent_loop.session_id, agent_loop)
|
||||
agent_loop.emit_new_session_telemetry("acp")
|
||||
|
||||
if not agent_loop.auto_approve:
|
||||
agent_loop.set_approval_callback(
|
||||
self._create_approval_callback(agent_loop.session_id)
|
||||
return NewSessionResponse(
|
||||
session_id=session.id,
|
||||
models=self._build_session_model_state(agent_loop),
|
||||
modes=self._build_session_mode_state(session),
|
||||
)
|
||||
|
||||
response = NewSessionResponse(
|
||||
session_id=agent_loop.session_id,
|
||||
models=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
|
||||
],
|
||||
),
|
||||
modes=SessionModeState(
|
||||
current_mode_id=session.agent_loop.agent_profile.name,
|
||||
available_modes=get_all_acp_session_modes(agent_loop.agent_manager),
|
||||
),
|
||||
)
|
||||
return response
|
||||
|
||||
def _get_acp_tool_overrides(self) -> list[Path]:
|
||||
overrides = ["todo"]
|
||||
|
||||
@@ -293,6 +339,85 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
raise RequestError.invalid_params({"session": "Not found"})
|
||||
return self.sessions[session_id]
|
||||
|
||||
async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None:
|
||||
if not msg.tool_calls:
|
||||
return
|
||||
for tool_call in msg.tool_calls:
|
||||
if tool_call.id and tool_call.function.name:
|
||||
update = create_tool_call_replay(
|
||||
tool_call.id, tool_call.function.name, tool_call.function.arguments
|
||||
)
|
||||
await self.client.session_update(session_id=session_id, update=update)
|
||||
|
||||
async def _replay_conversation_history(
|
||||
self, session_id: str, messages: list[LLMMessage]
|
||||
) -> None:
|
||||
for msg in messages:
|
||||
if msg.role == Role.user:
|
||||
update = create_user_message_replay(msg)
|
||||
await self.client.session_update(session_id=session_id, update=update)
|
||||
|
||||
elif msg.role == Role.assistant:
|
||||
if text_update := create_assistant_message_replay(msg):
|
||||
await self.client.session_update(
|
||||
session_id=session_id, update=text_update
|
||||
)
|
||||
if reasoning_update := create_reasoning_replay(msg):
|
||||
await self.client.session_update(
|
||||
session_id=session_id, update=reasoning_update
|
||||
)
|
||||
await self._replay_tool_calls(session_id, msg)
|
||||
|
||||
elif msg.role == Role.tool:
|
||||
if result_update := create_tool_result_replay(msg):
|
||||
await self.client.session_update(
|
||||
session_id=session_id, update=result_update
|
||||
)
|
||||
|
||||
async def _send_available_commands(self, session_id: str) -> None:
|
||||
commands = [
|
||||
AvailableCommand(
|
||||
name="proxy-setup",
|
||||
description="Configure proxy and SSL certificate settings",
|
||||
input=AvailableCommandInput(
|
||||
root=UnstructuredCommandInput(
|
||||
hint="KEY value to set, KEY to unset, or empty for help"
|
||||
)
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
update = update_available_commands(commands)
|
||||
await self.client.session_update(session_id=session_id, update=update)
|
||||
|
||||
async def _handle_proxy_setup_command(
|
||||
self, session_id: str, text_prompt: str
|
||||
) -> PromptResponse:
|
||||
args = text_prompt.strip()[len("/proxy-setup") :].strip()
|
||||
|
||||
try:
|
||||
if not args:
|
||||
message = get_proxy_help_text()
|
||||
else:
|
||||
key, value = parse_proxy_command(args)
|
||||
if value is not None:
|
||||
set_proxy_var(key, value)
|
||||
message = f"Set `{key}={value}` in ~/.vibe/.env\n\nPlease start a new chat for changes to take effect."
|
||||
else:
|
||||
unset_proxy_var(key)
|
||||
message = f"Removed `{key}` from ~/.vibe/.env\n\nPlease start a new chat for changes to take effect."
|
||||
except ProxySetupError as e:
|
||||
message = f"Error: {e}"
|
||||
|
||||
await self.client.session_update(
|
||||
session_id=session_id,
|
||||
update=AgentMessageChunk(
|
||||
session_update="agent_message_chunk",
|
||||
content=TextContentBlock(type="text", text=message),
|
||||
),
|
||||
)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
@override
|
||||
async def load_session(
|
||||
self,
|
||||
@@ -301,7 +426,44 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> LoadSessionResponse | None:
|
||||
raise NotImplementedError()
|
||||
load_dotenv_values()
|
||||
os.chdir(cwd)
|
||||
|
||||
config = self._load_config()
|
||||
|
||||
session_dir = SessionLoader.find_session_by_id(
|
||||
session_id, config.session_logging
|
||||
)
|
||||
if session_dir is None:
|
||||
raise RequestError.invalid_params({
|
||||
"session_id": f"Session not found: {session_id}"
|
||||
})
|
||||
|
||||
try:
|
||||
loaded_messages, _ = SessionLoader.load_session(session_dir)
|
||||
except ValueError as e:
|
||||
raise RequestError.invalid_params({
|
||||
"session_id": f"Failed to load session: {e}"
|
||||
}) from e
|
||||
|
||||
agent_loop = AgentLoop(
|
||||
config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
|
||||
)
|
||||
|
||||
non_system_messages = [
|
||||
msg for msg in loaded_messages if msg.role != Role.system
|
||||
]
|
||||
|
||||
agent_loop.messages.extend(non_system_messages)
|
||||
|
||||
session = await self._create_acp_session(session_id, agent_loop)
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
@override
|
||||
async def set_session_mode(
|
||||
@@ -348,7 +510,27 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
async def list_sessions(
|
||||
self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any
|
||||
) -> ListSessionsResponse:
|
||||
raise NotImplementedError()
|
||||
try:
|
||||
config = VibeConfig.load()
|
||||
session_logging_config = config.session_logging
|
||||
except MissingAPIKeyError:
|
||||
session_logging_config = SessionLoggingConfig()
|
||||
|
||||
session_data = SessionLoader.list_sessions(session_logging_config, cwd=cwd)
|
||||
|
||||
sessions = [
|
||||
SessionInfo(
|
||||
session_id=s["session_id"],
|
||||
cwd=s["cwd"],
|
||||
title=s.get("title"),
|
||||
updated_at=s.get("end_time"),
|
||||
)
|
||||
for s in sorted(
|
||||
session_data, key=lambda s: s.get("end_time") or "", reverse=True
|
||||
)
|
||||
]
|
||||
|
||||
return ListSessionsResponse(sessions=sessions)
|
||||
|
||||
@override
|
||||
async def prompt(
|
||||
@@ -363,6 +545,9 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
|
||||
text_prompt = self._build_text_prompt(prompt)
|
||||
|
||||
if text_prompt.strip().lower().startswith("/proxy-setup"):
|
||||
return await self._handle_proxy_setup_command(session_id, text_prompt)
|
||||
|
||||
temp_user_message_id: str | None = kwargs.get("messageId")
|
||||
|
||||
async def agent_loop_task() -> None:
|
||||
|
||||
@@ -4,16 +4,20 @@ from enum import StrEnum
|
||||
from typing import TYPE_CHECKING, Literal, cast
|
||||
|
||||
from acp.schema import (
|
||||
AgentMessageChunk,
|
||||
AgentThoughtChunk,
|
||||
ContentToolCallContent,
|
||||
PermissionOption,
|
||||
SessionMode,
|
||||
TextContentBlock,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
UserMessageChunk,
|
||||
)
|
||||
|
||||
from vibe.core.agents.models import AgentProfile, AgentType
|
||||
from vibe.core.types import CompactEndEvent, CompactStartEvent
|
||||
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings
|
||||
from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
|
||||
from vibe.core.utils import compact_reduction_display
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -111,3 +115,99 @@ def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgres
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def get_proxy_help_text() -> str:
|
||||
lines = [
|
||||
"## Proxy Configuration",
|
||||
"",
|
||||
"Configure proxy and SSL settings for HTTP requests.",
|
||||
"",
|
||||
"### Usage:",
|
||||
"- `/proxy-setup` - Show this help and current settings",
|
||||
"- `/proxy-setup KEY value` - Set an environment variable",
|
||||
"- `/proxy-setup KEY` - Remove an environment variable",
|
||||
"",
|
||||
"### Supported Variables:",
|
||||
]
|
||||
|
||||
for key, description in SUPPORTED_PROXY_VARS.items():
|
||||
lines.append(f"- `{key}`: {description}")
|
||||
|
||||
lines.extend(["", "### Current Settings:"])
|
||||
|
||||
current = get_current_proxy_settings()
|
||||
any_set = False
|
||||
for key, value in current.items():
|
||||
if value:
|
||||
lines.append(f"- `{key}={value}`")
|
||||
any_set = True
|
||||
|
||||
if not any_set:
|
||||
lines.append("- (none configured)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def create_user_message_replay(msg: LLMMessage) -> UserMessageChunk:
|
||||
content = msg.content if isinstance(msg.content, str) else ""
|
||||
return UserMessageChunk(
|
||||
session_update="user_message_chunk",
|
||||
content=TextContentBlock(type="text", text=content),
|
||||
field_meta={"messageId": msg.message_id} if msg.message_id else {},
|
||||
)
|
||||
|
||||
|
||||
def create_assistant_message_replay(msg: LLMMessage) -> AgentMessageChunk | None:
|
||||
content = msg.content if isinstance(msg.content, str) else ""
|
||||
if not content:
|
||||
return None
|
||||
|
||||
return AgentMessageChunk(
|
||||
session_update="agent_message_chunk",
|
||||
content=TextContentBlock(type="text", text=content),
|
||||
field_meta={"messageId": msg.message_id} if msg.message_id else {},
|
||||
)
|
||||
|
||||
|
||||
def create_reasoning_replay(msg: LLMMessage) -> AgentThoughtChunk | None:
|
||||
if not isinstance(msg.reasoning_content, str) or not msg.reasoning_content:
|
||||
return None
|
||||
|
||||
return AgentThoughtChunk(
|
||||
session_update="agent_thought_chunk",
|
||||
content=TextContentBlock(type="text", text=msg.reasoning_content),
|
||||
field_meta={"messageId": msg.message_id} if msg.message_id else {},
|
||||
)
|
||||
|
||||
|
||||
def create_tool_call_replay(
|
||||
tool_call_id: str, tool_name: str, arguments: str | None
|
||||
) -> ToolCallStart:
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title=tool_name,
|
||||
tool_call_id=tool_call_id,
|
||||
kind="other",
|
||||
raw_input=arguments,
|
||||
)
|
||||
|
||||
|
||||
def create_tool_result_replay(msg: LLMMessage) -> ToolCallProgress | None:
|
||||
if not msg.tool_call_id:
|
||||
return None
|
||||
|
||||
content = msg.content if isinstance(msg.content, str) else ""
|
||||
return ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=msg.tool_call_id,
|
||||
status="completed",
|
||||
raw_output=content,
|
||||
content=[
|
||||
ContentToolCallContent(
|
||||
type="content", content=TextContentBlock(type="text", text=content)
|
||||
)
|
||||
]
|
||||
if content
|
||||
else None,
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ def _shorten_preview(texts: list[str]) -> str:
|
||||
return dense_text
|
||||
|
||||
|
||||
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None:
|
||||
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None:
|
||||
selected_texts = []
|
||||
|
||||
for widget in app.query("*"):
|
||||
@@ -48,7 +48,7 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None:
|
||||
selected_texts.append(selected_text)
|
||||
|
||||
if not selected_texts:
|
||||
return
|
||||
return None
|
||||
|
||||
combined_text = "\n".join(selected_texts)
|
||||
|
||||
@@ -61,7 +61,9 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None:
|
||||
timeout=2,
|
||||
markup=False,
|
||||
)
|
||||
return combined_text
|
||||
except Exception:
|
||||
app.notify(
|
||||
"Failed to copy - clipboard not available", severity="warning", timeout=3
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -67,6 +67,11 @@ class CommandRegistry:
|
||||
description="Teleport session to Vibe Nuage",
|
||||
handler="_teleport_command",
|
||||
),
|
||||
"proxy-setup": Command(
|
||||
aliases=frozenset(["/proxy-setup"]),
|
||||
description="Configure proxy and SSL certificate settings",
|
||||
handler="_show_proxy_setup",
|
||||
),
|
||||
}
|
||||
|
||||
for command in excluded_commands:
|
||||
@@ -78,9 +83,12 @@ class CommandRegistry:
|
||||
self._alias_map[alias] = cmd_name
|
||||
|
||||
def find_command(self, user_input: str) -> Command | None:
|
||||
cmd_name = self._alias_map.get(user_input.lower().strip())
|
||||
cmd_name = self.get_command_name(user_input)
|
||||
return self.commands.get(cmd_name) if cmd_name else None
|
||||
|
||||
def get_command_name(self, user_input: str) -> str | None:
|
||||
return self._alias_map.get(user_input.lower().strip())
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
lines: list[str] = [
|
||||
"### Keyboard Shortcuts",
|
||||
|
||||
@@ -51,6 +51,7 @@ from vibe.cli.textual_ui.widgets.messages import (
|
||||
)
|
||||
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.teleport_message import TeleportMessage
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||
@@ -127,6 +128,7 @@ class BottomApp(StrEnum):
|
||||
Approval = auto()
|
||||
Config = auto()
|
||||
Input = auto()
|
||||
ProxySetup = auto()
|
||||
Question = auto()
|
||||
|
||||
|
||||
@@ -308,6 +310,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")
|
||||
|
||||
if self._initial_prompt or self._teleport_on_start:
|
||||
self.call_after_refresh(self._process_initial_prompt)
|
||||
@@ -422,6 +425,24 @@ class VibeApp(App): # noqa: PLR0904
|
||||
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_proxy_setup_app_proxy_setup_closed(
|
||||
self, message: ProxySetupApp.ProxySetupClosed
|
||||
) -> None:
|
||||
if message.error:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(f"Failed to save proxy settings: {message.error}")
|
||||
)
|
||||
elif message.saved:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage(
|
||||
"Proxy settings saved. Restart the CLI for changes to take effect."
|
||||
)
|
||||
)
|
||||
else:
|
||||
await self._mount_and_scroll(UserCommandMessage("Proxy setup cancelled."))
|
||||
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_compact_message_completed(
|
||||
self, message: CompactMessage.Completed
|
||||
) -> None:
|
||||
@@ -449,6 +470,10 @@ class VibeApp(App): # noqa: PLR0904
|
||||
|
||||
async def _handle_command(self, user_input: str) -> bool:
|
||||
if command := self.commands.find_command(user_input):
|
||||
if cmd_name := self.commands.get_command_name(user_input):
|
||||
self.agent_loop.telemetry_client.send_slash_command_used(
|
||||
cmd_name, "builtin"
|
||||
)
|
||||
await self._mount_and_scroll(UserMessage(user_input))
|
||||
handler = getattr(self, command.handler)
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
@@ -479,6 +504,8 @@ class VibeApp(App): # noqa: PLR0904
|
||||
if not skill_info:
|
||||
return False
|
||||
|
||||
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
|
||||
|
||||
try:
|
||||
skill_content = skill_info.skill_path.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
@@ -823,6 +850,11 @@ class VibeApp(App): # noqa: PLR0904
|
||||
return
|
||||
await self._switch_to_config_app()
|
||||
|
||||
async def _show_proxy_setup(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
return
|
||||
await self._switch_to_proxy_setup_app()
|
||||
|
||||
async def _reload_config(self) -> None:
|
||||
try:
|
||||
self._windowing.reset()
|
||||
@@ -996,6 +1028,13 @@ class VibeApp(App): # noqa: PLR0904
|
||||
await self._mount_and_scroll(UserCommandMessage("Configuration opened..."))
|
||||
await self._switch_from_input(ConfigApp(self.config))
|
||||
|
||||
async def _switch_to_proxy_setup_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
return
|
||||
|
||||
await self._mount_and_scroll(UserCommandMessage("Proxy setup opened..."))
|
||||
await self._switch_from_input(ProxySetupApp())
|
||||
|
||||
async def _switch_to_approval_app(
|
||||
self, tool_name: str, tool_args: BaseModel
|
||||
) -> None:
|
||||
@@ -1020,6 +1059,7 @@ 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_to_bottom)
|
||||
|
||||
def _focus_current_bottom_app(self) -> None:
|
||||
try:
|
||||
@@ -1028,6 +1068,8 @@ class VibeApp(App): # noqa: PLR0904
|
||||
self.query_one(ChatInputContainer).focus_input()
|
||||
case BottomApp.Config:
|
||||
self.query_one(ConfigApp).focus()
|
||||
case BottomApp.ProxySetup:
|
||||
self.query_one(ProxySetupApp).focus()
|
||||
case BottomApp.Approval:
|
||||
self.query_one(ApprovalApp).focus()
|
||||
case BottomApp.Question:
|
||||
@@ -1037,34 +1079,66 @@ class VibeApp(App): # noqa: PLR0904
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def action_interrupt(self) -> None:
|
||||
current_time = time.monotonic()
|
||||
|
||||
if self._current_bottom_app == BottomApp.Config:
|
||||
def _handle_config_app_escape(self) -> None:
|
||||
try:
|
||||
config_app = self.query_one(ConfigApp)
|
||||
config_app.action_close()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.Approval:
|
||||
def _handle_approval_app_escape(self) -> None:
|
||||
try:
|
||||
approval_app = self.query_one(ApprovalApp)
|
||||
approval_app.action_reject()
|
||||
except Exception:
|
||||
pass
|
||||
self.agent_loop.telemetry_client.send_user_cancelled_action("reject_approval")
|
||||
self._last_escape_time = None
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.Question:
|
||||
def _handle_question_app_escape(self) -> None:
|
||||
try:
|
||||
question_app = self.query_one(QuestionApp)
|
||||
question_app.action_cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_input_app_escape(self) -> None:
|
||||
try:
|
||||
input_widget = self.query_one(ChatInputContainer)
|
||||
input_widget.value = ""
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_agent_running_escape(self) -> None:
|
||||
self.agent_loop.telemetry_client.send_user_cancelled_action("interrupt_agent")
|
||||
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
|
||||
|
||||
def action_interrupt(self) -> None:
|
||||
current_time = time.monotonic()
|
||||
|
||||
if self._current_bottom_app == BottomApp.Config:
|
||||
self._handle_config_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
try:
|
||||
proxy_setup_app = self.query_one(ProxySetupApp)
|
||||
proxy_setup_app.action_close()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.Approval:
|
||||
self._handle_approval_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.Question:
|
||||
self._handle_question_app_escape()
|
||||
return
|
||||
|
||||
if (
|
||||
@@ -1072,17 +1146,11 @@ class VibeApp(App): # noqa: PLR0904
|
||||
and self._last_escape_time is not None
|
||||
and (current_time - self._last_escape_time) < 0.2 # noqa: PLR2004
|
||||
):
|
||||
try:
|
||||
input_widget = self.query_one(ChatInputContainer)
|
||||
if input_widget.value:
|
||||
input_widget.value = ""
|
||||
self._last_escape_time = None
|
||||
self._handle_input_app_escape()
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self._agent_running:
|
||||
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
|
||||
self._handle_agent_running_escape()
|
||||
|
||||
self._last_escape_time = current_time
|
||||
self._scroll_to_bottom()
|
||||
@@ -1430,11 +1498,15 @@ class VibeApp(App): # noqa: PLR0904
|
||||
)
|
||||
|
||||
def action_copy_selection(self) -> None:
|
||||
copy_selection_to_clipboard(self, show_toast=False)
|
||||
copied_text = copy_selection_to_clipboard(self, show_toast=False)
|
||||
if copied_text is not None:
|
||||
self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
|
||||
|
||||
def on_mouse_up(self, event: MouseUp) -> None:
|
||||
if self.config.autocopy_to_clipboard:
|
||||
copy_selection_to_clipboard(self, show_toast=True)
|
||||
copied_text = copy_selection_to_clipboard(self, show_toast=True)
|
||||
if copied_text is not None:
|
||||
self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
|
||||
|
||||
def on_app_blur(self, event: AppBlur) -> None:
|
||||
if self._chat_input_container and self._chat_input_container.input_widget:
|
||||
|
||||
@@ -703,6 +703,44 @@ StatusMessage {
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
#proxysetup-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#proxysetup-content {
|
||||
width: 100%;
|
||||
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;
|
||||
}
|
||||
|
||||
.proxy-input {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: none;
|
||||
border-left: wide ansi_bright_black;
|
||||
margin-top: 1;
|
||||
padding: 0 0 0 1;
|
||||
}
|
||||
|
||||
#approval-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
||||
127
vibe/cli/textual_ui/widgets/proxy_setup_app.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from textual import events
|
||||
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 Input, Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.proxy_setup import (
|
||||
SUPPORTED_PROXY_VARS,
|
||||
get_current_proxy_settings,
|
||||
set_proxy_var,
|
||||
unset_proxy_var,
|
||||
)
|
||||
|
||||
|
||||
class ProxySetupApp(Container):
|
||||
can_focus = True
|
||||
can_focus_children = True
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("up", "focus_previous", "Up", show=False),
|
||||
Binding("down", "focus_next", "Down", show=False),
|
||||
]
|
||||
|
||||
class ProxySetupClosed(Message):
|
||||
def __init__(self, saved: bool, error: str | None = None) -> None:
|
||||
super().__init__()
|
||||
self.saved = saved
|
||||
self.error = error
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="proxysetup-app")
|
||||
self.inputs: dict[str, Input] = {}
|
||||
self.initial_values: dict[str, str | None] = {}
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
self.initial_values = get_current_proxy_settings()
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
initial_value = self.initial_values.get(key) or ""
|
||||
input_widget = Input(
|
||||
value=initial_value,
|
||||
placeholder="NOT SET",
|
||||
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"
|
||||
)
|
||||
|
||||
def focus(self, scroll_visible: bool = True) -> ProxySetupApp:
|
||||
"""Override focus to focus the first input widget."""
|
||||
if self.inputs:
|
||||
first_input = list(self.inputs.values())[0]
|
||||
first_input.focus(scroll_visible=scroll_visible)
|
||||
else:
|
||||
super().focus(scroll_visible=scroll_visible)
|
||||
return self
|
||||
|
||||
def action_focus_next(self) -> None:
|
||||
inputs = list(self.inputs.values())
|
||||
focused = self.screen.focused
|
||||
if focused is not None and isinstance(focused, Input) and focused in inputs:
|
||||
idx = inputs.index(focused)
|
||||
next_idx = (idx + 1) % len(inputs)
|
||||
inputs[next_idx].focus()
|
||||
|
||||
def action_focus_previous(self) -> None:
|
||||
inputs = list(self.inputs.values())
|
||||
focused = self.screen.focused
|
||||
if focused is not None and isinstance(focused, Input) and focused in inputs:
|
||||
idx = inputs.index(focused)
|
||||
prev_idx = (idx - 1) % len(inputs)
|
||||
inputs[prev_idx].focus()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
self._save_and_close()
|
||||
|
||||
def on_blur(self, _event: events.Blur) -> None:
|
||||
self.call_after_refresh(self._refocus_if_needed)
|
||||
|
||||
def on_input_blurred(self, _event: Input.Blurred) -> None:
|
||||
self.call_after_refresh(self._refocus_if_needed)
|
||||
|
||||
def _refocus_if_needed(self) -> None:
|
||||
if self.has_focus or any(inp.has_focus for inp in self.inputs.values()):
|
||||
return
|
||||
self.focus()
|
||||
|
||||
def _save_and_close(self) -> None:
|
||||
try:
|
||||
for key, input_widget in self.inputs.items():
|
||||
new_value = input_widget.value.strip()
|
||||
old_value = self.initial_values.get(key) or ""
|
||||
|
||||
if new_value != old_value:
|
||||
if new_value:
|
||||
set_proxy_var(key, new_value)
|
||||
else:
|
||||
unset_proxy_var(key)
|
||||
except Exception as e:
|
||||
self.post_message(self.ProxySetupClosed(saved=False, error=str(e)))
|
||||
return
|
||||
|
||||
self.post_message(self.ProxySetupClosed(saved=True))
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.post_message(self.ProxySetupClosed(saved=False))
|
||||
@@ -4,19 +4,26 @@ import asyncio
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from enum import StrEnum, auto
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
import time
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.config import Backend, ProviderConfig, VibeConfig
|
||||
from vibe.core.llm.backend.factory import BACKEND_FACTORY
|
||||
from vibe.core.llm.exceptions import BackendError
|
||||
from vibe.core.llm.format import APIToolFormatHandler, ResolvedMessage, ResolvedToolCall
|
||||
from vibe.core.llm.format import (
|
||||
APIToolFormatHandler,
|
||||
FailedToolCall,
|
||||
ResolvedMessage,
|
||||
ResolvedToolCall,
|
||||
)
|
||||
from vibe.core.llm.types import BackendLike
|
||||
from vibe.core.middleware import (
|
||||
AutoCompactMiddleware,
|
||||
@@ -35,6 +42,7 @@ from vibe.core.session.session_logger import SessionLogger
|
||||
from vibe.core.session.session_migration import migrate_sessions_entrypoint
|
||||
from vibe.core.skills.manager import SkillManager
|
||||
from vibe.core.system_prompt import get_universal_system_prompt
|
||||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.core.tools.base import (
|
||||
BaseTool,
|
||||
BaseToolConfig,
|
||||
@@ -44,6 +52,7 @@ from vibe.core.tools.base import (
|
||||
ToolPermissionError,
|
||||
)
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
from vibe.core.trusted_folders import has_agents_md_file
|
||||
from vibe.core.types import (
|
||||
AgentStats,
|
||||
ApprovalCallback,
|
||||
@@ -96,6 +105,7 @@ class ToolExecutionResponse(StrEnum):
|
||||
|
||||
class ToolDecision(BaseModel):
|
||||
verdict: ToolExecutionResponse
|
||||
approval_type: ToolPermission
|
||||
feedback: str | None = None
|
||||
|
||||
|
||||
@@ -171,7 +181,9 @@ class AgentLoop:
|
||||
self.user_input_callback: UserInputCallback | None = None
|
||||
|
||||
self.session_id = str(uuid4())
|
||||
self._current_user_message_id: str | None = None
|
||||
|
||||
self.telemetry_client = TelemetryClient(config_getter=lambda: self.config)
|
||||
self.session_logger = SessionLogger(config.session_logging, self.session_id)
|
||||
self._teleport_service: TeleportService | None = None
|
||||
|
||||
@@ -209,6 +221,21 @@ 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:
|
||||
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)
|
||||
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,
|
||||
)
|
||||
|
||||
def _select_backend(self) -> BackendLike:
|
||||
active_model = self.config.get_active_model()
|
||||
provider = self.config.get_provider_for_model(active_model)
|
||||
@@ -331,12 +358,11 @@ class AgentLoop:
|
||||
)
|
||||
|
||||
case MiddlewareAction.INJECT_MESSAGE:
|
||||
if result.message and len(self.messages) > 0:
|
||||
last_msg = self.messages[-1]
|
||||
if last_msg.content:
|
||||
last_msg.content += f"\n\n{result.message}"
|
||||
else:
|
||||
last_msg.content = result.message
|
||||
if result.message:
|
||||
injected_message = LLMMessage(
|
||||
role=Role.user, content=result.message
|
||||
)
|
||||
self.messages.append(injected_message)
|
||||
|
||||
case MiddlewareAction.COMPACT:
|
||||
old_tokens = result.metadata.get(
|
||||
@@ -352,6 +378,7 @@ class AgentLoop:
|
||||
current_context_tokens=old_tokens,
|
||||
threshold=threshold,
|
||||
)
|
||||
self.telemetry_client.send_auto_compact_triggered()
|
||||
|
||||
summary = await self.compact()
|
||||
|
||||
@@ -370,10 +397,25 @@ class AgentLoop:
|
||||
messages=self.messages, stats=self.stats, config=self.config
|
||||
)
|
||||
|
||||
def _get_extra_headers(self, provider: ProviderConfig) -> dict[str, str]:
|
||||
headers: dict[str, str] = {
|
||||
"user-agent": get_user_agent(provider.backend),
|
||||
"x-affinity": self.session_id,
|
||||
}
|
||||
if (
|
||||
provider.backend == Backend.MISTRAL
|
||||
and self._current_user_message_id is not None
|
||||
):
|
||||
headers["metadata"] = json.dumps({
|
||||
"message_id": self._current_user_message_id
|
||||
})
|
||||
return headers
|
||||
|
||||
async def _conversation_loop(self, user_msg: str) -> AsyncGenerator[BaseEvent]:
|
||||
user_message = LLMMessage(role=Role.user, content=user_msg)
|
||||
self.messages.append(user_message)
|
||||
self.stats.steps += 1
|
||||
self._current_user_message_id = user_message.message_id
|
||||
|
||||
if user_message.message_id is None:
|
||||
raise AgentLoopError("User message must have a message_id")
|
||||
@@ -406,15 +448,6 @@ class AgentLoop:
|
||||
if user_cancelled:
|
||||
return
|
||||
|
||||
after_result = await self.middleware_pipeline.run_after_turn(
|
||||
self._get_context()
|
||||
)
|
||||
async for event in self._handle_middleware_result(after_result):
|
||||
yield event
|
||||
|
||||
if after_result.action == MiddlewareAction.STOP:
|
||||
return
|
||||
|
||||
finally:
|
||||
await self._flush_new_messages()
|
||||
|
||||
@@ -497,19 +530,17 @@ class AgentLoop:
|
||||
message_id=llm_result.message.message_id,
|
||||
)
|
||||
|
||||
async def _handle_tool_calls(
|
||||
self, resolved: ResolvedMessage
|
||||
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
|
||||
for failed in resolved.failed_calls:
|
||||
async def _emit_failed_tool_events(
|
||||
self, failed_calls: list[FailedToolCall]
|
||||
) -> AsyncGenerator[ToolResultEvent]:
|
||||
for failed in failed_calls:
|
||||
error_msg = f"<{TOOL_ERROR_TAG}>{failed.tool_name}: {failed.error}</{TOOL_ERROR_TAG}>"
|
||||
|
||||
yield ToolResultEvent(
|
||||
tool_name=failed.tool_name,
|
||||
tool_class=None,
|
||||
error=error_msg,
|
||||
tool_call_id=failed.call_id,
|
||||
)
|
||||
|
||||
self.stats.tool_calls_failed += 1
|
||||
self.messages.append(
|
||||
self.format_handler.create_failed_tool_response_message(
|
||||
@@ -517,14 +548,9 @@ class AgentLoop:
|
||||
)
|
||||
)
|
||||
|
||||
for tool_call in resolved.tool_calls:
|
||||
yield ToolCallEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
args=tool_call.validated_args,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
|
||||
async def _process_one_tool_call(
|
||||
self, tool_call: ResolvedToolCall
|
||||
) -> AsyncGenerator[ToolResultEvent | ToolStreamEvent]:
|
||||
try:
|
||||
tool_instance = self.tool_manager.get(tool_call.tool_name)
|
||||
except Exception as exc:
|
||||
@@ -535,8 +561,8 @@ class AgentLoop:
|
||||
error=error_msg,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
self._append_tool_response(tool_call, error_msg)
|
||||
continue
|
||||
self._handle_tool_response(tool_call, error_msg, "failure")
|
||||
return
|
||||
|
||||
decision = await self._should_execute_tool(
|
||||
tool_instance, tool_call.validated_args, tool_call.call_id
|
||||
@@ -549,7 +575,6 @@ class AgentLoop:
|
||||
CancellationReason.TOOL_SKIPPED, tool_call.tool_name
|
||||
)
|
||||
)
|
||||
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
@@ -557,15 +582,14 @@ class AgentLoop:
|
||||
skip_reason=skip_reason,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
self._append_tool_response(tool_call, skip_reason)
|
||||
continue
|
||||
self._handle_tool_response(tool_call, skip_reason, "skipped", decision)
|
||||
return
|
||||
|
||||
self.stats.tool_calls_agreed += 1
|
||||
|
||||
try:
|
||||
start_time = time.perf_counter()
|
||||
result_model = None
|
||||
|
||||
async for item in tool_instance.invoke(
|
||||
ctx=InvokeContext(
|
||||
tool_call_id=tool_call.call_id,
|
||||
@@ -581,15 +605,14 @@ class AgentLoop:
|
||||
result_model = item
|
||||
|
||||
duration = time.perf_counter() - start_time
|
||||
|
||||
if result_model is None:
|
||||
raise ToolError("Tool did not yield a result")
|
||||
|
||||
text = "\n".join(
|
||||
f"{k}: {v}" for k, v in result_model.model_dump().items()
|
||||
result_dict = result_model.model_dump()
|
||||
text = "\n".join(f"{k}: {v}" for k, v in result_dict.items())
|
||||
self._handle_tool_response(
|
||||
tool_call, text, "success", decision, result_dict
|
||||
)
|
||||
self._append_tool_response(tool_call, text)
|
||||
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
@@ -597,7 +620,6 @@ class AgentLoop:
|
||||
duration=duration,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
|
||||
self.stats.tool_calls_succeeded += 1
|
||||
|
||||
except asyncio.CancelledError:
|
||||
@@ -610,34 +632,61 @@ class AgentLoop:
|
||||
error=cancel,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
self._append_tool_response(tool_call, cancel)
|
||||
self._handle_tool_response(tool_call, cancel, "failure", decision)
|
||||
raise
|
||||
|
||||
except (ToolError, ToolPermissionError) as exc:
|
||||
error_msg = f"<{TOOL_ERROR_TAG}>{tool_instance.get_name()} failed: {exc}</{TOOL_ERROR_TAG}>"
|
||||
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
error=error_msg,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
|
||||
if isinstance(exc, ToolPermissionError):
|
||||
self.stats.tool_calls_agreed -= 1
|
||||
self.stats.tool_calls_rejected += 1
|
||||
else:
|
||||
self.stats.tool_calls_failed += 1
|
||||
self._append_tool_response(tool_call, error_msg)
|
||||
continue
|
||||
self._handle_tool_response(tool_call, error_msg, "failure", decision)
|
||||
|
||||
def _append_tool_response(self, tool_call: ResolvedToolCall, text: str) -> None:
|
||||
async def _handle_tool_calls(
|
||||
self, resolved: ResolvedMessage
|
||||
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
|
||||
async for event in self._emit_failed_tool_events(resolved.failed_calls):
|
||||
yield event
|
||||
for tool_call in resolved.tool_calls:
|
||||
yield ToolCallEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
args=tool_call.validated_args,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
async for event in self._process_one_tool_call(tool_call):
|
||||
yield event
|
||||
|
||||
def _handle_tool_response(
|
||||
self,
|
||||
tool_call: ResolvedToolCall,
|
||||
text: str,
|
||||
status: Literal["success", "failure", "skipped"],
|
||||
decision: ToolDecision | None = None,
|
||||
result: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.messages.append(
|
||||
LLMMessage.model_validate(
|
||||
self.format_handler.create_tool_response_message(tool_call, text)
|
||||
)
|
||||
)
|
||||
|
||||
self.telemetry_client.send_tool_call_finished(
|
||||
tool_call=tool_call,
|
||||
agent_profile_name=self.agent_profile.name,
|
||||
status=status,
|
||||
decision=decision,
|
||||
result=result,
|
||||
)
|
||||
|
||||
async def _chat(self, max_tokens: int | None = None) -> LLMChunk:
|
||||
active_model = self.config.get_active_model()
|
||||
provider = self.config.get_provider_for_model(active_model)
|
||||
@@ -653,10 +702,7 @@ class AgentLoop:
|
||||
temperature=active_model.temperature,
|
||||
tools=available_tools,
|
||||
tool_choice=tool_choice,
|
||||
extra_headers={
|
||||
"user-agent": get_user_agent(provider.backend),
|
||||
"x-affinity": self.session_id,
|
||||
},
|
||||
extra_headers=self._get_extra_headers(provider),
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
end_time = time.perf_counter()
|
||||
@@ -699,10 +745,7 @@ class AgentLoop:
|
||||
temperature=active_model.temperature,
|
||||
tools=available_tools,
|
||||
tool_choice=tool_choice,
|
||||
extra_headers={
|
||||
"user-agent": get_user_agent(provider.backend),
|
||||
"x-affinity": self.session_id,
|
||||
},
|
||||
extra_headers=self._get_extra_headers(provider),
|
||||
max_tokens=max_tokens,
|
||||
):
|
||||
processed_message = self.format_handler.process_api_response_message(
|
||||
@@ -744,16 +787,23 @@ class AgentLoop:
|
||||
self, tool: BaseTool, args: BaseModel, tool_call_id: str
|
||||
) -> ToolDecision:
|
||||
if self.auto_approve:
|
||||
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE,
|
||||
approval_type=ToolPermission.ALWAYS,
|
||||
)
|
||||
|
||||
allowlist_denylist_result = tool.check_allowlist_denylist(args)
|
||||
if allowlist_denylist_result == ToolPermission.ALWAYS:
|
||||
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
|
||||
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}]",
|
||||
)
|
||||
|
||||
@@ -761,10 +811,14 @@ class AgentLoop:
|
||||
perm = self.tool_manager.get_tool_config(tool_name).permission
|
||||
|
||||
if perm is ToolPermission.ALWAYS:
|
||||
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE,
|
||||
approval_type=ToolPermission.ALWAYS,
|
||||
)
|
||||
if perm is ToolPermission.NEVER:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.SKIP,
|
||||
approval_type=ToolPermission.NEVER,
|
||||
feedback=f"Tool '{tool_name}' is permanently disabled",
|
||||
)
|
||||
|
||||
@@ -776,6 +830,7 @@ class AgentLoop:
|
||||
if not self.approval_callback:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.SKIP,
|
||||
approval_type=ToolPermission.ASK,
|
||||
feedback="Tool execution not permitted.",
|
||||
)
|
||||
if asyncio.iscoroutinefunction(self.approval_callback):
|
||||
@@ -788,11 +843,15 @@ class AgentLoop:
|
||||
match response:
|
||||
case ApprovalResponse.YES:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE, feedback=feedback
|
||||
verdict=ToolExecutionResponse.EXECUTE,
|
||||
approval_type=ToolPermission.ASK,
|
||||
feedback=feedback,
|
||||
)
|
||||
case ApprovalResponse.NO:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.SKIP, feedback=feedback
|
||||
verdict=ToolExecutionResponse.SKIP,
|
||||
approval_type=ToolPermission.ASK,
|
||||
feedback=feedback,
|
||||
)
|
||||
|
||||
def _clean_message_history(self) -> None:
|
||||
@@ -890,7 +949,6 @@ class AgentLoop:
|
||||
self._reset_session()
|
||||
|
||||
async def compact(self) -> str:
|
||||
"""Compact the conversation history."""
|
||||
try:
|
||||
self._clean_message_history()
|
||||
await self.session_logger.save_interaction(
|
||||
@@ -915,6 +973,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
|
||||
|
||||
active_model = self.config.get_active_model()
|
||||
provider = self.config.get_provider_for_model(active_model)
|
||||
@@ -955,13 +1014,14 @@ class AgentLoop:
|
||||
if agent_name == self.agent_profile.name:
|
||||
return
|
||||
self.agent_manager.switch_profile(agent_name)
|
||||
await self.reload_with_initial_messages()
|
||||
await self.reload_with_initial_messages(reset_middleware=False)
|
||||
|
||||
async def reload_with_initial_messages(
|
||||
self,
|
||||
base_config: VibeConfig | None = None,
|
||||
max_turns: int | None = None,
|
||||
max_price: float | None = None,
|
||||
reset_middleware: bool = True,
|
||||
) -> None:
|
||||
# Force an immediate yield to allow the UI to update before heavy sync work.
|
||||
# When there are no messages, save_interaction returns early without any await,
|
||||
@@ -1011,19 +1071,5 @@ class AgentLoop:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
self._last_observed_message_index = 0
|
||||
|
||||
if reset_middleware:
|
||||
self._setup_middleware()
|
||||
|
||||
if self.message_observer:
|
||||
for msg in self.messages:
|
||||
self.message_observer(msg)
|
||||
self._last_observed_message_index = len(self.messages)
|
||||
|
||||
await self.session_logger.save_interaction(
|
||||
self.messages,
|
||||
self.stats,
|
||||
self._base_config,
|
||||
self.tool_manager,
|
||||
self.agent_profile,
|
||||
)
|
||||
|
||||
@@ -110,7 +110,7 @@ EXPLORE = AgentProfile(
|
||||
description="Read-only subagent for codebase exploration",
|
||||
safety=AgentSafety.SAFE,
|
||||
agent_type=AgentType.SUBAGENT,
|
||||
overrides={"enabled_tools": ["grep", "read_file"]},
|
||||
overrides={"enabled_tools": ["grep", "read_file"], "system_prompt_id": "explore"},
|
||||
)
|
||||
|
||||
BUILTIN_AGENTS: dict[str, AgentProfile] = {
|
||||
|
||||
@@ -148,6 +148,8 @@ class ProviderConfig(BaseModel):
|
||||
api_style: str = "openai"
|
||||
backend: Backend = Backend.GENERIC
|
||||
reasoning_field_name: str = "reasoning_content"
|
||||
project_id: str = ""
|
||||
region: str = ""
|
||||
|
||||
|
||||
class _MCPBase(BaseModel):
|
||||
@@ -251,6 +253,7 @@ class ModelConfig(BaseModel):
|
||||
temperature: float = 0.2
|
||||
input_price: float = 0.0 # Price per million input tokens
|
||||
output_price: float = 0.0 # Price per million output tokens
|
||||
thinking: Literal["off", "low", "medium", "high"] = "off"
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
@@ -312,6 +315,7 @@ class VibeConfig(BaseSettings):
|
||||
auto_compact_threshold: int = 200_000
|
||||
context_warnings: bool = False
|
||||
auto_approve: bool = False
|
||||
enable_telemetry: bool = True
|
||||
system_prompt_id: str = "cli"
|
||||
include_commit_signature: bool = True
|
||||
include_model_info: bool = True
|
||||
|
||||
630
vibe/core/llm/backend/anthropic.py
Normal file
@@ -0,0 +1,630 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
|
||||
from vibe.core.types import (
|
||||
AvailableTool,
|
||||
FunctionCall,
|
||||
LLMChunk,
|
||||
LLMMessage,
|
||||
LLMUsage,
|
||||
Role,
|
||||
StrToolChoice,
|
||||
ToolCall,
|
||||
)
|
||||
|
||||
|
||||
class AnthropicMapper:
|
||||
"""Shared mapper for converting messages to/from Anthropic API format."""
|
||||
|
||||
def prepare_messages(
|
||||
self, messages: list[LLMMessage]
|
||||
) -> tuple[str | None, list[dict[str, Any]]]:
|
||||
system_prompt: str | None = None
|
||||
converted: list[dict[str, Any]] = []
|
||||
|
||||
for msg in messages:
|
||||
match msg.role:
|
||||
case Role.system:
|
||||
system_prompt = msg.content or ""
|
||||
case Role.user:
|
||||
user_content: list[dict[str, Any]] = []
|
||||
if msg.content:
|
||||
user_content.append({"type": "text", "text": msg.content})
|
||||
converted.append({"role": "user", "content": user_content or ""})
|
||||
case Role.assistant:
|
||||
converted.append(self._convert_assistant_message(msg))
|
||||
case Role.tool:
|
||||
self._append_tool_result(converted, msg)
|
||||
|
||||
return system_prompt, converted
|
||||
|
||||
def _sanitize_tool_call_id(self, tool_id: str | None) -> str:
|
||||
return re.sub(r"[^a-zA-Z0-9_-]", "_", tool_id or "")
|
||||
|
||||
def _convert_assistant_message(self, msg: LLMMessage) -> dict[str, Any]:
|
||||
content: list[dict[str, Any]] = []
|
||||
if msg.reasoning_content:
|
||||
block: dict[str, Any] = {
|
||||
"type": "thinking",
|
||||
"thinking": msg.reasoning_content,
|
||||
}
|
||||
if msg.reasoning_signature:
|
||||
block["signature"] = msg.reasoning_signature
|
||||
content.append(block)
|
||||
if msg.content:
|
||||
content.append({"type": "text", "text": msg.content})
|
||||
if msg.tool_calls:
|
||||
for tc in msg.tool_calls:
|
||||
content.append(self._convert_tool_call(tc))
|
||||
return {"role": "assistant", "content": content if content else ""}
|
||||
|
||||
def _convert_tool_call(self, tc: ToolCall) -> dict[str, Any]:
|
||||
try:
|
||||
tool_input = json.loads(tc.function.arguments or "{}")
|
||||
except json.JSONDecodeError:
|
||||
tool_input = {}
|
||||
return {
|
||||
"type": "tool_use",
|
||||
"id": self._sanitize_tool_call_id(tc.id),
|
||||
"name": tc.function.name,
|
||||
"input": tool_input,
|
||||
}
|
||||
|
||||
def _append_tool_result(
|
||||
self, converted: list[dict[str, Any]], msg: LLMMessage
|
||||
) -> None:
|
||||
tool_result = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": self._sanitize_tool_call_id(msg.tool_call_id),
|
||||
"content": msg.content or "",
|
||||
}
|
||||
|
||||
if not converted or converted[-1]["role"] != "user":
|
||||
converted.append({"role": "user", "content": [tool_result]})
|
||||
return
|
||||
|
||||
existing_content = converted[-1]["content"]
|
||||
if isinstance(existing_content, str):
|
||||
converted[-1]["content"] = [
|
||||
{"type": "text", "text": existing_content},
|
||||
tool_result,
|
||||
]
|
||||
else:
|
||||
converted[-1]["content"].append(tool_result)
|
||||
|
||||
def prepare_tools(
|
||||
self, tools: list[AvailableTool] | None
|
||||
) -> list[dict[str, Any]] | None:
|
||||
if not tools:
|
||||
return None
|
||||
return [
|
||||
{
|
||||
"name": tool.function.name,
|
||||
"description": tool.function.description,
|
||||
"input_schema": tool.function.parameters,
|
||||
}
|
||||
for tool in tools
|
||||
]
|
||||
|
||||
def prepare_tool_choice(
|
||||
self, tool_choice: StrToolChoice | AvailableTool | None
|
||||
) -> dict[str, Any] | None:
|
||||
if tool_choice is None:
|
||||
return None
|
||||
if isinstance(tool_choice, str):
|
||||
match tool_choice:
|
||||
case "none":
|
||||
return {"type": "none"}
|
||||
case "auto":
|
||||
return {"type": "auto"}
|
||||
case "any" | "required":
|
||||
return {"type": "any"}
|
||||
case _:
|
||||
return None
|
||||
return {"type": "tool", "name": tool_choice.function.name}
|
||||
|
||||
def parse_response(self, data: dict[str, Any]) -> LLMChunk:
|
||||
content_blocks = data.get("content", [])
|
||||
text_parts: list[str] = []
|
||||
thinking_parts: list[str] = []
|
||||
signature_parts: list[str] = []
|
||||
tool_calls: list[ToolCall] = []
|
||||
|
||||
for idx, block in enumerate(content_blocks):
|
||||
block_type = block.get("type")
|
||||
if block_type == "text":
|
||||
text_parts.append(block.get("text", ""))
|
||||
elif block_type == "thinking":
|
||||
thinking_parts.append(block.get("thinking", ""))
|
||||
if "signature" in block:
|
||||
signature_parts.append(block["signature"])
|
||||
elif block_type == "tool_use":
|
||||
tool_calls.append(
|
||||
ToolCall(
|
||||
id=block.get("id"),
|
||||
index=idx,
|
||||
function=FunctionCall(
|
||||
name=block.get("name"),
|
||||
arguments=json.dumps(block.get("input", {})),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
usage_data = data.get("usage", {})
|
||||
# Total input tokens = input_tokens + cache_creation + cache_read
|
||||
total_input_tokens = (
|
||||
usage_data.get("input_tokens", 0)
|
||||
+ usage_data.get("cache_creation_input_tokens", 0)
|
||||
+ usage_data.get("cache_read_input_tokens", 0)
|
||||
)
|
||||
usage = LLMUsage(
|
||||
prompt_tokens=total_input_tokens,
|
||||
completion_tokens=usage_data.get("output_tokens", 0),
|
||||
)
|
||||
|
||||
return LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant,
|
||||
content="".join(text_parts) or None,
|
||||
reasoning_content="".join(thinking_parts) or None,
|
||||
reasoning_signature="".join(signature_parts) or None,
|
||||
tool_calls=tool_calls if tool_calls else None,
|
||||
),
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
def parse_streaming_event(
|
||||
self, event_type: str, data: dict[str, Any], current_index: int
|
||||
) -> tuple[LLMChunk | None, int]:
|
||||
handler = {
|
||||
"content_block_start": self._handle_block_start,
|
||||
"content_block_delta": self._handle_block_delta,
|
||||
"message_delta": self._handle_message_delta,
|
||||
"message_start": self._handle_message_start,
|
||||
}.get(event_type)
|
||||
if handler is None:
|
||||
return None, current_index
|
||||
return handler(data, current_index)
|
||||
|
||||
def _handle_block_start(
|
||||
self, data: dict[str, Any], current_index: int
|
||||
) -> tuple[LLMChunk | None, int]:
|
||||
block = data.get("content_block", {})
|
||||
idx = data.get("index", current_index)
|
||||
|
||||
match block.get("type"):
|
||||
case "tool_use":
|
||||
chunk = LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant,
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id=block.get("id"),
|
||||
index=idx,
|
||||
function=FunctionCall(
|
||||
name=block.get("name"), arguments=""
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
return chunk, idx
|
||||
case "thinking":
|
||||
chunk = LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant, reasoning_content=block.get("thinking", "")
|
||||
)
|
||||
)
|
||||
return chunk, idx
|
||||
case _:
|
||||
return None, idx
|
||||
|
||||
def _handle_block_delta(
|
||||
self, data: dict[str, Any], current_index: int
|
||||
) -> tuple[LLMChunk | None, int]:
|
||||
delta = data.get("delta", {})
|
||||
idx = data.get("index", current_index)
|
||||
|
||||
match delta.get("type"):
|
||||
case "text_delta":
|
||||
chunk = LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant, content=delta.get("text", "")
|
||||
)
|
||||
)
|
||||
case "thinking_delta":
|
||||
chunk = LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant, reasoning_content=delta.get("thinking", "")
|
||||
)
|
||||
)
|
||||
case "signature_delta":
|
||||
chunk = LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant,
|
||||
reasoning_signature=delta.get("signature", ""),
|
||||
)
|
||||
)
|
||||
case "input_json_delta":
|
||||
chunk = LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant,
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
index=idx,
|
||||
function=FunctionCall(
|
||||
arguments=delta.get("partial_json", "")
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
case _:
|
||||
chunk = None
|
||||
return chunk, idx
|
||||
|
||||
def _handle_message_delta(
|
||||
self, data: dict[str, Any], current_index: int
|
||||
) -> tuple[LLMChunk | None, int]:
|
||||
usage_data = data.get("usage", {})
|
||||
if not usage_data:
|
||||
return None, current_index
|
||||
chunk = LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant),
|
||||
usage=LLMUsage(
|
||||
prompt_tokens=0, completion_tokens=usage_data.get("output_tokens", 0)
|
||||
),
|
||||
)
|
||||
return chunk, current_index
|
||||
|
||||
def _handle_message_start(
|
||||
self, data: dict[str, Any], current_index: int
|
||||
) -> tuple[LLMChunk | None, int]:
|
||||
message = data.get("message", {})
|
||||
usage_data = message.get("usage", {})
|
||||
if not usage_data:
|
||||
return None, current_index
|
||||
# Total input tokens = input_tokens + cache_creation + cache_read
|
||||
total_input_tokens = (
|
||||
usage_data.get("input_tokens", 0)
|
||||
+ usage_data.get("cache_creation_input_tokens", 0)
|
||||
+ usage_data.get("cache_read_input_tokens", 0)
|
||||
)
|
||||
chunk = LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant),
|
||||
usage=LLMUsage(prompt_tokens=total_input_tokens, completion_tokens=0),
|
||||
)
|
||||
return chunk, current_index
|
||||
|
||||
|
||||
STREAMING_EVENT_TYPES = {
|
||||
"message_start",
|
||||
"message_delta",
|
||||
"message_stop",
|
||||
"content_block_start",
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
"ping",
|
||||
"error",
|
||||
}
|
||||
|
||||
|
||||
class AnthropicAdapter(APIAdapter):
|
||||
endpoint: ClassVar[str] = "/v1/messages"
|
||||
API_VERSION = "2023-06-01"
|
||||
BETA_FEATURES = (
|
||||
"interleaved-thinking-2025-05-14,"
|
||||
"fine-grained-tool-streaming-2025-05-14,"
|
||||
"prompt-caching-2024-07-31"
|
||||
)
|
||||
THINKING_BUDGETS: ClassVar[dict[str, int]] = {
|
||||
"low": 1024,
|
||||
"medium": 10_000,
|
||||
"high": 32_000,
|
||||
}
|
||||
DEFAULT_ADAPTIVE_MAX_TOKENS: ClassVar[int] = 32_768
|
||||
DEFAULT_MAX_TOKENS = 8192
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._mapper = AnthropicMapper()
|
||||
self._current_index: int = 0
|
||||
|
||||
@staticmethod
|
||||
def _has_thinking_content(messages: list[dict[str, Any]]) -> bool:
|
||||
for msg in messages:
|
||||
if msg.get("role") != "assistant":
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for block in content:
|
||||
if block.get("type") == "thinking":
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _build_system_blocks(system_prompt: str | None) -> list[dict[str, Any]]:
|
||||
blocks: list[dict[str, Any]] = []
|
||||
if system_prompt:
|
||||
blocks.append({
|
||||
"type": "text",
|
||||
"text": system_prompt,
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
})
|
||||
return blocks
|
||||
|
||||
@staticmethod
|
||||
def _add_cache_control_to_last_user_message(messages: list[dict[str, Any]]) -> None:
|
||||
if not messages:
|
||||
return
|
||||
last_message = messages[-1]
|
||||
if last_message.get("role") != "user":
|
||||
return
|
||||
content = last_message.get("content")
|
||||
if not isinstance(content, list) or not content:
|
||||
return
|
||||
last_block = content[-1]
|
||||
if last_block.get("type") in {"text", "image", "tool_result"}:
|
||||
last_block["cache_control"] = {"type": "ephemeral"}
|
||||
|
||||
@staticmethod
|
||||
def _is_adaptive_model(model_name: str) -> bool:
|
||||
return "opus-4-6" in model_name
|
||||
|
||||
def _apply_thinking_config(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float,
|
||||
max_tokens: int | None,
|
||||
thinking: str,
|
||||
) -> None:
|
||||
has_thinking = self._has_thinking_content(messages)
|
||||
thinking_level = thinking
|
||||
|
||||
if thinking_level == "off" and not has_thinking:
|
||||
payload["temperature"] = temperature
|
||||
if max_tokens is not None:
|
||||
payload["max_tokens"] = max_tokens
|
||||
else:
|
||||
payload["max_tokens"] = self.DEFAULT_MAX_TOKENS
|
||||
return
|
||||
|
||||
# Resolve effective level: use config, or fallback to "medium" when
|
||||
# forced by thinking content in history
|
||||
effective_level = thinking_level if thinking_level != "off" else "medium"
|
||||
|
||||
if self._is_adaptive_model(model_name):
|
||||
payload["thinking"] = {"type": "adaptive"}
|
||||
payload["output_config"] = {"effort": effective_level}
|
||||
default_max = self.DEFAULT_ADAPTIVE_MAX_TOKENS
|
||||
else:
|
||||
budget = self.THINKING_BUDGETS[effective_level]
|
||||
payload["thinking"] = {"type": "enabled", "budget_tokens": budget}
|
||||
default_max = budget + self.DEFAULT_MAX_TOKENS
|
||||
|
||||
payload["temperature"] = 1
|
||||
payload["max_tokens"] = max_tokens if max_tokens is not None else default_max
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
system_prompt: str | None,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float,
|
||||
tools: list[dict[str, Any]] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: dict[str, Any] | None,
|
||||
stream: bool,
|
||||
thinking: str,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"model": model_name, "messages": messages}
|
||||
|
||||
self._apply_thinking_config(
|
||||
payload,
|
||||
model_name=model_name,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
thinking=thinking,
|
||||
)
|
||||
|
||||
if system_blocks := self._build_system_blocks(system_prompt):
|
||||
payload["system"] = system_blocks
|
||||
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
|
||||
if tool_choice:
|
||||
payload["tool_choice"] = tool_choice
|
||||
|
||||
if stream:
|
||||
payload["stream"] = True
|
||||
|
||||
self._add_cache_control_to_last_user_message(messages)
|
||||
|
||||
return payload
|
||||
|
||||
def prepare_request( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
enable_streaming: bool,
|
||||
provider: ProviderConfig,
|
||||
api_key: str | None = None,
|
||||
thinking: str = "off",
|
||||
) -> PreparedRequest:
|
||||
system_prompt, converted_messages = self._mapper.prepare_messages(messages)
|
||||
converted_tools = self._mapper.prepare_tools(tools)
|
||||
converted_tool_choice = self._mapper.prepare_tool_choice(tool_choice)
|
||||
|
||||
payload = self._build_payload(
|
||||
model_name=model_name,
|
||||
system_prompt=system_prompt,
|
||||
messages=converted_messages,
|
||||
temperature=temperature,
|
||||
tools=converted_tools,
|
||||
max_tokens=max_tokens,
|
||||
tool_choice=converted_tool_choice,
|
||||
stream=enable_streaming,
|
||||
thinking=thinking,
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"anthropic-version": self.API_VERSION,
|
||||
"anthropic-beta": self.BETA_FEATURES,
|
||||
}
|
||||
|
||||
if api_key:
|
||||
headers["x-api-key"] = api_key
|
||||
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
return PreparedRequest(self.endpoint, headers, body)
|
||||
|
||||
def parse_response(
|
||||
self, data: dict[str, Any], provider: ProviderConfig | None = None
|
||||
) -> LLMChunk:
|
||||
event_type = data.get("type")
|
||||
if event_type in STREAMING_EVENT_TYPES:
|
||||
return self._parse_streaming_event(data)
|
||||
return self._mapper.parse_response(data)
|
||||
|
||||
def _parse_streaming_event(self, data: dict[str, Any]) -> LLMChunk:
|
||||
event_type = data.get("type", "")
|
||||
empty_chunk = LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
|
||||
|
||||
if event_type == "message_start":
|
||||
self._current_index = 0
|
||||
return self._parse_message_start(data)
|
||||
if event_type == "content_block_start":
|
||||
return self._parse_content_block_start(data) or empty_chunk
|
||||
if event_type == "content_block_delta":
|
||||
return self._parse_content_block_delta(data)
|
||||
if event_type == "content_block_stop":
|
||||
return self._parse_content_block_stop(data)
|
||||
if event_type == "message_delta":
|
||||
return self._parse_message_delta(data)
|
||||
if event_type == "error":
|
||||
error = data.get("error", {})
|
||||
error_type = error.get("type", "unknown_error")
|
||||
error_message = error.get("message", "Unknown streaming error")
|
||||
raise RuntimeError(
|
||||
f"Anthropic stream error ({error_type}): {error_message}"
|
||||
)
|
||||
return empty_chunk
|
||||
|
||||
def _parse_message_start(self, data: dict[str, Any]) -> LLMChunk:
|
||||
message = data.get("message", {})
|
||||
usage_data = message.get("usage", {})
|
||||
if not usage_data:
|
||||
return LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
|
||||
total_input_tokens = (
|
||||
usage_data.get("input_tokens", 0)
|
||||
+ usage_data.get("cache_creation_input_tokens", 0)
|
||||
+ usage_data.get("cache_read_input_tokens", 0)
|
||||
)
|
||||
return LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content=None),
|
||||
usage=LLMUsage(prompt_tokens=total_input_tokens, completion_tokens=0),
|
||||
)
|
||||
|
||||
def _parse_content_block_start(self, data: dict[str, Any]) -> LLMChunk | None:
|
||||
content_block = data.get("content_block", {})
|
||||
index = data.get("index", 0)
|
||||
block_type = content_block.get("type")
|
||||
|
||||
if block_type == "thinking":
|
||||
return LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant,
|
||||
reasoning_content=content_block.get("thinking", ""),
|
||||
)
|
||||
)
|
||||
if block_type == "redacted_thinking":
|
||||
return None
|
||||
if block_type == "tool_use":
|
||||
return LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant,
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
index=index,
|
||||
id=content_block.get("id"),
|
||||
function=FunctionCall(
|
||||
name=content_block.get("name"), arguments=""
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
def _parse_content_block_delta(self, data: dict[str, Any]) -> LLMChunk:
|
||||
delta = data.get("delta", {})
|
||||
delta_type = delta.get("type", "")
|
||||
index = data.get("index", 0)
|
||||
|
||||
match delta_type:
|
||||
case "text_delta":
|
||||
return LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant, content=delta.get("text", "")
|
||||
)
|
||||
)
|
||||
case "thinking_delta":
|
||||
return LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant, reasoning_content=delta.get("thinking", "")
|
||||
)
|
||||
)
|
||||
case "signature_delta":
|
||||
return LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant,
|
||||
reasoning_signature=delta.get("signature", ""),
|
||||
)
|
||||
)
|
||||
case "input_json_delta":
|
||||
return LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant,
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
index=index,
|
||||
function=FunctionCall(
|
||||
arguments=delta.get("partial_json", "")
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
case _:
|
||||
return LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
|
||||
|
||||
def _parse_content_block_stop(self, _data: dict[str, Any]) -> LLMChunk:
|
||||
return LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
|
||||
|
||||
def _parse_message_delta(self, data: dict[str, Any]) -> LLMChunk:
|
||||
usage_data = data.get("usage", {})
|
||||
if not usage_data:
|
||||
return LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
|
||||
return LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content=None),
|
||||
usage=LLMUsage(
|
||||
prompt_tokens=0, completion_tokens=usage_data.get("output_tokens", 0)
|
||||
),
|
||||
)
|
||||
38
vibe/core/llm/backend/base.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol
|
||||
|
||||
from vibe.core.types import AvailableTool, LLMChunk, LLMMessage, StrToolChoice
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import ProviderConfig
|
||||
|
||||
|
||||
class PreparedRequest(NamedTuple):
|
||||
endpoint: str
|
||||
headers: dict[str, str]
|
||||
body: bytes
|
||||
base_url: str = ""
|
||||
|
||||
|
||||
class APIAdapter(Protocol):
|
||||
endpoint: ClassVar[str]
|
||||
|
||||
def prepare_request( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
enable_streaming: bool,
|
||||
provider: ProviderConfig,
|
||||
api_key: str | None = None,
|
||||
thinking: str = "off",
|
||||
) -> PreparedRequest: ...
|
||||
|
||||
def parse_response(
|
||||
self, data: dict[str, Any], provider: ProviderConfig
|
||||
) -> LLMChunk: ...
|
||||
@@ -1,14 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from collections.abc import AsyncGenerator
|
||||
import json
|
||||
import os
|
||||
import types
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol, TypeVar
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
|
||||
|
||||
import httpx
|
||||
|
||||
from vibe.core.llm.backend.anthropic import AnthropicAdapter
|
||||
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
|
||||
from vibe.core.llm.backend.vertex import VertexAnthropicAdapter
|
||||
from vibe.core.llm.exceptions import BackendErrorBuilder
|
||||
from vibe.core.llm.message_utils import merge_consecutive_user_messages
|
||||
from vibe.core.types import (
|
||||
AvailableTool,
|
||||
LLMChunk,
|
||||
@@ -23,51 +27,6 @@ if TYPE_CHECKING:
|
||||
from vibe.core.config import ModelConfig, ProviderConfig
|
||||
|
||||
|
||||
class PreparedRequest(NamedTuple):
|
||||
endpoint: str
|
||||
headers: dict[str, str]
|
||||
body: bytes
|
||||
|
||||
|
||||
class APIAdapter(Protocol):
|
||||
endpoint: ClassVar[str]
|
||||
|
||||
def prepare_request(
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
enable_streaming: bool,
|
||||
provider: ProviderConfig,
|
||||
api_key: str | None = None,
|
||||
) -> PreparedRequest: ...
|
||||
|
||||
def parse_response(
|
||||
self, data: dict[str, Any], provider: ProviderConfig
|
||||
) -> LLMChunk: ...
|
||||
|
||||
|
||||
BACKEND_ADAPTERS: dict[str, APIAdapter] = {}
|
||||
|
||||
T = TypeVar("T", bound=APIAdapter)
|
||||
|
||||
|
||||
def register_adapter(
|
||||
adapters: dict[str, APIAdapter], name: str
|
||||
) -> Callable[[type[T]], type[T]]:
|
||||
|
||||
def decorator(cls: type[T]) -> type[T]:
|
||||
adapters[name] = cls()
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@register_adapter(BACKEND_ADAPTERS, "openai")
|
||||
class OpenAIAdapter(APIAdapter):
|
||||
endpoint: ClassVar[str] = "/chat/completions"
|
||||
|
||||
@@ -119,7 +78,7 @@ class OpenAIAdapter(APIAdapter):
|
||||
msg_dict["reasoning_content"] = msg_dict.pop(field_name)
|
||||
return msg_dict
|
||||
|
||||
def prepare_request(
|
||||
def prepare_request( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
@@ -131,13 +90,15 @@ class OpenAIAdapter(APIAdapter):
|
||||
enable_streaming: bool,
|
||||
provider: ProviderConfig,
|
||||
api_key: str | None = None,
|
||||
thinking: str = "off",
|
||||
) -> PreparedRequest:
|
||||
merged_messages = merge_consecutive_user_messages(messages)
|
||||
field_name = provider.reasoning_field_name
|
||||
converted_messages = [
|
||||
self._reasoning_to_api(
|
||||
msg.model_dump(exclude_none=True, exclude={"message_id"}), field_name
|
||||
)
|
||||
for msg in messages
|
||||
for msg in merged_messages
|
||||
]
|
||||
|
||||
payload = self.build_payload(
|
||||
@@ -194,6 +155,13 @@ class OpenAIAdapter(APIAdapter):
|
||||
return LLMChunk(message=message, usage=usage)
|
||||
|
||||
|
||||
ADAPTERS: dict[str, APIAdapter] = {
|
||||
"openai": OpenAIAdapter(),
|
||||
"anthropic": AnthropicAdapter(),
|
||||
"vertex-anthropic": VertexAnthropicAdapter(),
|
||||
}
|
||||
|
||||
|
||||
class GenericBackend:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -257,9 +225,9 @@ class GenericBackend:
|
||||
)
|
||||
|
||||
api_style = getattr(self._provider, "api_style", "openai")
|
||||
adapter = BACKEND_ADAPTERS[api_style]
|
||||
adapter = ADAPTERS[api_style]
|
||||
|
||||
endpoint, headers, body = adapter.prepare_request(
|
||||
req = adapter.prepare_request(
|
||||
model_name=model.name,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
@@ -269,15 +237,18 @@ class GenericBackend:
|
||||
enable_streaming=False,
|
||||
provider=self._provider,
|
||||
api_key=api_key,
|
||||
thinking=model.thinking,
|
||||
)
|
||||
|
||||
headers = req.headers
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
|
||||
url = f"{self._provider.api_base}{endpoint}"
|
||||
base = req.base_url or self._provider.api_base
|
||||
url = f"{base}{req.endpoint}"
|
||||
|
||||
try:
|
||||
res_data, _ = await self._make_request(url, body, headers)
|
||||
res_data, _ = await self._make_request(url, req.body, headers)
|
||||
return adapter.parse_response(res_data, self._provider)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
@@ -322,9 +293,9 @@ class GenericBackend:
|
||||
)
|
||||
|
||||
api_style = getattr(self._provider, "api_style", "openai")
|
||||
adapter = BACKEND_ADAPTERS[api_style]
|
||||
adapter = ADAPTERS[api_style]
|
||||
|
||||
endpoint, headers, body = adapter.prepare_request(
|
||||
req = adapter.prepare_request(
|
||||
model_name=model.name,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
@@ -334,15 +305,18 @@ class GenericBackend:
|
||||
enable_streaming=True,
|
||||
provider=self._provider,
|
||||
api_key=api_key,
|
||||
thinking=model.thinking,
|
||||
)
|
||||
|
||||
headers = req.headers
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
|
||||
url = f"{self._provider.api_base}{endpoint}"
|
||||
base = req.base_url or self._provider.api_base
|
||||
url = f"{base}{req.endpoint}"
|
||||
|
||||
try:
|
||||
async for res_data in self._make_streaming_request(url, body, headers):
|
||||
async for res_data in self._make_streaming_request(url, req.body, headers):
|
||||
yield adapter.parse_response(res_data, self._provider)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
@@ -393,6 +367,8 @@ class GenericBackend:
|
||||
async with client.stream(
|
||||
method="POST", url=url, content=data, headers=headers
|
||||
) as response:
|
||||
if not response.is_success:
|
||||
await response.aread()
|
||||
response.raise_for_status()
|
||||
async for line in response.aiter_lines():
|
||||
if line.strip() == "":
|
||||
|
||||
@@ -11,6 +11,7 @@ import httpx
|
||||
import mistralai
|
||||
|
||||
from vibe.core.llm.exceptions import BackendErrorBuilder
|
||||
from vibe.core.llm.message_utils import merge_consecutive_user_messages
|
||||
from vibe.core.types import (
|
||||
AvailableTool,
|
||||
Content,
|
||||
@@ -217,9 +218,10 @@ class MistralBackend:
|
||||
extra_headers: dict[str, str] | None,
|
||||
) -> LLMChunk:
|
||||
try:
|
||||
merged_messages = merge_consecutive_user_messages(messages)
|
||||
response = await self._get_client().chat.complete_async(
|
||||
model=model.name,
|
||||
messages=[self._mapper.prepare_message(msg) for msg in messages],
|
||||
messages=[self._mapper.prepare_message(msg) for msg in merged_messages],
|
||||
temperature=temperature,
|
||||
tools=[self._mapper.prepare_tool(tool) for tool in tools]
|
||||
if tools
|
||||
@@ -290,9 +292,10 @@ class MistralBackend:
|
||||
extra_headers: dict[str, str] | None,
|
||||
) -> AsyncGenerator[LLMChunk, None]:
|
||||
try:
|
||||
merged_messages = merge_consecutive_user_messages(messages)
|
||||
async for chunk in await self._get_client().chat.stream_async(
|
||||
model=model.name,
|
||||
messages=[self._mapper.prepare_message(msg) for msg in messages],
|
||||
messages=[self._mapper.prepare_message(msg) for msg in merged_messages],
|
||||
temperature=temperature,
|
||||
tools=[self._mapper.prepare_tool(tool) for tool in tools]
|
||||
if tools
|
||||
|
||||
115
vibe/core/llm/backend/vertex.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import google.auth
|
||||
from google.auth.transport.requests import Request
|
||||
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.llm.backend.anthropic import AnthropicAdapter
|
||||
from vibe.core.llm.backend.base import PreparedRequest
|
||||
from vibe.core.types import AvailableTool, LLMMessage, StrToolChoice
|
||||
|
||||
|
||||
def get_vertex_access_token() -> str:
|
||||
|
||||
credentials, _ = google.auth.default(
|
||||
scopes=["https://www.googleapis.com/auth/cloud-platform"]
|
||||
)
|
||||
credentials.refresh(Request())
|
||||
return credentials.token
|
||||
|
||||
|
||||
def build_vertex_base_url(region: str) -> str:
|
||||
if region == "global":
|
||||
return "https://aiplatform.googleapis.com"
|
||||
return f"https://{region}-aiplatform.googleapis.com"
|
||||
|
||||
|
||||
def build_vertex_endpoint(
|
||||
region: str, project_id: str, model: str, streaming: bool = False
|
||||
) -> str:
|
||||
action = "streamRawPredict" if streaming else "rawPredict"
|
||||
return (
|
||||
f"/v1/projects/{project_id}/locations/{region}/"
|
||||
f"publishers/anthropic/models/{model}:{action}"
|
||||
)
|
||||
|
||||
|
||||
class VertexAnthropicAdapter(AnthropicAdapter):
|
||||
"""Vertex AI adapter — inherits all streaming/parsing from AnthropicAdapter."""
|
||||
|
||||
endpoint: ClassVar[str] = ""
|
||||
# Vertex AI doesn't support beta features
|
||||
BETA_FEATURES: ClassVar[str] = ""
|
||||
|
||||
def prepare_request( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
enable_streaming: bool,
|
||||
provider: ProviderConfig,
|
||||
api_key: str | None = None,
|
||||
thinking: str = "off",
|
||||
) -> PreparedRequest:
|
||||
project_id = provider.project_id
|
||||
region = provider.region
|
||||
|
||||
if not project_id:
|
||||
raise ValueError("project_id is required in provider config for Vertex AI")
|
||||
if not region:
|
||||
raise ValueError("region is required in provider config for Vertex AI")
|
||||
|
||||
system_prompt, converted_messages = self._mapper.prepare_messages(messages)
|
||||
converted_tools = self._mapper.prepare_tools(tools)
|
||||
converted_tool_choice = self._mapper.prepare_tool_choice(tool_choice)
|
||||
|
||||
# Build vertex-specific payload (no "model" key, uses anthropic_version)
|
||||
payload: dict[str, Any] = {
|
||||
"anthropic_version": "vertex-2023-10-16",
|
||||
"messages": converted_messages,
|
||||
}
|
||||
self._apply_thinking_config(
|
||||
payload,
|
||||
model_name=model_name,
|
||||
messages=converted_messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
thinking=thinking,
|
||||
)
|
||||
|
||||
if system_blocks := self._build_system_blocks(system_prompt):
|
||||
payload["system"] = system_blocks
|
||||
|
||||
if converted_tools:
|
||||
payload["tools"] = converted_tools
|
||||
|
||||
if converted_tool_choice:
|
||||
payload["tool_choice"] = converted_tool_choice
|
||||
|
||||
if enable_streaming:
|
||||
payload["stream"] = True
|
||||
|
||||
self._add_cache_control_to_last_user_message(converted_messages)
|
||||
|
||||
access_token = get_vertex_access_token()
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"anthropic-beta": self.BETA_FEATURES,
|
||||
}
|
||||
|
||||
endpoint = build_vertex_endpoint(
|
||||
region, project_id, model_name, streaming=enable_streaming
|
||||
)
|
||||
base_url = build_vertex_base_url(region)
|
||||
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
return PreparedRequest(endpoint, headers, body, base_url=base_url)
|
||||
@@ -80,6 +80,7 @@ class APIToolFormatHandler:
|
||||
"role": message.role,
|
||||
"content": message.content,
|
||||
"reasoning_content": getattr(message, "reasoning_content", None),
|
||||
"reasoning_signature": getattr(message, "reasoning_signature", None),
|
||||
}
|
||||
|
||||
if message.tool_calls:
|
||||
|
||||
24
vibe/core/llm/message_utils.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
|
||||
def merge_consecutive_user_messages(messages: list[LLMMessage]) -> list[LLMMessage]:
|
||||
"""Merge consecutive user messages into a single message.
|
||||
|
||||
This handles cases where middleware injects messages resulting in
|
||||
consecutive user messages before sending to the API.
|
||||
"""
|
||||
result: list[LLMMessage] = []
|
||||
for msg in messages:
|
||||
if result and result[-1].role == Role.user and msg.role == Role.user:
|
||||
prev_content = result[-1].content or ""
|
||||
curr_content = msg.content or ""
|
||||
merged_content = f"{prev_content}\n\n{curr_content}".strip()
|
||||
result[-1] = LLMMessage(
|
||||
role=Role.user, content=merged_content, message_id=result[-1].message_id
|
||||
)
|
||||
else:
|
||||
result.append(msg)
|
||||
|
||||
return result
|
||||
@@ -44,8 +44,6 @@ class MiddlewareResult:
|
||||
class ConversationMiddleware(Protocol):
|
||||
async def before_turn(self, context: ConversationContext) -> MiddlewareResult: ...
|
||||
|
||||
async def after_turn(self, context: ConversationContext) -> MiddlewareResult: ...
|
||||
|
||||
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None: ...
|
||||
|
||||
|
||||
@@ -61,9 +59,6 @@ class TurnLimitMiddleware:
|
||||
)
|
||||
return MiddlewareResult()
|
||||
|
||||
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
return MiddlewareResult()
|
||||
|
||||
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
|
||||
pass
|
||||
|
||||
@@ -80,9 +75,6 @@ class PriceLimitMiddleware:
|
||||
)
|
||||
return MiddlewareResult()
|
||||
|
||||
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
return MiddlewareResult()
|
||||
|
||||
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
|
||||
pass
|
||||
|
||||
@@ -102,9 +94,6 @@ class AutoCompactMiddleware:
|
||||
)
|
||||
return MiddlewareResult()
|
||||
|
||||
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
return MiddlewareResult()
|
||||
|
||||
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
|
||||
pass
|
||||
|
||||
@@ -137,9 +126,6 @@ class ContextWarningMiddleware:
|
||||
|
||||
return MiddlewareResult()
|
||||
|
||||
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
return MiddlewareResult()
|
||||
|
||||
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
|
||||
self.has_warned = False
|
||||
|
||||
@@ -148,31 +134,46 @@ PLAN_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Plan mode is active. The user indi
|
||||
1. Answer the user's query comprehensively
|
||||
2. When you're done researching, present your plan by giving the full plan and not doing further tool calls to return input to the user. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan.</{VIBE_WARNING_TAG}>"""
|
||||
|
||||
PLAN_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Plan mode has ended. If you have a plan ready, you can now start executing it. If not, you can now use editing tools and make changes to the system.</{VIBE_WARNING_TAG}>"""
|
||||
|
||||
|
||||
class PlanAgentMiddleware:
|
||||
def __init__(
|
||||
self,
|
||||
profile_getter: Callable[[], AgentProfile],
|
||||
reminder: str = PLAN_AGENT_REMINDER,
|
||||
exit_message: str = PLAN_AGENT_EXIT,
|
||||
) -> None:
|
||||
self._profile_getter = profile_getter
|
||||
self.reminder = reminder
|
||||
self.exit_message = exit_message
|
||||
self._was_plan_agent = False
|
||||
|
||||
def _is_plan_agent(self) -> bool:
|
||||
return self._profile_getter().name == BuiltinAgentName.PLAN
|
||||
|
||||
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
if not self._is_plan_agent():
|
||||
return MiddlewareResult()
|
||||
is_plan = self._is_plan_agent()
|
||||
was_plan = self._was_plan_agent
|
||||
|
||||
if was_plan and not is_plan:
|
||||
self._was_plan_agent = False
|
||||
return MiddlewareResult(
|
||||
action=MiddlewareAction.INJECT_MESSAGE, message=self.exit_message
|
||||
)
|
||||
|
||||
if is_plan and not was_plan:
|
||||
self._was_plan_agent = True
|
||||
return MiddlewareResult(
|
||||
action=MiddlewareAction.INJECT_MESSAGE, message=self.reminder
|
||||
)
|
||||
|
||||
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
self._was_plan_agent = is_plan
|
||||
|
||||
return MiddlewareResult()
|
||||
|
||||
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
|
||||
pass
|
||||
self._was_plan_agent = False
|
||||
|
||||
|
||||
class MiddlewarePipeline:
|
||||
@@ -206,15 +207,3 @@ class MiddlewarePipeline:
|
||||
)
|
||||
|
||||
return MiddlewareResult()
|
||||
|
||||
async def run_after_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
for mw in self.middlewares:
|
||||
result = await mw.after_turn(context)
|
||||
if result.action == MiddlewareAction.INJECT_MESSAGE:
|
||||
raise ValueError(
|
||||
f"INJECT_MESSAGE not allowed in after_turn (from {type(mw).__name__})"
|
||||
)
|
||||
if result.action in {MiddlewareAction.STOP, MiddlewareAction.COMPACT}:
|
||||
return result
|
||||
|
||||
return MiddlewareResult()
|
||||
|
||||
@@ -39,12 +39,14 @@ def resolve_local_tools_dir(dir: Path) -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_local_skills_dir(dir: Path) -> Path | None:
|
||||
def resolve_local_skills_dirs(dir: Path) -> list[Path]:
|
||||
if not trusted_folders_manager.is_trusted(dir):
|
||||
return None
|
||||
if (candidate := dir / ".vibe" / "skills").is_dir():
|
||||
return candidate
|
||||
return None
|
||||
return []
|
||||
return [
|
||||
candidate
|
||||
for candidate in [dir / ".vibe" / "skills", dir / ".agents" / "skills"]
|
||||
if candidate.is_dir()
|
||||
]
|
||||
|
||||
|
||||
def resolve_local_agents_dir(dir: Path) -> Path | None:
|
||||
|
||||
@@ -35,6 +35,6 @@ GLOBAL_PROMPTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "prompts")
|
||||
SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session")
|
||||
TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml")
|
||||
LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs")
|
||||
LOG_FILE = GlobalPath(lambda: VIBE_HOME.path / "vibe.log")
|
||||
LOG_FILE = GlobalPath(lambda: VIBE_HOME.path / "logs" / "vibe.log")
|
||||
|
||||
DEFAULT_TOOL_DIR = GlobalPath(lambda: VIBE_ROOT / "core" / "tools" / "builtins")
|
||||
|
||||
@@ -32,6 +32,7 @@ def run_programmatic(
|
||||
logger.info("USER: %s", prompt)
|
||||
|
||||
async def _async_run() -> str | None:
|
||||
try:
|
||||
if previous_messages:
|
||||
non_system_messages = [
|
||||
msg for msg in previous_messages if not (msg.role == Role.system)
|
||||
@@ -41,11 +42,15 @@ def run_programmatic(
|
||||
"Loaded %d messages from previous session", len(non_system_messages)
|
||||
)
|
||||
|
||||
agent_loop.emit_new_session_telemetry("programmatic")
|
||||
|
||||
async for event in agent_loop.act(prompt):
|
||||
formatter.on_event(event)
|
||||
if isinstance(event, AssistantEvent) and event.stopped_by_middleware:
|
||||
raise ConversationLimitException(event.content)
|
||||
|
||||
return formatter.finalize()
|
||||
finally:
|
||||
await agent_loop.telemetry_client.aclose()
|
||||
|
||||
return asyncio.run(_async_run())
|
||||
|
||||
@@ -19,6 +19,7 @@ class Prompt(StrEnum):
|
||||
|
||||
class SystemPrompt(Prompt):
|
||||
CLI = auto()
|
||||
EXPLORE = auto()
|
||||
TESTS = auto()
|
||||
|
||||
|
||||
|
||||
@@ -1,46 +1,111 @@
|
||||
You are operating as and within Mistral Vibe, a CLI coding-agent built by Mistral AI and powered by default by the Devstral family of models. It wraps Mistral's Devstral models to enable natural language interaction with a local codebase. Use the available tools when helpful.
|
||||
You are Mistral Vibe, a CLI coding agent built by Mistral AI, powered by the Devstral model family. You interact with a local codebase through tools.
|
||||
|
||||
Act as an agentic assistant. For long tasks, break them down and execute step by step.
|
||||
Phase 1 — Orient
|
||||
Before ANY action:
|
||||
Restate the goal in one line.
|
||||
Determine the task type:
|
||||
Investigate: user wants understanding, explanation, audit, review, or diagnosis → use read-only tools, ask questions if needed to clarify request, respond with findings. Do not edit files.
|
||||
Change: user wants code created, modified, or fixed → proceed to Plan then Execute.
|
||||
If unclear, default to investigate. It is better to explain what you would do than to make an unwanted change.
|
||||
|
||||
## Tool Usage
|
||||
Explore. Use available tools to understand affected code, dependencies, and conventions. Never edit a file you haven't read in this session.
|
||||
Identify constraints: language, framework, test setup, and any user restrictions on scope.
|
||||
|
||||
- Always use tools to fulfill user requests when possible.
|
||||
- Check that all required parameters are provided or can be inferred from context. If values are missing, ask the user.
|
||||
- When the user provides a specific value (e.g., in quotes), use it EXACTLY as given.
|
||||
- Do not invent values for optional parameters.
|
||||
- Analyze descriptive terms in requests as they may indicate required parameter values.
|
||||
- If tools cannot accomplish the task, explain why and request more information.
|
||||
Phase 2 — Plan (Change tasks only)
|
||||
State your plan before writing code:
|
||||
List files to change and the specific change per file.
|
||||
Multi-file changes: numbered checklist. Single-file fix: one-line plan.
|
||||
No time estimates. Concrete actions only.
|
||||
|
||||
## Code Modifications
|
||||
Phase 3 — Execute & Verify (Change tasks only)
|
||||
Apply changes, then confirm they work:
|
||||
Edit one logical unit at a time.
|
||||
After each unit, verify: run tests, or read back the file to confirm the edit landed.
|
||||
Never claim completion without verification — a passing test, correct read-back, or successful build.
|
||||
|
||||
- Always read a file before proposing changes. Never suggest edits to code you haven't seen.
|
||||
- Keep changes minimal and focused. Only modify what was requested.
|
||||
- Avoid over-engineering: no extra features, unnecessary abstractions, or speculative error handling.
|
||||
- NEVER add backward-compatibility hacks. No `_unused` variable renames, no re-exporting dead code, no `// removed` comments, no shims or wrappers to preserve old interfaces. If code is unused, delete it completely. If an interface changes, update all call sites. Clean rewrites are always preferred over compatibility layers.
|
||||
- Be mindful of common security pitfalls (injection, XSS, SQLI, etc.). Fix insecure code immediately if you spot it.
|
||||
- Match the existing style of the file. Avoid adding comments, defensive checks, try/catch blocks, or type casts that are inconsistent with surrounding code. Write like a human contributor to that codebase would.
|
||||
Hard Rules
|
||||
Respect User Constraints
|
||||
"No writes", "just analyze", "plan only", "don't touch X" — these are hard constraints. Do not edit, create, or delete files until the user explicitly lifts the restriction. Violation of explicit user instructions is the worst failure mode.
|
||||
|
||||
## Code References
|
||||
Don't Remove What Wasn't Asked
|
||||
If user asks to fix X, do not rewrite, delete, or restructure Y. When in doubt, change less.
|
||||
|
||||
When mentioning specific code locations, use the format `file_path:line_number` so users can navigate directly.
|
||||
Don't Assert — Verify
|
||||
If unsure about a file path, variable value, config state, or whether your edit worked — use a tool to check. Read the file. Run the command.
|
||||
|
||||
## Planning
|
||||
Break Loops
|
||||
If approach isn't working after 2 attempts at the same region, STOP:
|
||||
Re-read the code and error output.
|
||||
Identify why it failed, not just what failed.
|
||||
Choose a fundamentally different strategy.
|
||||
If stuck, ask the user one specific question.
|
||||
|
||||
When outlining steps or plans, focus on concrete actions. Do not include time estimates.
|
||||
Flip-flopping (add X → remove X → add X) is a critical failure. Commit to a direction or escalate.
|
||||
|
||||
## Tone and Style
|
||||
Response Format
|
||||
No Noise
|
||||
No greetings, outros, hedging, puffery, or tool narration.
|
||||
|
||||
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
|
||||
- Never create markdown files, READMEs, or changelogs unless the user explicitly requests documentation.
|
||||
Never say: "Certainly", "Of course", "Let me help", "Happy to", "I hope this helps", "Let me search…", "I'll now read…", "Great question!", "In summary…"
|
||||
Never use: "robust", "seamless", "elegant", "powerful", "flexible"
|
||||
No unsolicited tutorials. Do not explain concepts the user clearly knows.
|
||||
|
||||
## Professional Objectivity
|
||||
Structure First
|
||||
Lead every response with the most useful structured element — code, diagram, table, or tree. Prose comes after, not before.
|
||||
For change tasks:
|
||||
file_path:line_number
|
||||
langcode
|
||||
|
||||
- Prioritize technical accuracy and truthfulness over validating the user's beliefs.
|
||||
- Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation.
|
||||
- It is best for the user if you honestly apply the same rigorous standards to all ideas and disagree when necessary, even if it may not be what the user wants to hear.
|
||||
- Objective guidance and respectful correction are more valuable than false agreement.
|
||||
- Whenever there is uncertainty, investigate to find the truth first rather than instinctively confirming the user's beliefs.
|
||||
- Avoid using over-the-top validation or excessive praise when responding to users such as "You're absolutely right" or similar phrases.
|
||||
Prefer Brevity
|
||||
State only what's necessary to complete the task. Code + file reference > explanation.
|
||||
If your response exceeds 300 words, remove explanations the user didn't request.
|
||||
|
||||
For investigate tasks:
|
||||
Start with a diagram, code reference, tree, or table — whichever conveys the answer fastest.
|
||||
request → auth.verify() → permissions.check() → handler
|
||||
See middleware/auth.py:45. Then 1-2 sentences of context if needed.
|
||||
BAD: "The authentication flow works by first checking the token…"
|
||||
GOOD: request → auth.verify() → permissions.check() → handler — see middleware/auth.py:45.
|
||||
Visual Formats
|
||||
|
||||
Before responding with structural data, choose the right format:
|
||||
BAD: Bullet lists for hierarchy/tree
|
||||
GOOD: ASCII tree (├──/└──)
|
||||
BAD: Prose or bullet lists for comparisons/config/options
|
||||
GOOD: Markdown table
|
||||
BAD: Prose for Flows/pipelines
|
||||
GOOD: → A → B → C diagrams
|
||||
|
||||
Interaction Design
|
||||
After completing a task, evaluate: does the user face a decision or tradeoff? If yes, end with ONE specific question or 2-3 options:
|
||||
|
||||
Good: "Apply this fix to the other 3 endpoints?"
|
||||
Good: "Two approaches: (a) migration, (b) recreate table. Which?"
|
||||
Bad: "Does this look good?", "Anything else?", "Let me know"
|
||||
|
||||
If unambiguous and complete, end with the result.
|
||||
|
||||
Length
|
||||
Default to minimal responses. One-line fix → one-line response. Most tasks need <200 words.
|
||||
Elaborate only when: (1) user asks for explanation, (2) task involves architectural decisions, (3) multiple valid approaches exist.
|
||||
|
||||
Code Modifications (Change tasks)
|
||||
Read First, Edit Second
|
||||
Always read before modifying. Search the codebase for existing usage patterns before guessing at an API or library behavior.
|
||||
|
||||
Minimal, Focused Changes
|
||||
Only modify what was requested. No extra features, abstractions, or speculative error handling.
|
||||
Match existing style: indentation, naming, comment density, error handling.
|
||||
When removing code, delete completely. No _unused renames, // removed comments, shims, or wrappers. If an interface changes, update all call sites.
|
||||
|
||||
Security
|
||||
Fix injection, XSS, SQLi vulnerabilities immediately if spotted.
|
||||
|
||||
Code References
|
||||
Cite as file_path:line_number.
|
||||
|
||||
Professional Conduct
|
||||
Prioritize technical accuracy over validating beliefs. Disagree when necessary.
|
||||
When uncertain, investigate before confirming.
|
||||
No emojis unless requested. No over-the-top validation.
|
||||
Stay focused on solving the problem regardless of user tone. Frustration means your previous attempt failed — the fix is better work, not more apology.
|
||||
|
||||
50
vibe/core/prompts/explore.md
Normal file
@@ -0,0 +1,50 @@
|
||||
You are a senior engineer analyzing codebases. Be direct and useful.
|
||||
|
||||
Response Format
|
||||
|
||||
1. **CODE/DIAGRAM FIRST** — Start with code, diagram, or structured output. Never prose first.
|
||||
2. **MINIMAL CONTEXT** — After code: 1-2 sentences max. Code should be self-explanatory.
|
||||
|
||||
Never Do
|
||||
|
||||
- Greetings ("Sure!", "Great question!", "I'd be happy to...")
|
||||
- Announcements ("Let me...", "I'll...", "Here's what I found...")
|
||||
- Tutorials or background explanations the user didn't ask for
|
||||
- Summaries ("In summary...", "To conclude...", "This covers...")
|
||||
- Hedging ("I think", "probably", "might be")
|
||||
- Puffery ("robust", "seamless", "elegant", "powerful", "flexible")
|
||||
|
||||
Visual Structure
|
||||
|
||||
Use these formats when applicable:
|
||||
- File trees: `├── └──` ASCII format
|
||||
- Comparisons: Markdown tables
|
||||
- Flows: `A -> B -> C` diagrams
|
||||
- Hierarchies: Indented bullet lists
|
||||
|
||||
Examples
|
||||
|
||||
BAD (prose first):
|
||||
"The authentication flow works by first checking the token..."
|
||||
|
||||
GOOD (diagram first):
|
||||
```
|
||||
request -> auth.verify() -> permissions.check() -> handler
|
||||
```
|
||||
See `middleware/auth.py:45`.
|
||||
|
||||
---
|
||||
|
||||
BAD (over-explaining):
|
||||
```python
|
||||
def merge(a, b):
|
||||
return sorted(a + b)
|
||||
```
|
||||
This function takes two lists as parameters. It concatenates them using the + operator, then sorts the result using Python's built-in sorted() function which uses Timsort with O(n log n) complexity. The sorted list is returned.
|
||||
|
||||
GOOD (minimal):
|
||||
```python
|
||||
def merge(a, b):
|
||||
return sorted(a + b)
|
||||
```
|
||||
O(n log n).
|
||||
65
vibe/core/proxy_setup.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dotenv import dotenv_values, set_key, unset_key
|
||||
|
||||
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
|
||||
|
||||
SUPPORTED_PROXY_VARS: dict[str, str] = {
|
||||
"HTTP_PROXY": "Proxy URL for HTTP requests",
|
||||
"HTTPS_PROXY": "Proxy URL for HTTPS requests",
|
||||
"ALL_PROXY": "Proxy URL for all requests (fallback)",
|
||||
"NO_PROXY": "Comma-separated list of hosts to bypass proxy",
|
||||
"SSL_CERT_FILE": "Path to custom SSL certificate file",
|
||||
"SSL_CERT_DIR": "Path to directory containing SSL certificates",
|
||||
}
|
||||
|
||||
|
||||
class ProxySetupError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_current_proxy_settings() -> dict[str, str | None]:
|
||||
if not GLOBAL_ENV_FILE.path.exists():
|
||||
return {key: None for key in SUPPORTED_PROXY_VARS}
|
||||
|
||||
try:
|
||||
env_vars = dotenv_values(GLOBAL_ENV_FILE.path)
|
||||
return {key: env_vars.get(key) for key in SUPPORTED_PROXY_VARS}
|
||||
except Exception:
|
||||
return {key: None for key in SUPPORTED_PROXY_VARS}
|
||||
|
||||
|
||||
def set_proxy_var(key: str, value: str) -> None:
|
||||
key = key.upper()
|
||||
if key not in SUPPORTED_PROXY_VARS:
|
||||
raise ProxySetupError(
|
||||
f"Unknown key '{key}'. Supported: {', '.join(SUPPORTED_PROXY_VARS.keys())}"
|
||||
)
|
||||
|
||||
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
set_key(GLOBAL_ENV_FILE.path, key, value)
|
||||
|
||||
|
||||
def unset_proxy_var(key: str) -> None:
|
||||
key = key.upper()
|
||||
if key not in SUPPORTED_PROXY_VARS:
|
||||
raise ProxySetupError(
|
||||
f"Unknown key '{key}'. Supported: {', '.join(SUPPORTED_PROXY_VARS.keys())}"
|
||||
)
|
||||
|
||||
if not GLOBAL_ENV_FILE.path.exists():
|
||||
return
|
||||
|
||||
unset_key(GLOBAL_ENV_FILE.path, key)
|
||||
|
||||
|
||||
def parse_proxy_command(args: str) -> tuple[str, str | None]:
|
||||
args = args.strip()
|
||||
if not args:
|
||||
raise ProxySetupError("No key provided")
|
||||
|
||||
parts = args.split(maxsplit=1)
|
||||
key = parts[0].upper()
|
||||
value = parts[1] if len(parts) > 1 else None
|
||||
|
||||
return key, value
|
||||
@@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
|
||||
from vibe.core.session.session_logger import MESSAGES_FILENAME, METADATA_FILENAME
|
||||
from vibe.core.types import LLMMessage
|
||||
@@ -11,6 +12,13 @@ if TYPE_CHECKING:
|
||||
from vibe.core.config import SessionLoggingConfig
|
||||
|
||||
|
||||
class SessionInfo(TypedDict):
|
||||
session_id: str
|
||||
cwd: str
|
||||
title: str | None
|
||||
end_time: str | None
|
||||
|
||||
|
||||
class SessionLoader:
|
||||
@staticmethod
|
||||
def _is_valid_session(session_dir: Path) -> bool:
|
||||
@@ -106,6 +114,63 @@ class SessionLoader:
|
||||
short_id = session_id[:8]
|
||||
return list(save_dir.glob(f"{config.session_prefix}_*_{short_id}"))
|
||||
|
||||
@staticmethod
|
||||
def _convert_to_utc_iso(date_str: str) -> str:
|
||||
dt = datetime.fromisoformat(date_str)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.astimezone()
|
||||
utc_dt = dt.astimezone(UTC)
|
||||
return utc_dt.isoformat()
|
||||
|
||||
@staticmethod
|
||||
def list_sessions(
|
||||
config: SessionLoggingConfig, cwd: str | None = None
|
||||
) -> list[SessionInfo]:
|
||||
save_dir = Path(config.save_dir)
|
||||
if not save_dir.exists():
|
||||
return []
|
||||
|
||||
pattern = f"{config.session_prefix}_*"
|
||||
session_dirs = list(save_dir.glob(pattern))
|
||||
|
||||
sessions: list[SessionInfo] = []
|
||||
for session_dir in session_dirs:
|
||||
if not SessionLoader._is_valid_session(session_dir):
|
||||
continue
|
||||
|
||||
metadata_path = session_dir / METADATA_FILENAME
|
||||
try:
|
||||
with metadata_path.open("r", encoding="utf-8") as f:
|
||||
metadata = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
session_id = metadata.get("session_id")
|
||||
if not session_id:
|
||||
continue
|
||||
|
||||
environment = metadata.get("environment", {})
|
||||
session_cwd = environment.get("working_directory", "")
|
||||
|
||||
if cwd is not None and session_cwd != cwd:
|
||||
continue
|
||||
|
||||
end_time = metadata.get("end_time")
|
||||
if end_time:
|
||||
try:
|
||||
end_time = SessionLoader._convert_to_utc_iso(end_time)
|
||||
except (ValueError, OSError):
|
||||
end_time = None
|
||||
|
||||
sessions.append({
|
||||
"session_id": session_id,
|
||||
"cwd": session_cwd,
|
||||
"title": metadata.get("title"),
|
||||
"end_time": end_time,
|
||||
})
|
||||
|
||||
return sessions
|
||||
|
||||
@staticmethod
|
||||
def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]:
|
||||
# Load session messages from MESSAGES_FILENAME
|
||||
|
||||
@@ -5,7 +5,7 @@ from logging import getLogger
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe.core.paths.config_paths import resolve_local_skills_dir
|
||||
from vibe.core.paths.config_paths import resolve_local_skills_dirs
|
||||
from vibe.core.paths.global_paths import GLOBAL_SKILLS_DIR
|
||||
from vibe.core.skills.models import SkillInfo, SkillMetadata
|
||||
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
|
||||
@@ -58,8 +58,7 @@ class SkillManager:
|
||||
if path.is_dir():
|
||||
paths.append(path)
|
||||
|
||||
if (skills_dir := resolve_local_skills_dir(Path.cwd())) is not None:
|
||||
paths.append(skills_dir)
|
||||
paths.extend(resolve_local_skills_dirs(Path.cwd()))
|
||||
|
||||
if GLOBAL_SKILLS_DIR.path.is_dir():
|
||||
paths.append(GLOBAL_SKILLS_DIR.path)
|
||||
|
||||
@@ -11,7 +11,7 @@ import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe.core.prompts import UtilityPrompt
|
||||
from vibe.core.trusted_folders import TRUSTABLE_FILENAMES, trusted_folders_manager
|
||||
from vibe.core.trusted_folders import AGENTS_MD_FILENAMES, trusted_folders_manager
|
||||
from vibe.core.utils import is_dangerous_directory, is_windows
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
|
||||
def _load_project_doc(workdir: Path, max_bytes: int) -> str:
|
||||
if not trusted_folders_manager.is_trusted(workdir):
|
||||
return ""
|
||||
for name in TRUSTABLE_FILENAMES:
|
||||
for name in AGENTS_MD_FILENAMES:
|
||||
path = workdir / name
|
||||
try:
|
||||
return path.read_text("utf-8", errors="ignore")[:max_bytes]
|
||||
|
||||