v2.18.0 (#843)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Cyprien <courtot.c@gmail.com> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com> Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com> Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: josephine-delas <57808586+josephine-delas@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
40
.github/workflows/issue-labeler.yml
vendored
|
|
@ -29,11 +29,13 @@ jobs:
|
|||
|
||||
- name: Apply component label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
env:
|
||||
BUG_COMPONENT: ${{ steps.parse-bug.outputs.issueparser_component }}
|
||||
FEATURE_COMPONENT: ${{ steps.parse-feature.outputs.issueparser_component }}
|
||||
with:
|
||||
script: |
|
||||
// Get component from either bug report or feature request
|
||||
const bugComponent = '${{ steps.parse-bug.outputs.issueparser_component }}';
|
||||
const featureComponent = '${{ steps.parse-feature.outputs.issueparser_component }}';
|
||||
const bugComponent = process.env.BUG_COMPONENT;
|
||||
const featureComponent = process.env.FEATURE_COMPONENT;
|
||||
const component = bugComponent || featureComponent;
|
||||
|
||||
if (!component) {
|
||||
|
|
@ -41,22 +43,22 @@ jobs:
|
|||
return;
|
||||
}
|
||||
|
||||
const labels = [];
|
||||
const componentLabels = new Map([
|
||||
['CLI', 'CLI'],
|
||||
['ACP', 'ACP'],
|
||||
['VS Code extension', 'VS Code extension'],
|
||||
]);
|
||||
const label = componentLabels.get(component);
|
||||
|
||||
if (component === 'CLI') {
|
||||
labels.push('CLI');
|
||||
} else if (component === 'ACP') {
|
||||
labels.push('ACP');
|
||||
} else if (component === 'VS Code extension') {
|
||||
labels.push('VS Code extension');
|
||||
if (!label) {
|
||||
console.log(`Unknown component '${component}', skipping labeling`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (labels.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: labels
|
||||
});
|
||||
console.log(`Added labels: ${labels.join(', ')}`);
|
||||
}
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: [label],
|
||||
});
|
||||
console.log(`Added label: ${label}`);
|
||||
|
|
|
|||
|
|
@ -5,3 +5,8 @@ extend-ignore-re = ["(?m)^.*(#|//)\\s*typos:disable-line$", "datas"]
|
|||
iterm = "iterm"
|
||||
ITERM = "ITERM"
|
||||
asend = "asend"
|
||||
|
||||
[default.extend-identifiers]
|
||||
# macOS pasteboard four-char-code for the PNG flavor — required verbatim
|
||||
# by AppleScript syntax (`the clipboard as «class PNGf»`).
|
||||
PNGf = "PNGf"
|
||||
|
|
|
|||
26
CHANGELOG.md
|
|
@ -5,6 +5,32 @@ 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.18.0] - 2026-06-25
|
||||
|
||||
### Added
|
||||
|
||||
- Clickable URLs in web fetch and web search tool output
|
||||
- Context window usage display in tokens at the bottom of the TUI
|
||||
- Clipboard image paste support in the TUI (macOS)
|
||||
- Max generated tokens set_config_option in ACP
|
||||
|
||||
### Changed
|
||||
|
||||
- MCP OAuth authentication UX improvements in `/mcp` panel
|
||||
- Diff gutter and body split into separate widgets
|
||||
- Faster UI startup with lazy heavy imports
|
||||
- Improved connector performance with bootstrap caching
|
||||
|
||||
### Fixed
|
||||
|
||||
- macOS keychain access for Vibe credentials
|
||||
- Standalone denylist incorrectly blocking commands with heredocs
|
||||
- Copy selected text from prompt input and text areas
|
||||
- Repeated keyring requests in Vibe Code
|
||||
- Brew upgrade now always runs even when uv upgrade succeeds
|
||||
- Terminal kill/release bounding for ACP to prevent hangs
|
||||
|
||||
|
||||
## [2.17.1] - 2026-06-19
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -760,6 +760,7 @@ This affects where Vibe looks for:
|
|||
|
||||
- `config.toml` - Main configuration
|
||||
- `.env` - API keys
|
||||
- `connector_bootstrap_cache.json` - Short-lived connector discovery cache
|
||||
- `agents/` - Custom agent configurations
|
||||
- `prompts/` - Custom system and compaction prompts
|
||||
- `tools/` - Custom tools
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
id = "mistral-vibe"
|
||||
name = "Mistral Vibe"
|
||||
description = "Mistral's open-source coding assistant"
|
||||
version = "2.17.1"
|
||||
version = "2.18.0"
|
||||
schema_version = 1
|
||||
authors = ["Mistral AI"]
|
||||
repository = "https://github.com/mistralai/mistral-vibe"
|
||||
|
|
@ -11,21 +11,21 @@ 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.17.1/vibe-acp-darwin-aarch64-2.17.1.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-darwin-aarch64-2.18.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-darwin-x86_64-2.17.1.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-darwin-x86_64-2.18.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-linux-aarch64-2.17.1.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-linux-aarch64-2.18.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-linux-x86_64-2.17.1.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-linux-x86_64-2.18.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-windows-x86_64-2.17.1.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-windows-x86_64-2.18.0.zip"
|
||||
cmd = "./vibe-acp.exe"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Once you have set up `vibe` with the API keys, you are ready to use `vibe-acp` i
|
|||
|
||||
## Zed
|
||||
|
||||
For usage in Zed, we recommend using the [Mistral Vibe Zed's extension](https://zed.dev/extensions/mistral-vibe). Alternatively, you can set up a local install as follows:
|
||||
For usage in Zed, we recommend using the [Mistral Vibe Zed ACP agent](https://zed.dev/acp/agent/mistral-vibe). Alternatively, you can set up a local install as follows:
|
||||
|
||||
1. Go to `~/.config/zed/settings.json` and, under the `agent_servers` JSON object, add the following key-value pair to invoke the `vibe-acp` command. Here is the snippet:
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ For usage in Zed, we recommend using the [Mistral Vibe Zed's extension](https://
|
|||
}
|
||||
```
|
||||
|
||||
1. In the `New Thread` pane on the right, select the `vibe` agent and start the conversation.
|
||||
2. In the `Agent Panel` view, select the `Mistral Vibe` agent and start the conversation.
|
||||
|
||||
## JetBrains IDEs
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "mistral-vibe"
|
||||
version = "2.17.1"
|
||||
version = "2.18.0"
|
||||
description = "Minimal CLI coding agent by Mistral"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
|
@ -33,7 +33,7 @@ dependencies = [
|
|||
"attrs==26.1.0",
|
||||
"beautifulsoup4==4.14.3",
|
||||
"cachetools==7.0.6",
|
||||
"certifi==2026.4.22",
|
||||
"certifi==2026.6.17",
|
||||
"cffi==2.0.0",
|
||||
"charset-normalizer==3.4.7",
|
||||
"click==8.3.3 ; sys_platform != 'emscripten'",
|
||||
|
|
@ -41,7 +41,7 @@ dependencies = [
|
|||
"cryptography==48.0.1",
|
||||
"eval-type-backport==0.3.1",
|
||||
"gitdb==4.0.12",
|
||||
"gitpython==3.1.47",
|
||||
"gitpython==3.1.50",
|
||||
"giturlparse==0.14.0",
|
||||
"google-auth==2.49.2",
|
||||
"googleapis-common-protos==1.74.0",
|
||||
|
|
@ -86,7 +86,7 @@ dependencies = [
|
|||
"pycparser==3.0 ; implementation_name != 'PyPy'",
|
||||
"pydantic==2.13.3",
|
||||
"pydantic-core==2.46.3",
|
||||
"pydantic-settings==2.14.0",
|
||||
"pydantic-settings==2.14.2",
|
||||
"pygments==2.20.0",
|
||||
"pyjwt==2.13.0",
|
||||
"pyperclip==1.11.0",
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ async def test_acp_initialize(binary: Path) -> None:
|
|||
env = os.environ.copy()
|
||||
env["VIBE_HOME"] = str(vibe_home)
|
||||
env["MISTRAL_API_KEY"] = "smoke-test-mock-key"
|
||||
env["VIBE_TEST_DISABLE_KEYRING"] = "1"
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
str(binary),
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@ async def get_acp_agent_loop_process(
|
|||
env.update(mock_env)
|
||||
env["MISTRAL_API_KEY"] = "mock"
|
||||
env["VIBE_HOME"] = str(vibe_home)
|
||||
env["VIBE_TEST_DISABLE_KEYRING"] = "1"
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ def _build_env(vibe_home_dir: Path, *, include_api_key: bool) -> dict[str, str]:
|
|||
env = os.environ.copy()
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
env["VIBE_HOME"] = str(vibe_home_dir)
|
||||
env["VIBE_TEST_DISABLE_KEYRING"] = "1"
|
||||
|
||||
vibe_home_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_file = vibe_home_dir / "config.toml"
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ class TestACPAuthSignOut:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removes_keyring_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
deleted: list[str] = []
|
||||
deleted: list[tuple[str, str]] = []
|
||||
monkeypatch.delenv(DEFAULT_MISTRAL_API_ENV_KEY, raising=False)
|
||||
monkeypatch.setattr(
|
||||
keyring, "get_password", lambda service, username: "keyring-key"
|
||||
|
|
@ -268,14 +268,17 @@ class TestACPAuthSignOut:
|
|||
monkeypatch.setattr(
|
||||
keyring,
|
||||
"delete_password",
|
||||
lambda service, username: deleted.append(username),
|
||||
lambda service, username: deleted.append((service, username)),
|
||||
)
|
||||
acp_agent_loop = build_acp_agent_loop(build_mistral_provider())
|
||||
|
||||
response = await acp_agent_loop.ext_method("auth/signOut", {})
|
||||
|
||||
assert response == {}
|
||||
assert deleted == [DEFAULT_MISTRAL_API_ENV_KEY]
|
||||
assert deleted == [
|
||||
("ai.mistral.vibe", DEFAULT_MISTRAL_API_ENV_KEY),
|
||||
("vibe", DEFAULT_MISTRAL_API_ENV_KEY),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_surfaces_internal_error_when_keyring_delete_fails(
|
||||
|
|
|
|||
|
|
@ -290,6 +290,37 @@ class TestAcpBashTimeout:
|
|||
assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"
|
||||
assert custom_handle._killed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_timeout_bounded_when_kill_hangs(
|
||||
self, mock_client: MockClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"vibe.acp.tools.builtins.bash._TERMINAL_CLEANUP_TIMEOUT", 0.05
|
||||
)
|
||||
|
||||
custom_handle = MockTerminalHandle(
|
||||
terminal_id="hanging_kill_terminal", wait_delay=20
|
||||
)
|
||||
mock_client._terminal_handle = custom_handle
|
||||
|
||||
async def hanging_kill() -> None:
|
||||
await asyncio.sleep(30)
|
||||
|
||||
custom_handle.kill = hanging_kill
|
||||
|
||||
tool = Bash(
|
||||
config_getter=lambda: BashToolConfig(),
|
||||
state=AcpBashState.model_construct(
|
||||
client=mock_client, session_id="test_session"
|
||||
),
|
||||
)
|
||||
|
||||
args = BashArgs(command="slow_command", timeout=1)
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await asyncio.wait_for(collect_result(tool.run(args)), timeout=5)
|
||||
|
||||
assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_timeout_handles_kill_failure(
|
||||
self, mock_client: MockClient
|
||||
|
|
|
|||
|
|
@ -156,6 +156,34 @@ class TestACPForkSession:
|
|||
forked_session = acp_agent_loop.sessions[response.session_id]
|
||||
assert forked_session.agent_loop.parent_session_id == source_session.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_session_inherits_limits(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
source_session = acp_agent_loop.sessions[session_response.session_id]
|
||||
|
||||
await acp_agent_loop.set_config_option(
|
||||
session_id=source_session.id, config_id="max_turns", value="42"
|
||||
)
|
||||
await acp_agent_loop.set_config_option(
|
||||
session_id=source_session.id, config_id="max_tokens", value="8192"
|
||||
)
|
||||
source_session.agent_loop._max_price = 1.5
|
||||
source_session.agent_loop._max_session_tokens = 100_000
|
||||
|
||||
response = await acp_agent_loop.fork_session(
|
||||
cwd=str(Path.cwd()), session_id=source_session.id, mcp_servers=[]
|
||||
)
|
||||
|
||||
forked = acp_agent_loop.sessions[response.session_id].agent_loop
|
||||
assert forked._max_turns == 42
|
||||
assert forked._max_tokens == 8192
|
||||
assert forked._max_price == 1.5
|
||||
assert forked._max_session_tokens == 100_000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_session_rejects_running_session(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ class TestACPInitialize:
|
|||
),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.1"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.0"
|
||||
)
|
||||
|
||||
assert response.auth_methods is not None
|
||||
|
|
@ -172,7 +172,7 @@ class TestACPInitialize:
|
|||
),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.1"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.0"
|
||||
)
|
||||
|
||||
assert response.auth_methods is not None
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ def acp_agent_with_session_config(
|
|||
),
|
||||
],
|
||||
session_logging=session_config,
|
||||
include_project_context=True,
|
||||
)
|
||||
|
||||
class PatchedAgentLoop(AgentLoop):
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ def _workspace_trust_meta(session_response: NewSessionResponse) -> dict:
|
|||
def acp_agent_loop(backend) -> VibeAcpAgentLoop:
|
||||
config = build_test_vibe_config(
|
||||
active_model="devstral-latest",
|
||||
include_project_context=True,
|
||||
models=[
|
||||
ModelConfig(
|
||||
name="devstral-latest", provider="mistral", alias="devstral-latest"
|
||||
|
|
|
|||
|
|
@ -528,3 +528,67 @@ class TestACPSetConfigOptionMaxTurns:
|
|||
]
|
||||
assert len(turn_limits) == 1
|
||||
assert turn_limits[0].max_turns == 200
|
||||
|
||||
|
||||
class TestACPSetConfigOptionMaxTokens:
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_max_tokens_success(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="max_tokens", value="8192"
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent_loop._max_tokens == 8192
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_max_tokens_invalid_string_returns_none(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
initial_max_tokens = acp_session.agent_loop._max_tokens
|
||||
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="max_tokens", value="abc"
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.agent_loop._max_tokens == initial_max_tokens
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_max_tokens_bool_returns_none(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
initial_max_tokens = acp_session.agent_loop._max_tokens
|
||||
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="max_tokens", value=True
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.agent_loop._max_tokens == initial_max_tokens
|
||||
|
|
|
|||
|
|
@ -1,34 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from tests.agent_loop.e2e.providers import MistralAPI
|
||||
from tests.conftest import build_test_agent_loop, build_test_vibe_config
|
||||
from tests.constants import (
|
||||
ANTHROPIC_BASE_URL,
|
||||
ANTHROPIC_MESSAGES_PATH,
|
||||
CHAT_COMPLETIONS_PATH,
|
||||
CONNECTORS_BOOTSTRAP_PATH,
|
||||
MISTRAL_BASE_URL,
|
||||
OPENAI_BASE_URL,
|
||||
OPENAI_RESPONSES_PATH,
|
||||
)
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.llm.backend.factory import BACKEND_FACTORY
|
||||
from vibe.core.types import AssistantEvent, Backend, BaseEvent
|
||||
|
||||
|
||||
def assistant_text(events: list[BaseEvent]) -> str:
|
||||
return "".join(
|
||||
e.content for e in events if isinstance(e, AssistantEvent) and e.content
|
||||
).strip()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -38,71 +21,23 @@ def _git_offline(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
monkeypatch.setenv("GIT_ALLOW_PROTOCOL", "file")
|
||||
|
||||
|
||||
def e2e_config(**overrides: Any) -> VibeConfig:
|
||||
# Defaults give the mistral provider/model (api.mistral.ai, the respx-mocked
|
||||
# base). The default model reasons, but that only adds reasoning_effort to the
|
||||
# request, which the mocked responses ignore.
|
||||
# The e2e harness mocks the connector bootstrap endpoint via respx, so
|
||||
# connector discovery is enabled (and fast) here unlike the unit-test default.
|
||||
return build_test_vibe_config(
|
||||
enabled_tools=overrides.pop("enabled_tools", []),
|
||||
enable_connectors=overrides.pop("enable_connectors", True),
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
**overrides,
|
||||
)
|
||||
|
||||
|
||||
def _generic_e2e_config(
|
||||
*, name: str, api_base: str, api_style: str, model: str, **overrides: Any
|
||||
) -> VibeConfig:
|
||||
provider = ProviderConfig(
|
||||
name=name,
|
||||
api_base=api_base,
|
||||
api_key_env_var=f"{name.upper()}_API_KEY",
|
||||
api_style=api_style,
|
||||
backend=Backend.GENERIC,
|
||||
)
|
||||
models = [ModelConfig(name=model, provider=name, alias=name)]
|
||||
return e2e_config(
|
||||
active_model=name, models=models, providers=[provider], **overrides
|
||||
)
|
||||
|
||||
|
||||
def anthropic_e2e_config(**overrides: Any) -> VibeConfig:
|
||||
return _generic_e2e_config(
|
||||
name="anthropic",
|
||||
api_base=ANTHROPIC_BASE_URL,
|
||||
api_style="anthropic",
|
||||
model="claude-test",
|
||||
**overrides,
|
||||
)
|
||||
|
||||
|
||||
def openai_responses_e2e_config(**overrides: Any) -> VibeConfig:
|
||||
return _generic_e2e_config(
|
||||
name="openai",
|
||||
api_base=f"{OPENAI_BASE_URL}/v1",
|
||||
api_style="openai-responses",
|
||||
model="gpt-test",
|
||||
**overrides,
|
||||
)
|
||||
@pytest.fixture
|
||||
def mock_mistral() -> Iterator[respx.MockRouter]:
|
||||
with respx.mock(base_url=MistralAPI.base_url, assert_all_called=False) as router:
|
||||
yield router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mistral() -> Iterator[respx.MockRouter]:
|
||||
with respx.mock(base_url=MISTRAL_BASE_URL, assert_all_called=False) as router:
|
||||
router.get(CONNECTORS_BOOTSTRAP_PATH).mock(
|
||||
return_value=httpx.Response(200, json={"connectors": []})
|
||||
)
|
||||
yield router
|
||||
def mistral_api(mock_mistral: respx.MockRouter) -> MistralAPI:
|
||||
api = MistralAPI()
|
||||
api.setup_router(mock_mistral)
|
||||
return api
|
||||
|
||||
|
||||
def build_e2e_agent_loop(
|
||||
*, config: VibeConfig | None = None, enable_streaming: bool = False, **kwargs: Any
|
||||
) -> AgentLoop:
|
||||
resolved_config = config or e2e_config()
|
||||
resolved_config = config or build_test_vibe_config()
|
||||
provider = resolved_config.providers[0]
|
||||
# todo now we use build_test_agent_loop for the fake mcp registry.
|
||||
# Maybe mock http tool calls and instantiate a real registry later for more coverage
|
||||
|
|
@ -113,69 +48,3 @@ def build_e2e_agent_loop(
|
|||
enable_streaming=enable_streaming,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class ProviderAPI:
|
||||
"""Stubs a provider's completion wire; the AgentLoop stays the subject."""
|
||||
|
||||
def __init__(self, router: respx.MockRouter, path: str) -> None:
|
||||
self.route = router.post(path)
|
||||
|
||||
def reply(self, *completions: dict[str, Any]) -> None:
|
||||
responses = [httpx.Response(200, json=c) for c in completions]
|
||||
if len(responses) == 1:
|
||||
self.route.mock(return_value=responses[0])
|
||||
else:
|
||||
self.route.mock(side_effect=responses)
|
||||
|
||||
def reply_stream(self, chunks: list[bytes]) -> None:
|
||||
self.route.mock(return_value=self._stream_response(chunks))
|
||||
|
||||
def reply_streams(self, *chunk_lists: list[bytes]) -> None:
|
||||
self.route.mock(
|
||||
side_effect=[self._stream_response(chunks) for chunks in chunk_lists]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _stream_response(chunks: list[bytes]) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
)
|
||||
|
||||
@property
|
||||
def request_json(self) -> dict[str, Any]:
|
||||
return json.loads(self.route.calls.last.request.content)
|
||||
|
||||
|
||||
class MistralAPI(ProviderAPI):
|
||||
def __init__(self, router: respx.MockRouter) -> None:
|
||||
super().__init__(router, CHAT_COMPLETIONS_PATH)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mistral_api(mock_mistral: respx.MockRouter) -> MistralAPI:
|
||||
return MistralAPI(mock_mistral)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anthropic() -> Iterator[respx.MockRouter]:
|
||||
with respx.mock(base_url=ANTHROPIC_BASE_URL, assert_all_called=False) as router:
|
||||
yield router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anthropic_api(mock_anthropic: respx.MockRouter) -> ProviderAPI:
|
||||
return ProviderAPI(mock_anthropic, ANTHROPIC_MESSAGES_PATH)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_openai() -> Iterator[respx.MockRouter]:
|
||||
with respx.mock(base_url=OPENAI_BASE_URL, assert_all_called=False) as router:
|
||||
yield router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def openai_responses_api(mock_openai: respx.MockRouter) -> ProviderAPI:
|
||||
return ProviderAPI(mock_openai, OPENAI_RESPONSES_PATH)
|
||||
|
|
|
|||
10
tests/agent_loop/e2e/providers/__init__.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from tests.agent_loop.e2e.providers.base import (
|
||||
ProviderAPI,
|
||||
ProviderMocks,
|
||||
assistant_text,
|
||||
)
|
||||
from tests.agent_loop.e2e.providers.mistral import MistralAPI
|
||||
|
||||
__all__ = ["MistralAPI", "ProviderAPI", "ProviderMocks", "assistant_text"]
|
||||
61
tests/agent_loop/e2e/providers/anthropic.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from tests import constants as c
|
||||
from tests.agent_loop.e2e.providers.base import ProviderAPI
|
||||
from tests.backend.data import Chunk, JsonResponse
|
||||
from tests.backend.data.anthropic import (
|
||||
anthropic_message,
|
||||
anthropic_reasoning_tool_use_stream,
|
||||
anthropic_text_stream,
|
||||
anthropic_tool_use,
|
||||
)
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
|
||||
def e2e_config(**overrides: Any) -> VibeConfig:
|
||||
provider = ProviderConfig(
|
||||
name="anthropic",
|
||||
api_base=c.ANTHROPIC_BASE_URL,
|
||||
api_key_env_var="ANTHROPIC_API_KEY",
|
||||
api_style="anthropic",
|
||||
backend=Backend.GENERIC,
|
||||
)
|
||||
models = [ModelConfig(name="claude-test", provider="anthropic", alias="anthropic")]
|
||||
return build_test_vibe_config(
|
||||
active_model="anthropic", models=models, providers=[provider], **overrides
|
||||
)
|
||||
|
||||
|
||||
class API(ProviderAPI):
|
||||
base_url = c.ANTHROPIC_BASE_URL
|
||||
post_path = c.ANTHROPIC_MESSAGES_PATH
|
||||
|
||||
|
||||
class Mocks:
|
||||
def answer(self, text: str) -> JsonResponse:
|
||||
return anthropic_message(text)
|
||||
|
||||
def text_stream(self, text: str) -> list[Chunk]:
|
||||
return anthropic_text_stream(text)
|
||||
|
||||
def tool_call(self, name: str, arguments: dict[str, Any]) -> JsonResponse:
|
||||
return anthropic_tool_use(name, arguments)
|
||||
|
||||
def reasoning_answer(self, text: str, reasoning: str) -> JsonResponse:
|
||||
message = anthropic_message(text)
|
||||
message["content"].insert(
|
||||
0, {"type": "thinking", "thinking": reasoning, "signature": "sig"}
|
||||
)
|
||||
return message
|
||||
|
||||
def reasoning_tool_call_stream(
|
||||
self, name: str, arguments: dict[str, Any], *, reasoning: str
|
||||
) -> list[Chunk]:
|
||||
return anthropic_reasoning_tool_use_stream(
|
||||
name, json.dumps(arguments), reasoning=reasoning
|
||||
)
|
||||
98
tests/agent_loop/e2e/providers/base.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, ClassVar, Protocol
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from tests.backend.data import Chunk, JsonResponse
|
||||
from vibe.core.types import AssistantEvent, BaseEvent
|
||||
|
||||
|
||||
def assistant_text(events: list[BaseEvent]) -> str:
|
||||
return "".join(
|
||||
e.content for e in events if isinstance(e, AssistantEvent) and e.content
|
||||
).strip()
|
||||
|
||||
|
||||
class ProviderMocks(Protocol):
|
||||
"""Canonical wire payloads a provider must produce for the shared e2e suite."""
|
||||
|
||||
def answer(self, text: str) -> JsonResponse:
|
||||
"""A non-streaming assistant turn that replies with `text`."""
|
||||
...
|
||||
|
||||
def text_stream(self, text: str) -> list[Chunk]:
|
||||
"""A streaming assistant turn whose deltas reassemble into `text`."""
|
||||
...
|
||||
|
||||
def tool_call(self, name: str, arguments: dict[str, Any]) -> JsonResponse:
|
||||
"""A non-streaming turn that calls tool `name` with `arguments`."""
|
||||
...
|
||||
|
||||
def reasoning_answer(self, text: str, reasoning: str) -> JsonResponse:
|
||||
"""A non-streaming turn that thinks `reasoning`, then replies `text`."""
|
||||
...
|
||||
|
||||
def reasoning_tool_call_stream(
|
||||
self, name: str, arguments: dict[str, Any], *, reasoning: str
|
||||
) -> list[Chunk]:
|
||||
"""A streamed turn that thinks `reasoning`, then calls tool `name`."""
|
||||
...
|
||||
|
||||
|
||||
class ProviderAPI:
|
||||
"""Stubs a provider's completion wire; the AgentLoop stays the subject.
|
||||
|
||||
Subclasses describe one provider via `base_url`/`post_path`; override
|
||||
`setup_monkeypatch` for non-HTTP stubs (e.g. credentials) a provider needs.
|
||||
"""
|
||||
|
||||
base_url: ClassVar[str]
|
||||
post_path: ClassVar[str]
|
||||
|
||||
route: respx.Route
|
||||
|
||||
def setup_router(self, router: respx.MockRouter) -> None:
|
||||
"""Bind the completion route; subclasses extend for extra wires/defaults."""
|
||||
self.route = router.post(self.post_path)
|
||||
|
||||
@classmethod
|
||||
def setup_monkeypatch(cls, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Apply non-HTTP stubs (e.g. credential lookups) before use."""
|
||||
|
||||
def reply(self, *completions: dict[str, Any]) -> None:
|
||||
responses = [httpx.Response(200, json=completion) for completion in completions]
|
||||
if len(responses) == 1:
|
||||
self.route.mock(return_value=responses[0])
|
||||
else:
|
||||
self.route.mock(side_effect=responses)
|
||||
|
||||
def reply_stream(self, chunks: list[bytes]) -> None:
|
||||
self.route.mock(return_value=self._stream_response(chunks))
|
||||
|
||||
def reply_streams(self, *chunk_lists: list[bytes]) -> None:
|
||||
self.route.mock(
|
||||
side_effect=[self._stream_response(chunks) for chunks in chunk_lists]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _stream_response(chunks: list[bytes]) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
)
|
||||
|
||||
@property
|
||||
def request_json(self) -> dict[str, Any]:
|
||||
return json.loads(self.route.calls.last.request.content)
|
||||
|
||||
def model_facing_text(self, index: int) -> str:
|
||||
"""Return the text (raw prompt) that the model would have seen in the request
|
||||
at the given call index.
|
||||
"""
|
||||
body = json.loads(self.route.calls[index].request.content)
|
||||
return json.dumps([m for m in body["messages"] if m.get("role") != "system"])
|
||||
18
tests/agent_loop/e2e/providers/mistral.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import respx
|
||||
|
||||
from tests import constants as c
|
||||
from tests.agent_loop.e2e.providers.base import ProviderAPI
|
||||
|
||||
|
||||
class MistralAPI(ProviderAPI):
|
||||
base_url = c.MISTRAL_BASE_URL
|
||||
post_path = c.CHAT_COMPLETIONS_PATH
|
||||
|
||||
def setup_router(self, router: respx.MockRouter) -> None:
|
||||
super().setup_router(router)
|
||||
router.get(c.CONNECTORS_BOOTSTRAP_PATH).mock(
|
||||
return_value=httpx.Response(200, json={"connectors": []})
|
||||
)
|
||||
61
tests/agent_loop/e2e/providers/openai_responses.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from tests import constants as c
|
||||
from tests.agent_loop.e2e.providers.base import ProviderAPI
|
||||
from tests.backend.data import Chunk, JsonResponse
|
||||
from tests.backend.data.openai_responses import (
|
||||
openai_function_call_item,
|
||||
openai_message_item,
|
||||
openai_reasoning_tool_call_stream,
|
||||
openai_response,
|
||||
openai_text_stream,
|
||||
)
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
|
||||
def e2e_config(**overrides: Any) -> VibeConfig:
|
||||
provider = ProviderConfig(
|
||||
name="openai",
|
||||
api_base=f"{c.OPENAI_BASE_URL}/v1",
|
||||
api_key_env_var="OPENAI_API_KEY",
|
||||
api_style="openai-responses",
|
||||
backend=Backend.GENERIC,
|
||||
)
|
||||
models = [ModelConfig(name="gpt-test", provider="openai", alias="openai")]
|
||||
return build_test_vibe_config(
|
||||
active_model="openai", models=models, providers=[provider], **overrides
|
||||
)
|
||||
|
||||
|
||||
class API(ProviderAPI):
|
||||
base_url = c.OPENAI_BASE_URL
|
||||
post_path = c.OPENAI_RESPONSES_PATH
|
||||
|
||||
|
||||
class Mocks:
|
||||
def answer(self, text: str) -> JsonResponse:
|
||||
return openai_response([openai_message_item(text)])
|
||||
|
||||
def text_stream(self, text: str) -> list[Chunk]:
|
||||
return openai_text_stream(text)
|
||||
|
||||
def tool_call(self, name: str, arguments: dict[str, Any]) -> JsonResponse:
|
||||
return openai_response([openai_function_call_item(name, json.dumps(arguments))])
|
||||
|
||||
def reasoning_answer(self, text: str, reasoning: str) -> JsonResponse:
|
||||
return openai_response([
|
||||
openai_message_item(reasoning, phase="commentary"),
|
||||
openai_message_item(text, phase="final_answer"),
|
||||
])
|
||||
|
||||
def reasoning_tool_call_stream(
|
||||
self, name: str, arguments: dict[str, Any], *, reasoning: str
|
||||
) -> list[Chunk]:
|
||||
return openai_reasoning_tool_call_stream(
|
||||
name, json.dumps(arguments), reasoning=reasoning
|
||||
)
|
||||
65
tests/agent_loop/e2e/providers/reasoning.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from tests import constants as c
|
||||
from tests.agent_loop.e2e.providers.base import ProviderAPI
|
||||
from tests.backend.data import Chunk, JsonResponse
|
||||
from tests.backend.data.reasoning import (
|
||||
reasoning_message,
|
||||
reasoning_text_stream,
|
||||
reasoning_thinking_message,
|
||||
reasoning_thinking_tool_use_stream,
|
||||
reasoning_tool_use,
|
||||
)
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.core.config import ModelConfig, ProviderConfig, ThinkingLevel, VibeConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
|
||||
def e2e_config(*, thinking: ThinkingLevel = "off", **overrides: Any) -> VibeConfig:
|
||||
provider = ProviderConfig(
|
||||
name="reasoning",
|
||||
api_base=c.REASONING_BASE_URL,
|
||||
api_key_env_var="REASONING_API_KEY",
|
||||
api_style="reasoning",
|
||||
backend=Backend.GENERIC,
|
||||
)
|
||||
models = [
|
||||
ModelConfig(
|
||||
name="reasoning-test",
|
||||
provider="reasoning",
|
||||
alias="reasoning",
|
||||
thinking=thinking,
|
||||
)
|
||||
]
|
||||
return build_test_vibe_config(
|
||||
active_model="reasoning", models=models, providers=[provider], **overrides
|
||||
)
|
||||
|
||||
|
||||
class API(ProviderAPI):
|
||||
base_url = c.REASONING_BASE_URL
|
||||
post_path = c.REASONING_COMPLETIONS_PATH
|
||||
|
||||
|
||||
class Mocks:
|
||||
def answer(self, text: str) -> JsonResponse:
|
||||
return reasoning_message(text)
|
||||
|
||||
def text_stream(self, text: str) -> list[Chunk]:
|
||||
return reasoning_text_stream(text)
|
||||
|
||||
def tool_call(self, name: str, arguments: dict[str, Any]) -> JsonResponse:
|
||||
return reasoning_tool_use(name, json.dumps(arguments))
|
||||
|
||||
def reasoning_answer(self, text: str, reasoning: str) -> JsonResponse:
|
||||
return reasoning_thinking_message(text, reasoning)
|
||||
|
||||
def reasoning_tool_call_stream(
|
||||
self, name: str, arguments: dict[str, Any], *, reasoning: str
|
||||
) -> list[Chunk]:
|
||||
return reasoning_thinking_tool_use_stream(
|
||||
name, json.dumps(arguments), reasoning=reasoning
|
||||
)
|
||||
69
tests/agent_loop/e2e/providers/vertex.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from tests import constants as c
|
||||
from tests.agent_loop.e2e.providers.anthropic import Mocks
|
||||
from tests.agent_loop.e2e.providers.base import ProviderAPI
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
__all__ = ["API", "Mocks", "e2e_config"]
|
||||
|
||||
|
||||
def e2e_config(**overrides: Any) -> VibeConfig:
|
||||
provider = ProviderConfig(
|
||||
name="vertex",
|
||||
api_base="",
|
||||
api_key_env_var="VERTEX_API_KEY",
|
||||
api_style="vertex-anthropic",
|
||||
backend=Backend.GENERIC,
|
||||
project_id=c.VERTEX_PROJECT_ID,
|
||||
region=c.VERTEX_REGION,
|
||||
)
|
||||
models = [ModelConfig(name=c.VERTEX_MODEL, provider="vertex", alias="vertex")]
|
||||
return build_test_vibe_config(
|
||||
active_model="vertex", models=models, providers=[provider], **overrides
|
||||
)
|
||||
|
||||
|
||||
class API(ProviderAPI):
|
||||
"""Vertex splits rawPredict and streamRawPredict across two wire paths."""
|
||||
|
||||
base_url = c.VERTEX_BASE_URL
|
||||
post_path = c.VERTEX_RAW_PREDICT_PATH
|
||||
|
||||
def setup_router(self, router: respx.MockRouter) -> None:
|
||||
super().setup_router(router)
|
||||
self._stream_route = router.post(c.VERTEX_STREAM_PREDICT_PATH)
|
||||
|
||||
@classmethod
|
||||
def setup_monkeypatch(cls, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# google.auth is the only external dependency Vertex pulls in; stub the
|
||||
# credential lookup so the real token-refresh logic runs without network.
|
||||
creds = MagicMock()
|
||||
creds.valid = True
|
||||
creds.token = "fake-vertex-token"
|
||||
monkeypatch.setattr(
|
||||
"vibe.core.llm.backend.vertex.google.auth.default",
|
||||
lambda **_kwargs: (creds, c.VERTEX_PROJECT_ID),
|
||||
)
|
||||
|
||||
def reply_stream(self, chunks: list[bytes]) -> None:
|
||||
self._stream_route.mock(return_value=self._stream_response(chunks))
|
||||
|
||||
def reply_streams(self, *chunk_lists: list[bytes]) -> None:
|
||||
self._stream_route.mock(
|
||||
side_effect=[self._stream_response(chunks) for chunks in chunk_lists]
|
||||
)
|
||||
|
||||
@property
|
||||
def request_json(self) -> dict[str, Any]:
|
||||
route = self._stream_route if self._stream_route.calls else self.route
|
||||
return json.loads(route.calls.last.request.content)
|
||||
|
|
@ -4,16 +4,13 @@ from typing import Any
|
|||
|
||||
import pytest
|
||||
|
||||
from tests.agent_loop.e2e.conftest import (
|
||||
MistralAPI,
|
||||
assistant_text,
|
||||
build_e2e_agent_loop,
|
||||
e2e_config,
|
||||
)
|
||||
from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop
|
||||
from tests.agent_loop.e2e.providers import assistant_text
|
||||
from tests.backend.data.mistral import (
|
||||
STREAMED_SIMPLE_CONVERSATION_PARAMS,
|
||||
mistral_completion,
|
||||
)
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.core.types import (
|
||||
AssistantEvent,
|
||||
ToolCallEvent,
|
||||
|
|
@ -75,7 +72,7 @@ async def test_act_tool_call_round_trip(mistral_api: MistralAPI) -> None:
|
|||
mistral_completion("", tool_calls=TODO_TOOL_CALL),
|
||||
mistral_completion("All done"),
|
||||
)
|
||||
agent = build_e2e_agent_loop(config=e2e_config(enabled_tools=["todo"]))
|
||||
agent = build_e2e_agent_loop(config=build_test_vibe_config(enabled_tools=["todo"]))
|
||||
|
||||
events = [event async for event in agent.act("Show my todos")]
|
||||
|
||||
|
|
@ -89,7 +86,7 @@ async def test_act_tool_call_round_trip(mistral_api: MistralAPI) -> None:
|
|||
async def test_act_serializes_tools_in_request_payload(mistral_api: MistralAPI) -> None:
|
||||
# Enabled tools are serialized into the outgoing chat-completions request.
|
||||
mistral_api.reply(mistral_completion("ok"))
|
||||
agent = build_e2e_agent_loop(config=e2e_config(enabled_tools=["todo"]))
|
||||
agent = build_e2e_agent_loop(config=build_test_vibe_config(enabled_tools=["todo"]))
|
||||
|
||||
_ = [event async for event in agent.act("Hello")]
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ from typing import Any, cast
|
|||
from pydantic import BaseModel
|
||||
import pytest
|
||||
|
||||
from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop, e2e_config
|
||||
from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop
|
||||
from tests.backend.data.mistral import mistral_completion
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.tools.builtins.bash import BashResult
|
||||
from vibe.core.tools.permissions import RequiredPermission
|
||||
|
|
@ -41,7 +42,7 @@ async def _run_bash(
|
|||
) -> ToolResultEvent:
|
||||
mistral_api.reply(_bash_call(command, timeout), mistral_completion("done"))
|
||||
agent = build_e2e_agent_loop(
|
||||
config=e2e_config(enabled_tools=["bash"]), agent_name=agent_name
|
||||
config=build_test_vibe_config(enabled_tools=["bash"]), agent_name=agent_name
|
||||
)
|
||||
if approval is not None:
|
||||
|
||||
|
|
|
|||
82
tests/agent_loop/e2e/test_e2e_compaction.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop
|
||||
from tests.backend.data.mistral import mistral_completion
|
||||
from tests.conftest import build_test_vibe_config, make_test_models
|
||||
from vibe.core.types import CompactStartEvent
|
||||
|
||||
COMPACTION_MODELS = make_test_models(auto_compact_threshold=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compaction_preserves_user_message_and_embeds_summary(
|
||||
mistral_api: MistralAPI,
|
||||
) -> None:
|
||||
mistral_api.reply(
|
||||
# First: the compaction summary
|
||||
mistral_completion("A summary of what happened so far"),
|
||||
# Then: the final answer to user message
|
||||
mistral_completion("final answer"),
|
||||
)
|
||||
agent = build_e2e_agent_loop(
|
||||
config=build_test_vibe_config(models=COMPACTION_MODELS)
|
||||
)
|
||||
agent.stats.context_tokens = 5 # Trigger the auto-compaction immediately
|
||||
|
||||
events = [event async for event in agent.act("Investigate the bug")]
|
||||
|
||||
assert any(isinstance(e, CompactStartEvent) for e in events)
|
||||
sent_after_compaction = mistral_api.model_facing_text(1)
|
||||
assert "Investigate the bug" in sent_after_compaction
|
||||
assert "A summary of what happened so far" in sent_after_compaction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repeated_auto_compaction_preserves_earlier_user_messages(
|
||||
mistral_api: MistralAPI,
|
||||
) -> None:
|
||||
mistral_api.reply(
|
||||
mistral_completion("summary one"),
|
||||
mistral_completion("reply one"),
|
||||
mistral_completion("summary two"),
|
||||
mistral_completion("reply two"),
|
||||
)
|
||||
agent = build_e2e_agent_loop(
|
||||
config=build_test_vibe_config(models=COMPACTION_MODELS)
|
||||
)
|
||||
|
||||
agent.stats.context_tokens = 5
|
||||
[_ async for _ in agent.act("first ask")]
|
||||
agent.stats.context_tokens = 5
|
||||
[_ async for _ in agent.act("second ask")]
|
||||
|
||||
sent_after_second_compaction = mistral_api.model_facing_text(3)
|
||||
assert "first ask" in sent_after_second_compaction
|
||||
assert "second ask" in sent_after_second_compaction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oversized_user_message_is_middle_truncated_in_compaction(
|
||||
mistral_api: MistralAPI,
|
||||
) -> None:
|
||||
huge_message = "alpha " * 20_000
|
||||
mistral_api.reply(
|
||||
mistral_completion("summary intro"),
|
||||
mistral_completion("reply intro"),
|
||||
mistral_completion("summary huge"),
|
||||
mistral_completion("reply huge"),
|
||||
)
|
||||
agent = build_e2e_agent_loop(
|
||||
config=build_test_vibe_config(models=COMPACTION_MODELS)
|
||||
)
|
||||
|
||||
agent.stats.context_tokens = 5
|
||||
[_ async for _ in agent.act("intro")]
|
||||
agent.stats.context_tokens = 5
|
||||
[_ async for _ in agent.act(huge_message)]
|
||||
|
||||
sent_after_second_compaction = mistral_api.model_facing_text(3)
|
||||
assert "[... truncated ...]" in sent_after_second_compaction
|
||||
assert "intro" not in sent_after_second_compaction
|
||||
|
|
@ -6,8 +6,9 @@ import httpx
|
|||
import pytest
|
||||
import respx
|
||||
|
||||
from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop, e2e_config
|
||||
from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop
|
||||
from tests.backend.data.mistral import mistral_completion
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from tests.constants import CONNECTORS_BOOTSTRAP_PATH
|
||||
from vibe.core.config import ConnectorConfig
|
||||
|
||||
|
|
@ -44,8 +45,9 @@ def _set_bootstrap(router: respx.MockRouter, connectors: list[dict[str, Any]]) -
|
|||
|
||||
|
||||
def _connectors_config(*aliases: str) -> Any:
|
||||
return e2e_config(
|
||||
connectors=[ConnectorConfig(name=alias, disabled=False) for alias in aliases]
|
||||
return build_test_vibe_config(
|
||||
enable_connectors=True,
|
||||
connectors=[ConnectorConfig(name=alias, disabled=False) for alias in aliases],
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ from typing import Any
|
|||
|
||||
import pytest
|
||||
|
||||
from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop, e2e_config
|
||||
from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop
|
||||
from tests.backend.data.mistral import mistral_completion
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.tools.builtins.ask_user_question import (
|
||||
AskUserQuestionArgs,
|
||||
|
|
@ -93,7 +94,7 @@ def _plan_agent(mistral_api: MistralAPI) -> Any:
|
|||
mistral_completion("Staying in plan mode."),
|
||||
)
|
||||
return build_e2e_agent_loop(
|
||||
config=e2e_config(enabled_tools=["exit_plan_mode"]),
|
||||
config=build_test_vibe_config(enabled_tools=["exit_plan_mode"]),
|
||||
agent_name=BuiltinAgentName.PLAN,
|
||||
)
|
||||
|
||||
|
|
|
|||
164
tests/agent_loop/e2e/test_e2e_hooks.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
import sys
|
||||
import textwrap
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop
|
||||
from tests.backend.data.mistral import mistral_completion
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.core.hooks.config import HookConfigResult
|
||||
from vibe.core.hooks.models import HookConfig, HookType
|
||||
from vibe.core.types import AssistantEvent, ToolResultEvent
|
||||
|
||||
TODO_TOOL_CALL: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "call_todo",
|
||||
"function": {"name": "todo", "arguments": '{"action": "read"}'},
|
||||
"index": 0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _emit_cmd(payload: dict[str, Any]) -> str:
|
||||
# Payload is passed via stdin of the runner (stdout of parent process)
|
||||
body = f"import sys; sys.stdout.write({json.dumps(payload)!r})"
|
||||
return f"{sys.executable} -c {shlex.quote(body)}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_before_tool_hook_denies_tool_and_skips_execution(
|
||||
mistral_api: MistralAPI,
|
||||
) -> None:
|
||||
deny_hook = HookConfig(
|
||||
name="deny",
|
||||
type=HookType.BEFORE_TOOL,
|
||||
command=_emit_cmd({"decision": "deny", "reason": "blocked by policy"}),
|
||||
match="todo",
|
||||
)
|
||||
mistral_api.reply(
|
||||
mistral_completion("", tool_calls=TODO_TOOL_CALL), mistral_completion("done")
|
||||
)
|
||||
agent = build_e2e_agent_loop(
|
||||
config=build_test_vibe_config(enabled_tools=["todo"]),
|
||||
hook_config_result=HookConfigResult(hooks=[deny_hook], issues=[]),
|
||||
)
|
||||
|
||||
events = [event async for event in agent.act("Show my todos")]
|
||||
|
||||
tool_result = next(e for e in events if isinstance(e, ToolResultEvent))
|
||||
assert tool_result.skipped is True
|
||||
assert "blocked by policy" in (tool_result.skip_reason or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_before_tool_hook_rewrites_tool_input_seen_by_model(
|
||||
mistral_api: MistralAPI,
|
||||
) -> None:
|
||||
rewritten = {
|
||||
"action": "write",
|
||||
"todos": [{"id": "1", "content": "rewritten by hook"}],
|
||||
}
|
||||
rewrite_hook = HookConfig(
|
||||
name="rewrite",
|
||||
type=HookType.BEFORE_TOOL,
|
||||
command=_emit_cmd({"hook_specific_output": {"tool_input": rewritten}}),
|
||||
match="todo",
|
||||
)
|
||||
mistral_api.reply(
|
||||
mistral_completion("", tool_calls=TODO_TOOL_CALL), mistral_completion("done")
|
||||
)
|
||||
agent = build_e2e_agent_loop(
|
||||
config=build_test_vibe_config(enabled_tools=["todo"]),
|
||||
hook_config_result=HookConfigResult(hooks=[rewrite_hook], issues=[]),
|
||||
)
|
||||
|
||||
[_ async for _ in agent.act("Update my todos")]
|
||||
|
||||
assert "rewritten by hook" in mistral_api.model_facing_text(1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_before_tool_rewrite_failing_validation_is_denied(
|
||||
mistral_api: MistralAPI,
|
||||
) -> None:
|
||||
bad_rewrite_hook = HookConfig(
|
||||
name="bad-rewrite",
|
||||
type=HookType.BEFORE_TOOL,
|
||||
command=_emit_cmd({"hook_specific_output": {"tool_input": {"todos": "x"}}}),
|
||||
match="todo",
|
||||
)
|
||||
mistral_api.reply(
|
||||
mistral_completion("", tool_calls=TODO_TOOL_CALL), mistral_completion("done")
|
||||
)
|
||||
agent = build_e2e_agent_loop(
|
||||
config=build_test_vibe_config(enabled_tools=["todo"]),
|
||||
hook_config_result=HookConfigResult(hooks=[bad_rewrite_hook], issues=[]),
|
||||
)
|
||||
|
||||
events = [event async for event in agent.act("Update my todos")]
|
||||
|
||||
tool_result = next(e for e in events if isinstance(e, ToolResultEvent))
|
||||
assert tool_result.skipped is True
|
||||
assert "failed validation" in (tool_result.skip_reason or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_after_tool_hook_replaces_tool_output_seen_by_model(
|
||||
mistral_api: MistralAPI,
|
||||
) -> None:
|
||||
replace_output_hook = HookConfig(
|
||||
name="replace",
|
||||
type=HookType.AFTER_TOOL,
|
||||
command=_emit_cmd({"decision": "deny", "reason": "REPLACED OUTPUT"}),
|
||||
match="todo",
|
||||
)
|
||||
mistral_api.reply(
|
||||
mistral_completion("", tool_calls=TODO_TOOL_CALL), mistral_completion("done")
|
||||
)
|
||||
agent = build_e2e_agent_loop(
|
||||
config=build_test_vibe_config(enabled_tools=["todo"]),
|
||||
hook_config_result=HookConfigResult(hooks=[replace_output_hook], issues=[]),
|
||||
)
|
||||
|
||||
[_ async for _ in agent.act("Show my todos")]
|
||||
|
||||
assert "REPLACED OUTPUT" in mistral_api.model_facing_text(1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_agent_turn_hook_injects_retry_user_message(
|
||||
mistral_api: MistralAPI,
|
||||
) -> None:
|
||||
# A post-agent-turn hook that denies forever would loop, so this script
|
||||
# denies only on its first run, using a sentinel file to stay silent after.
|
||||
deny_decision = json.dumps({"decision": "deny", "reason": "please continue"})
|
||||
deny_once = textwrap.dedent(f"""
|
||||
import os, sys
|
||||
sentinel = "__post_turn_sentinel__"
|
||||
if not os.path.exists(sentinel):
|
||||
open(sentinel, "w").close()
|
||||
sys.stdout.write({deny_decision!r})
|
||||
""")
|
||||
retry_hook = HookConfig(
|
||||
name="retry",
|
||||
type=HookType.POST_AGENT_TURN,
|
||||
command=f"{sys.executable} -c {shlex.quote(deny_once)}",
|
||||
)
|
||||
mistral_api.reply(
|
||||
mistral_completion("first answer"), mistral_completion("second answer")
|
||||
)
|
||||
agent = build_e2e_agent_loop(
|
||||
config=build_test_vibe_config(enabled_tools=["todo"]),
|
||||
hook_config_result=HookConfigResult(hooks=[retry_hook], issues=[]),
|
||||
)
|
||||
|
||||
events = [event async for event in agent.act("Hello")]
|
||||
|
||||
assert "please continue" in mistral_api.model_facing_text(1)
|
||||
answers = [e.content for e in events if isinstance(e, AssistantEvent)]
|
||||
assert answers == ["first answer", "second answer"]
|
||||
|
|
@ -1,135 +1,146 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
|
||||
from tests.agent_loop.e2e.conftest import (
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from tests.agent_loop.e2e.conftest import build_e2e_agent_loop
|
||||
from tests.agent_loop.e2e.providers import (
|
||||
anthropic,
|
||||
openai_responses,
|
||||
reasoning,
|
||||
vertex,
|
||||
)
|
||||
from tests.agent_loop.e2e.providers.base import (
|
||||
ProviderAPI,
|
||||
anthropic_e2e_config,
|
||||
ProviderMocks,
|
||||
assistant_text,
|
||||
build_e2e_agent_loop,
|
||||
openai_responses_e2e_config,
|
||||
)
|
||||
from tests.backend.data.anthropic import (
|
||||
anthropic_message,
|
||||
anthropic_reasoning_tool_use_stream,
|
||||
anthropic_request_content_blocks,
|
||||
anthropic_text_stream,
|
||||
anthropic_tool_use,
|
||||
)
|
||||
from tests.backend.data.openai_responses import (
|
||||
openai_function_call_item,
|
||||
openai_message_item,
|
||||
openai_reasoning_tool_call_stream,
|
||||
openai_response,
|
||||
openai_text_stream,
|
||||
)
|
||||
from tests.backend.data import ANSWER_CONTEXT_TOKENS
|
||||
from tests.backend.data.anthropic import anthropic_message
|
||||
from tests.backend.data.reasoning import reasoning_thinking_message
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.types import ToolResultEvent
|
||||
|
||||
|
||||
class TestAnthropic:
|
||||
@dataclass(frozen=True)
|
||||
class ProviderScenario:
|
||||
"""Everything the shared e2e suite needs to exercise one provider.
|
||||
|
||||
Adding a provider is: implement `ProviderMocks`, then append one entry here.
|
||||
"""
|
||||
|
||||
id: str
|
||||
config: Callable[..., VibeConfig]
|
||||
api_cls: type[ProviderAPI]
|
||||
mocks: ProviderMocks
|
||||
|
||||
|
||||
PROVIDER_SCENARIOS: list[ProviderScenario] = [
|
||||
ProviderScenario(
|
||||
id="anthropic",
|
||||
config=anthropic.e2e_config,
|
||||
api_cls=anthropic.API,
|
||||
mocks=anthropic.Mocks(),
|
||||
),
|
||||
ProviderScenario(
|
||||
id="openai-responses",
|
||||
config=openai_responses.e2e_config,
|
||||
api_cls=openai_responses.API,
|
||||
mocks=openai_responses.Mocks(),
|
||||
),
|
||||
ProviderScenario(
|
||||
id="reasoning",
|
||||
config=reasoning.e2e_config,
|
||||
api_cls=reasoning.API,
|
||||
mocks=reasoning.Mocks(),
|
||||
),
|
||||
ProviderScenario(
|
||||
id="vertex-anthropic",
|
||||
config=vertex.e2e_config,
|
||||
api_cls=vertex.API,
|
||||
mocks=vertex.Mocks(),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def open_provider_api(
|
||||
api_cls: type[ProviderAPI], request: pytest.FixtureRequest
|
||||
) -> Iterator[ProviderAPI]:
|
||||
"""Instantiate a provider API and setup respx routers and monkeypatches"""
|
||||
api_cls.setup_monkeypatch(request.getfixturevalue("monkeypatch"))
|
||||
with respx.mock(base_url=api_cls.base_url, assert_all_called=False) as router:
|
||||
api = api_cls()
|
||||
api.setup_router(router)
|
||||
yield api
|
||||
|
||||
|
||||
@pytest.fixture(params=PROVIDER_SCENARIOS, ids=lambda s: s.id)
|
||||
def scenario(request: pytest.FixtureRequest) -> ProviderScenario:
|
||||
"""Parametrized fixture for each provider scenario in the shared e2e suite."""
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider_api(
|
||||
scenario: ProviderScenario, request: pytest.FixtureRequest
|
||||
) -> Iterator[ProviderAPI]:
|
||||
"""Returned setup provider API for the current scenario"""
|
||||
with open_provider_api(scenario.api_cls, request) as api:
|
||||
yield api
|
||||
|
||||
|
||||
class TestProviderCommonBehaviors:
|
||||
"""Answer / stream / tool-call behaviors every provider shares."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_answers(self, anthropic_api: ProviderAPI) -> None:
|
||||
# A plain prompt is answered through the Anthropic messages wire.
|
||||
anthropic_api.reply(anthropic_message("pong"))
|
||||
agent = build_e2e_agent_loop(config=anthropic_e2e_config())
|
||||
async def test_agent_answers(
|
||||
self, scenario: ProviderScenario, provider_api: ProviderAPI
|
||||
) -> None:
|
||||
provider_api.reply(scenario.mocks.answer("pong"))
|
||||
agent = build_e2e_agent_loop(config=scenario.config())
|
||||
|
||||
events = [event async for event in agent.act("Reply with exactly: pong")]
|
||||
|
||||
assert assistant_text(events) == "pong"
|
||||
assert agent.stats.context_tokens == 15
|
||||
assert agent.stats.context_tokens == ANSWER_CONTEXT_TOKENS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_streams(self, anthropic_api: ProviderAPI) -> None:
|
||||
# Anthropic SSE deltas are reassembled into the final assistant content.
|
||||
anthropic_api.reply_stream(anthropic_text_stream("pong"))
|
||||
agent = build_e2e_agent_loop(
|
||||
config=anthropic_e2e_config(), enable_streaming=True
|
||||
)
|
||||
async def test_agent_streams(
|
||||
self, scenario: ProviderScenario, provider_api: ProviderAPI
|
||||
) -> None:
|
||||
provider_api.reply_stream(scenario.mocks.text_stream("pong"))
|
||||
agent = build_e2e_agent_loop(config=scenario.config(), enable_streaming=True)
|
||||
|
||||
events = [event async for event in agent.act("Reply with exactly: pong")]
|
||||
|
||||
assert assistant_text(events) == "pong"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_executes_tool_call(self, anthropic_api: ProviderAPI) -> None:
|
||||
# A tool_use turn runs the tool, then Anthropic returns the final answer.
|
||||
anthropic_api.reply(
|
||||
anthropic_tool_use("todo", {"action": "read"}),
|
||||
anthropic_message("Your list is empty."),
|
||||
)
|
||||
agent = build_e2e_agent_loop(
|
||||
config=anthropic_e2e_config(enabled_tools=["todo"])
|
||||
async def test_agent_executes_tool_call(
|
||||
self, scenario: ProviderScenario, provider_api: ProviderAPI
|
||||
) -> None:
|
||||
provider_api.reply(
|
||||
scenario.mocks.tool_call("todo", {"action": "read"}),
|
||||
scenario.mocks.answer("Your list is empty."),
|
||||
)
|
||||
agent = build_e2e_agent_loop(config=scenario.config(enabled_tools=["todo"]))
|
||||
|
||||
events = [event async for event in agent.act("What's on my todo list?")]
|
||||
|
||||
assert any(isinstance(e, ToolResultEvent) for e in events)
|
||||
assert "Your list is empty." in assistant_text(events)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_streams_reasoning_and_tool_call(
|
||||
self, anthropic_api: ProviderAPI
|
||||
) -> None:
|
||||
# A streamed thinking + tool_use turn runs the tool, then replays that
|
||||
# reasoning/tool history into the follow-up Anthropic request.
|
||||
anthropic_api.reply_streams(
|
||||
anthropic_reasoning_tool_use_stream("todo", '{"action": "read"}'),
|
||||
anthropic_text_stream("Your list is empty."),
|
||||
)
|
||||
agent = build_e2e_agent_loop(
|
||||
config=anthropic_e2e_config(enabled_tools=["todo"]), enable_streaming=True
|
||||
)
|
||||
|
||||
events = [event async for event in agent.act("What's on my todo list?")]
|
||||
|
||||
assert "Your list is empty." in assistant_text(events)
|
||||
blocks = anthropic_request_content_blocks(anthropic_api.request_json)
|
||||
thinking = [b["thinking"] for b in blocks if b.get("type") == "thinking"]
|
||||
tool_uses = [b for b in blocks if b.get("type") == "tool_use"]
|
||||
|
||||
assert any("thinking..." in text for text in thinking)
|
||||
assert tool_uses
|
||||
|
||||
|
||||
class TestOpenAIResponses:
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_answers(self, openai_responses_api: ProviderAPI) -> None:
|
||||
# A plain prompt is answered through the OpenAI Responses wire.
|
||||
openai_responses_api.reply(openai_response([openai_message_item("pong")]))
|
||||
agent = build_e2e_agent_loop(config=openai_responses_e2e_config())
|
||||
|
||||
events = [event async for event in agent.act("Reply with exactly: pong")]
|
||||
|
||||
assert assistant_text(events) == "pong"
|
||||
assert agent.stats.context_tokens == 12
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_streams(self, openai_responses_api: ProviderAPI) -> None:
|
||||
# OpenAI Responses SSE deltas are reassembled into the final content.
|
||||
openai_responses_api.reply_stream(openai_text_stream("pong"))
|
||||
agent = build_e2e_agent_loop(
|
||||
config=openai_responses_e2e_config(), enable_streaming=True
|
||||
)
|
||||
|
||||
events = [event async for event in agent.act("Reply with exactly: pong")]
|
||||
|
||||
assert assistant_text(events) == "pong"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_captures_reasoning(
|
||||
self, openai_responses_api: ProviderAPI
|
||||
self, scenario: ProviderScenario, provider_api: ProviderAPI
|
||||
) -> None:
|
||||
# Commentary phase output is captured as reasoning on the assistant message.
|
||||
openai_responses_api.reply(
|
||||
openai_response(
|
||||
[
|
||||
openai_message_item("Let me think.", phase="commentary"),
|
||||
openai_message_item("pong", phase="final_answer"),
|
||||
],
|
||||
output_tokens=5,
|
||||
)
|
||||
)
|
||||
agent = build_e2e_agent_loop(config=openai_responses_e2e_config())
|
||||
provider_api.reply(scenario.mocks.reasoning_answer("pong", "Let me think."))
|
||||
agent = build_e2e_agent_loop(config=scenario.config())
|
||||
|
||||
events = [event async for event in agent.act("Reply with exactly: pong")]
|
||||
|
||||
|
|
@ -140,39 +151,55 @@ class TestOpenAIResponses:
|
|||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_executes_tool_call(
|
||||
self, openai_responses_api: ProviderAPI
|
||||
async def test_agent_streams_reasoning_then_runs_tool(
|
||||
self, scenario: ProviderScenario, provider_api: ProviderAPI
|
||||
) -> None:
|
||||
# A function_call item runs the tool, then OpenAI returns the final answer.
|
||||
openai_responses_api.reply(
|
||||
openai_response([openai_function_call_item("todo", '{"action": "read"}')]),
|
||||
openai_response([openai_message_item("Your list is empty.")]),
|
||||
provider_api.reply_streams(
|
||||
scenario.mocks.reasoning_tool_call_stream(
|
||||
"todo", {"action": "read"}, reasoning="thinking..."
|
||||
),
|
||||
scenario.mocks.text_stream("Your list is empty."),
|
||||
)
|
||||
agent = build_e2e_agent_loop(
|
||||
config=openai_responses_e2e_config(enabled_tools=["todo"])
|
||||
config=scenario.config(enabled_tools=["todo"]), enable_streaming=True
|
||||
)
|
||||
|
||||
events = [event async for event in agent.act("What's on my todo list?")]
|
||||
|
||||
assert any(isinstance(e, ToolResultEvent) for e in events)
|
||||
assert "Your list is empty." in assistant_text(events)
|
||||
assert any(
|
||||
m.reasoning_content and "thinking..." in m.reasoning_content
|
||||
for m in agent.messages
|
||||
)
|
||||
|
||||
|
||||
# The following tests are provider-specific and not shared across all providers, so they are not parametrized by scenario.
|
||||
class TestReasoning:
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_streams_reasoning_and_tool_call(
|
||||
self, openai_responses_api: ProviderAPI
|
||||
async def test_forwards_thinking_level_as_reasoning_effort(
|
||||
self, request: pytest.FixtureRequest
|
||||
) -> None:
|
||||
# A streamed commentary + function_call turn runs the tool, then OpenAI
|
||||
# streams the final answer on the follow-up request.
|
||||
openai_responses_api.reply_streams(
|
||||
openai_reasoning_tool_call_stream("todo", '{"action": "read"}'),
|
||||
openai_text_stream("Your list is empty."),
|
||||
)
|
||||
agent = build_e2e_agent_loop(
|
||||
config=openai_responses_e2e_config(enabled_tools=["todo"]),
|
||||
enable_streaming=True,
|
||||
)
|
||||
# The model's thinking level maps to reasoning_effort on the wire.
|
||||
with open_provider_api(reasoning.API, request) as api:
|
||||
api.reply(reasoning_thinking_message("pong", "Let me think."))
|
||||
agent = build_e2e_agent_loop(config=reasoning.e2e_config(thinking="medium"))
|
||||
|
||||
events = [event async for event in agent.act("What's on my todo list?")]
|
||||
async for _ in agent.act("Reply with exactly: pong"):
|
||||
pass
|
||||
|
||||
assert any(isinstance(e, ToolResultEvent) for e in events)
|
||||
assert "Your list is empty." in assistant_text(events)
|
||||
assert api.request_json["reasoning_effort"] == "medium"
|
||||
|
||||
|
||||
class TestVertexAnthropic:
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_vertex_wire(self, request: pytest.FixtureRequest) -> None:
|
||||
# The request goes out on the Vertex rawPredict wire format.
|
||||
with open_provider_api(vertex.API, request) as api:
|
||||
api.reply(anthropic_message("pong"))
|
||||
agent = build_e2e_agent_loop(config=vertex.e2e_config())
|
||||
|
||||
events = [event async for event in agent.act("Reply with exactly: pong")]
|
||||
|
||||
assert assistant_text(events) == "pong"
|
||||
assert api.request_json["anthropic_version"] == "vertex-2023-10-16"
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ from typing import Any, cast
|
|||
|
||||
import pytest
|
||||
|
||||
from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop, e2e_config
|
||||
from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop
|
||||
from tests.backend.data.mistral import mistral_completion
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.core.tools.builtins.grep import GrepResult
|
||||
from vibe.core.tools.builtins.read import ReadResult
|
||||
from vibe.core.tools.builtins.todo import TodoResult
|
||||
|
|
@ -33,7 +34,7 @@ async def _run_tool(
|
|||
# Drive one tool call against the real cwd, then a final reply, and return
|
||||
# the single tool result for the test to assert on.
|
||||
mistral_api.reply(_tool_call(name, arguments), mistral_completion("done"))
|
||||
agent = build_e2e_agent_loop(config=e2e_config(enabled_tools=[name]))
|
||||
agent = build_e2e_agent_loop(config=build_test_vibe_config(enabled_tools=[name]))
|
||||
events: list[BaseEvent] = [event async for event in agent.act("go")]
|
||||
result = next(e for e in events if isinstance(e, ToolResultEvent))
|
||||
assert result.error is None
|
||||
|
|
|
|||
|
|
@ -100,6 +100,30 @@ async def test_passes_x_affinity_header_when_asking_an_answer_streaming(
|
|||
assert headers["x-affinity"] == agent.session_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_tokens_is_passed_to_backend(vibe_config: VibeConfig):
|
||||
backend = FakeBackend([mock_llm_chunk(content="Response")])
|
||||
agent = build_test_agent_loop(config=vibe_config, backend=backend)
|
||||
|
||||
agent.set_max_tokens(8192)
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
assert backend.requests_max_tokens == [8192]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_tokens_is_passed_to_streaming_backend(vibe_config: VibeConfig):
|
||||
backend = FakeBackend([mock_llm_chunk(content="Response")])
|
||||
agent = build_test_agent_loop(
|
||||
config=vibe_config, backend=backend, enable_streaming=True
|
||||
)
|
||||
|
||||
agent.set_max_tokens(8192)
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
assert backend.requests_max_tokens == [8192]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_tokens_stats_based_on_backend_response(vibe_config: VibeConfig):
|
||||
chunk = mock_llm_chunk(content="Response", prompt_tokens=100, completion_tokens=50)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ async def test_refresh_system_prompt_preserves_scratchpad_section() -> None:
|
|||
# of the scratchpad on the very first turn for any user with telemetry
|
||||
# enabled and a Mistral API key.
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=True,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
|
|
|
|||
|
|
@ -57,9 +57,6 @@ def make_config(
|
|||
*, enabled_tools: list[str] | None = None, tools: dict[str, dict] | None = None
|
||||
) -> VibeConfig:
|
||||
return build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
enabled_tools=enabled_tools or [],
|
||||
|
|
|
|||
|
|
@ -39,11 +39,7 @@ async def act_and_collect_events(agent_loop: AgentLoop, prompt: str) -> list[Bas
|
|||
|
||||
def make_config(todo_permission: ToolPermission = ToolPermission.ALWAYS) -> VibeConfig:
|
||||
return build_test_vibe_config(
|
||||
enabled_tools=["todo"],
|
||||
tools={"todo": {"permission": todo_permission.value}},
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_tools=["todo"], tools={"todo": {"permission": todo_permission.value}}
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -366,12 +366,6 @@ class TestAgentProfileOverrides:
|
|||
|
||||
|
||||
class TestAgentManagerCycling:
|
||||
@pytest.fixture
|
||||
def base_config(self) -> VibeConfig:
|
||||
return build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def backend(self) -> FakeBackend:
|
||||
return FakeBackend([
|
||||
|
|
@ -382,10 +376,10 @@ class TestAgentManagerCycling:
|
|||
])
|
||||
|
||||
def test_get_agent_order_includes_primary_agents(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
self, vibe_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = build_test_agent_loop(
|
||||
config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
config=vibe_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
)
|
||||
order = agent.agent_manager.get_agent_order()
|
||||
assert len(order) == 4
|
||||
|
|
@ -395,10 +389,10 @@ class TestAgentManagerCycling:
|
|||
assert BuiltinAgentName.ACCEPT_EDITS in order
|
||||
|
||||
def test_next_agent_cycles_through_all(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
self, vibe_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = build_test_agent_loop(
|
||||
config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
config=vibe_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
)
|
||||
order = agent.agent_manager.get_agent_order()
|
||||
current = agent.agent_manager.active_profile
|
||||
|
|
@ -409,10 +403,10 @@ class TestAgentManagerCycling:
|
|||
assert len(set(visited)) == len(order)
|
||||
|
||||
def test_next_agent_wraps_around(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
self, vibe_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = build_test_agent_loop(
|
||||
config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
config=vibe_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
)
|
||||
order = agent.agent_manager.get_agent_order()
|
||||
last_profile = agent.agent_manager.get_agent(order[-1])
|
||||
|
|
@ -433,12 +427,6 @@ class TestAgentProfileConfig:
|
|||
|
||||
|
||||
class TestAgentSwitchAgent:
|
||||
@pytest.fixture
|
||||
def base_config(self) -> VibeConfig:
|
||||
return build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def backend(self) -> FakeBackend:
|
||||
return FakeBackend([
|
||||
|
|
@ -450,10 +438,10 @@ class TestAgentSwitchAgent:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_to_plan_agent_has_tools_with_restricted_permissions(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
self, vibe_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = build_test_agent_loop(
|
||||
config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
config=vibe_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
)
|
||||
await agent.switch_agent(BuiltinAgentName.PLAN)
|
||||
|
||||
|
|
@ -471,10 +459,10 @@ class TestAgentSwitchAgent:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_from_plan_to_default_restores_tools(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
self, vibe_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = build_test_agent_loop(
|
||||
config=base_config, agent_name=BuiltinAgentName.PLAN, backend=backend
|
||||
config=vibe_config, agent_name=BuiltinAgentName.PLAN, backend=backend
|
||||
)
|
||||
|
||||
await agent.switch_agent(BuiltinAgentName.DEFAULT)
|
||||
|
|
@ -486,10 +474,10 @@ class TestAgentSwitchAgent:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_agent_preserves_conversation_history(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
self, vibe_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = build_test_agent_loop(
|
||||
config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
config=vibe_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
)
|
||||
user_msg = LLMMessage(role=Role.user, content="Hello")
|
||||
assistant_msg = LLMMessage(role=Role.assistant, content="Hi there")
|
||||
|
|
@ -504,10 +492,10 @@ class TestAgentSwitchAgent:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_to_same_agent_is_noop(
|
||||
self, base_config: VibeConfig, backend: FakeBackend
|
||||
self, vibe_config: VibeConfig, backend: FakeBackend
|
||||
) -> None:
|
||||
agent = build_test_agent_loop(
|
||||
config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
config=vibe_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
|
||||
)
|
||||
original_config = agent.config
|
||||
|
||||
|
|
@ -587,11 +575,7 @@ class TestPlanAgentToolRestriction:
|
|||
|
||||
class TestAgentManagerFiltering:
|
||||
def test_enabled_agents_filters_to_only_enabled(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_agents=["default", "plan"],
|
||||
)
|
||||
config = build_test_vibe_config(enabled_agents=["default", "plan"])
|
||||
manager = AgentManager(lambda: config)
|
||||
|
||||
agents = manager.available_agents
|
||||
|
|
@ -603,9 +587,7 @@ class TestAgentManagerFiltering:
|
|||
|
||||
def test_disabled_agents_excludes_disabled(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
disabled_agents=["auto-approve", "accept-edits"],
|
||||
disabled_agents=["auto-approve", "accept-edits"]
|
||||
)
|
||||
manager = AgentManager(lambda: config)
|
||||
|
||||
|
|
@ -618,8 +600,6 @@ class TestAgentManagerFiltering:
|
|||
|
||||
def test_enabled_agents_takes_precedence_over_disabled(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_agents=["default"],
|
||||
disabled_agents=["default"], # Should be ignored
|
||||
)
|
||||
|
|
@ -630,11 +610,7 @@ class TestAgentManagerFiltering:
|
|||
assert "default" in agents
|
||||
|
||||
def test_glob_pattern_matching(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
disabled_agents=["auto-*", "accept-*"],
|
||||
)
|
||||
config = build_test_vibe_config(disabled_agents=["auto-*", "accept-*"])
|
||||
manager = AgentManager(lambda: config)
|
||||
|
||||
agents = manager.available_agents
|
||||
|
|
@ -644,11 +620,7 @@ class TestAgentManagerFiltering:
|
|||
assert "accept-edits" not in agents
|
||||
|
||||
def test_regex_pattern_matching(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_agents=["re:^(default|plan)$"],
|
||||
)
|
||||
config = build_test_vibe_config(enabled_agents=["re:^(default|plan)$"])
|
||||
manager = AgentManager(lambda: config)
|
||||
|
||||
agents = manager.available_agents
|
||||
|
|
@ -657,11 +629,7 @@ class TestAgentManagerFiltering:
|
|||
assert "plan" in agents
|
||||
|
||||
def test_empty_enabled_agents_returns_all(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_agents=[],
|
||||
)
|
||||
config = build_test_vibe_config(enabled_agents=[])
|
||||
manager = AgentManager(lambda: config)
|
||||
|
||||
agents = manager.available_agents
|
||||
|
|
@ -671,31 +639,21 @@ class TestAgentManagerFiltering:
|
|||
assert "explore" in agents
|
||||
|
||||
def test_install_required_agents_hidden_by_default(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
config = build_test_vibe_config()
|
||||
manager = AgentManager(lambda: config)
|
||||
|
||||
agents = manager.available_agents
|
||||
assert "lean" not in agents
|
||||
|
||||
def test_install_required_agents_visible_when_installed(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
installed_agents=["lean"],
|
||||
)
|
||||
config = build_test_vibe_config(installed_agents=["lean"])
|
||||
manager = AgentManager(lambda: config)
|
||||
|
||||
agents = manager.available_agents
|
||||
assert "lean" in agents
|
||||
|
||||
def test_get_subagents_respects_filtering(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
disabled_agents=["explore"],
|
||||
)
|
||||
config = build_test_vibe_config(disabled_agents=["explore"])
|
||||
manager = AgentManager(lambda: config)
|
||||
|
||||
subagents = manager.get_subagents()
|
||||
|
|
@ -722,11 +680,9 @@ class TestAgentLoopInitialization:
|
|||
monkeypatch.setattr("vibe.core.agents.models.BUILTIN_AGENTS", patched_agents)
|
||||
monkeypatch.setattr("vibe.core.agents.manager.BUILTIN_AGENTS", patched_agents)
|
||||
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
config = build_test_vibe_config(system_prompt_id="cli")
|
||||
assert config.system_prompt_id == "cli", (
|
||||
"Base config should use default 'cli' prompt"
|
||||
"Base config should use the 'cli' prompt"
|
||||
)
|
||||
|
||||
agent_loop = build_test_agent_loop(
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from vibe.core.config import MCPStdio
|
|||
from vibe.core.telemetry.types import TerminalEmulator
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
from vibe.core.tools.mcp import AuthStatus
|
||||
from vibe.core.tools.mcp.tools import RemoteTool
|
||||
from vibe.core.tools.remote import RemoteTool
|
||||
|
||||
|
||||
def _build_uninitiated_loop(**kwargs):
|
||||
|
|
@ -67,8 +67,8 @@ class TestCompleteInit:
|
|||
loop = _build_uninitiated_loop(config=config)
|
||||
|
||||
with patch.object(
|
||||
loop.tool_manager._mcp_registry,
|
||||
"get_tools_async",
|
||||
loop.tool_manager,
|
||||
"integrate_all",
|
||||
side_effect=RuntimeError("mcp discovery boom"),
|
||||
):
|
||||
_run_init(loop)
|
||||
|
|
@ -77,6 +77,20 @@ class TestCompleteInit:
|
|||
assert isinstance(loop._init_error, RuntimeError)
|
||||
assert str(loop._init_error) == "mcp discovery boom"
|
||||
|
||||
def test_delays_connector_registry_until_deferred_init(self) -> None:
|
||||
config = build_test_vibe_config(enable_connectors=True)
|
||||
with patch.object(AgentLoop, "_start_deferred_init"):
|
||||
loop = AgentLoop(
|
||||
config=config, backend=FakeBackend(), defer_heavy_init=True
|
||||
)
|
||||
|
||||
assert loop.connector_registry is None
|
||||
|
||||
with patch.object(loop.tool_manager, "integrate_all"):
|
||||
_run_init(loop)
|
||||
|
||||
assert loop.connector_registry is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wait_until_ready
|
||||
|
|
@ -232,6 +246,27 @@ class TestDeferredInitPublicMethods:
|
|||
|
||||
assert loop.is_initialized
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_creates_shared_mcp_registry_after_servers_are_added(
|
||||
self,
|
||||
) -> None:
|
||||
loop = build_test_agent_loop(defer_heavy_init=True, mcp_registry=None)
|
||||
await loop.wait_until_ready()
|
||||
assert loop.mcp_registry is None
|
||||
|
||||
mcp_server = MCPStdio(name="srv", transport="stdio", command="echo")
|
||||
config = build_test_vibe_config(mcp_servers=[mcp_server])
|
||||
registry = FakeMCPRegistry()
|
||||
|
||||
with (
|
||||
patch.object(AgentLoop, "_create_mcp_registry", return_value=registry),
|
||||
patch.object(ToolManager, "integrate_all"),
|
||||
):
|
||||
await loop.reload_with_initial_messages(base_config=config)
|
||||
|
||||
assert loop.mcp_registry is registry
|
||||
assert loop.tool_manager._mcp_registry is registry
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_agent_waits_for_deferred_init(self) -> None:
|
||||
loop = build_test_agent_loop(defer_heavy_init=True)
|
||||
|
|
|
|||
|
|
@ -4,3 +4,8 @@ Url = str
|
|||
JsonResponse = dict
|
||||
ResultData = dict
|
||||
Chunk = bytes
|
||||
|
||||
# Shared usage every provider's `answer()` mock reports
|
||||
ANSWER_PROMPT_TOKENS = 10
|
||||
ANSWER_COMPLETION_TOKENS = 5
|
||||
ANSWER_CONTEXT_TOKENS = ANSWER_PROMPT_TOKENS + ANSWER_COMPLETION_TOKENS
|
||||
|
|
|
|||
|
|
@ -3,13 +3,42 @@ from __future__ import annotations
|
|||
import json
|
||||
from typing import Any
|
||||
|
||||
from tests.backend.data import Chunk, JsonResponse
|
||||
from tests.backend.data import (
|
||||
ANSWER_COMPLETION_TOKENS,
|
||||
ANSWER_PROMPT_TOKENS,
|
||||
Chunk,
|
||||
JsonResponse,
|
||||
)
|
||||
|
||||
|
||||
def _sse_event(data: dict[str, Any]) -> Chunk:
|
||||
return f"data: {json.dumps(data, separators=(',', ':'))}".encode()
|
||||
|
||||
|
||||
def _block_start(index: int, block: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"type": "content_block_start", "index": index, "content_block": block}
|
||||
|
||||
|
||||
def _block_delta(index: int, delta: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"type": "content_block_delta", "index": index, "delta": delta}
|
||||
|
||||
|
||||
def _content_stream(
|
||||
blocks: list[dict[str, Any]], *, input_tokens: int, output_tokens: int, stop: str
|
||||
) -> list[Chunk]:
|
||||
events = [
|
||||
{"type": "message_start", "message": {"usage": {"input_tokens": input_tokens}}},
|
||||
*blocks,
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": stop},
|
||||
"usage": {"output_tokens": output_tokens},
|
||||
},
|
||||
{"type": "message_stop"},
|
||||
]
|
||||
return [_sse_event(e) for e in events]
|
||||
|
||||
|
||||
def anthropic_request_content_blocks(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
# Flatten every structured content block across a request's messages
|
||||
# (text/thinking/tool_use/tool_result blocks).
|
||||
|
|
@ -24,8 +53,8 @@ def anthropic_request_content_blocks(payload: dict[str, Any]) -> list[dict[str,
|
|||
def anthropic_message(
|
||||
text: str,
|
||||
*,
|
||||
input_tokens: int = 12,
|
||||
output_tokens: int = 3,
|
||||
input_tokens: int = ANSWER_PROMPT_TOKENS,
|
||||
output_tokens: int = ANSWER_COMPLETION_TOKENS,
|
||||
stop_reason: str = "end_turn",
|
||||
) -> JsonResponse:
|
||||
return {
|
||||
|
|
@ -62,71 +91,31 @@ def anthropic_reasoning_tool_use_stream(
|
|||
input_tokens: int = 20,
|
||||
output_tokens: int = 5,
|
||||
) -> list[Chunk]:
|
||||
return [
|
||||
_sse_event(e)
|
||||
for e in (
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {"usage": {"input_tokens": input_tokens}},
|
||||
},
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "thinking", "thinking": reasoning},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "signature_delta", "signature": signature},
|
||||
},
|
||||
return _content_stream(
|
||||
[
|
||||
_block_start(0, {"type": "thinking", "thinking": reasoning}),
|
||||
_block_delta(0, {"type": "signature_delta", "signature": signature}),
|
||||
{"type": "content_block_stop", "index": 0},
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 1,
|
||||
"content_block": {"type": "tool_use", "id": tool_id, "name": name},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 1,
|
||||
"delta": {"type": "input_json_delta", "partial_json": arguments},
|
||||
},
|
||||
_block_start(1, {"type": "tool_use", "id": tool_id, "name": name}),
|
||||
_block_delta(1, {"type": "input_json_delta", "partial_json": arguments}),
|
||||
{"type": "content_block_stop", "index": 1},
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "tool_use"},
|
||||
"usage": {"output_tokens": output_tokens},
|
||||
},
|
||||
{"type": "message_stop"},
|
||||
)
|
||||
]
|
||||
],
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
stop="tool_use",
|
||||
)
|
||||
|
||||
|
||||
def anthropic_text_stream(
|
||||
text: str, *, input_tokens: int = 12, output_tokens: int = 3
|
||||
) -> list[Chunk]:
|
||||
return [
|
||||
_sse_event(e)
|
||||
for e in (
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {"usage": {"input_tokens": input_tokens}},
|
||||
},
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": text},
|
||||
},
|
||||
return _content_stream(
|
||||
[
|
||||
_block_start(0, {"type": "text", "text": ""}),
|
||||
_block_delta(0, {"type": "text_delta", "text": text}),
|
||||
{"type": "content_block_stop", "index": 0},
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "end_turn"},
|
||||
"usage": {"output_tokens": output_tokens},
|
||||
},
|
||||
{"type": "message_stop"},
|
||||
)
|
||||
]
|
||||
],
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
stop="end_turn",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,14 @@ from __future__ import annotations
|
|||
import json
|
||||
from typing import Any
|
||||
|
||||
from tests.backend.data import Chunk, JsonResponse, ResultData, Url
|
||||
from tests.backend.data import (
|
||||
ANSWER_COMPLETION_TOKENS,
|
||||
ANSWER_PROMPT_TOKENS,
|
||||
Chunk,
|
||||
JsonResponse,
|
||||
ResultData,
|
||||
Url,
|
||||
)
|
||||
from tests.constants import OPENAI_BASE_URL
|
||||
|
||||
|
||||
|
|
@ -104,8 +111,8 @@ def openai_function_call_item(
|
|||
def openai_response(
|
||||
output: list[dict[str, Any]],
|
||||
*,
|
||||
input_tokens: int = 10,
|
||||
output_tokens: int = 2,
|
||||
input_tokens: int = ANSWER_PROMPT_TOKENS,
|
||||
output_tokens: int = ANSWER_COMPLETION_TOKENS,
|
||||
response_id: str = "resp_1",
|
||||
model: str = "gpt-test",
|
||||
) -> JsonResponse:
|
||||
|
|
|
|||
135
tests/backend/data/reasoning.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from tests.backend.data import (
|
||||
ANSWER_COMPLETION_TOKENS,
|
||||
ANSWER_PROMPT_TOKENS,
|
||||
Chunk,
|
||||
JsonResponse,
|
||||
)
|
||||
|
||||
|
||||
def _sse_event(data: dict[str, Any]) -> Chunk:
|
||||
return f"data: {json.dumps(data, separators=(',', ':'))}".encode()
|
||||
|
||||
|
||||
def _thinking_block(reasoning: str) -> dict[str, Any]:
|
||||
return {"type": "thinking", "thinking": [{"type": "text", "text": reasoning}]}
|
||||
|
||||
|
||||
def reasoning_message(
|
||||
text: str,
|
||||
*,
|
||||
prompt_tokens: int = ANSWER_PROMPT_TOKENS,
|
||||
completion_tokens: int = ANSWER_COMPLETION_TOKENS,
|
||||
) -> JsonResponse:
|
||||
return {
|
||||
"choices": [{"message": {"role": "assistant", "content": text}}],
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def reasoning_thinking_message(
|
||||
text: str, reasoning: str, *, prompt_tokens: int = 12, completion_tokens: int = 5
|
||||
) -> JsonResponse:
|
||||
return {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
_thinking_block(reasoning),
|
||||
{"type": "text", "text": text},
|
||||
],
|
||||
}
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def reasoning_tool_use(
|
||||
name: str,
|
||||
arguments: str,
|
||||
*,
|
||||
reasoning: str | None = None,
|
||||
tool_id: str = "call_1",
|
||||
prompt_tokens: int = 20,
|
||||
completion_tokens: int = 5,
|
||||
) -> JsonResponse:
|
||||
message: dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tool_id,
|
||||
"type": "function",
|
||||
"function": {"name": name, "arguments": arguments},
|
||||
}
|
||||
],
|
||||
}
|
||||
if reasoning is not None:
|
||||
message["content"] = [_thinking_block(reasoning)]
|
||||
return {
|
||||
"choices": [{"message": message}],
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _delta_stream(
|
||||
deltas: list[dict[str, Any]], *, prompt_tokens: int, completion_tokens: int
|
||||
) -> list[Chunk]:
|
||||
events = [
|
||||
{"choices": [{"delta": {"role": "assistant"}}]},
|
||||
*({"choices": [{"delta": delta}]} for delta in deltas),
|
||||
{
|
||||
"choices": [{"delta": {}}],
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
},
|
||||
},
|
||||
]
|
||||
return [_sse_event(e) for e in events] + [b"data: [DONE]"]
|
||||
|
||||
|
||||
def reasoning_text_stream(
|
||||
text: str, *, prompt_tokens: int = 10, completion_tokens: int = 3
|
||||
) -> list[Chunk]:
|
||||
return _delta_stream(
|
||||
[{"content": text}],
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
)
|
||||
|
||||
|
||||
def reasoning_thinking_tool_use_stream(
|
||||
name: str,
|
||||
arguments: str,
|
||||
*,
|
||||
reasoning: str = "thinking...",
|
||||
tool_id: str = "call_1",
|
||||
prompt_tokens: int = 20,
|
||||
completion_tokens: int = 5,
|
||||
) -> list[Chunk]:
|
||||
tool_call = {
|
||||
"id": tool_id,
|
||||
"type": "function",
|
||||
"index": 0,
|
||||
"function": {"name": name, "arguments": arguments},
|
||||
}
|
||||
return _delta_stream(
|
||||
[{"content": [_thinking_block(reasoning)]}, {"tool_calls": [tool_call]}],
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
)
|
||||
|
|
@ -5,6 +5,7 @@ from unittest.mock import MagicMock, PropertyMock, patch
|
|||
|
||||
import pytest
|
||||
|
||||
from tests import constants as c
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.llm.backend.vertex import (
|
||||
VertexAnthropicAdapter,
|
||||
|
|
@ -32,8 +33,8 @@ def provider():
|
|||
return ProviderConfig(
|
||||
name="vertex",
|
||||
api_base="",
|
||||
project_id="test-project",
|
||||
region="us-central1",
|
||||
project_id=c.VERTEX_PROJECT_ID,
|
||||
region=c.VERTEX_REGION,
|
||||
api_style="vertex-anthropic",
|
||||
)
|
||||
|
||||
|
|
@ -58,8 +59,8 @@ class TestBuildVertexEndpoint:
|
|||
)
|
||||
|
||||
def test_base_url(self):
|
||||
base = build_vertex_base_url("us-central1")
|
||||
assert base == "https://us-central1-aiplatform.googleapis.com"
|
||||
base = build_vertex_base_url(c.VERTEX_REGION)
|
||||
assert base == c.VERTEX_BASE_URL
|
||||
|
||||
def test_global_endpoint(self):
|
||||
endpoint = build_vertex_endpoint("global", "my-project", "claude-3-5-sonnet")
|
||||
|
|
@ -96,7 +97,7 @@ class TestPrepareRequest:
|
|||
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"
|
||||
assert req.base_url == c.VERTEX_BASE_URL
|
||||
|
||||
def test_streaming_request(self, adapter, provider):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
|
|
@ -182,7 +183,7 @@ class TestPrepareRequest:
|
|||
provider = ProviderConfig(
|
||||
name="vertex",
|
||||
api_base="",
|
||||
region="us-central1",
|
||||
region=c.VERTEX_REGION,
|
||||
api_style="vertex-anthropic",
|
||||
)
|
||||
with pytest.raises(ValueError, match="project_id"):
|
||||
|
|
@ -201,7 +202,7 @@ class TestPrepareRequest:
|
|||
provider = ProviderConfig(
|
||||
name="vertex",
|
||||
api_base="",
|
||||
project_id="test-project",
|
||||
project_id=c.VERTEX_PROJECT_ID,
|
||||
api_style="vertex-anthropic",
|
||||
)
|
||||
with pytest.raises(ValueError, match="region"):
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ def test_resolve_api_key_for_plan_falls_back_to_keyring(
|
|||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
keyring_api_key = "keyring_mistral_api_key"
|
||||
monkeypatch.setattr(
|
||||
"vibe.core.config._settings.keyring.get_password",
|
||||
"vibe.core.utils.keyring.keyring.get_password",
|
||||
lambda service, username: keyring_api_key,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ def _fail(msg: str) -> NoReturn:
|
|||
def _isolated_env(vibe_home: Path) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env["VIBE_HOME"] = str(vibe_home)
|
||||
env["VIBE_TEST_DISABLE_KEYRING"] = "1"
|
||||
env["TERM"] = env.get("TERM") or "xterm-256color"
|
||||
env.pop("MISTRAL_API_KEY", None)
|
||||
env.pop("VIBE_MISTRAL_API_KEY", None)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ from __future__ import annotations
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from textual.widgets import TextArea
|
||||
|
||||
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
||||
from vibe.cli.clipboard import copy_selection_to_clipboard
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -51,3 +53,20 @@ async def test_mouse_up_respects_autocopy_config_disabled() -> None:
|
|||
async with app.run_test() as pilot:
|
||||
await pilot.click()
|
||||
mock_copy.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_selection_copies_text_area_selection() -> None:
|
||||
app = build_test_vibe_app()
|
||||
|
||||
with patch("vibe.cli.clipboard.try_copy_text_to_clipboard") as mock_copy:
|
||||
mock_copy.return_value = True
|
||||
async with app.run_test():
|
||||
text_area = app.query_one(TextArea)
|
||||
text_area.load_text("hello world")
|
||||
text_area.select_all()
|
||||
|
||||
result = copy_selection_to_clipboard(app, show_toast=False)
|
||||
|
||||
assert result == "hello world"
|
||||
mock_copy.assert_called_once_with("hello world")
|
||||
|
|
|
|||
171
tests/cli/test_lazy_audio_managers.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import (
|
||||
build_test_agent_loop,
|
||||
build_test_vibe_app,
|
||||
build_test_vibe_config,
|
||||
)
|
||||
from tests.stubs.fake_voice_manager import FakeVoiceManager
|
||||
from vibe.cli.narrator_manager.narrator_manager_port import (
|
||||
NarratorManagerListener,
|
||||
NarratorState,
|
||||
)
|
||||
from vibe.cli.textual_ui.lazy_audio_managers import (
|
||||
LazyNarratorManager,
|
||||
LazyVoiceManager,
|
||||
)
|
||||
from vibe.cli.voice_manager.voice_manager_port import TranscribeState
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.types import BaseEvent
|
||||
|
||||
|
||||
class FakeNarratorManager:
|
||||
state = NarratorState.IDLE
|
||||
is_playing = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.listeners: list[NarratorManagerListener] = []
|
||||
self.synced = False
|
||||
|
||||
def on_turn_start(self, user_message: str) -> None:
|
||||
pass
|
||||
|
||||
def on_turn_event(self, event: BaseEvent) -> None:
|
||||
pass
|
||||
|
||||
def on_turn_error(self, message: str) -> None:
|
||||
pass
|
||||
|
||||
def on_turn_cancel(self) -> None:
|
||||
pass
|
||||
|
||||
def on_turn_end(self) -> None:
|
||||
pass
|
||||
|
||||
def cancel(self) -> None:
|
||||
pass
|
||||
|
||||
def sync(self) -> None:
|
||||
self.synced = True
|
||||
|
||||
def add_listener(self, listener: NarratorManagerListener) -> None:
|
||||
if listener not in self.listeners:
|
||||
self.listeners.append(listener)
|
||||
|
||||
def remove_listener(self, listener: NarratorManagerListener) -> None:
|
||||
try:
|
||||
self.listeners.remove(listener)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_importing_tui_app_does_not_import_optional_audio_modules() -> None:
|
||||
code = """
|
||||
import sys
|
||||
import vibe.cli.textual_ui.app
|
||||
|
||||
blocked = [
|
||||
"sounddevice",
|
||||
"vibe.cli.narrator_manager.narrator_manager",
|
||||
"vibe.cli.voice_manager.voice_manager",
|
||||
"vibe.core.audio_player.audio_player",
|
||||
"vibe.core.audio_recorder.audio_recorder",
|
||||
"vibe.core.transcribe.factory",
|
||||
"vibe.core.tts.factory",
|
||||
]
|
||||
loaded = [name for name in blocked if name in sys.modules]
|
||||
if loaded:
|
||||
raise SystemExit(f"unexpected optional modules loaded: {loaded}")
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code], check=False, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
|
||||
|
||||
def test_default_tui_app_does_not_materialize_disabled_optional_managers() -> None:
|
||||
config = build_test_vibe_config(voice_mode_enabled=False, narrator_enabled=False)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.textual_ui.lazy_audio_managers._create_real_voice_manager",
|
||||
side_effect=AssertionError("voice should stay lazy"),
|
||||
),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.lazy_audio_managers._create_real_narrator_manager",
|
||||
side_effect=AssertionError("narrator should stay lazy"),
|
||||
),
|
||||
):
|
||||
app = build_test_vibe_app(config=config, voice_manager=None)
|
||||
|
||||
assert app._voice_manager.is_enabled is False
|
||||
assert app._voice_manager.transcribe_state == TranscribeState.IDLE
|
||||
assert app._narrator_manager.state == NarratorState.IDLE
|
||||
|
||||
|
||||
def test_lazy_voice_manager_materializes_when_used() -> None:
|
||||
config = build_test_vibe_config(voice_mode_enabled=False)
|
||||
factory = MagicMock(return_value=FakeVoiceManager(is_voice_ready=True))
|
||||
manager = LazyVoiceManager(lambda: config, factory)
|
||||
|
||||
assert manager.is_enabled is False
|
||||
assert manager.transcribe_state == TranscribeState.IDLE
|
||||
factory.assert_not_called()
|
||||
|
||||
manager.start_recording()
|
||||
|
||||
factory.assert_called_once()
|
||||
assert manager.transcribe_state == TranscribeState.RECORDING
|
||||
|
||||
|
||||
def test_lazy_narrator_manager_materializes_when_enabled_at_startup() -> None:
|
||||
config = build_test_vibe_config(narrator_enabled=True)
|
||||
narrator = FakeNarratorManager()
|
||||
factory = MagicMock(return_value=narrator)
|
||||
|
||||
manager = LazyNarratorManager(lambda: config, factory)
|
||||
|
||||
factory.assert_called_once()
|
||||
assert manager.state == NarratorState.IDLE
|
||||
|
||||
|
||||
def test_lazy_narrator_manager_sync_materializes_after_enable() -> None:
|
||||
config = build_test_vibe_config(narrator_enabled=False)
|
||||
narrator = FakeNarratorManager()
|
||||
factory = MagicMock(return_value=narrator)
|
||||
manager = LazyNarratorManager(lambda: config, factory)
|
||||
|
||||
config.narrator_enabled = True
|
||||
manager.sync()
|
||||
|
||||
factory.assert_called_once()
|
||||
assert narrator.synced is False
|
||||
|
||||
|
||||
def test_tui_config_refresh_syncs_lazy_narrator(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
initial_config = build_test_vibe_config(narrator_enabled=False)
|
||||
refreshed_config = build_test_vibe_config(narrator_enabled=True)
|
||||
agent_loop = build_test_agent_loop(config=initial_config)
|
||||
narrator = FakeNarratorManager()
|
||||
factory = MagicMock(return_value=narrator)
|
||||
narrator_manager = LazyNarratorManager(lambda: agent_loop.config, factory)
|
||||
app = build_test_vibe_app(agent_loop=agent_loop, narrator_manager=narrator_manager)
|
||||
monkeypatch.setattr(VibeConfig, "load", staticmethod(lambda: refreshed_config))
|
||||
|
||||
app._refresh_config_from_disk()
|
||||
|
||||
factory.assert_called_once()
|
||||
assert narrator.synced is False
|
||||
|
|
@ -8,6 +8,7 @@ import pytest
|
|||
from textual.widgets.option_list import Option
|
||||
|
||||
from tests.stubs.fake_connector_registry import FakeConnectorRegistry
|
||||
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
|
||||
from vibe.cli.textual_ui.widgets.mcp_app import (
|
||||
_LIST_VIEW_HELP_AUTH,
|
||||
_LIST_VIEW_HELP_TOOLS,
|
||||
|
|
@ -16,7 +17,7 @@ from vibe.cli.textual_ui.widgets.mcp_app import (
|
|||
_sort_connector_names_for_menu,
|
||||
collect_mcp_tool_index,
|
||||
)
|
||||
from vibe.core.config import MCPStdio
|
||||
from vibe.core.config import MCPHttp, MCPOAuth, MCPStdio
|
||||
from vibe.core.tools.base import InvokeContext
|
||||
from vibe.core.tools.connectors.connector_registry import (
|
||||
ConnectorAuthAction,
|
||||
|
|
@ -62,6 +63,16 @@ def _make_tool_manager(
|
|||
return mgr
|
||||
|
||||
|
||||
def _make_oauth_server(*, name: str = "oauth", disabled: bool = False) -> MCPHttp:
|
||||
return MCPHttp(
|
||||
name=name,
|
||||
transport="http",
|
||||
url="http://mcp.example.test",
|
||||
auth=MCPOAuth(type="oauth", scopes=[]),
|
||||
disabled=disabled,
|
||||
)
|
||||
|
||||
|
||||
class TestCollectMcpToolIndex:
|
||||
def test_non_mcp_tools_are_excluded(self) -> None:
|
||||
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
|
||||
|
|
@ -343,6 +354,26 @@ class TestHelpBarChanges:
|
|||
option = Option("", id="connector:slack")
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_AUTH
|
||||
|
||||
def test_help_shows_authenticate_for_oauth_server(self) -> None:
|
||||
server = _make_oauth_server()
|
||||
registry = FakeMCPRegistry()
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry)
|
||||
|
||||
option = Option("", id="server:oauth")
|
||||
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_AUTH
|
||||
|
||||
def test_help_shows_tools_for_disabled_oauth_server(self) -> None:
|
||||
server = _make_oauth_server(disabled=True)
|
||||
registry = FakeMCPRegistry()
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry)
|
||||
|
||||
option = Option("", id="server:oauth")
|
||||
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS
|
||||
|
||||
def test_help_shows_tools_for_degraded_connector(self) -> None:
|
||||
registry = FakeConnectorRegistry(connectors={"slack": []})
|
||||
mgr = _make_tool_manager({})
|
||||
|
|
@ -443,3 +474,84 @@ class TestConnectorAuthRequested:
|
|||
assert "No tools discovered" in str(
|
||||
option_list.add_option.call_args.args[0].prompt
|
||||
)
|
||||
|
||||
def test_oauth_server_with_no_tools_posts_auth_requested(self) -> None:
|
||||
server = _make_oauth_server()
|
||||
registry = FakeMCPRegistry()
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry)
|
||||
app.query_one = MagicMock()
|
||||
app.post_message = MagicMock()
|
||||
|
||||
option_list = MagicMock()
|
||||
app._show_detail_view(
|
||||
"oauth", option_list, app._index, kind=MCPSourceKind.SERVER
|
||||
)
|
||||
|
||||
app.post_message.assert_called_once()
|
||||
msg = app.post_message.call_args.args[0]
|
||||
assert isinstance(msg, MCPApp.MCPOAuthRequested)
|
||||
assert msg.server_name == "oauth"
|
||||
assert msg.mcp_registry is registry
|
||||
option_list.add_option.assert_not_called()
|
||||
|
||||
def test_oauth_server_with_stale_tools_posts_auth_requested(self) -> None:
|
||||
server = _make_oauth_server()
|
||||
registry = FakeMCPRegistry()
|
||||
stale_tool = _make_tool_cls(
|
||||
is_mcp=True, server_name="oauth", remote_name="stale"
|
||||
)
|
||||
mgr = _make_tool_manager({"oauth_stale": stale_tool})
|
||||
app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry)
|
||||
app.query_one = MagicMock()
|
||||
app.post_message = MagicMock()
|
||||
|
||||
option_list = MagicMock()
|
||||
app._show_detail_view(
|
||||
"oauth", option_list, app._index, kind=MCPSourceKind.SERVER
|
||||
)
|
||||
|
||||
app.post_message.assert_called_once()
|
||||
assert isinstance(app.post_message.call_args.args[0], MCPApp.MCPOAuthRequested)
|
||||
option_list.add_option.assert_not_called()
|
||||
|
||||
|
||||
class TestServerStatusLabels:
|
||||
def test_servers_show_enabled_disabled_and_needs_auth(self) -> None:
|
||||
servers = [
|
||||
MCPStdio(name="filesystem", transport="stdio", command="npx"),
|
||||
MCPHttp(name="search", transport="http", url="http://mcp.example.test"),
|
||||
_make_oauth_server(name="oauth"),
|
||||
_make_oauth_server(name="disabled-oauth", disabled=True),
|
||||
]
|
||||
registry = FakeMCPRegistry()
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=servers, tool_manager=mgr, mcp_registry=registry)
|
||||
option_list = MagicMock()
|
||||
|
||||
app._list_mcp_servers(option_list, app._index)
|
||||
|
||||
prompts = [
|
||||
str(call.args[0].prompt) for call in option_list.add_option.call_args_list
|
||||
]
|
||||
assert any("filesystem" in prompt and "enabled" in prompt for prompt in prompts)
|
||||
assert any("search" in prompt and "enabled" in prompt for prompt in prompts)
|
||||
assert any("oauth" in prompt and "needs auth" in prompt for prompt in prompts)
|
||||
assert any(
|
||||
"disabled-oauth" in prompt and "disabled" in prompt for prompt in prompts
|
||||
)
|
||||
|
||||
def test_oauth_server_with_cached_tools_shows_connected(self) -> None:
|
||||
server = _make_oauth_server()
|
||||
registry = FakeMCPRegistry()
|
||||
registry.set_tools([server], {})
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry)
|
||||
option_list = MagicMock()
|
||||
|
||||
app._list_mcp_servers(option_list, app._index)
|
||||
|
||||
prompts = [
|
||||
str(call.args[0].prompt) for call in option_list.add_option.call_args_list
|
||||
]
|
||||
assert any("oauth" in prompt and "connected" in prompt for prompt in prompts)
|
||||
|
|
|
|||
172
tests/cli/test_mcp_oauth_app.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from textual.widgets import OptionList
|
||||
|
||||
from vibe.cli.textual_ui.widgets.mcp_oauth_app import (
|
||||
MCPOAuthApp,
|
||||
_LoginResult,
|
||||
_OAuthOptionId,
|
||||
)
|
||||
|
||||
|
||||
class FakeLoginRegistry:
|
||||
def __init__(self, *, error: Exception | None = None) -> None:
|
||||
self.error = error
|
||||
self.login_calls: list[str] = []
|
||||
|
||||
async def login(
|
||||
self, alias: str, *, on_url: Callable[[str], Awaitable[None]]
|
||||
) -> None:
|
||||
self.login_calls.append(alias)
|
||||
await on_url("https://auth.example.com/oauth")
|
||||
if self.error:
|
||||
raise self.error
|
||||
|
||||
|
||||
def _make_app(registry: FakeLoginRegistry | None = None) -> MCPOAuthApp:
|
||||
return MCPOAuthApp(
|
||||
server_name="oauth", mcp_registry=cast(Any, registry or FakeLoginRegistry())
|
||||
)
|
||||
|
||||
|
||||
def _wire_query(app: MCPOAuthApp) -> tuple[MagicMock, MagicMock, MagicMock]:
|
||||
option_list = MagicMock()
|
||||
option_list.get_option_index.return_value = 2
|
||||
detail = MagicMock()
|
||||
help_widget = MagicMock()
|
||||
|
||||
def query(sel: object, *a: object, **kw: object) -> MagicMock:
|
||||
if sel is OptionList:
|
||||
return option_list
|
||||
s = str(sel)
|
||||
if "detail" in s:
|
||||
return detail
|
||||
if "help" in s:
|
||||
return help_widget
|
||||
return MagicMock()
|
||||
|
||||
app.query_one = cast(Any, query)
|
||||
return option_list, detail, help_widget
|
||||
|
||||
|
||||
class TestMCPOAuthApp:
|
||||
def test_widget_id(self) -> None:
|
||||
app = _make_app()
|
||||
assert app.id == "mcpoauth-app"
|
||||
|
||||
def test_action_close_posts_cancelled_message(self) -> None:
|
||||
app = _make_app()
|
||||
app.post_message = MagicMock()
|
||||
|
||||
app.action_close()
|
||||
|
||||
msg = app.post_message.call_args.args[0]
|
||||
assert isinstance(msg, MCPOAuthApp.MCPOAuthClosed)
|
||||
assert msg.refreshed is False
|
||||
assert msg.server_name == ""
|
||||
|
||||
def test_auth_url_available_shows_menu(self) -> None:
|
||||
app = _make_app()
|
||||
option_list, _detail, _help = _wire_query(app)
|
||||
|
||||
app._on_auth_url_available("https://auth.example.com/oauth")
|
||||
|
||||
assert app._auth_url == "https://auth.example.com/oauth"
|
||||
assert option_list.clear_options.called
|
||||
assert option_list.add_option.call_count == 5
|
||||
option_ids = [
|
||||
call.args[0].id
|
||||
for call in option_list.add_option.call_args_list
|
||||
if hasattr(call.args[0], "id") and call.args[0].id
|
||||
]
|
||||
assert _OAuthOptionId.OPEN in option_ids
|
||||
assert _OAuthOptionId.COPY in option_ids
|
||||
assert _OAuthOptionId.SHOW in option_ids
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_login_starts_registry_login(self) -> None:
|
||||
registry = FakeLoginRegistry()
|
||||
app = _make_app(registry)
|
||||
_wire_query(app)
|
||||
|
||||
result = await app._run_login()
|
||||
|
||||
assert result == _LoginResult(authenticated=True)
|
||||
assert registry.login_calls == ["oauth"]
|
||||
assert app._auth_url == "https://auth.example.com/oauth"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_login_returns_error(self) -> None:
|
||||
registry = FakeLoginRegistry(error=ValueError("bad auth"))
|
||||
app = _make_app(registry)
|
||||
_wire_query(app)
|
||||
|
||||
result = await app._run_login()
|
||||
|
||||
assert result == _LoginResult(authenticated=False, error="bad auth")
|
||||
|
||||
def test_worker_success_posts_closed(self) -> None:
|
||||
app = _make_app()
|
||||
app.post_message = MagicMock()
|
||||
worker = MagicMock()
|
||||
worker.group = "mcp_oauth_login"
|
||||
worker.is_finished = True
|
||||
worker.result = _LoginResult(authenticated=True)
|
||||
event = MagicMock()
|
||||
event.worker = worker
|
||||
|
||||
app.on_worker_state_changed(event)
|
||||
|
||||
msg = app.post_message.call_args.args[0]
|
||||
assert isinstance(msg, MCPOAuthApp.MCPOAuthClosed)
|
||||
assert msg.refreshed is True
|
||||
assert msg.server_name == "oauth"
|
||||
|
||||
def test_worker_failure_shows_retry_message(self) -> None:
|
||||
app = _make_app()
|
||||
_wire_query(app)
|
||||
app.post_message = MagicMock()
|
||||
worker = MagicMock()
|
||||
worker.group = "mcp_oauth_login"
|
||||
worker.is_finished = True
|
||||
worker.result = _LoginResult(authenticated=False, error="bad auth")
|
||||
event = MagicMock()
|
||||
event.worker = worker
|
||||
|
||||
app.on_worker_state_changed(event)
|
||||
|
||||
app.post_message.assert_not_called()
|
||||
assert app._status_message == "bad auth"
|
||||
|
||||
def test_open_browser_calls_webbrowser(self) -> None:
|
||||
app = _make_app()
|
||||
app._auth_url = "https://auth.example.com/oauth"
|
||||
app.query_one = MagicMock()
|
||||
|
||||
with patch("vibe.cli.textual_ui.widgets.mcp_oauth_app.webbrowser") as wb:
|
||||
app._open_browser()
|
||||
wb.open.assert_called_once_with("https://auth.example.com/oauth")
|
||||
|
||||
assert app._status_message == "Opened in browser."
|
||||
|
||||
def test_copy_url_calls_clipboard(self) -> None:
|
||||
app = cast(Any, _make_app())
|
||||
app._auth_url = "https://auth.example.com/oauth"
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
type(app), "app", new_callable=lambda: property(lambda s: MagicMock())
|
||||
),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.mcp_oauth_app.copy_text_to_clipboard"
|
||||
) as copy_fn,
|
||||
):
|
||||
app._copy_url()
|
||||
|
||||
copy_fn.assert_called_once()
|
||||
assert copy_fn.call_args.args[1] == "https://auth.example.com/oauth"
|
||||
|
|
@ -12,6 +12,7 @@ from tests.constants import OPENAI_BASE_URL
|
|||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
||||
from vibe.cli.textual_ui.widgets.messages import ErrorMessage
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
|
|
@ -73,6 +74,23 @@ async def test_teleport_command_visible_for_paid_chat_users() -> None:
|
|||
assert "&" in input_widget.mode_characters
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_resolution_updates_subscription_banner() -> None:
|
||||
app = build_test_vibe_app(
|
||||
config=_vibe_code_enabled_config(),
|
||||
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=False),
|
||||
)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await _wait_until(
|
||||
pilot.pause,
|
||||
lambda: (
|
||||
"[Subscription] Pro"
|
||||
in str(app.query_one("#banner-user-plan", NoMarkupStatic).content)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teleport_command_without_history_sends_early_failure_telemetry(
|
||||
telemetry_events: list[dict[str, Any]],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from textual.geometry import Offset
|
||||
from textual.selection import Selection
|
||||
|
||||
from tests.conftest import build_test_vibe_app
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer, ChatTextArea
|
||||
|
|
@ -62,3 +64,46 @@ async def test_shift_backspace_resets_mode_when_empty() -> None:
|
|||
await pilot.pause(0.1)
|
||||
|
||||
assert text_area.input_mode == ">"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_cleared_when_focus_leaves_text_area() -> None:
|
||||
app = build_test_vibe_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
text_area.load_text("hello world")
|
||||
text_area.select_all()
|
||||
await pilot.pause(0.1)
|
||||
assert text_area.selected_text == "hello world"
|
||||
|
||||
app.query_one("#chat").focus()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert text_area.selected_text == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blur_does_not_clear_other_widget_selection() -> None:
|
||||
app = build_test_vibe_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
text_area.load_text("hello world")
|
||||
text_area.select_all()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
chat = app.query_one("#chat")
|
||||
sentinel = {chat: Selection(Offset(0, 0), Offset(0, 1))}
|
||||
app.screen.selections = sentinel
|
||||
text_area.screen.set_focus(chat)
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert text_area.selected_text == ""
|
||||
assert app.screen.selections == sentinel
|
||||
|
|
|
|||
35
tests/cli/textual_ui/test_context_progress.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
|
||||
|
||||
|
||||
def test_context_progress_shows_percentage_when_empty() -> None:
|
||||
widget = ContextProgress()
|
||||
|
||||
widget.watch_tokens(TokenState(max_tokens=200_000, current_tokens=0))
|
||||
|
||||
assert str(widget.render()) == "0/200k tokens (0%)"
|
||||
|
||||
|
||||
def test_context_progress_uses_compact_integer_format_for_used_tokens() -> None:
|
||||
widget = ContextProgress()
|
||||
|
||||
widget.watch_tokens(TokenState(max_tokens=200_000, current_tokens=12_500))
|
||||
|
||||
assert str(widget.render()) == "12k/200k tokens (6%)"
|
||||
|
||||
|
||||
def test_context_progress_uses_compact_integer_k_format() -> None:
|
||||
widget = ContextProgress()
|
||||
|
||||
widget.watch_tokens(TokenState(max_tokens=568_000, current_tokens=170_000))
|
||||
|
||||
assert str(widget.render()) == "170k/568k tokens (30%)"
|
||||
|
||||
|
||||
def test_context_progress_uses_compact_integer_m_format() -> None:
|
||||
widget = ContextProgress()
|
||||
|
||||
widget.watch_tokens(TokenState(max_tokens=40_000_000, current_tokens=35_900_000))
|
||||
|
||||
assert str(widget.render()) == "35.9M/40.0M tokens (90%)"
|
||||
162
tests/cli/textual_ui/test_lazy_startup_imports.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def test_importing_tui_app_does_not_import_deferred_startup_modules() -> None:
|
||||
code = """
|
||||
import sys
|
||||
import vibe.cli.textual_ui.app
|
||||
|
||||
blocked = [
|
||||
"vibe.cli.textual_ui.widgets.connector_auth_app",
|
||||
"vibe.cli.textual_ui.widgets.mcp_app",
|
||||
"vibe.core.agent_loop",
|
||||
"vibe.core.tools.connectors.connector_registry",
|
||||
"vibe.core.tools.mcp.tools",
|
||||
"mcp",
|
||||
"git",
|
||||
]
|
||||
loaded = [name for name in blocked if name in sys.modules]
|
||||
if loaded:
|
||||
raise SystemExit(f"unexpected startup modules loaded: {loaded}")
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code], check=False, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
|
||||
|
||||
def test_importing_agent_loop_does_not_import_remote_tool_modules() -> None:
|
||||
code = """
|
||||
import sys
|
||||
import vibe.core.agent_loop
|
||||
|
||||
blocked = [
|
||||
"vibe.core.tools.connectors.connector_registry",
|
||||
"vibe.core.tools.mcp.tools",
|
||||
"vibe.core.teleport.git",
|
||||
"vibe.core.teleport.teleport",
|
||||
"mcp",
|
||||
"git",
|
||||
]
|
||||
loaded = [name for name in blocked if name in sys.modules]
|
||||
if loaded:
|
||||
raise SystemExit(f"unexpected agent loop modules loaded: {loaded}")
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code], check=False, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
|
||||
|
||||
def test_importing_connector_registry_does_not_import_mcp_runtime() -> None:
|
||||
code = """
|
||||
import sys
|
||||
import vibe.core.tools.connectors.connector_registry
|
||||
|
||||
blocked = [
|
||||
"vibe.core.tools.mcp.tools",
|
||||
"mcp",
|
||||
]
|
||||
loaded = [name for name in blocked if name in sys.modules]
|
||||
if loaded:
|
||||
raise SystemExit(f"unexpected connector registry modules loaded: {loaded}")
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code], check=False, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
|
||||
|
||||
def test_importing_mcp_app_does_not_import_mcp_runtime() -> None:
|
||||
code = """
|
||||
import sys
|
||||
import vibe.cli.textual_ui.widgets.mcp_app
|
||||
|
||||
blocked = [
|
||||
"vibe.core.tools.mcp.tools",
|
||||
"mcp",
|
||||
]
|
||||
loaded = [name for name in blocked if name in sys.modules]
|
||||
if loaded:
|
||||
raise SystemExit(f"unexpected mcp app modules loaded: {loaded}")
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code], check=False, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
|
||||
|
||||
def test_constructing_deferred_agent_loop_does_not_import_mcp_package(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
code = """
|
||||
import sys
|
||||
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
from vibe.core.config.harness_files import (
|
||||
init_harness_files_manager,
|
||||
reset_harness_files_manager,
|
||||
)
|
||||
|
||||
|
||||
class Backend:
|
||||
async def complete(self, **kwargs):
|
||||
raise AssertionError
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
return None
|
||||
|
||||
|
||||
init_harness_files_manager("user", "project")
|
||||
try:
|
||||
config = VibeConfig(
|
||||
enable_connectors=False,
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
)
|
||||
loop = AgentLoop(
|
||||
config=config,
|
||||
backend=Backend(),
|
||||
defer_heavy_init=True,
|
||||
headless=True,
|
||||
)
|
||||
if loop._deferred_init_thread is not None:
|
||||
loop._deferred_init_thread.join()
|
||||
finally:
|
||||
reset_harness_files_manager()
|
||||
|
||||
blocked = [
|
||||
"vibe.core.tools.mcp.tools",
|
||||
"mcp",
|
||||
]
|
||||
loaded = [name for name in blocked if name in sys.modules]
|
||||
if loaded:
|
||||
raise SystemExit(f"unexpected deferred agent loop modules loaded: {loaded}")
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={
|
||||
**os.environ,
|
||||
"VIBE_HOME": str(tmp_path),
|
||||
"VIBE_TEST_DISABLE_KEYRING": "1",
|
||||
},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
500
tests/cli/textual_ui/test_paste_image.py
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from textual import events
|
||||
from textual.app import App, ComposeResult
|
||||
|
||||
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
||||
from vibe.cli.commands import CommandRegistry
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
from vibe.cli.textual_ui.widgets.chat_input import paste_image
|
||||
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
|
||||
from vibe.cli.textual_ui.widgets.chat_input.paste_image import (
|
||||
_read_macos,
|
||||
_read_macos_class,
|
||||
handle_clipboard_image_paste,
|
||||
read_clipboard_image,
|
||||
write_clipboard_image,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
||||
from vibe.core.config import ModelConfig, ProviderConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
_PNG_HEADER = b"\x89PNG\r\n\x1a\n"
|
||||
_FAKE_PNG = _PNG_HEADER + b"fake-image-payload"
|
||||
|
||||
|
||||
def _build_vision_app(*, supports_images: bool = True) -> VibeApp:
|
||||
config = build_test_vibe_config(
|
||||
active_model="devstral-latest",
|
||||
models=[
|
||||
ModelConfig(
|
||||
name="mistral-vibe-cli-latest",
|
||||
provider="mistral",
|
||||
alias="devstral-latest",
|
||||
supports_images=supports_images,
|
||||
)
|
||||
],
|
||||
providers=[
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
],
|
||||
)
|
||||
return build_test_vibe_app(config=config)
|
||||
|
||||
|
||||
def _completed(stdout: bytes = b"", returncode: int = 0) -> subprocess.CompletedProcess:
|
||||
return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _force_supported_platform(monkeypatch) -> None:
|
||||
monkeypatch.setattr(paste_image, "is_clipboard_image_paste_supported", lambda: True)
|
||||
|
||||
|
||||
async def _press_ctrl_v_under_platform(monkeypatch, system: str) -> tuple[bool, list]:
|
||||
# The ctrl+v binding is registered at ChatTextArea class-definition time only
|
||||
# on macOS, so the result of pressing it depends on the OS. Reload the module
|
||||
# under a forced platform.system() to get a deterministically-defined class,
|
||||
# then exercise it in a minimal app (the full app shares binding maps across
|
||||
# instances via a shallow copy, which makes a host-dependent setup flaky).
|
||||
monkeypatch.setattr(platform, "system", lambda: system)
|
||||
text_area_module = importlib.import_module(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.text_area"
|
||||
)
|
||||
importlib.reload(text_area_module)
|
||||
try:
|
||||
chat_text_area = text_area_module.ChatTextArea
|
||||
has_binding = any(b.key == "ctrl+v" for b in chat_text_area.BINDINGS)
|
||||
|
||||
class _MiniApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield chat_text_area(command_registry=CommandRegistry())
|
||||
|
||||
posted: list = []
|
||||
async with _MiniApp().run_test() as pilot:
|
||||
text_area = pilot.app.query_one(chat_text_area)
|
||||
text_area.focus()
|
||||
await pilot.pause()
|
||||
original_post = text_area.post_message
|
||||
|
||||
def wrapped(message):
|
||||
if isinstance(message, chat_text_area.ClipboardImagePasted):
|
||||
posted.append(message)
|
||||
return original_post(message)
|
||||
|
||||
text_area.post_message = wrapped
|
||||
await pilot.press("ctrl+v")
|
||||
await pilot.pause()
|
||||
return has_binding, posted
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
importlib.reload(text_area_module)
|
||||
|
||||
|
||||
def test_read_clipboard_image_returns_none_when_no_readers(
|
||||
monkeypatch, _force_supported_platform
|
||||
) -> None:
|
||||
monkeypatch.setattr(paste_image, "_readers_for_platform", lambda: [])
|
||||
assert read_clipboard_image() is None
|
||||
|
||||
|
||||
def test_read_clipboard_image_skips_non_png_bytes(
|
||||
monkeypatch, _force_supported_platform
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
paste_image, "_readers_for_platform", lambda: [lambda: b"not-a-png"]
|
||||
)
|
||||
assert read_clipboard_image() is None
|
||||
|
||||
|
||||
def test_read_clipboard_image_returns_first_png(
|
||||
monkeypatch, _force_supported_platform
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
paste_image,
|
||||
"_readers_for_platform",
|
||||
lambda: [lambda: None, lambda: _FAKE_PNG, lambda: b"unused"],
|
||||
)
|
||||
assert read_clipboard_image() == _FAKE_PNG
|
||||
|
||||
|
||||
def test_read_clipboard_image_swallows_reader_exceptions(
|
||||
monkeypatch, _force_supported_platform
|
||||
) -> None:
|
||||
def _raises() -> bytes | None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(
|
||||
paste_image, "_readers_for_platform", lambda: [_raises, lambda: _FAKE_PNG]
|
||||
)
|
||||
assert read_clipboard_image() == _FAKE_PNG
|
||||
|
||||
|
||||
def test_macos_class_reader_returns_bytes_when_osascript_succeeds(monkeypatch) -> None:
|
||||
def fake_run(cmd, **kwargs):
|
||||
# osascript wrote the temp file before returning 0; mimic that.
|
||||
script_text = cmd[2]
|
||||
prefix = 'set targetFile to POSIX file "'
|
||||
start = script_text.index(prefix) + len(prefix)
|
||||
end = script_text.index('"', start)
|
||||
Path(script_text[start:end]).write_bytes(_FAKE_PNG)
|
||||
return _completed()
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
assert _read_macos_class("PNGf") == _FAKE_PNG
|
||||
|
||||
|
||||
def test_macos_class_reader_returns_none_when_osascript_fails(monkeypatch) -> None:
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _completed(returncode=1))
|
||||
assert _read_macos_class("PNGf") is None
|
||||
|
||||
|
||||
def test_macos_reader_falls_back_to_tiff_when_png_missing(monkeypatch) -> None:
|
||||
fake_tiff = b"II*\x00fake-tiff"
|
||||
calls: list[str] = []
|
||||
# `sips` only exists on macOS; force the which() probe so the conversion
|
||||
# path runs (and hits the mocked subprocess.run) on Linux CI too.
|
||||
monkeypatch.setattr(paste_image.shutil, "which", lambda _name: "/usr/bin/sips")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd[0] == "osascript":
|
||||
script_text = cmd[2]
|
||||
if "PNGf" in script_text:
|
||||
calls.append("PNGf")
|
||||
return _completed(returncode=1)
|
||||
calls.append("TIFF")
|
||||
prefix = 'set targetFile to POSIX file "'
|
||||
start = script_text.index(prefix) + len(prefix)
|
||||
end = script_text.index('"', start)
|
||||
Path(script_text[start:end]).write_bytes(fake_tiff)
|
||||
return _completed()
|
||||
# sips invocation
|
||||
calls.append("sips")
|
||||
src = Path(cmd[cmd.index("--out") - 1])
|
||||
out = Path(cmd[cmd.index("--out") + 1])
|
||||
# Mimic conversion: write a fake PNG to the --out path.
|
||||
assert src.read_bytes() == fake_tiff
|
||||
out.write_bytes(_FAKE_PNG)
|
||||
return _completed()
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
assert _read_macos() == _FAKE_PNG
|
||||
assert calls == ["PNGf", "TIFF", "sips"]
|
||||
|
||||
|
||||
def test_reader_timeout_is_swallowed(monkeypatch) -> None:
|
||||
def fake_run(*args, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd=args[0], timeout=1.0)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(paste_image, "_readers_for_platform", lambda: [_read_macos])
|
||||
monkeypatch.setattr(paste_image, "is_clipboard_image_paste_supported", lambda: True)
|
||||
assert read_clipboard_image() is None
|
||||
|
||||
|
||||
def test_read_clipboard_image_returns_none_on_unsupported_platform(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
paste_image, "is_clipboard_image_paste_supported", lambda: False
|
||||
)
|
||||
# Even if a reader were defined, the platform gate short-circuits.
|
||||
monkeypatch.setattr(
|
||||
paste_image, "_readers_for_platform", lambda: [lambda: _FAKE_PNG]
|
||||
)
|
||||
assert read_clipboard_image() is None
|
||||
|
||||
|
||||
def test_write_clipboard_image_uses_filename_without_whitespace(tmp_path) -> None:
|
||||
path = write_clipboard_image(_FAKE_PNG, session_dir=tmp_path)
|
||||
|
||||
assert path.parent == tmp_path / "attachments"
|
||||
assert not any(char.isspace() for char in path.name)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_paste_posts_clipboard_image_pasted_message(
|
||||
vibe_app: VibeApp,
|
||||
) -> None:
|
||||
posted: list[ChatTextArea.ClipboardImagePasted] = []
|
||||
|
||||
def capture(message) -> None:
|
||||
if isinstance(message, ChatTextArea.ClipboardImagePasted):
|
||||
posted.append(message)
|
||||
|
||||
async with vibe_app.run_test() as pilot:
|
||||
text_area = vibe_app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
original_post = text_area.post_message
|
||||
|
||||
def wrapped(message):
|
||||
capture(message)
|
||||
return original_post(message)
|
||||
|
||||
text_area.post_message = wrapped # type: ignore[method-assign]
|
||||
text_area.post_message(events.Paste(text=""))
|
||||
await pilot.pause()
|
||||
|
||||
assert len(posted) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_empty_paste_does_not_post_clipboard_image_pasted(
|
||||
vibe_app: VibeApp,
|
||||
) -> None:
|
||||
posted: list[ChatTextArea.ClipboardImagePasted] = []
|
||||
|
||||
async with vibe_app.run_test() as pilot:
|
||||
text_area = vibe_app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
original_post = text_area.post_message
|
||||
|
||||
def wrapped(message):
|
||||
if isinstance(message, ChatTextArea.ClipboardImagePasted):
|
||||
posted.append(message)
|
||||
return original_post(message)
|
||||
|
||||
text_area.post_message = wrapped # type: ignore[method-assign]
|
||||
text_area.post_message(events.Paste(text="hello world"))
|
||||
await pilot.pause()
|
||||
|
||||
assert posted == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_clipboard_image_paste_writes_file_and_inserts_token() -> None:
|
||||
app = _build_vision_app()
|
||||
notifications: list[str] = []
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image",
|
||||
return_value=_FAKE_PNG,
|
||||
),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
with patch.object(
|
||||
app, "notify", side_effect=lambda msg, **_kw: notifications.append(msg)
|
||||
):
|
||||
await handle_clipboard_image_paste(app, notify_when_empty=False)
|
||||
await pilot.pause()
|
||||
chat_input = app.query_one(ChatInputContainer)
|
||||
value = chat_input.value
|
||||
assert value.startswith("@")
|
||||
assert value.endswith(".png ")
|
||||
assert "clipboard-" in value
|
||||
assert len(notifications) == 1
|
||||
assert notifications[0].startswith("Image pasted as clipboard-")
|
||||
assert notifications[0].endswith(".png (26 Bytes)")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_clipboard_image_paste_warns_when_token_insert_fails(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
app = _build_vision_app()
|
||||
path = tmp_path / "clipboard.png"
|
||||
notifications: list[tuple[str, str | None]] = []
|
||||
|
||||
def fake_write_clipboard_image(data: bytes, *, session_dir: Path | None) -> Path:
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
def fake_query_one(*_args, **_kwargs):
|
||||
raise RuntimeError("not mounted")
|
||||
|
||||
monkeypatch.setattr(paste_image, "is_clipboard_image_paste_supported", lambda: True)
|
||||
monkeypatch.setattr(paste_image, "read_clipboard_image", lambda: _FAKE_PNG)
|
||||
monkeypatch.setattr(
|
||||
paste_image, "write_clipboard_image", fake_write_clipboard_image
|
||||
)
|
||||
monkeypatch.setattr(app, "query_one", fake_query_one)
|
||||
monkeypatch.setattr(
|
||||
app,
|
||||
"notify",
|
||||
lambda msg, **kwargs: notifications.append((msg, kwargs.get("severity"))),
|
||||
)
|
||||
|
||||
await handle_clipboard_image_paste(app, notify_when_empty=False)
|
||||
|
||||
assert not path.exists()
|
||||
assert notifications == [("Failed to paste image into prompt.", "warning")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_clipboard_image_paste_noop_when_clipboard_empty() -> None:
|
||||
app = _build_vision_app()
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
await handle_clipboard_image_paste(app, notify_when_empty=False)
|
||||
await pilot.pause()
|
||||
assert app.query_one(ChatInputContainer).value == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_clipboard_image_paste_blocks_when_model_no_vision() -> None:
|
||||
app = _build_vision_app(supports_images=False)
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image",
|
||||
return_value=_FAKE_PNG,
|
||||
),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
await handle_clipboard_image_paste(app, notify_when_empty=False)
|
||||
await pilot.pause()
|
||||
assert app.query_one(ChatInputContainer).value == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_clipboard_image_paste_rejects_oversize(monkeypatch) -> None:
|
||||
app = _build_vision_app()
|
||||
notifications: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.MAX_IMAGE_BYTES", 10
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image",
|
||||
return_value=_FAKE_PNG,
|
||||
),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
with patch.object(
|
||||
app, "notify", side_effect=lambda msg, **_kw: notifications.append(msg)
|
||||
):
|
||||
await handle_clipboard_image_paste(app, notify_when_empty=False)
|
||||
await pilot.pause()
|
||||
assert app.query_one(ChatInputContainer).value == ""
|
||||
assert notifications == ["Clipboard image is 26 Bytes; max is 10 Bytes."]
|
||||
|
||||
|
||||
def test_paste_image_slash_command_hidden_on_unsupported_platform(monkeypatch) -> None:
|
||||
from vibe.cli.commands import CommandRegistry
|
||||
|
||||
monkeypatch.setattr("platform.system", lambda: "Linux")
|
||||
registry = CommandRegistry()
|
||||
assert not registry.has_command("paste-image")
|
||||
|
||||
|
||||
def test_paste_image_slash_command_available_on_darwin(monkeypatch) -> None:
|
||||
from vibe.cli.commands import CommandRegistry
|
||||
|
||||
monkeypatch.setattr("platform.system", lambda: "Darwin")
|
||||
registry = CommandRegistry()
|
||||
assert registry.has_command("paste-image")
|
||||
|
||||
|
||||
def test_ctrl_v_binding_absent_on_unsupported_platform(monkeypatch) -> None:
|
||||
# Bindings are evaluated at class-definition time, so we have to
|
||||
# re-import the module after patching platform.system().
|
||||
import importlib
|
||||
|
||||
from vibe.cli.textual_ui.widgets.chat_input import text_area as text_area_module
|
||||
|
||||
monkeypatch.setattr("platform.system", lambda: "Linux")
|
||||
reloaded = importlib.reload(text_area_module)
|
||||
try:
|
||||
assert all(b.key != "ctrl+v" for b in reloaded.ChatTextArea.BINDINGS)
|
||||
finally:
|
||||
importlib.reload(text_area_module)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("notify_when_empty", [True, False])
|
||||
async def test_handle_clipboard_image_paste_is_silent_on_unsupported_platform(
|
||||
notify_when_empty: bool,
|
||||
) -> None:
|
||||
app = _build_vision_app()
|
||||
notifications: list[str] = []
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
|
||||
return_value=False,
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
with patch.object(
|
||||
app, "notify", side_effect=lambda msg, **_kw: notifications.append(msg)
|
||||
):
|
||||
await handle_clipboard_image_paste(
|
||||
app, notify_when_empty=notify_when_empty
|
||||
)
|
||||
await pilot.pause()
|
||||
assert notifications == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ctrl_v_keybinding_triggers_image_paste_with_notify_on_darwin(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
has_binding, posted = await _press_ctrl_v_under_platform(monkeypatch, "Darwin")
|
||||
assert has_binding
|
||||
assert len(posted) == 1
|
||||
assert posted[0].notify_when_empty is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ctrl_v_keybinding_does_not_paste_image_on_linux(monkeypatch) -> None:
|
||||
has_binding, posted = await _press_ctrl_v_under_platform(monkeypatch, "Linux")
|
||||
assert not has_binding
|
||||
assert posted == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_image_token_adds_leading_space_when_needed() -> None:
|
||||
app = _build_vision_app()
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image",
|
||||
return_value=_FAKE_PNG,
|
||||
),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
text_area.text = "look at this:"
|
||||
text_area.move_cursor((0, len("look at this:")))
|
||||
await handle_clipboard_image_paste(app, notify_when_empty=False)
|
||||
await pilot.pause()
|
||||
value = app.query_one(ChatInputContainer).value
|
||||
assert " @" in value and value.startswith("look at this: @")
|
||||
88
tests/cli/textual_ui/test_startup_prompt.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_app
|
||||
from vibe.cli.plan_offer.decide_plan_offer import PlanInfo
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType
|
||||
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_prompt_waits_for_startup_resume_picker(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app = build_test_vibe_app(initial_prompt="continue the work")
|
||||
app._show_resume_picker = True
|
||||
process_prompt = Mock()
|
||||
|
||||
monkeypatch.setattr(app, "_resolve_plan", AsyncMock())
|
||||
monkeypatch.setattr(app, "_check_and_show_whats_new", AsyncMock())
|
||||
monkeypatch.setattr(app, "_schedule_update_notification", Mock())
|
||||
monkeypatch.setattr(app, "_process_initial_prompt", process_prompt)
|
||||
|
||||
await app._complete_post_ready_startup()
|
||||
|
||||
process_prompt.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_prompt_runs_after_startup_resume_picker_selection(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app = build_test_vibe_app(initial_prompt="continue the work")
|
||||
app._show_resume_picker = True
|
||||
app._startup_command_availability_ready.set()
|
||||
process_prompt = Mock()
|
||||
|
||||
monkeypatch.setattr(app, "_switch_to_input_app", AsyncMock())
|
||||
monkeypatch.setattr(app, "_resume_local_session", AsyncMock())
|
||||
monkeypatch.setattr(app, "_process_initial_prompt", process_prompt)
|
||||
|
||||
await app.on_session_picker_app_session_selected(
|
||||
SessionPickerApp.SessionSelected("local:session-1", "session-1")
|
||||
)
|
||||
|
||||
assert app._show_resume_picker is False
|
||||
process_prompt.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_teleport_waits_for_plan_resolution_after_session_selection(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app = build_test_vibe_app(initial_prompt="continue the work")
|
||||
app._show_resume_picker = True
|
||||
app._teleport_on_start = True
|
||||
run_worker = Mock()
|
||||
handle_teleport = Mock(return_value=object())
|
||||
handle_user_message = Mock(return_value=object())
|
||||
|
||||
monkeypatch.setattr(app, "_switch_to_input_app", AsyncMock())
|
||||
monkeypatch.setattr(app, "_resume_local_session", AsyncMock())
|
||||
monkeypatch.setattr(app, "run_worker", run_worker)
|
||||
monkeypatch.setattr(app, "_handle_teleport_command", handle_teleport)
|
||||
monkeypatch.setattr(app, "_handle_user_message", handle_user_message)
|
||||
monkeypatch.setattr(app.commands, "has_command", lambda name: name == "teleport")
|
||||
|
||||
task = asyncio.create_task(
|
||||
app.on_session_picker_app_session_selected(
|
||||
SessionPickerApp.SessionSelected("local:session-1", "session-1")
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
handle_teleport.assert_not_called()
|
||||
handle_user_message.assert_not_called()
|
||||
|
||||
app._plan_info = PlanInfo(WhoAmIPlanType.CHAT)
|
||||
app._refresh_command_registry()
|
||||
app._startup_command_availability_ready.set()
|
||||
await task
|
||||
|
||||
handle_teleport.assert_called_once_with("continue the work")
|
||||
handle_user_message.assert_not_called()
|
||||
run_worker.assert_called_once_with(handle_teleport.return_value, exclusive=False)
|
||||
236
tests/cli/textual_ui/test_tool_result_link.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from textual.app import App, ComposeResult
|
||||
|
||||
from vibe.cli.textual_ui.widgets.links import (
|
||||
LinkStatic,
|
||||
link_markup,
|
||||
linkify_urls_in_text,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.tool_widgets import WebSearchResultWidget
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage
|
||||
from vibe.core.tools.builtins.websearch import WebSearchResult, WebSearchSource
|
||||
|
||||
|
||||
def test_link_markup_encodes_url_in_action_and_renders_label() -> None:
|
||||
# The action arg is percent-encoded; the visible label is the page name.
|
||||
assert (
|
||||
link_markup("Example", "https://example.com")
|
||||
== "[@click=open_url('https%3A%2F%2Fexample.com')]Example[/]"
|
||||
)
|
||||
|
||||
|
||||
def test_link_markup_only_links_http_schemes() -> None:
|
||||
# Non-http(s) schemes render as the plain label, not a clickable @click span.
|
||||
for url in ("file:///etc/passwd", "javascript:alert(1)", "vscode://x"):
|
||||
assert link_markup(url, url) == url
|
||||
assert "@click" not in link_markup(url, url)
|
||||
|
||||
|
||||
def test_link_markup_handles_previously_unsafe_urls() -> None:
|
||||
# Brackets, quotes, parens — all encoded into the action, never a fallback.
|
||||
for url in (
|
||||
"https://e.org/x[1]",
|
||||
"https://e.org/it's",
|
||||
"https://e.org/x)",
|
||||
"https://en.wikipedia.org/wiki/Python_(programming_language)",
|
||||
):
|
||||
markup = link_markup(url, url)
|
||||
assert markup.startswith("[@click=open_url('")
|
||||
assert "[1]" not in markup.split("]", 1)[0] # raw bracket not in the tag
|
||||
|
||||
|
||||
class _Harness(App):
|
||||
def __init__(self, url: str = "https://example.com") -> None:
|
||||
super().__init__()
|
||||
self.url = url
|
||||
self.opened: list[str] = []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield LinkStatic(link_markup(self.url, self.url))
|
||||
|
||||
def open_url(self, url: str, *, new_tab: bool = True) -> None:
|
||||
self.opened.append(url)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clicking_link_span_opens_url() -> None:
|
||||
app = _Harness()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.click(LinkStatic)
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert app.opened == ["https://example.com"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://en.wikipedia.org/wiki/Python_(programming_language)",
|
||||
"https://httpbin.org/anything/test[1]",
|
||||
"https://e.org/it's",
|
||||
],
|
||||
)
|
||||
async def test_clicking_decodes_back_to_original_url(url: str) -> None:
|
||||
app = _Harness(url)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.click(LinkStatic)
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert app.opened == [url]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_open_url_ignores_non_http_scheme() -> None:
|
||||
from urllib.parse import quote
|
||||
|
||||
app = _Harness()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
app.query_one(LinkStatic).action_open_url(quote("file:///etc/passwd", safe=""))
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert app.opened == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("scheme", ["javascript", "file", "data"])
|
||||
async def test_unsafe_schemes_are_rejected(scheme: str) -> None:
|
||||
app = _Harness(f"{scheme}:payload")
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.click(LinkStatic)
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert app.opened == []
|
||||
|
||||
|
||||
def test_linkify_urls_in_text_auto_detects_url() -> None:
|
||||
# Once a tool is opted into linkification, URLs are found in the message
|
||||
# itself — the call site doesn't have to point at the URL span.
|
||||
markup = linkify_urls_in_text("Fetched https://example.com (10 chars, text/html)")
|
||||
assert markup.startswith("Fetched ")
|
||||
assert (
|
||||
"[@click=open_url('https%3A%2F%2Fexample.com')]https://example.com[/]" in markup
|
||||
)
|
||||
|
||||
|
||||
def test_linkify_urls_in_text_handles_multiple_urls() -> None:
|
||||
markup = linkify_urls_in_text("see https://a.com and https://b.com here")
|
||||
assert "[@click=open_url('https%3A%2F%2Fa.com')]https://a.com[/]" in markup
|
||||
assert "[@click=open_url('https%3A%2F%2Fb.com')]https://b.com[/]" in markup
|
||||
|
||||
|
||||
def test_linkify_urls_in_text_keeps_balanced_parens_in_url() -> None:
|
||||
# Wikipedia-style URLs with `(…)` were the reason the @click action is
|
||||
# percent-encoded; Rich's URL detector already keeps them in the span.
|
||||
url = "https://en.wikipedia.org/wiki/Python_(programming_language)"
|
||||
markup = linkify_urls_in_text(f"see {url} for details")
|
||||
assert link_markup(url, url) in markup
|
||||
|
||||
|
||||
def test_linkify_urls_in_text_escapes_and_keeps_plain_when_no_url() -> None:
|
||||
# Brackets must be escaped so raw tool text can't break the markup.
|
||||
assert (
|
||||
linkify_urls_in_text("Searched '[a]' (2 sources)")
|
||||
== "Searched '\\[a]' (2 sources)"
|
||||
)
|
||||
|
||||
|
||||
async def _rendered_lines(widget: WebSearchResultWidget) -> list[str]:
|
||||
from textual.widgets import Static
|
||||
|
||||
class _H(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield widget
|
||||
|
||||
async with _H().run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
return [str(w.render()) for w in widget.query(Static)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websearch_single_source_is_bulleted_without_header() -> None:
|
||||
result = WebSearchResult(
|
||||
query="uv",
|
||||
answer="ans",
|
||||
sources=[WebSearchSource(title="Docs", url="https://x.com")],
|
||||
)
|
||||
lines = await _rendered_lines(
|
||||
WebSearchResultWidget(result, success=True, message="m")
|
||||
)
|
||||
joined = "\n".join(lines)
|
||||
|
||||
assert "• Docs" in joined # bulleted, page name as the link label
|
||||
assert "https://x.com" not in joined # url lives in the @click action, not text
|
||||
assert "Source:" not in joined # singular "Source:" prefix dropped
|
||||
assert "Sources:" not in joined # no header for a lone source
|
||||
assert "WebSearchSource(" not in joined # raw `sources: [...]` dump dropped
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websearch_multiple_sources_is_bulleted_plural() -> None:
|
||||
result = WebSearchResult(
|
||||
query="uv",
|
||||
answer="ans",
|
||||
sources=[
|
||||
WebSearchSource(title="A", url="https://a.com"),
|
||||
WebSearchSource(title="B", url="https://b.com"),
|
||||
],
|
||||
)
|
||||
lines = await _rendered_lines(
|
||||
WebSearchResultWidget(result, success=True, message="m")
|
||||
)
|
||||
joined = "\n".join(lines)
|
||||
|
||||
assert "Sources:" in joined
|
||||
assert "• A" in joined
|
||||
assert "• B" in joined
|
||||
assert "https://a.com" not in joined # url lives in the @click action, not text
|
||||
assert "WebSearchSource(" not in joined
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_message_set_result_text_renders_clickable_url() -> None:
|
||||
call = ToolCallMessage(tool_name="web_fetch")
|
||||
|
||||
class _H(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield call
|
||||
|
||||
async with _H().run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
call.set_result_text(
|
||||
"Fetched https://example.com (10 chars, text/html)", linkify=True
|
||||
)
|
||||
await pilot.pause(0.1)
|
||||
rendered = str(call._text_widget.render()) if call._text_widget else ""
|
||||
|
||||
# The URL becomes a clickable span in the status line; surrounding text stays.
|
||||
assert "https://example.com" in rendered
|
||||
assert "Fetched" in rendered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_message_set_result_text_escapes_when_linkify_off() -> None:
|
||||
call = ToolCallMessage(tool_name="bash")
|
||||
|
||||
class _H(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield call
|
||||
|
||||
async with _H().run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
# Bash isn't in the linkify whitelist; URLs must stay plain text and
|
||||
# brackets in the message must not be interpreted as markup.
|
||||
call.set_result_text("ran: see https://example.com [exit 0]")
|
||||
await pilot.pause(0.1)
|
||||
rendered = str(call._text_widget.render()) if call._text_widget else ""
|
||||
|
||||
assert "@click=open_url" not in rendered
|
||||
assert "https://example.com" in rendered
|
||||
assert "[exit 0]" in rendered
|
||||
|
|
@ -1,11 +1,23 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
import pytest
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Link
|
||||
|
||||
from vibe.cli.textual_ui.widgets.messages import UserMessage
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.windowing.history import build_history_widgets
|
||||
from vibe.core.types import FileImageSource, ImageAttachment, LLMMessage, Role
|
||||
from vibe.core.types import (
|
||||
FileImageSource,
|
||||
ImageAttachment,
|
||||
InlineImageSource,
|
||||
LLMMessage,
|
||||
Role,
|
||||
)
|
||||
|
||||
|
||||
def _att(path: Path, alias: str) -> ImageAttachment:
|
||||
|
|
@ -15,35 +27,92 @@ def _att(path: Path, alias: str) -> ImageAttachment:
|
|||
)
|
||||
|
||||
|
||||
def test_attachments_footer_singular(tmp_path: Path) -> None:
|
||||
att = _att(tmp_path / "shot.png", "shot.png")
|
||||
class _UserMessageApp(App[None]):
|
||||
CSS = """
|
||||
.user-message-wrapper,
|
||||
.user-message-container,
|
||||
.user-message-attachments,
|
||||
.user-message-attachment-line {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
rendered = UserMessage._format_attachments_footer([att])
|
||||
.user-message-attachment-label,
|
||||
.user-message-attachment-link {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
"""
|
||||
|
||||
assert "attached image:" in rendered
|
||||
assert '[link="file://' in rendered
|
||||
assert "]shot.png[/link]" in rendered
|
||||
def __init__(self, message: UserMessage) -> None:
|
||||
super().__init__()
|
||||
self._message = message
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield self._message
|
||||
|
||||
|
||||
def test_attachments_footer_plural(tmp_path: Path) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachments_render_one_clickable_link_per_image(tmp_path: Path) -> None:
|
||||
a = _att(tmp_path / "a.png", "a.png")
|
||||
b = _att(tmp_path / "b.png", "b.png")
|
||||
app = _UserMessageApp(UserMessage("look", images=[a, b]))
|
||||
opened: list[str] = []
|
||||
|
||||
rendered = UserMessage._format_attachments_footer([a, b])
|
||||
a_uri = (tmp_path / "a.png").as_uri()
|
||||
b_uri = (tmp_path / "b.png").as_uri()
|
||||
|
||||
assert "attached images:" in rendered
|
||||
assert rendered.count('[link="file://') == 2
|
||||
assert "a.png" in rendered
|
||||
assert "b.png" in rendered
|
||||
async with app.run_test() as pilot:
|
||||
links = list(app.query(Link))
|
||||
assert [link.text for link in links] == ["a.png", "b.png"]
|
||||
assert [link.url for link in links] == [a_uri, b_uri]
|
||||
|
||||
with patch.object(
|
||||
app, "open_url", side_effect=lambda url, **_kwargs: opened.append(url)
|
||||
):
|
||||
await pilot.click(links[0])
|
||||
|
||||
assert opened == [a_uri]
|
||||
|
||||
|
||||
def test_attachments_footer_escapes_alias_brackets(tmp_path: Path) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_attachment_renders_label_without_link() -> None:
|
||||
att = ImageAttachment(
|
||||
source=InlineImageSource(data="aW1n"), alias="pasted.png", mime_type="image/png"
|
||||
)
|
||||
app = _UserMessageApp(UserMessage("look", images=[att]))
|
||||
|
||||
async with app.run_test():
|
||||
assert list(app.query(Link)) == []
|
||||
label = app.query_one(".user-message-attachment-link")
|
||||
|
||||
assert isinstance(label, NoMarkupStatic)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachment_link_renders_alias_brackets_literally(tmp_path: Path) -> None:
|
||||
att = _att(tmp_path / "shot.png", "weird [bracket].png")
|
||||
app = _UserMessageApp(UserMessage("look", images=[att]))
|
||||
|
||||
rendered = UserMessage._format_attachments_footer([att])
|
||||
async with app.run_test():
|
||||
link = app.query_one(Link)
|
||||
|
||||
# Rich's escape() turns "[" into "\[".
|
||||
assert "\\[bracket]" in rendered
|
||||
assert link.text == "weird [bracket].png"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachment_link_shortens_home_absolute_alias() -> None:
|
||||
path = Path.home() / "Pictures" / "shot.png"
|
||||
att = ImageAttachment(
|
||||
source=FileImageSource(path=path), alias=str(path), mime_type="image/png"
|
||||
)
|
||||
app = _UserMessageApp(UserMessage("look", images=[att]))
|
||||
|
||||
async with app.run_test():
|
||||
link = app.query_one(Link)
|
||||
|
||||
assert link.text == "~/Pictures/shot.png"
|
||||
assert link.url == path.as_uri()
|
||||
|
||||
|
||||
def test_resumed_user_message_with_images_renders_footer(tmp_path: Path) -> None:
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from vibe.core.config.harness_files import (
|
|||
reset_harness_files_manager,
|
||||
)
|
||||
from vibe.core.llm.types import BackendLike
|
||||
from vibe.core.utils import keyring as keyring_utils
|
||||
|
||||
|
||||
class _EmptyKeyring(KeyringBackend):
|
||||
|
|
@ -49,11 +50,11 @@ class _EmptyKeyring(KeyringBackend):
|
|||
return None
|
||||
|
||||
def delete_password(self, service: str, username: str) -> None:
|
||||
raise keyring.errors.PasswordDeleteError(username)
|
||||
raise keyring.errors.PasswordDeleteError()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_os_keyring() -> Generator[None, None, None]:
|
||||
def _disable_os_keyring(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]:
|
||||
"""Keep the suite off the real OS keyring.
|
||||
|
||||
``resolve_api_key`` and ``VibeConfig._check_api_key`` now consult the keyring, so
|
||||
|
|
@ -63,10 +64,13 @@ def _disable_os_keyring() -> Generator[None, None, None]:
|
|||
behaviour opt in by patching ``keyring.get_password`` / ``set_password`` directly.
|
||||
"""
|
||||
original = keyring.get_keyring()
|
||||
monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: False)
|
||||
keyring.set_keyring(_EmptyKeyring())
|
||||
keyring_utils.clear_api_key_keyring_cache()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
keyring_utils.clear_api_key_keyring_cache()
|
||||
keyring.set_keyring(original)
|
||||
|
||||
|
||||
|
|
@ -181,6 +185,8 @@ def _mock_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
monkeypatch.setenv("MISTRAL_API_KEY", "mock")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "mock")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "mock")
|
||||
monkeypatch.setenv("VERTEX_API_KEY", "mock")
|
||||
monkeypatch.setenv("REASONING_API_KEY", "mock")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -303,6 +309,12 @@ def build_test_vibe_config(**kwargs) -> VibeConfig:
|
|||
# Connectors trigger a real HTTP discovery on agent construction; off by
|
||||
# default so tests don't pay for it. Connector tests pass enable_connectors=True.
|
||||
kwargs.setdefault("enable_connectors", False)
|
||||
# Use the lightweight test system prompt unless a test asks for a real one.
|
||||
kwargs.setdefault("system_prompt_id", "tests")
|
||||
# Keep the test prompt minimal: skip project-context discovery and prompt
|
||||
# detail unless a test opts in.
|
||||
kwargs.setdefault("include_project_context", False)
|
||||
kwargs.setdefault("include_prompt_detail", False)
|
||||
return VibeConfig(
|
||||
session_logging=resolved_session_logging,
|
||||
enable_update_checks=resolved_enable_update_checks,
|
||||
|
|
|
|||
|
|
@ -10,5 +10,21 @@ ANTHROPIC_MESSAGES_PATH = "/v1/messages"
|
|||
OPENAI_BASE_URL = "https://api.openai.com"
|
||||
OPENAI_RESPONSES_PATH = "/v1/responses"
|
||||
|
||||
VERTEX_PROJECT_ID = "test-project"
|
||||
VERTEX_REGION = "us-central1"
|
||||
VERTEX_MODEL = "claude-test"
|
||||
VERTEX_BASE_URL = "https://us-central1-aiplatform.googleapis.com"
|
||||
VERTEX_RAW_PREDICT_PATH = (
|
||||
"/v1/projects/test-project/locations/us-central1/"
|
||||
"publishers/anthropic/models/claude-test:rawPredict"
|
||||
)
|
||||
VERTEX_STREAM_PREDICT_PATH = (
|
||||
"/v1/projects/test-project/locations/us-central1/"
|
||||
"publishers/anthropic/models/claude-test:streamRawPredict"
|
||||
)
|
||||
|
||||
REASONING_BASE_URL = "https://api.reasoning.test"
|
||||
REASONING_COMPLETIONS_PATH = "/chat/completions"
|
||||
|
||||
TELEPORT_SESSIONS_PATH = "/api/v1/code/sessions"
|
||||
TELEPORT_COMPLETE_URL = "https://chat.example.com/code/project-id/web-session-id"
|
||||
|
|
|
|||
|
|
@ -147,11 +147,7 @@ def _build_managers(config):
|
|||
@pytest.mark.asyncio
|
||||
async def test_graduated_experiment_with_deleted_variant_file_falls_back() -> None:
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="cli",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
system_prompt_id="cli", include_model_info=False, include_commit_signature=False
|
||||
)
|
||||
response = _response_forcing("removed_after_graduation_2025_07")
|
||||
manager = ExperimentManager(client=_StubClient(None))
|
||||
|
|
|
|||
|
|
@ -47,11 +47,7 @@ def _build_managers(config):
|
|||
@pytest.mark.asyncio
|
||||
async def test_system_prompt_uses_assigned_variant() -> None:
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="cli",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
system_prompt_id="cli", include_model_info=False, include_commit_signature=False
|
||||
)
|
||||
response = EvalResponse.model_validate({
|
||||
"features": {
|
||||
|
|
@ -78,11 +74,7 @@ async def test_system_prompt_uses_assigned_variant() -> None:
|
|||
@pytest.mark.asyncio
|
||||
async def test_system_prompt_falls_back_to_default_when_variant_unknown() -> None:
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="cli",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
system_prompt_id="cli", include_model_info=False, include_commit_signature=False
|
||||
)
|
||||
response = EvalResponse.model_validate({
|
||||
"features": {
|
||||
|
|
@ -109,11 +101,7 @@ async def test_system_prompt_falls_back_to_default_when_variant_unknown() -> Non
|
|||
|
||||
def test_system_prompt_uses_default_when_no_manager() -> None:
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="cli",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
system_prompt_id="cli", include_model_info=False, include_commit_signature=False
|
||||
)
|
||||
tool_manager, skill_manager, agent_manager = _build_managers(config)
|
||||
prompt = get_universal_system_prompt(
|
||||
|
|
@ -125,8 +113,6 @@ def test_system_prompt_uses_default_when_no_manager() -> None:
|
|||
def test_system_prompt_honors_user_config_when_manager_uninitialized() -> None:
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="lean",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
)
|
||||
|
|
@ -143,8 +129,6 @@ def test_system_prompt_honors_user_config_when_manager_uninitialized() -> None:
|
|||
async def test_system_prompt_honors_user_config_when_no_remote_assignment() -> None:
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="lean",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
)
|
||||
|
|
@ -169,8 +153,6 @@ async def test_system_prompt_honors_user_config_when_no_remote_assignment() -> N
|
|||
async def test_user_config_overrides_assigned_experiment_variant() -> None:
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="lean",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,9 +31,7 @@ class TestAgentProfile:
|
|||
class TestAgentManager:
|
||||
@pytest.fixture
|
||||
def manager(self) -> AgentManager:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
config = build_test_vibe_config()
|
||||
return AgentManager(lambda: config)
|
||||
|
||||
def test_get_subagents_returns_only_subagents(self, manager: AgentManager) -> None:
|
||||
|
|
@ -81,17 +79,13 @@ class TestAgentManager:
|
|||
|
||||
def test_initial_agent_rejects_subagent(self) -> None:
|
||||
"""Test that creating AgentManager with a subagent as initial_agent raises."""
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
config = build_test_vibe_config()
|
||||
with pytest.raises(ValueError, match="cannot be used as the primary agent"):
|
||||
AgentManager(lambda: config, initial_agent="explore")
|
||||
|
||||
def test_initial_agent_accepts_subagent_when_allowed(self) -> None:
|
||||
"""Test that allow_subagent=True permits subagent as initial_agent."""
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
config = build_test_vibe_config()
|
||||
manager = AgentManager(
|
||||
lambda: config, initial_agent="explore", allow_subagent=True
|
||||
)
|
||||
|
|
@ -99,18 +93,12 @@ class TestAgentManager:
|
|||
|
||||
def test_initial_agent_accepts_agent_type(self) -> None:
|
||||
"""Test that creating AgentManager with an agent-type agent works."""
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
config = build_test_vibe_config()
|
||||
manager = AgentManager(lambda: config, initial_agent="plan")
|
||||
assert manager.active_profile.name == "plan"
|
||||
|
||||
def test_initial_agent_raises_when_agent_is_disabled(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
disabled_agents=["plan"],
|
||||
)
|
||||
config = build_test_vibe_config(disabled_agents=["plan"])
|
||||
with pytest.raises(ValueError, match="disabled_agents") as exc_info:
|
||||
AgentManager(lambda: config, initial_agent="plan")
|
||||
message = str(exc_info.value)
|
||||
|
|
@ -120,11 +108,7 @@ class TestAgentManager:
|
|||
def test_explicit_agent_excluded_by_enabled_agents_does_not_blame_default(
|
||||
self,
|
||||
) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_agents=["default"],
|
||||
)
|
||||
config = build_test_vibe_config(enabled_agents=["default"])
|
||||
with pytest.raises(ValueError, match="enabled_agents") as exc_info:
|
||||
AgentManager(lambda: config, initial_agent="plan")
|
||||
message = str(exc_info.value)
|
||||
|
|
@ -132,20 +116,14 @@ class TestAgentManager:
|
|||
assert message.startswith("Agent 'plan'")
|
||||
|
||||
def test_initial_agent_raises_when_agent_does_not_exist(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
config = build_test_vibe_config()
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
AgentManager(lambda: config, initial_agent="nonexistent-agent")
|
||||
|
||||
def test_default_agent_excluded_by_enabled_agents_raises_config_contradiction(
|
||||
self,
|
||||
) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_agents=["plan"],
|
||||
)
|
||||
config = build_test_vibe_config(enabled_agents=["plan"])
|
||||
with pytest.raises(ValueError, match="enabled_agents") as exc_info:
|
||||
AgentManager(lambda: config)
|
||||
message = str(exc_info.value)
|
||||
|
|
@ -155,11 +133,7 @@ class TestAgentManager:
|
|||
def test_default_agent_excluded_by_disabled_agents_raises_config_contradiction(
|
||||
self,
|
||||
) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
disabled_agents=["default"],
|
||||
)
|
||||
config = build_test_vibe_config(disabled_agents=["default"])
|
||||
with pytest.raises(ValueError, match="disabled_agents") as exc_info:
|
||||
AgentManager(lambda: config)
|
||||
assert "default_agent" in str(exc_info.value)
|
||||
|
|
@ -168,10 +142,7 @@ class TestAgentManager:
|
|||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_agents=["plan"],
|
||||
disabled_agents=["plan"],
|
||||
enabled_agents=["plan"], disabled_agents=["plan"]
|
||||
)
|
||||
with caplog.at_level("WARNING"):
|
||||
manager = AgentManager(lambda: config, initial_agent="plan")
|
||||
|
|
@ -181,11 +152,7 @@ class TestAgentManager:
|
|||
def test_install_required_agent_reports_install_not_disabled_agents(self) -> None:
|
||||
# 'lean' is install_required and enabled but not installed: the message
|
||||
# must point to installation, not blame disabled_agents.
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_agents=["lean"],
|
||||
)
|
||||
config = build_test_vibe_config(enabled_agents=["lean"])
|
||||
with pytest.raises(ValueError, match="requires installation") as exc_info:
|
||||
AgentManager(lambda: config, initial_agent="lean")
|
||||
assert "disabled_agents" not in str(exc_info.value)
|
||||
|
|
|
|||
93
tests/core/test_config_bus.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.config.event_bus import EventBus
|
||||
from vibe.core.config.types import ConfigChangeEvent
|
||||
|
||||
|
||||
def _make_event(changed: set[str]) -> ConfigChangeEvent:
|
||||
return ConfigChangeEvent(
|
||||
changed_keys=frozenset(changed), before={}, after={}, reason=""
|
||||
)
|
||||
|
||||
|
||||
def test_wildcard_subscriber_fires_on_every_event() -> None:
|
||||
bus = EventBus()
|
||||
received: list[ConfigChangeEvent] = []
|
||||
bus.subscribe(received.append)
|
||||
|
||||
event = _make_event({"value"})
|
||||
bus.publish(event)
|
||||
|
||||
assert received == [event]
|
||||
|
||||
|
||||
def test_keyed_subscriber_filters_by_key() -> None:
|
||||
bus = EventBus()
|
||||
received: list[ConfigChangeEvent] = []
|
||||
bus.subscribe(received.append, keys={"value"})
|
||||
|
||||
bus.publish(_make_event({"other"}))
|
||||
assert received == []
|
||||
|
||||
bus.publish(_make_event({"value", "other"}))
|
||||
assert len(received) == 1
|
||||
|
||||
|
||||
def test_ancestor_descendant_keys_match_bidirectionally() -> None:
|
||||
bus = EventBus()
|
||||
received: list[ConfigChangeEvent] = []
|
||||
bus.subscribe(received.append, keys={"models"})
|
||||
bus.subscribe(received.append, keys={"models/models"})
|
||||
|
||||
bus.publish(_make_event({"models/models"}))
|
||||
bus.publish(_make_event({"models"}))
|
||||
|
||||
assert len(received) == 4
|
||||
|
||||
|
||||
def test_partial_segment_is_not_a_prefix_match() -> None:
|
||||
bus = EventBus()
|
||||
received: list[ConfigChangeEvent] = []
|
||||
bus.subscribe(received.append, keys={"model"})
|
||||
|
||||
bus.publish(_make_event({"models"}))
|
||||
|
||||
assert received == []
|
||||
|
||||
|
||||
def test_sibling_keys_do_not_match() -> None:
|
||||
bus = EventBus()
|
||||
received: list[ConfigChangeEvent] = []
|
||||
bus.subscribe(received.append, keys={"model/other1"})
|
||||
|
||||
bus.publish(_make_event({"model/other2"}))
|
||||
|
||||
assert received == []
|
||||
|
||||
|
||||
def test_unsubscribe_stops_delivery() -> None:
|
||||
bus = EventBus()
|
||||
received: list[ConfigChangeEvent] = []
|
||||
unsubscribe = bus.subscribe(received.append)
|
||||
unsubscribe()
|
||||
|
||||
bus.publish(_make_event({"value"}))
|
||||
|
||||
assert received == []
|
||||
|
||||
|
||||
def test_unsubscribe_during_publish_is_safe() -> None:
|
||||
bus = EventBus()
|
||||
calls: list[str] = []
|
||||
|
||||
def first(_: ConfigChangeEvent) -> None:
|
||||
calls.append("first")
|
||||
unsubscribe_second()
|
||||
|
||||
bus.subscribe(first)
|
||||
unsubscribe_second = bus.subscribe(lambda _: calls.append("second"))
|
||||
|
||||
bus.publish(_make_event({"value"}))
|
||||
bus.publish(_make_event({"value"}))
|
||||
|
||||
assert calls == ["first", "second", "first"]
|
||||
|
|
@ -1,15 +1,41 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
from pydantic import Field, ValidationError
|
||||
import pytest
|
||||
|
||||
from vibe.core.config.layer import ConfigLayer, RawConfig
|
||||
from vibe.core.config.orchestrator import ConfigOrchestrator
|
||||
from vibe.core.config.patch import ConfigPatch
|
||||
from vibe.core.config.schema import ConfigSchema, WithReplaceMerge
|
||||
from vibe.core.config.types import LayerConfigSnapshot
|
||||
from vibe.core.config.event_bus import EventBus
|
||||
from vibe.core.config.layer import (
|
||||
ConfigLayer,
|
||||
ConfigPatchApplicationError,
|
||||
LayerImplementationError,
|
||||
LayerNotLoadedError,
|
||||
RawConfig,
|
||||
)
|
||||
from vibe.core.config.layers.environment import EnvironmentLayer
|
||||
from vibe.core.config.layers.user import UserConfigLayer
|
||||
from vibe.core.config.orchestrator import ConfigOrchestrator, ConfigPatchValidationError
|
||||
from vibe.core.config.patch import (
|
||||
AddOperationPatch,
|
||||
RemoveOperationPatch,
|
||||
ReplaceOperationPatch,
|
||||
)
|
||||
from vibe.core.config.schema import (
|
||||
ConfigFragment,
|
||||
ConfigSchema,
|
||||
WithConcatMerge,
|
||||
WithReplaceMerge,
|
||||
)
|
||||
from vibe.core.config.types import (
|
||||
ConcurrencyConflictError,
|
||||
ConfigChangeEvent,
|
||||
LayerConfigSnapshot,
|
||||
)
|
||||
|
||||
|
||||
class FakeLayer(ConfigLayer[RawConfig]):
|
||||
|
|
@ -27,15 +53,115 @@ class FakeLayer(ConfigLayer[RawConfig]):
|
|||
raise NotImplementedError
|
||||
|
||||
|
||||
class NormalizingWritableLayer(FakeLayer):
|
||||
async def _save_to_store(self, next_config: RawConfig) -> str:
|
||||
value = next_config.model_dump()["value"]
|
||||
self._data = {"value": f"{value}-normalized"}
|
||||
return "write-fp"
|
||||
|
||||
|
||||
class FieldWritableLayer(FakeLayer):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
field_name: str,
|
||||
data: dict[str, Any],
|
||||
barrier: ParallelSaveBarrier | None = None,
|
||||
) -> None:
|
||||
super().__init__(name=name, data=data)
|
||||
self._field_name = field_name
|
||||
self._barrier = barrier
|
||||
|
||||
async def _save_to_store(self, next_config: RawConfig) -> str:
|
||||
if self._barrier is not None:
|
||||
await self._barrier.wait(self.name)
|
||||
|
||||
value = next_config.model_dump()[self._field_name]
|
||||
self._data = {self._field_name: value}
|
||||
return "write-fp"
|
||||
|
||||
|
||||
class RawWritableLayer(FakeLayer):
|
||||
async def _save_to_store(self, next_config: RawConfig) -> str:
|
||||
self._data = next_config.model_dump()
|
||||
return "write-fp"
|
||||
|
||||
|
||||
class FailingSaveLayer(FakeLayer):
|
||||
async def _save_to_store(self, _next_config: RawConfig) -> str:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
class ApplyErrorLayer(FakeLayer):
|
||||
def __init__(self, *, name: str, data: dict[str, Any], error: Exception) -> None:
|
||||
super().__init__(name=name, data=data)
|
||||
self._error = error
|
||||
|
||||
async def apply(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise self._error
|
||||
|
||||
|
||||
class ParallelSaveBarrier:
|
||||
def __init__(self, expected_starts: int) -> None:
|
||||
self.expected_starts = expected_starts
|
||||
self.started_layers: list[str] = []
|
||||
self.all_started = asyncio.Event()
|
||||
|
||||
async def wait(self, layer_name: str) -> None:
|
||||
self.started_layers.append(layer_name)
|
||||
if len(self.started_layers) == self.expected_starts:
|
||||
self.all_started.set()
|
||||
|
||||
await asyncio.wait_for(self.all_started.wait(), timeout=0.1)
|
||||
|
||||
|
||||
class SimpleSchema(ConfigSchema):
|
||||
value: Annotated[str, WithReplaceMerge()] = "default"
|
||||
|
||||
|
||||
class MultiValueSchema(ConfigSchema):
|
||||
first: Annotated[str, WithReplaceMerge()] = "default-first"
|
||||
second: Annotated[str, WithReplaceMerge()] = "default-second"
|
||||
|
||||
|
||||
class ToolsFragment(ConfigFragment):
|
||||
enabled_tools: Annotated[list[str], WithConcatMerge()] = Field(default_factory=list)
|
||||
disabled_tools: Annotated[list[str], WithConcatMerge()] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
deprecated_setting: Annotated[bool, WithReplaceMerge()] = False
|
||||
|
||||
|
||||
class ToolSchema(ConfigSchema):
|
||||
active_model: Annotated[str, WithReplaceMerge()] = "default-model"
|
||||
tools: ToolsFragment = Field(default_factory=ToolsFragment)
|
||||
|
||||
|
||||
class RoutingSchema(ConfigSchema):
|
||||
active_model: Annotated[str, WithReplaceMerge()] = "default-model"
|
||||
default_agent: Annotated[str, WithReplaceMerge()] = "default-agent"
|
||||
|
||||
|
||||
class RequiredPairSchema(ConfigSchema):
|
||||
first: Annotated[str, WithReplaceMerge()]
|
||||
second: Annotated[str, WithReplaceMerge()]
|
||||
|
||||
|
||||
def assert_single_failure[E: BaseException](
|
||||
result: Sequence[BaseException], expected_type: type[E]
|
||||
) -> E:
|
||||
assert len(result) == 1
|
||||
failure = result[0]
|
||||
assert isinstance(failure, expected_type)
|
||||
return failure
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_builds_config() -> None:
|
||||
layer = FakeLayer(name="test", data={"value": "hello"})
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
|
||||
assert orch.config.value == "hello"
|
||||
assert orch.config.model_dump() == {"value": "hello"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -48,7 +174,7 @@ async def test_get_layer_returns_named_layer() -> None:
|
|||
@pytest.mark.asyncio
|
||||
async def test_get_layer_unknown_raises() -> None:
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
|
||||
with pytest.raises(KeyError, match="unknown"):
|
||||
with pytest.raises(KeyError, match="No layer named 'unknown'"):
|
||||
orch.get_layer("unknown")
|
||||
|
||||
|
||||
|
|
@ -78,14 +204,333 @@ async def test_origin_of_missing_key_returns_none() -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_raises_not_implemented() -> None:
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
|
||||
with pytest.raises(NotImplementedError, match="M2"):
|
||||
await orch.apply_patch(ConfigPatch(fingerprint="fp-1"))
|
||||
async def test_apply_patch_empty_operations_is_noop() -> None:
|
||||
layer = FakeLayer(name="test", data={"value": "hello"})
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
|
||||
|
||||
result = await orch.apply_patch([], reason="no-op")
|
||||
|
||||
assert result == []
|
||||
assert orch.config.value == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_raises_not_implemented() -> None:
|
||||
async def test_apply_patch_rejects_invalid_schema_result_before_routing() -> None:
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
|
||||
with pytest.raises(NotImplementedError, match="M3"):
|
||||
await orch.subscribe(lambda: None)
|
||||
with pytest.raises(ConfigPatchValidationError) as exc_info:
|
||||
await orch.apply_patch(
|
||||
[ReplaceOperationPatch(path="/value", value={"invalid": "shape"})],
|
||||
reason="test invalid patch",
|
||||
)
|
||||
|
||||
assert exc_info.value.args == (
|
||||
"Config patch failed preflight validation against the merged config; fix the patch payload and retry",
|
||||
)
|
||||
assert isinstance(exc_info.value.__cause__, ValidationError)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_returns_failure_when_default_fallback_layer_is_missing() -> (
|
||||
None
|
||||
):
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[ReplaceOperationPatch(path="/value", value="updated")], reason="test update"
|
||||
)
|
||||
|
||||
failure = assert_single_failure(result, KeyError)
|
||||
assert str(failure) == f'"No layer named {UserConfigLayer.LAYER_NAME!r}"'
|
||||
assert orch.config.value == "default"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_unknown_explicit_target_returns_failure() -> None:
|
||||
layer = NormalizingWritableLayer(name=UserConfigLayer.LAYER_NAME, data={})
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[
|
||||
AddOperationPatch(
|
||||
path="/value", value="updated", target_layer_name="missing-layer"
|
||||
)
|
||||
],
|
||||
reason="test update",
|
||||
)
|
||||
|
||||
failure = assert_single_failure(result, KeyError)
|
||||
assert str(failure) == "\"No layer named 'missing-layer'\""
|
||||
assert orch.config.value == "default"
|
||||
assert layer._data == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_falls_back_to_default_user_layer_and_reloads_config() -> (
|
||||
None
|
||||
):
|
||||
layer = NormalizingWritableLayer(name=UserConfigLayer.LAYER_NAME, data={})
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[AddOperationPatch(path="/value", value="updated")], reason="test update"
|
||||
)
|
||||
|
||||
assert result == []
|
||||
assert orch.config.value == "updated-normalized"
|
||||
assert layer._data == {"value": "updated-normalized"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_returns_layer_save_error_after_other_layer_commits() -> None:
|
||||
first_layer = FieldWritableLayer(
|
||||
name="first-layer", field_name="first", data={"first": "one"}
|
||||
)
|
||||
second_layer = FailingSaveLayer(name="second-layer", data={"second": "two"})
|
||||
orch = await ConfigOrchestrator.create(
|
||||
schema=MultiValueSchema, layers=[first_layer, second_layer]
|
||||
)
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[
|
||||
ReplaceOperationPatch(
|
||||
path="/first", value="updated-one", target_layer_name="first-layer"
|
||||
),
|
||||
ReplaceOperationPatch(
|
||||
path="/second", value="updated-two", target_layer_name="second-layer"
|
||||
),
|
||||
],
|
||||
reason="test partial apply",
|
||||
)
|
||||
|
||||
failure = assert_single_failure(result, LayerImplementationError)
|
||||
assert str(failure) == "Layer 'second-layer': _save_to_store() failed"
|
||||
assert isinstance(failure.__cause__, RuntimeError)
|
||||
assert first_layer._data == {"first": "updated-one"}
|
||||
assert second_layer._data == {"second": "two"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
pytest.param(
|
||||
ConcurrencyConflictError(expected_fp="before", actual_fp="after"),
|
||||
id="concurrency-conflict",
|
||||
),
|
||||
pytest.param(ConfigPatchApplicationError("test"), id="patch-application-error"),
|
||||
pytest.param(RuntimeError("unexpected bug"), id="unexpected-runtime-error"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_returns_layer_apply_error_in_failures(
|
||||
error: Exception,
|
||||
) -> None:
|
||||
layer = ApplyErrorLayer(name="test", data={"value": "hello"}, error=error)
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[
|
||||
ReplaceOperationPatch(
|
||||
path="/value", value="updated", target_layer_name="test"
|
||||
)
|
||||
],
|
||||
reason="test update",
|
||||
)
|
||||
|
||||
assert assert_single_failure(result, type(error)) is error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_returns_unloaded_layer_error_in_failures() -> None:
|
||||
loaded_layer = FakeLayer(name="loaded", data={"value": "hello"})
|
||||
target_layer = FakeLayer(name="target", data={"value": "hello"})
|
||||
orch = await ConfigOrchestrator.create(
|
||||
schema=SimpleSchema, layers=[loaded_layer, target_layer]
|
||||
)
|
||||
await target_layer.invalidate_cache()
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[
|
||||
ReplaceOperationPatch(
|
||||
path="/value", value="updated", target_layer_name="target"
|
||||
)
|
||||
],
|
||||
reason="test update",
|
||||
)
|
||||
|
||||
failure = assert_single_failure(result, LayerNotLoadedError)
|
||||
assert str(failure) == "Layer 'target' must be loaded before applying patches"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_applies_layers_in_parallel() -> None:
|
||||
barrier = ParallelSaveBarrier(expected_starts=2)
|
||||
first_layer = FieldWritableLayer(
|
||||
name="first-layer", field_name="first", data={"first": "one"}, barrier=barrier
|
||||
)
|
||||
second_layer = FieldWritableLayer(
|
||||
name="second-layer",
|
||||
field_name="second",
|
||||
data={"second": "two"},
|
||||
barrier=barrier,
|
||||
)
|
||||
orch = await ConfigOrchestrator.create(
|
||||
schema=MultiValueSchema, layers=[first_layer, second_layer]
|
||||
)
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[
|
||||
ReplaceOperationPatch(
|
||||
path="/first", value="updated-one", target_layer_name="first-layer"
|
||||
),
|
||||
ReplaceOperationPatch(
|
||||
path="/second", value="updated-two", target_layer_name="second-layer"
|
||||
),
|
||||
],
|
||||
reason="test parallel apply",
|
||||
)
|
||||
|
||||
assert result == []
|
||||
assert barrier.started_layers == ["first-layer", "second-layer"]
|
||||
assert first_layer._data == {"first": "updated-one"}
|
||||
assert second_layer._data == {"second": "updated-two"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_end_to_end_updates_real_user_config_file(
|
||||
tmp_working_directory: Path,
|
||||
) -> None:
|
||||
toml_path = tmp_working_directory / "config.toml"
|
||||
toml_path.write_text(
|
||||
"""\
|
||||
active_model = "old"
|
||||
|
||||
[tools]
|
||||
disabled_tools = ["bash", "python"]
|
||||
deprecated_setting = true
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
user_layer = UserConfigLayer(path=toml_path)
|
||||
orch = await ConfigOrchestrator.create(schema=ToolSchema, layers=[user_layer])
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[
|
||||
ReplaceOperationPatch(path="/active_model", value="new"),
|
||||
AddOperationPatch(path="/tools/enabled_tools", value=["read"]),
|
||||
AddOperationPatch(path="/tools/disabled_tools/-", value="node"),
|
||||
RemoveOperationPatch(path="/tools/disabled_tools/0"),
|
||||
RemoveOperationPatch(path="/tools/deprecated_setting"),
|
||||
],
|
||||
reason="update user defaults",
|
||||
)
|
||||
|
||||
assert result == []
|
||||
with toml_path.open("rb") as file:
|
||||
assert tomllib.load(file) == {
|
||||
"active_model": "new",
|
||||
"tools": {"disabled_tools": ["python", "node"], "enabled_tools": ["read"]},
|
||||
}
|
||||
assert orch.config.active_model == "new"
|
||||
assert orch.config.tools.disabled_tools == ["python", "node"]
|
||||
assert orch.config.tools.enabled_tools == ["read"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_fallback_to_user_layer_fails_when_user_file_is_missing(
|
||||
tmp_working_directory: Path,
|
||||
) -> None:
|
||||
toml_path = tmp_working_directory / "config.toml"
|
||||
user_layer = UserConfigLayer(path=toml_path)
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[user_layer])
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[AddOperationPatch(path="/value", value="created-later")],
|
||||
reason="fallback write without user file",
|
||||
)
|
||||
|
||||
failure = assert_single_failure(result, LayerNotLoadedError)
|
||||
assert str(failure) == "Layer 'user-toml' must be loaded before applying patches"
|
||||
assert not toml_path.exists()
|
||||
assert orch.config.value == "default"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_end_to_end_falls_back_to_user_layer_when_no_target_is_provided(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_working_directory: Path
|
||||
) -> None:
|
||||
toml_path = tmp_working_directory / "config.toml"
|
||||
toml_path.write_text('default_agent = "plan"\n', encoding="utf-8")
|
||||
monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model")
|
||||
|
||||
user_layer = UserConfigLayer(path=toml_path)
|
||||
environment_layer = EnvironmentLayer(schema=RoutingSchema)
|
||||
orch = await ConfigOrchestrator.create(
|
||||
schema=RoutingSchema, layers=[user_layer, environment_layer]
|
||||
)
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[
|
||||
AddOperationPatch(path="/active_model", value="persisted-in-user-file"),
|
||||
ReplaceOperationPatch(path="/default_agent", value="accept-edits"),
|
||||
],
|
||||
reason="update runtime defaults",
|
||||
)
|
||||
|
||||
assert result == []
|
||||
with toml_path.open("rb") as file:
|
||||
assert tomllib.load(file) == {
|
||||
"active_model": "persisted-in-user-file",
|
||||
"default_agent": "accept-edits",
|
||||
}
|
||||
assert orch.config.active_model == "env-model"
|
||||
assert orch.config.default_agent == "accept-edits"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_end_to_end_respects_explicit_target_layer(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_working_directory: Path
|
||||
) -> None:
|
||||
toml_path = tmp_working_directory / "config.toml"
|
||||
toml_path.write_text('default_agent = "plan"\n', encoding="utf-8")
|
||||
monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model")
|
||||
|
||||
user_layer = UserConfigLayer(path=toml_path)
|
||||
environment_layer = EnvironmentLayer(schema=RoutingSchema)
|
||||
orch = await ConfigOrchestrator.create(
|
||||
schema=RoutingSchema, layers=[user_layer, environment_layer]
|
||||
)
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[
|
||||
ReplaceOperationPatch(
|
||||
path="/active_model",
|
||||
value="persist-me-nowhere",
|
||||
target_layer_name="environment",
|
||||
),
|
||||
ReplaceOperationPatch(path="/default_agent", value="accept-edits"),
|
||||
],
|
||||
reason="update runtime defaults",
|
||||
)
|
||||
|
||||
failure = assert_single_failure(result, NotImplementedError)
|
||||
assert str(failure) == "EnvironmentLayer patch persistence is not implemented yet"
|
||||
with toml_path.open("rb") as file:
|
||||
assert tomllib.load(file) == {"default_agent": "accept-edits"}
|
||||
assert orch.config.active_model == "env-model"
|
||||
assert orch.config.default_agent == "accept-edits"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_registers_on_the_bus() -> None:
|
||||
bus = EventBus()
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[], bus=bus)
|
||||
received: list[ConfigChangeEvent] = []
|
||||
orch.subscribe(received.append)
|
||||
|
||||
event = ConfigChangeEvent(
|
||||
changed_keys=frozenset({"value"}), before={}, after={}, reason=""
|
||||
)
|
||||
bus.publish(event)
|
||||
|
||||
assert received == [event]
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ api_base = "https://api.mistral.ai/v1"
|
|||
from vibe.core.config.layers.user import UserConfigLayer
|
||||
from vibe.core.config.orchestrator import ConfigOrchestrator
|
||||
|
||||
layer = UserConfigLayer(path=toml_path, name="user-toml")
|
||||
layer = UserConfigLayer(path=toml_path)
|
||||
orchestrator = await ConfigOrchestrator.create(schema=MinimalSchema, layers=[layer])
|
||||
|
||||
assert orchestrator.config.models.active_model == "mistral-large"
|
||||
|
|
|
|||
614
tests/core/test_keyring.py
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from threading import Event, Thread
|
||||
|
||||
import keyring
|
||||
from keyring.errors import KeyringError, PasswordDeleteError
|
||||
import pytest
|
||||
|
||||
import vibe.core.utils.keyring as keyring_utils
|
||||
from vibe.core.utils.keyring import (
|
||||
delete_api_key_from_keyring,
|
||||
get_api_key_from_keyring,
|
||||
set_api_key_in_keyring,
|
||||
)
|
||||
|
||||
_CURRENT_SERVICE = "ai.mistral.vibe"
|
||||
_RELEASED_LEGACY_SERVICE = "vibe"
|
||||
_LEGACY_SERVICES = (_RELEASED_LEGACY_SERVICE,)
|
||||
_ALL_SERVICES = (_CURRENT_SERVICE, *_LEGACY_SERVICES)
|
||||
|
||||
|
||||
def test_set_writes_to_current_vibe_service_and_deletes_legacy_services(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
writes: list[tuple[str, str, str]] = []
|
||||
deletes: list[tuple[str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
keyring,
|
||||
"set_password",
|
||||
lambda service, username, password: writes.append((
|
||||
service,
|
||||
username,
|
||||
password,
|
||||
)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
keyring,
|
||||
"delete_password",
|
||||
lambda service, username: deletes.append((service, username)),
|
||||
)
|
||||
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
|
||||
assert writes == [(_CURRENT_SERVICE, "CUSTOM_API_KEY", "new-key")]
|
||||
assert deletes == [(service, "CUSTOM_API_KEY") for service in _LEGACY_SERVICES]
|
||||
|
||||
|
||||
def test_set_ignores_legacy_deletion_errors_after_successful_write(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
writes: list[tuple[str, str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
keyring,
|
||||
"set_password",
|
||||
lambda service, username, password: writes.append((
|
||||
service,
|
||||
username,
|
||||
password,
|
||||
)),
|
||||
)
|
||||
|
||||
def _delete_failed(service: str, username: str) -> None:
|
||||
raise KeyringError("delete failed")
|
||||
|
||||
monkeypatch.setattr(keyring, "delete_password", _delete_failed)
|
||||
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
|
||||
assert writes == [(_CURRENT_SERVICE, "CUSTOM_API_KEY", "new-key")]
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "new-key"
|
||||
|
||||
|
||||
def test_set_populates_cache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
keyring, "set_password", lambda service, username, password: None
|
||||
)
|
||||
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
|
||||
# A subsequent read is served from cache without consulting the backend.
|
||||
def _fail(service: str, username: str) -> str | None:
|
||||
raise AssertionError("keyring must not be consulted; value is cached")
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _fail)
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "new-key"
|
||||
|
||||
|
||||
def test_get_does_not_overwrite_concurrent_cache_write(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
read_started = Event()
|
||||
allow_read_to_finish = Event()
|
||||
results: list[str | None] = []
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def _get(service: str, username: str) -> str | None:
|
||||
if service != _CURRENT_SERVICE:
|
||||
return None
|
||||
read_started.set()
|
||||
assert allow_read_to_finish.wait(timeout=5)
|
||||
return "old-key"
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get)
|
||||
monkeypatch.setattr(
|
||||
keyring, "set_password", lambda service, username, password: None
|
||||
)
|
||||
monkeypatch.setattr(keyring, "delete_password", lambda service, username: None)
|
||||
|
||||
def _read_key() -> None:
|
||||
try:
|
||||
results.append(get_api_key_from_keyring("CUSTOM_API_KEY"))
|
||||
except BaseException as exc:
|
||||
errors.append(exc)
|
||||
|
||||
reader = Thread(target=_read_key)
|
||||
reader.start()
|
||||
assert read_started.wait(timeout=5)
|
||||
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
allow_read_to_finish.set()
|
||||
reader.join(timeout=5)
|
||||
|
||||
assert not reader.is_alive()
|
||||
if errors:
|
||||
raise errors[0]
|
||||
assert results == ["new-key"]
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "new-key"
|
||||
|
||||
|
||||
def test_disabled_keyring_get_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("VIBE_TEST_DISABLE_KEYRING", "1")
|
||||
|
||||
def _fail(service: str, username: str) -> str | None:
|
||||
raise AssertionError("disabled keyring must not be consulted")
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _fail)
|
||||
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") is None
|
||||
|
||||
|
||||
def test_disabled_keyring_set_raises_and_forgets_cache(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
keyring, "set_password", lambda service, username, password: None
|
||||
)
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "cached-key")
|
||||
monkeypatch.setenv("VIBE_TEST_DISABLE_KEYRING", "1")
|
||||
|
||||
with pytest.raises(KeyringError):
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
|
||||
monkeypatch.delenv("VIBE_TEST_DISABLE_KEYRING")
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") is None
|
||||
|
||||
|
||||
def test_disabled_keyring_delete_forgets_cache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
keyring, "set_password", lambda service, username, password: None
|
||||
)
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "cached-key")
|
||||
monkeypatch.setenv("VIBE_TEST_DISABLE_KEYRING", "1")
|
||||
|
||||
delete_api_key_from_keyring("CUSTOM_API_KEY")
|
||||
|
||||
monkeypatch.delenv("VIBE_TEST_DISABLE_KEYRING")
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") is None
|
||||
|
||||
|
||||
def test_set_forgets_stale_cache_and_propagates_on_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Seed the cache with a stale value.
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: "stale")
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "stale"
|
||||
|
||||
def _unavailable(service: str, username: str, password: str) -> None:
|
||||
raise KeyringError("no keyring")
|
||||
|
||||
monkeypatch.setattr(keyring, "set_password", _unavailable)
|
||||
|
||||
with pytest.raises(KeyringError):
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
|
||||
# The stale entry was dropped, so the next read consults the backend again.
|
||||
reads: list[tuple[str, str]] = []
|
||||
|
||||
def _get(service: str, username: str) -> str | None:
|
||||
reads.append((service, username))
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get)
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") is None
|
||||
assert reads == [
|
||||
(_CURRENT_SERVICE, "CUSTOM_API_KEY"),
|
||||
(_RELEASED_LEGACY_SERVICE, "CUSTOM_API_KEY"),
|
||||
]
|
||||
|
||||
|
||||
def test_get_prefers_current_service(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
reads: list[tuple[str, str]] = []
|
||||
|
||||
def _get(service: str, username: str) -> str | None:
|
||||
reads.append((service, username))
|
||||
if service == _CURRENT_SERVICE:
|
||||
return "current-key"
|
||||
return "legacy-key"
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get)
|
||||
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "current-key"
|
||||
assert reads == [(_CURRENT_SERVICE, "CUSTOM_API_KEY")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy_service", _LEGACY_SERVICES)
|
||||
def test_get_migrates_legacy_service_value(
|
||||
monkeypatch: pytest.MonkeyPatch, legacy_service: str
|
||||
) -> None:
|
||||
reads: list[tuple[str, str]] = []
|
||||
writes: list[tuple[str, str, str]] = []
|
||||
deletes: list[tuple[str, str]] = []
|
||||
|
||||
def _get(service: str, username: str) -> str | None:
|
||||
reads.append((service, username))
|
||||
if service == legacy_service:
|
||||
return "legacy-key"
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get)
|
||||
monkeypatch.setattr(
|
||||
keyring,
|
||||
"set_password",
|
||||
lambda service, username, password: writes.append((
|
||||
service,
|
||||
username,
|
||||
password,
|
||||
)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
keyring,
|
||||
"delete_password",
|
||||
lambda service, username: deletes.append((service, username)),
|
||||
)
|
||||
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "legacy-key"
|
||||
expected_services = _ALL_SERVICES[: _ALL_SERVICES.index(legacy_service) + 1]
|
||||
assert reads == [(service, "CUSTOM_API_KEY") for service in expected_services]
|
||||
assert writes == [(_CURRENT_SERVICE, "CUSTOM_API_KEY", "legacy-key")]
|
||||
assert deletes == [(legacy_service, "CUSTOM_API_KEY")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy_service", _LEGACY_SERVICES)
|
||||
def test_get_returns_legacy_value_when_migration_write_fails(
|
||||
monkeypatch: pytest.MonkeyPatch, legacy_service: str
|
||||
) -> None:
|
||||
deletes: list[tuple[str, str]] = []
|
||||
|
||||
def _get(service: str, username: str) -> str | None:
|
||||
if service == legacy_service:
|
||||
return "legacy-key"
|
||||
return None
|
||||
|
||||
def _unavailable(service: str, username: str, password: str) -> None:
|
||||
raise KeyringError("no keyring")
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get)
|
||||
monkeypatch.setattr(keyring, "set_password", _unavailable)
|
||||
monkeypatch.setattr(
|
||||
keyring,
|
||||
"delete_password",
|
||||
lambda service, username: deletes.append((service, username)),
|
||||
)
|
||||
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "legacy-key"
|
||||
assert deletes == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy_service", _LEGACY_SERVICES)
|
||||
def test_get_ignores_legacy_deletion_error_after_migration(
|
||||
monkeypatch: pytest.MonkeyPatch, legacy_service: str
|
||||
) -> None:
|
||||
def _get(service: str, username: str) -> str | None:
|
||||
if service == legacy_service:
|
||||
return "legacy-key"
|
||||
return None
|
||||
|
||||
def _delete_failed(service: str, username: str) -> None:
|
||||
raise KeyringError("delete failed")
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get)
|
||||
monkeypatch.setattr(
|
||||
keyring, "set_password", lambda service, username, password: None
|
||||
)
|
||||
monkeypatch.setattr(keyring, "delete_password", _delete_failed)
|
||||
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "legacy-key"
|
||||
|
||||
|
||||
def test_delete_uses_current_and_legacy_vibe_services_and_forgets_cache(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Seed the cache.
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: "cached")
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "cached"
|
||||
|
||||
deleted: list[tuple[str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
keyring,
|
||||
"delete_password",
|
||||
lambda service, username: deleted.append((service, username)),
|
||||
)
|
||||
|
||||
delete_api_key_from_keyring("CUSTOM_API_KEY")
|
||||
|
||||
assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES]
|
||||
# Cache entry dropped: the next read consults the backend.
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") is None
|
||||
|
||||
|
||||
def test_delete_forgets_cache_even_when_backend_raises(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: "cached")
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "cached"
|
||||
|
||||
def _unavailable(service: str, username: str) -> None:
|
||||
raise KeyringError("no keyring")
|
||||
|
||||
monkeypatch.setattr(keyring, "delete_password", _unavailable)
|
||||
|
||||
with pytest.raises(KeyringError):
|
||||
delete_api_key_from_keyring("CUSTOM_API_KEY")
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") is None
|
||||
|
||||
|
||||
def test_delete_attempts_legacy_service_when_current_service_raises(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
deleted: list[tuple[str, str]] = []
|
||||
|
||||
def _delete(service: str, username: str) -> None:
|
||||
deleted.append((service, username))
|
||||
if service == _CURRENT_SERVICE:
|
||||
raise KeyringError("delete failed")
|
||||
|
||||
monkeypatch.setattr(keyring, "delete_password", _delete)
|
||||
|
||||
with pytest.raises(KeyringError):
|
||||
delete_api_key_from_keyring("CUSTOM_API_KEY")
|
||||
|
||||
assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES]
|
||||
|
||||
|
||||
def test_delete_prefers_operation_error_over_missing_entry(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
deleted: list[tuple[str, str]] = []
|
||||
|
||||
def _delete(service: str, username: str) -> None:
|
||||
deleted.append((service, username))
|
||||
if service == _CURRENT_SERVICE:
|
||||
raise PasswordDeleteError()
|
||||
if service == _RELEASED_LEGACY_SERVICE:
|
||||
raise KeyringError("delete failed")
|
||||
raise PasswordDeleteError()
|
||||
|
||||
monkeypatch.setattr(keyring, "delete_password", _delete)
|
||||
|
||||
with pytest.raises(KeyringError, match="delete failed"):
|
||||
delete_api_key_from_keyring("CUSTOM_API_KEY")
|
||||
|
||||
assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES]
|
||||
|
||||
|
||||
def test_delete_ignores_missing_entries_after_successful_delete(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
deleted: list[tuple[str, str]] = []
|
||||
|
||||
def _delete(service: str, username: str) -> None:
|
||||
deleted.append((service, username))
|
||||
if service != _RELEASED_LEGACY_SERVICE:
|
||||
raise PasswordDeleteError()
|
||||
|
||||
monkeypatch.setattr(keyring, "delete_password", _delete)
|
||||
|
||||
delete_api_key_from_keyring("CUSTOM_API_KEY")
|
||||
|
||||
assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES]
|
||||
|
||||
|
||||
def test_delete_raises_missing_when_all_services_are_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
deleted: list[tuple[str, str]] = []
|
||||
|
||||
def _delete(service: str, username: str) -> None:
|
||||
deleted.append((service, username))
|
||||
raise PasswordDeleteError()
|
||||
|
||||
monkeypatch.setattr(keyring, "delete_password", _delete)
|
||||
|
||||
with pytest.raises(PasswordDeleteError):
|
||||
delete_api_key_from_keyring("CUSTOM_API_KEY")
|
||||
|
||||
assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES]
|
||||
|
||||
|
||||
def test_macos_set_recreates_item_with_security_stdin_and_unrestricted_acl(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[tuple[list[str], dict[str, object]]] = []
|
||||
monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True)
|
||||
|
||||
def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
calls.append((args, kwargs))
|
||||
return subprocess.CompletedProcess(args, 0)
|
||||
|
||||
monkeypatch.setattr(keyring_utils.subprocess, "run", _run)
|
||||
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
|
||||
assert len(calls) == 3
|
||||
delete_args, delete_kwargs = calls[0]
|
||||
assert delete_args == [
|
||||
"/usr/bin/security",
|
||||
"delete-generic-password",
|
||||
"-s",
|
||||
_CURRENT_SERVICE,
|
||||
"-a",
|
||||
"CUSTOM_API_KEY",
|
||||
]
|
||||
assert "new-key" not in delete_args
|
||||
assert delete_kwargs["input"] is None
|
||||
|
||||
args, kwargs = calls[1]
|
||||
assert args == ["/usr/bin/security", "-i"]
|
||||
assert kwargs["text"] is True
|
||||
assert kwargs["capture_output"] is True
|
||||
assert kwargs["check"] is True
|
||||
assert "new-key" not in args
|
||||
|
||||
command = kwargs["input"]
|
||||
assert isinstance(command, str)
|
||||
assert command.endswith("\n")
|
||||
assert "add-generic-password" in command
|
||||
assert "-A" in command
|
||||
assert "-U" not in command
|
||||
assert _CURRENT_SERVICE in command
|
||||
assert "CUSTOM_API_KEY" in command
|
||||
assert "new-key" in command
|
||||
assert [call[0] for call in calls[2:]] == [
|
||||
[
|
||||
"/usr/bin/security",
|
||||
"delete-generic-password",
|
||||
"-s",
|
||||
_RELEASED_LEGACY_SERVICE,
|
||||
"-a",
|
||||
"CUSTOM_API_KEY",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def test_macos_set_ignores_missing_item_before_recreate(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[list[str]] = []
|
||||
monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True)
|
||||
|
||||
def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
calls.append(args)
|
||||
if args[1] == "delete-generic-password":
|
||||
raise subprocess.CalledProcessError(
|
||||
44,
|
||||
args,
|
||||
stderr="The specified item could not be found in the keychain.",
|
||||
)
|
||||
return subprocess.CompletedProcess(args, 0)
|
||||
|
||||
monkeypatch.setattr(keyring_utils.subprocess, "run", _run)
|
||||
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
|
||||
assert calls == [
|
||||
[
|
||||
"/usr/bin/security",
|
||||
"delete-generic-password",
|
||||
"-s",
|
||||
_CURRENT_SERVICE,
|
||||
"-a",
|
||||
"CUSTOM_API_KEY",
|
||||
],
|
||||
["/usr/bin/security", "-i"],
|
||||
[
|
||||
"/usr/bin/security",
|
||||
"delete-generic-password",
|
||||
"-s",
|
||||
_RELEASED_LEGACY_SERVICE,
|
||||
"-a",
|
||||
"CUSTOM_API_KEY",
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def test_macos_get_uses_security_find_password(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[tuple[list[str], dict[str, object]]] = []
|
||||
monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True)
|
||||
|
||||
def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
calls.append((args, kwargs))
|
||||
return subprocess.CompletedProcess(args, 0, stdout="stored-key\n")
|
||||
|
||||
monkeypatch.setattr(keyring_utils.subprocess, "run", _run)
|
||||
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "stored-key"
|
||||
assert calls == [
|
||||
(
|
||||
[
|
||||
"/usr/bin/security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
_CURRENT_SERVICE,
|
||||
"-a",
|
||||
"CUSTOM_API_KEY",
|
||||
"-w",
|
||||
],
|
||||
{"input": None, "text": True, "capture_output": True, "check": True},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_macos_get_raises_when_item_is_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True)
|
||||
|
||||
def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
raise subprocess.CalledProcessError(
|
||||
44, args, stderr="The specified item could not be found in the keychain."
|
||||
)
|
||||
|
||||
monkeypatch.setattr(keyring_utils.subprocess, "run", _run)
|
||||
|
||||
with pytest.raises(keyring_utils._PasswordNotFoundError):
|
||||
keyring_utils._get_password(_CURRENT_SERVICE, "CUSTOM_API_KEY")
|
||||
|
||||
|
||||
def test_macos_get_checks_legacy_service_when_current_item_is_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[list[str]] = []
|
||||
monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True)
|
||||
|
||||
def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
calls.append(args)
|
||||
if args[1] in {"find-generic-password", "delete-generic-password"} and (
|
||||
args[3] == _CURRENT_SERVICE
|
||||
):
|
||||
raise subprocess.CalledProcessError(
|
||||
44,
|
||||
args,
|
||||
stderr="The specified item could not be found in the keychain.",
|
||||
)
|
||||
return subprocess.CompletedProcess(args, 0, stdout="legacy-key\n")
|
||||
|
||||
monkeypatch.setattr(keyring_utils.subprocess, "run", _run)
|
||||
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "legacy-key"
|
||||
assert calls[:2] == [
|
||||
[
|
||||
"/usr/bin/security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
_CURRENT_SERVICE,
|
||||
"-a",
|
||||
"CUSTOM_API_KEY",
|
||||
"-w",
|
||||
],
|
||||
[
|
||||
"/usr/bin/security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
_RELEASED_LEGACY_SERVICE,
|
||||
"-a",
|
||||
"CUSTOM_API_KEY",
|
||||
"-w",
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def test_macos_set_wraps_security_failure_and_forgets_cache(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: "stale")
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "stale"
|
||||
|
||||
monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True)
|
||||
|
||||
def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if args[1] == "delete-generic-password":
|
||||
return subprocess.CompletedProcess(args, 0)
|
||||
raise subprocess.CalledProcessError(1, args)
|
||||
|
||||
monkeypatch.setattr(keyring_utils.subprocess, "run", _run)
|
||||
|
||||
with pytest.raises(KeyringError):
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") is None
|
||||
|
|
@ -31,6 +31,8 @@ from vibe.core.auth.mcp_oauth import (
|
|||
)
|
||||
from vibe.core.config import MCPOAuth, MCPStreamableHttp
|
||||
|
||||
_KEYRING_SERVICE = "ai.mistral.vibe"
|
||||
|
||||
|
||||
class _MemoryKeyring(KeyringBackend):
|
||||
priority = 100 # type: ignore[assignment]
|
||||
|
|
@ -46,7 +48,7 @@ class _MemoryKeyring(KeyringBackend):
|
|||
|
||||
def delete_password(self, service: str, username: str) -> None:
|
||||
if (service, username) not in self.store:
|
||||
raise keyring.errors.PasswordDeleteError(username)
|
||||
raise keyring.errors.PasswordDeleteError()
|
||||
del self.store[(service, username)]
|
||||
|
||||
|
||||
|
|
@ -143,7 +145,7 @@ class TestKeyringTokenStorage:
|
|||
assert loaded.access_token == "at"
|
||||
assert loaded.refresh_token == "rt"
|
||||
assert loaded.scope == "read write"
|
||||
assert ("vibe", "mcp-oauth:linear:tokens") in memory_keyring.store
|
||||
assert (_KEYRING_SERVICE, "mcp-oauth:linear:tokens") in memory_keyring.store
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_round_trip_client_info(self, memory_keyring: _MemoryKeyring) -> None:
|
||||
|
|
@ -160,7 +162,10 @@ class TestKeyringTokenStorage:
|
|||
loaded = await storage.get_client_info()
|
||||
assert loaded is not None
|
||||
assert loaded.client_id == "abc123"
|
||||
assert ("vibe", "mcp-oauth:linear:client_info") in memory_keyring.store
|
||||
assert (
|
||||
_KEYRING_SERVICE,
|
||||
"mcp-oauth:linear:client_info",
|
||||
) in memory_keyring.store
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_alias_isolation(self, memory_keyring: _MemoryKeyring) -> None:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import pytest
|
|||
|
||||
from vibe.core.config.layer import UntrustedLayerError
|
||||
from vibe.core.config.layers.project import ProjectConfigLayer
|
||||
from vibe.core.config.types import MISSING_CONFIG_FILE_FINGERPRINT
|
||||
from vibe.core.config.types import MISSING_BACKING_STORE_DATA_FINGERPRINT
|
||||
from vibe.core.paths._vibe_home import GlobalPath
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ async def test_reads_toml_when_trusted(tmp_working_directory: Path) -> None:
|
|||
config_path.unlink()
|
||||
data = await layer.load(force=True)
|
||||
assert data.model_extra == {}
|
||||
assert layer.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT
|
||||
assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -63,7 +63,7 @@ async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None:
|
|||
layer = ProjectConfigLayer(path=tmp_working_directory)
|
||||
data = await layer.load()
|
||||
assert data.model_extra == {}
|
||||
assert layer.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT
|
||||
assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from tests.conftest import build_test_vibe_config
|
|||
from vibe.core.config import MissingAPIKeyError, ProviderConfig, resolve_api_key
|
||||
from vibe.core.llm.backend.mistral import MistralBackend
|
||||
from vibe.core.types import Backend
|
||||
from vibe.core.utils.keyring import clear_api_key_keyring_cache
|
||||
|
||||
|
||||
def test_resolve_returns_env_value_without_consulting_keyring(
|
||||
|
|
@ -34,6 +35,40 @@ def test_resolve_falls_back_to_keyring_when_env_unset(
|
|||
assert resolve_api_key("CUSTOM_API_KEY") == "keyring-key"
|
||||
|
||||
|
||||
def test_resolve_caches_keyring_lookup_for_env_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[tuple[str, str]] = []
|
||||
monkeypatch.delenv("CUSTOM_API_KEY", raising=False)
|
||||
|
||||
def _get_password(service: str, username: str) -> str:
|
||||
calls.append((service, username))
|
||||
return "keyring-key"
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get_password)
|
||||
|
||||
assert resolve_api_key("CUSTOM_API_KEY") == "keyring-key"
|
||||
assert resolve_api_key("CUSTOM_API_KEY") == "keyring-key"
|
||||
assert calls == [("ai.mistral.vibe", "CUSTOM_API_KEY")]
|
||||
|
||||
|
||||
def test_resolve_caches_empty_keyring_lookup_for_env_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[tuple[str, str]] = []
|
||||
monkeypatch.delenv("CUSTOM_API_KEY", raising=False)
|
||||
|
||||
def _get_password(service: str, username: str) -> None:
|
||||
calls.append((service, username))
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get_password)
|
||||
|
||||
assert resolve_api_key("CUSTOM_API_KEY") is None
|
||||
assert resolve_api_key("CUSTOM_API_KEY") is None
|
||||
assert calls == [("ai.mistral.vibe", "CUSTOM_API_KEY"), ("vibe", "CUSTOM_API_KEY")]
|
||||
|
||||
|
||||
def test_resolve_returns_none_when_env_unset_and_keyring_empty(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
@ -56,6 +91,24 @@ def test_resolve_returns_none_when_keyring_raises(
|
|||
assert resolve_api_key("CUSTOM_API_KEY") is None
|
||||
|
||||
|
||||
def test_resolve_does_not_cache_keyring_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = 0
|
||||
monkeypatch.delenv("CUSTOM_API_KEY", raising=False)
|
||||
|
||||
def _sometimes_unavailable(service: str, username: str) -> str:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise KeyringError("temporary failure")
|
||||
return "keyring-key"
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _sometimes_unavailable)
|
||||
|
||||
assert resolve_api_key("CUSTOM_API_KEY") is None
|
||||
assert resolve_api_key("CUSTOM_API_KEY") == "keyring-key"
|
||||
assert calls == 2
|
||||
|
||||
|
||||
def test_resolve_returns_none_for_empty_env_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
@ -123,7 +176,7 @@ def test_vibe_code_api_key_resolves_from_keyring(
|
|||
assert config.vibe_code_api_key == "keyring-key"
|
||||
|
||||
|
||||
def test_vibe_code_api_key_empty_when_unresolved(
|
||||
def test_vibe_code_api_key_reuses_cached_keyring_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
|
|
@ -132,7 +185,21 @@ def test_vibe_code_api_key_empty_when_unresolved(
|
|||
)
|
||||
config = build_test_vibe_config()
|
||||
|
||||
# Nothing resolves the key anymore; the property must return "" (not None).
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
|
||||
assert config.vibe_code_api_key == "keyring-key"
|
||||
|
||||
|
||||
def test_vibe_code_api_key_empty_when_cache_cleared_and_unresolved(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
monkeypatch.setattr(
|
||||
keyring, "get_password", lambda service, username: "keyring-key"
|
||||
)
|
||||
config = build_test_vibe_config()
|
||||
|
||||
clear_api_key_keyring_cache()
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
|
||||
assert config.vibe_code_api_key == ""
|
||||
|
|
|
|||
|
|
@ -61,9 +61,6 @@ def _make_agent_loop(backend: FakeBackend):
|
|||
"edit": {"permission": "always"},
|
||||
"bash": {"permission": "always"},
|
||||
},
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
)
|
||||
return build_test_agent_loop(
|
||||
config=config, agent_name=BuiltinAgentName.AUTO_APPROVE, backend=backend
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from vibe.core.config.patch import (
|
|||
RemoveOperationPatch,
|
||||
ReplaceOperationPatch,
|
||||
)
|
||||
from vibe.core.config.types import MISSING_CONFIG_FILE_FINGERPRINT
|
||||
from vibe.core.config.types import MISSING_BACKING_STORE_DATA_FINGERPRINT
|
||||
|
||||
|
||||
def random_config_file_name() -> str:
|
||||
|
|
@ -28,7 +28,7 @@ async def test_reads_toml_file(tmp_working_directory: Path) -> None:
|
|||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text('active_model = "mistral-large"\ncount = 42\n')
|
||||
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
data = await layer.load()
|
||||
assert data.model_extra == {"active_model": "mistral-large", "count": 42}
|
||||
fingerprint = layer.fingerprint
|
||||
|
|
@ -41,7 +41,7 @@ async def test_always_trusted(tmp_working_directory: Path) -> None:
|
|||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text('key = "value"\n')
|
||||
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
assert layer.is_trusted is None
|
||||
data = await layer.load()
|
||||
assert layer.is_trusted is True
|
||||
|
|
@ -51,10 +51,10 @@ async def test_always_trusted(tmp_working_directory: Path) -> None:
|
|||
@pytest.mark.asyncio
|
||||
async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
data = await layer.load()
|
||||
assert data.model_extra == {}
|
||||
assert layer.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT
|
||||
assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -62,16 +62,16 @@ async def test_apply_raises_when_file_does_not_exist(
|
|||
tmp_working_directory: Path,
|
||||
) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
await layer.load()
|
||||
assert layer.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT
|
||||
assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT
|
||||
|
||||
with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"):
|
||||
await layer.apply(
|
||||
ConfigPatch(
|
||||
AddOperationPatch(path="/active_model", value="mistral-large"),
|
||||
fingerprint=MISSING_CONFIG_FILE_FINGERPRINT,
|
||||
fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ active_model = "old"
|
|||
disabled_tools = ["bash", "python"]
|
||||
deprecated_setting = true
|
||||
""")
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
await layer.load()
|
||||
fingerprint = layer.fingerprint
|
||||
|
|
@ -126,7 +126,7 @@ async def test_apply_cache_fingerprint_matches_written_file(
|
|||
) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text("")
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
await layer.load()
|
||||
fingerprint = layer.fingerprint
|
||||
|
|
@ -149,7 +149,7 @@ async def test_apply_uses_unique_temp_file(tmp_working_directory: Path) -> None:
|
|||
fixed_tmp_path = tmp_working_directory / f".{path.name}.tmp"
|
||||
path.write_text("")
|
||||
fixed_tmp_path.write_text("stale")
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
await layer.load()
|
||||
fingerprint = layer.fingerprint
|
||||
|
|
@ -190,13 +190,13 @@ async def test_apply_raises_when_layer_is_not_loaded(
|
|||
tmp_working_directory: Path,
|
||||
) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"):
|
||||
await layer.apply(
|
||||
ConfigPatch(
|
||||
AddOperationPatch(path="/active_model", value="mistral-large"),
|
||||
fingerprint=MISSING_CONFIG_FILE_FINGERPRINT,
|
||||
fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -207,7 +207,7 @@ async def test_apply_raises_when_cache_is_invalidated(
|
|||
) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text('active_model = "old"\n')
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
await layer.load()
|
||||
fingerprint = layer.fingerprint
|
||||
|
|
@ -228,7 +228,7 @@ async def test_apply_raises_when_parent_directory_does_not_exist(
|
|||
tmp_working_directory: Path,
|
||||
) -> None:
|
||||
path = tmp_working_directory / "nested" / random_config_file_name()
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
await layer.load()
|
||||
|
||||
|
|
@ -236,7 +236,7 @@ async def test_apply_raises_when_parent_directory_does_not_exist(
|
|||
await layer.apply(
|
||||
ConfigPatch(
|
||||
AddOperationPatch(path="/active_model", value="mistral-large"),
|
||||
fingerprint=MISSING_CONFIG_FILE_FINGERPRINT,
|
||||
fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -247,7 +247,7 @@ async def test_apply_raises_when_parent_directory_does_not_exist(
|
|||
async def test_commit_sets_missing_nested_field(tmp_working_directory: Path) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text("[models]\n")
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
await layer.load()
|
||||
fingerprint = layer.fingerprint
|
||||
|
|
@ -270,7 +270,7 @@ async def test_apply_overwrites_external_file_changes(
|
|||
) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text('active_model = "old"\n')
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
await layer.load()
|
||||
fingerprint = layer.fingerprint
|
||||
|
|
@ -301,7 +301,7 @@ active_model = "test"
|
|||
alias = "a"
|
||||
provider = "p"
|
||||
""")
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
data = await layer.load()
|
||||
assert data.model_extra == {
|
||||
"models": {"active_model": "test", "items": [{"alias": "a", "provider": "p"}]}
|
||||
|
|
@ -312,7 +312,7 @@ provider = "p"
|
|||
async def test_invalid_toml_raises(tmp_working_directory: Path) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text("this is not valid = = = toml [[[")
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
with pytest.raises(LayerImplementationError, match="_build_config_snapshot"):
|
||||
await layer.load()
|
||||
|
||||
|
|
@ -321,7 +321,7 @@ async def test_invalid_toml_raises(tmp_working_directory: Path) -> None:
|
|||
async def test_force_reload_reads_fresh_data(tmp_working_directory: Path) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text('value = "first"\n')
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
data1 = await layer.load()
|
||||
fp1 = layer.fingerprint
|
||||
|
|
@ -340,13 +340,13 @@ async def test_force_reload_reads_fresh_data(tmp_working_directory: Path) -> Non
|
|||
path.unlink()
|
||||
data3 = await layer.load(force=True)
|
||||
assert data3.model_extra == {}
|
||||
assert layer.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT
|
||||
assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_toml_file(tmp_working_directory: Path) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text("")
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
data = await layer.load()
|
||||
assert data.model_extra == {}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,15 @@ from __future__ import annotations
|
|||
|
||||
from pathlib import Path
|
||||
|
||||
import keyring
|
||||
import pytest
|
||||
|
||||
from vibe.core.config import (
|
||||
DEFAULT_THEME,
|
||||
MissingAPIKeyError,
|
||||
ModelConfig,
|
||||
ProviderConfig,
|
||||
)
|
||||
from vibe.core.config._settings import VibeConfig
|
||||
from vibe.core.config.vibe_schema import VibeConfigSchema
|
||||
|
||||
|
|
@ -47,7 +54,7 @@ provider = "mistral"
|
|||
class VibeConfig(VibeConfigSchema):
|
||||
pass
|
||||
|
||||
layer = UserConfigLayer(path=toml_path, name="user-toml")
|
||||
layer = UserConfigLayer(path=toml_path)
|
||||
orchestrator = await ConfigOrchestrator[VibeConfig].create(
|
||||
schema=VibeConfig, layers=[layer]
|
||||
)
|
||||
|
|
@ -62,3 +69,43 @@ provider = "mistral"
|
|||
assert config.default_agent == "plan"
|
||||
assert "search" in config.enabled_skills
|
||||
assert config.enable_otel is True
|
||||
|
||||
|
||||
def test_duplicate_model_alias_raises() -> None:
|
||||
with pytest.raises(ValueError, match="Duplicate alias"):
|
||||
VibeConfigSchema(
|
||||
models=[
|
||||
ModelConfig(name="model-a", provider="mistral", alias="same"),
|
||||
ModelConfig(name="model-b", provider="mistral", alias="same"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_compaction_model_provider_must_match_active() -> None:
|
||||
providers = [
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
),
|
||||
ProviderConfig(
|
||||
name="other",
|
||||
api_base="https://other.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
),
|
||||
]
|
||||
compaction = ModelConfig(name="compact-model", provider="other", alias="compact")
|
||||
with pytest.raises(ValueError, match="must share the same provider"):
|
||||
VibeConfigSchema(compaction_model=compaction, providers=providers)
|
||||
|
||||
|
||||
def test_check_api_key_raises_when_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
with pytest.raises(MissingAPIKeyError):
|
||||
VibeConfigSchema()
|
||||
|
||||
|
||||
def test_unknown_theme_falls_back_to_default() -> None:
|
||||
config = VibeConfigSchema(theme="totally-unknown-theme")
|
||||
assert config.theme == DEFAULT_THEME
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ def test_denylisted_bash_tool_does_not_run_and_is_reported_to_the_model(
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(25)
|
||||
@pytest.mark.timeout(40)
|
||||
@pytest.mark.parametrize(
|
||||
"streaming_mock_server",
|
||||
[pytest.param(_failing_bash_factory, id="failing-bash-tool")],
|
||||
|
|
@ -148,7 +148,7 @@ def test_failed_bash_tool_result_is_reported_to_the_model_and_turn_recovers(
|
|||
timeout=10,
|
||||
)
|
||||
wait_for_rendered_text(
|
||||
child, captured, needle="Recovered after the shell failure.", timeout=10
|
||||
child, captured, needle="Recovered after the shell failure.", timeout=20
|
||||
)
|
||||
|
||||
send_ctrl_c_until_quit_confirmation(child, captured, timeout=5)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ def setup_e2e_env(
|
|||
write_e2e_config(vibe_home, streaming_mock_server.api_base)
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "fake-key")
|
||||
monkeypatch.setenv("VIBE_HOME", str(vibe_home))
|
||||
monkeypatch.setenv("VIBE_TEST_DISABLE_KEYRING", "1")
|
||||
monkeypatch.setenv("TERM", "xterm-256color")
|
||||
|
||||
|
||||
|
|
@ -64,11 +65,13 @@ def spawned_vibe_process() -> SpawnedVibeFactory:
|
|||
workdir: Path, extra_args: Sequence[str] | None = None
|
||||
) -> SpawnedVibeContext:
|
||||
captured = io.StringIO()
|
||||
env = os.environ.copy()
|
||||
env["VIBE_TEST_DISABLE_KEYRING"] = "1"
|
||||
child = pexpect.spawn(
|
||||
"uv",
|
||||
["run", "vibe", "--workdir", str(workdir), *(extra_args or [])],
|
||||
cwd=str(TESTS_ROOT.parent),
|
||||
env=os.environ,
|
||||
env=cast("os._Environ[str]", env),
|
||||
encoding="utf-8",
|
||||
timeout=30,
|
||||
dimensions=(36, 120),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import keyring
|
|||
from keyring.errors import KeyringError, NoKeyringError, PasswordDeleteError
|
||||
import pytest
|
||||
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.config import ProviderConfig, resolve_api_key
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE
|
||||
from vibe.core.types import Backend
|
||||
from vibe.setup.auth.api_key_persistence import persist_api_key, remove_api_key
|
||||
|
|
@ -45,6 +45,22 @@ def test_persist_stores_in_keyring_and_clears_stale_env(
|
|||
assert "CUSTOM_API_KEY" not in dotenv_values(GLOBAL_ENV_FILE.path)
|
||||
|
||||
|
||||
def test_persist_updates_cached_keyring_value(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CUSTOM_API_KEY", raising=False)
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: "old-key")
|
||||
monkeypatch.setattr(
|
||||
keyring, "set_password", lambda service, username, password: None
|
||||
)
|
||||
|
||||
assert resolve_api_key("CUSTOM_API_KEY") == "old-key"
|
||||
|
||||
result = persist_api_key(_provider(), "new-key")
|
||||
monkeypatch.delenv("CUSTOM_API_KEY", raising=False)
|
||||
|
||||
assert result == "completed"
|
||||
assert resolve_api_key("CUSTOM_API_KEY") == "new-key"
|
||||
|
||||
|
||||
def test_persist_falls_back_to_env_when_keyring_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
@ -62,6 +78,27 @@ def test_persist_falls_back_to_env_when_keyring_unavailable(
|
|||
assert dotenv_values(GLOBAL_ENV_FILE.path)["CUSTOM_API_KEY"] == "new-key"
|
||||
|
||||
|
||||
def test_persist_fallback_clears_stale_cached_keyring_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("CUSTOM_API_KEY", raising=False)
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: "old-key")
|
||||
|
||||
def _unavailable(service: str, username: str, password: str) -> None:
|
||||
raise KeyringError("no keyring")
|
||||
|
||||
assert resolve_api_key("CUSTOM_API_KEY") == "old-key"
|
||||
|
||||
monkeypatch.setattr(keyring, "set_password", _unavailable)
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
|
||||
result = persist_api_key(_provider(), "new-key")
|
||||
monkeypatch.delenv("CUSTOM_API_KEY", raising=False)
|
||||
|
||||
assert result == "completed"
|
||||
assert resolve_api_key("CUSTOM_API_KEY") is None
|
||||
|
||||
|
||||
def test_persist_returns_env_var_error_for_empty_env_var(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
@ -78,21 +115,39 @@ def test_persist_returns_env_var_error_for_empty_env_var(
|
|||
def test_remove_deletes_keyring_env_and_process_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
deleted: list[str] = []
|
||||
deleted: list[tuple[str, str]] = []
|
||||
monkeypatch.setenv("CUSTOM_API_KEY", "live-key")
|
||||
monkeypatch.setattr(
|
||||
keyring, "delete_password", lambda service, username: deleted.append(username)
|
||||
keyring,
|
||||
"delete_password",
|
||||
lambda service, username: deleted.append((service, username)),
|
||||
)
|
||||
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
set_key(GLOBAL_ENV_FILE.path, "CUSTOM_API_KEY", "file-key")
|
||||
|
||||
remove_api_key(_provider())
|
||||
|
||||
assert deleted == ["CUSTOM_API_KEY"]
|
||||
assert deleted == [
|
||||
("ai.mistral.vibe", "CUSTOM_API_KEY"),
|
||||
("vibe", "CUSTOM_API_KEY"),
|
||||
]
|
||||
assert "CUSTOM_API_KEY" not in dotenv_values(GLOBAL_ENV_FILE.path)
|
||||
assert "CUSTOM_API_KEY" not in os.environ
|
||||
|
||||
|
||||
def test_remove_clears_cached_keyring_value(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CUSTOM_API_KEY", raising=False)
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: "live-key")
|
||||
monkeypatch.setattr(keyring, "delete_password", lambda service, username: None)
|
||||
|
||||
assert resolve_api_key("CUSTOM_API_KEY") == "live-key"
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
remove_api_key(_provider())
|
||||
|
||||
assert resolve_api_key("CUSTOM_API_KEY") is None
|
||||
|
||||
|
||||
def test_remove_ignores_keyring_unavailable_and_still_clears_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
@ -117,7 +172,7 @@ def test_remove_ignores_missing_keyring_entry_and_still_clears_env(
|
|||
monkeypatch.setenv("CUSTOM_API_KEY", "live-key")
|
||||
|
||||
def _missing(service: str, username: str) -> None:
|
||||
raise PasswordDeleteError("not found")
|
||||
raise PasswordDeleteError()
|
||||
|
||||
monkeypatch.setattr(keyring, "delete_password", _missing)
|
||||
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
|
|
|||
|
|
@ -243,6 +243,30 @@ def test_assess_os_keyring_when_default_key_is_in_keyring(
|
|||
)
|
||||
|
||||
|
||||
def test_assess_os_keyring_reuses_cached_keyring_lookup(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def _get_password(service: str, username: str) -> str:
|
||||
calls.append((service, username))
|
||||
return "keyring-key"
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get_password)
|
||||
provider = _mistral_provider()
|
||||
env_path = tmp_path / ".env"
|
||||
|
||||
assert (
|
||||
assess_auth_state(provider, env_path=env_path, environ={}).kind
|
||||
== AuthStateKind.OS_KEYRING
|
||||
)
|
||||
assert (
|
||||
assess_auth_state(provider, env_path=env_path, environ={}).kind
|
||||
== AuthStateKind.OS_KEYRING
|
||||
)
|
||||
assert calls == [("ai.mistral.vibe", DEFAULT_MISTRAL_API_ENV_KEY)]
|
||||
|
||||
|
||||
def test_assess_vibe_home_env_file_when_dotenv_and_keyring_both_have_value(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -19,11 +19,7 @@ def skills_dir(tmp_path: Path) -> Path:
|
|||
|
||||
@pytest.fixture
|
||||
def skill_config(skills_dir: Path) -> VibeConfig:
|
||||
return build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
return build_test_vibe_config(skill_paths=[skills_dir])
|
||||
|
||||
|
||||
def create_skill(
|
||||
|
|
|
|||
|
|
@ -38,9 +38,7 @@ class TestBuiltinSkills:
|
|||
|
||||
def test_discovers_builtin_skills(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("vibe.core.skills.manager.BUILTIN_SKILLS", BUILTIN_SKILLS)
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests", include_project_context=False
|
||||
)
|
||||
config = build_test_vibe_config()
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
assert "vibe" in manager.available_skills
|
||||
|
|
@ -53,11 +51,7 @@ class TestBuiltinSkills:
|
|||
skills_dir.mkdir()
|
||||
create_skill(skills_dir, "vibe", "Custom vibe override")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
assert "vibe" in manager.available_skills
|
||||
|
|
|
|||
|
|
@ -14,15 +14,8 @@ from vibe.core.trusted_folders import trusted_folders_manager
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def config() -> VibeConfig:
|
||||
return build_test_vibe_config(
|
||||
system_prompt_id="tests", include_project_context=False
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skill_manager(config: VibeConfig) -> SkillManager:
|
||||
return SkillManager(lambda: config)
|
||||
def skill_manager(vibe_config: VibeConfig) -> SkillManager:
|
||||
return SkillManager(lambda: vibe_config)
|
||||
|
||||
|
||||
class TestSkillManagerDiscovery:
|
||||
|
|
@ -39,11 +32,7 @@ class TestSkillManagerDiscovery:
|
|||
def test_discovers_skill_from_skill_paths(self, skills_dir: Path) -> None:
|
||||
create_skill(skills_dir, "test-skill", "A test skill")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
assert "test-skill" in manager.available_skills
|
||||
|
|
@ -54,11 +43,7 @@ class TestSkillManagerDiscovery:
|
|||
create_skill(skills_dir, "skill-two", "Second skill")
|
||||
create_skill(skills_dir, "skill-three", "Third skill")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
assert len(manager.available_skills) == 3 + len(BUILTIN_SKILLS)
|
||||
|
|
@ -75,11 +60,7 @@ class TestSkillManagerDiscovery:
|
|||
# Create a valid skill
|
||||
create_skill(skills_dir, "valid-skill", "A valid skill")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skills = manager.available_skills
|
||||
|
|
@ -94,11 +75,7 @@ class TestSkillManagerDiscovery:
|
|||
# Create a valid skill
|
||||
create_skill(skills_dir, "valid-skill", "A valid skill")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skills = manager.available_skills
|
||||
|
|
@ -118,11 +95,7 @@ class TestSkillManagerParsing:
|
|||
allowed_tools="bash read",
|
||||
)
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skill = manager.get_skill("full-skill")
|
||||
|
|
@ -137,11 +110,7 @@ class TestSkillManagerParsing:
|
|||
def test_sets_correct_skill_path(self, skills_dir: Path) -> None:
|
||||
create_skill(skills_dir, "test-skill", "A test skill")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skill = manager.get_skill("test-skill")
|
||||
|
|
@ -158,11 +127,7 @@ class TestSkillManagerParsing:
|
|||
# Create a valid skill
|
||||
create_skill(skills_dir, "valid-skill", "A valid skill")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skills = manager.available_skills
|
||||
|
|
@ -179,11 +144,7 @@ class TestSkillManagerParsing:
|
|||
# Create a valid skill
|
||||
create_skill(skills_dir, "valid-skill", "A valid skill")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skills = manager.available_skills
|
||||
|
|
@ -200,9 +161,7 @@ class TestSkillManagerSearchPaths:
|
|||
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=[]
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
assert "vibe-skill" in manager.available_skills
|
||||
|
|
@ -219,9 +178,7 @@ class TestSkillManagerSearchPaths:
|
|||
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=[]
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
assert "agents-skill" in manager.available_skills
|
||||
|
|
@ -241,9 +198,7 @@ class TestSkillManagerSearchPaths:
|
|||
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=[]
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skills = manager.available_skills
|
||||
|
|
@ -262,9 +217,7 @@ class TestSkillManagerSearchPaths:
|
|||
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=[]
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skills = manager.available_skills
|
||||
|
|
@ -281,11 +234,7 @@ class TestSkillManagerSearchPaths:
|
|||
skills_dir_2.mkdir()
|
||||
create_skill(skills_dir_2, "skill-from-dir2", "Skill from directory 2")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir_1, skills_dir_2],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir_1, skills_dir_2])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skills = manager.available_skills
|
||||
|
|
@ -303,11 +252,7 @@ class TestSkillManagerSearchPaths:
|
|||
skills_dir_2.mkdir()
|
||||
create_skill(skills_dir_2, "duplicate-skill", "Second version")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir_1, skills_dir_2],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir_1, skills_dir_2])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skills = manager.available_skills
|
||||
|
|
@ -320,9 +265,7 @@ class TestSkillManagerSearchPaths:
|
|||
create_skill(skills_dir, "valid-skill", "A valid skill")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir, tmp_path / "nonexistent"],
|
||||
skill_paths=[skills_dir, tmp_path / "nonexistent"]
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
|
|
@ -335,11 +278,7 @@ class TestSkillManagerGetSkill:
|
|||
def test_returns_skill_by_name(self, skills_dir: Path) -> None:
|
||||
create_skill(skills_dir, "test-skill", "A test skill")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skill = manager.get_skill("test-skill")
|
||||
|
|
@ -357,10 +296,7 @@ class TestSkillManagerFiltering:
|
|||
create_skill(skills_dir, "skill-c", "Skill C")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
enabled_skills=["skill-a", "skill-c"],
|
||||
skill_paths=[skills_dir], enabled_skills=["skill-a", "skill-c"]
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
|
|
@ -376,10 +312,7 @@ class TestSkillManagerFiltering:
|
|||
create_skill(skills_dir, "skill-c", "Skill C")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
disabled_skills=["skill-b"],
|
||||
skill_paths=[skills_dir], disabled_skills=["skill-b"]
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
|
|
@ -396,8 +329,6 @@ class TestSkillManagerFiltering:
|
|||
create_skill(skills_dir, "skill-b", "Skill B")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
enabled_skills=["skill-a"],
|
||||
disabled_skills=["skill-a"], # Should be ignored
|
||||
|
|
@ -414,10 +345,7 @@ class TestSkillManagerFiltering:
|
|||
create_skill(skills_dir, "other-skill", "Other skill")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
enabled_skills=["search-*"],
|
||||
skill_paths=[skills_dir], enabled_skills=["search-*"]
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
|
|
@ -433,10 +361,7 @@ class TestSkillManagerFiltering:
|
|||
create_skill(skills_dir, "other-skill", "Other skill")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
enabled_skills=["re:skill-v\\d+"],
|
||||
skill_paths=[skills_dir], enabled_skills=["re:skill-v\\d+"]
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
|
|
@ -451,10 +376,7 @@ class TestSkillManagerFiltering:
|
|||
create_skill(skills_dir, "disabled-skill", "Disabled")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
disabled_skills=["disabled-skill"],
|
||||
skill_paths=[skills_dir], disabled_skills=["disabled-skill"]
|
||||
)
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
|
|
@ -466,11 +388,7 @@ class TestSkillUserInvocable:
|
|||
def test_user_invocable_defaults_to_true(self, skills_dir: Path) -> None:
|
||||
create_skill(skills_dir, "default-skill", "A default skill")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skill = manager.get_skill("default-skill")
|
||||
|
|
@ -480,11 +398,7 @@ class TestSkillUserInvocable:
|
|||
def test_user_invocable_can_be_set_to_false(self, skills_dir: Path) -> None:
|
||||
create_skill(skills_dir, "hidden-skill", "A hidden skill", user_invocable=False)
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skill = manager.get_skill("hidden-skill")
|
||||
|
|
@ -498,11 +412,7 @@ class TestSkillUserInvocable:
|
|||
skills_dir, "explicit-skill", "An explicit skill", user_invocable=True
|
||||
)
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skill = manager.get_skill("explicit-skill")
|
||||
|
|
@ -514,11 +424,7 @@ class TestSkillUserInvocable:
|
|||
create_skill(skills_dir, "hidden-skill", "Hidden", user_invocable=False)
|
||||
create_skill(skills_dir, "default-skill", "Default")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
skills = manager.available_skills
|
||||
|
|
@ -576,11 +482,7 @@ class TestSkillManagerConfigIssues:
|
|||
broken_dir.mkdir()
|
||||
(broken_dir / "SKILL.md").write_text("No frontmatter here")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
assert len(manager.config_issues) == 1
|
||||
|
|
@ -597,11 +499,7 @@ class TestSkillManagerConfigIssues:
|
|||
(broken_dir / "SKILL.md").write_text("not valid")
|
||||
create_skill(skills_dir, "good-skill", "Works fine")
|
||||
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
skill_paths=[skills_dir],
|
||||
)
|
||||
config = build_test_vibe_config(skill_paths=[skills_dir])
|
||||
manager = SkillManager(lambda: config)
|
||||
|
||||
assert {
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@
|
|||
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="0" y="459.2" textLength="1464" clip-path="url(#terminal-line-18)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r4" x="0" y="483.6" textLength="158.6" clip-path="url(#terminal-line-19)">/test/workdir</text><text class="terminal-r4" x="1256.6" y="483.6" textLength="207.4" clip-path="url(#terminal-line-19)">0% of 200k tokens</text>
|
||||
</text><text class="terminal-r4" x="0" y="483.6" textLength="158.6" clip-path="url(#terminal-line-19)">/test/workdir</text><text class="terminal-r4" x="1244.4" y="483.6" textLength="219.6" clip-path="url(#terminal-line-19)">0/200k tokens (0%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
|
@ -170,7 +170,7 @@
|
|||
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="0" y="703.2" textLength="1464" clip-path="url(#terminal-line-28)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r4" x="0" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r4" x="1256.6" y="727.6" textLength="207.4" clip-path="url(#terminal-line-29)">0% of 200k tokens</text>
|
||||
</text><text class="terminal-r4" x="0" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r4" x="1244.4" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">0/200k tokens (0%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
|
@ -195,7 +195,7 @@
|
|||
</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">6% of 200k tokens</text>
|
||||
</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="1220" y="874" textLength="244" clip-path="url(#terminal-line-35)">12k/200k tokens (6%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
|
@ -194,7 +194,7 @@
|
|||
</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1244.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k tokens (0%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
|
@ -201,7 +201,7 @@
|
|||
</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r11" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r11" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</text><text class="terminal-r11" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r11" x="1244.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k tokens (0%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
|
@ -194,7 +194,7 @@
|
|||
</text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r1" 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>
|
||||
</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="1000.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k tokens (0%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
|
@ -198,7 +198,7 @@
|
|||
</text><text class="terminal-r1" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" 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-r1" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r8" x="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓ Navigate  Enter Select/Toggle  Esc Exit</text><text class="terminal-r1" 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-r1" 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-r8" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r8" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</text><text class="terminal-r8" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r8" x="1000.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k tokens (0%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
|
@ -198,7 +198,7 @@
|
|||
</text><text class="terminal-r1" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" 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-r1" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r8" x="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓ Navigate  Enter Select/Toggle  Esc Exit</text><text class="terminal-r1" 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-r1" 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-r8" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r8" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</text><text class="terminal-r8" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r8" x="1000.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k tokens (0%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
|
@ -198,7 +198,7 @@
|
|||
</text><text class="terminal-r1" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" 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-r1" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r8" x="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓ Navigate  Enter Select/Toggle  Esc Exit</text><text class="terminal-r1" 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-r1" 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-r8" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r8" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</text><text class="terminal-r8" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r8" x="1000.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k tokens (0%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
|
@ -195,7 +195,7 @@
|
|||
</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</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="1244.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k tokens (0%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
|
@ -195,7 +195,7 @@
|
|||
</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</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="1244.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k tokens (0%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
|
@ -196,7 +196,7 @@
|
|||
</text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r1" 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-r6" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r6" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</text><text class="terminal-r6" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r6" x="1000.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k tokens (0%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
|
@ -194,7 +194,7 @@
|
|||
</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1244.4" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k tokens (0%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
|
@ -197,7 +197,7 @@
|
|||
</text><text class="terminal-r1" x="24.4" y="800.8" textLength="866.2" clip-path="url(#terminal-line-32)">                                                                      │</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r1" x="24.4" y="825.2" textLength="866.2" clip-path="url(#terminal-line-33)">                                                                      │</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r1" x="0" y="849.6" textLength="890.6" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────│</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r2" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r2" x="671" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text><text class="terminal-r1" x="878.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)">│</text>
|
||||
</text><text class="terminal-r2" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r2" x="658.8" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k tokens (0%)</text><text class="terminal-r1" x="878.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)">│</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
|
@ -196,7 +196,7 @@
|
|||
</text><text class="terminal-r1" x="24.4" y="800.8" textLength="866.2" clip-path="url(#terminal-line-32)">                                                                      │</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r1" x="24.4" y="825.2" textLength="866.2" clip-path="url(#terminal-line-33)">                                                                      │</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r1" x="0" y="849.6" textLength="890.6" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────│</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r2" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r2" x="671" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text><text class="terminal-r1" x="878.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)">│</text>
|
||||
</text><text class="terminal-r2" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r2" x="658.8" y="874" textLength="219.6" clip-path="url(#terminal-line-35)">0/200k tokens (0%)</text><text class="terminal-r1" x="878.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)">│</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
|
@ -216,7 +216,7 @@
|
|||
</text><text class="terminal-r1" x="0" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)">│</text><text class="terminal-r1" x="1451.8" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)">│</text><text class="terminal-r1" x="1464" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)">
|
||||
</text><text class="terminal-r1" x="0" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)">│</text><text class="terminal-r10" x="24.4" y="922.8" textLength="451.4" clip-path="url(#terminal-line-37)">↑↓ navigate  Enter select  ESC reject</text><text class="terminal-r1" x="1451.8" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)">│</text><text class="terminal-r1" x="1464" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)">
|
||||
</text><text class="terminal-r1" x="0" y="947.2" textLength="1464" clip-path="url(#terminal-line-38)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)">
|
||||
</text><text class="terminal-r10" x="0" y="971.6" textLength="158.6" clip-path="url(#terminal-line-39)">/test/workdir</text><text class="terminal-r10" x="1256.6" y="971.6" textLength="207.4" clip-path="url(#terminal-line-39)">0% of 200k tokens</text>
|
||||
</text><text class="terminal-r10" x="0" y="971.6" textLength="158.6" clip-path="url(#terminal-line-39)">/test/workdir</text><text class="terminal-r10" x="1244.4" y="971.6" textLength="219.6" clip-path="url(#terminal-line-39)">0/200k tokens (0%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
|
@ -175,7 +175,7 @@
|
|||
</text><text class="terminal-r1" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">│</text><text class="terminal-r1" 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-r1" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r9" x="24.4" y="678.8" textLength="451.4" clip-path="url(#terminal-line-27)">↑↓ navigate  Enter select  ESC reject</text><text class="terminal-r1" 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-r1" x="0" y="703.2" textLength="1220" 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-r9" x="0" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r9" x="1012.6" y="727.6" textLength="207.4" clip-path="url(#terminal-line-29)">0% of 200k tokens</text>
|
||||
</text><text class="terminal-r9" x="0" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r9" x="1000.4" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">0/200k tokens (0%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
|
@ -174,7 +174,7 @@
|
|||
</text><text class="terminal-r1" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">│</text><text class="terminal-r1" 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-r1" 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="451.4" clip-path="url(#terminal-line-27)">↑↓ navigate  Enter select  ESC reject</text><text class="terminal-r1" 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-r1" x="0" y="703.2" textLength="1220" 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="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r5" x="1012.6" y="727.6" textLength="207.4" clip-path="url(#terminal-line-29)">0% of 200k tokens</text>
|
||||
</text><text class="terminal-r5" x="0" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r5" x="1000.4" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">0/200k tokens (0%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
|
@ -177,7 +177,7 @@
|
|||
</text><text class="terminal-r1" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">│</text><text class="terminal-r1" 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-r1" 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="451.4" clip-path="url(#terminal-line-27)">↑↓ navigate  Enter select  ESC reject</text><text class="terminal-r1" 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-r1" x="0" y="703.2" textLength="1220" 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="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r5" x="1012.6" y="727.6" textLength="207.4" clip-path="url(#terminal-line-29)">0% of 200k tokens</text>
|
||||
</text><text class="terminal-r5" x="0" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r5" x="1000.4" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">0/200k tokens (0%)</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |