v2.17.0 (#822)
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Hdandria <henri.dandria@mistral.ai> Co-authored-by: Ivana Dunisijevic <ivana.dunisijevic@mistral.ai> Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Mert Unsal <mert.unsal@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@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: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
564a14365e
commit
6bedf271ce
223 changed files with 10533 additions and 6947 deletions
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.17.0] - 2026-06-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `/mcp login`, `/mcp logout`, and `/mcp status` commands to authenticate OAuth-backed MCP servers from the TUI
|
||||||
|
- `vibe --check-upgrade` to force an immediate update check and exit
|
||||||
|
- `--yolo` as an alias for `--auto-approve`
|
||||||
|
- ACP now accepts inline image content blocks
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- API keys are now stored in the OS keyring instead of plain text
|
||||||
|
- Edit diff view now shows all replaced occurrences instead of just the first
|
||||||
|
- Completion popup now uses a two-column layout
|
||||||
|
- Chat messages now have right padding so text no longer collapses into the scrollbar
|
||||||
|
- Faster CLI shutdown by deferring resource cleanup on exit
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Skill autocomplete popup now dismisses after Tab completion
|
||||||
|
- Stdio MCP connections now persist across tool calls
|
||||||
|
- Retryable 5xx responses from the Mistral backend are now retried instead of failing
|
||||||
|
|
||||||
|
|
||||||
## [2.16.1] - 2026-06-16
|
## [2.16.1] - 2026-06-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -137,8 +137,8 @@ custom agent file in `~/.vibe/agents/` or the project's `.vibe/agents/`
|
||||||
directory. Subagents such as `explore` are not accepted.
|
directory. Subagents such as `explore` are not accepted.
|
||||||
|
|
||||||
> Note: `default_agent` applies in both interactive and programmatic
|
> Note: `default_agent` applies in both interactive and programmatic
|
||||||
> (`-p` / `--prompt`) sessions. Pass `--auto-approve` when a run should
|
> (`-p` / `--prompt`) sessions. Pass `--auto-approve` or `--yolo` when
|
||||||
> approve all tool calls without prompting.
|
> a run should approve all tool calls without prompting.
|
||||||
|
|
||||||
### Subagents and Task Delegation
|
### Subagents and Task Delegation
|
||||||
|
|
||||||
|
|
@ -262,8 +262,8 @@ vibe --prompt "Refactor the main function in cli/main.py to be more modular."
|
||||||
```
|
```
|
||||||
|
|
||||||
By default, it uses your configured `default_agent` (`default` unless changed).
|
By default, it uses your configured `default_agent` (`default` unless changed).
|
||||||
To approve all tool calls without prompting, pass `--auto-approve` (also
|
To approve all tool calls without prompting, pass `--auto-approve` or `--yolo`
|
||||||
available for interactive sessions):
|
(also available for interactive sessions):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vibe --prompt "Refactor the main function in cli/main.py to be more modular." --auto-approve
|
vibe --prompt "Refactor the main function in cli/main.py to be more modular." --auto-approve
|
||||||
|
|
@ -277,7 +277,7 @@ When using `--prompt`, you can specify additional options:
|
||||||
- **`--max-price DOLLARS`**: Set a maximum cost limit in dollars. The session will be interrupted if the cost exceeds this limit.
|
- **`--max-price DOLLARS`**: Set a maximum cost limit in dollars. The session will be interrupted if the cost exceeds this limit.
|
||||||
- **`--max-tokens N`**: Set a maximum cumulative LLM token budget for the session, counting both prompt and completion tokens. The session will be interrupted if usage exceeds this limit.
|
- **`--max-tokens N`**: Set a maximum cumulative LLM token budget for the session, counting both prompt and completion tokens. The session will be interrupted if usage exceeds this limit.
|
||||||
- **`--agent NAME`**: Select the agent profile for this run.
|
- **`--agent NAME`**: Select the agent profile for this run.
|
||||||
- **`--auto-approve`**: Shortcut for `--agent auto-approve`. Approves all tool calls without prompting, including in interactive sessions.
|
- **`--auto-approve`, `--yolo`**: Shortcut for `--agent auto-approve`. Approves all tool calls without prompting, including in interactive sessions.
|
||||||
- **`--enabled-tools TOOL`**: Enable specific tools. In programmatic mode, this disables all other tools. Can be specified multiple times. Supports exact names, glob patterns (e.g., `bash*`), or regex with `re:` prefix (e.g., `re:^serena_.*$`).
|
- **`--enabled-tools TOOL`**: Enable specific tools. In programmatic mode, this disables all other tools. Can be specified multiple times. Supports exact names, glob patterns (e.g., `bash*`), or regex with `re:` prefix (e.g., `re:^serena_.*$`).
|
||||||
- **`--output FORMAT`**: Set the output format. Options:
|
- **`--output FORMAT`**: Set the output format. Options:
|
||||||
- `text` (default): Human-readable text output
|
- `text` (default): Human-readable text output
|
||||||
|
|
@ -730,6 +730,8 @@ Each path is implicitly trusted (no trust prompt) and contributes its `AGENTS.md
|
||||||
|
|
||||||
Vibe checks PyPI at most once per day during a session. When a newer version is found, the next launch shows an update prompt before opening the chat, offering to either update immediately (via `uv tool upgrade mistral-vibe` or `brew upgrade mistral-vibe`) or continue with the current version.
|
Vibe checks PyPI at most once per day during a session. When a newer version is found, the next launch shows an update prompt before opening the chat, offering to either update immediately (via `uv tool upgrade mistral-vibe` or `brew upgrade mistral-vibe`) or continue with the current version.
|
||||||
|
|
||||||
|
Run `vibe --check-upgrade` to check PyPI immediately, prompt to install a newer version if one exists, and exit.
|
||||||
|
|
||||||
To disable the daily check entirely, add this to your `config.toml`:
|
To disable the daily check entirely, add this to your `config.toml`:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
id = "mistral-vibe"
|
id = "mistral-vibe"
|
||||||
name = "Mistral Vibe"
|
name = "Mistral Vibe"
|
||||||
description = "Mistral's open-source coding assistant"
|
description = "Mistral's open-source coding assistant"
|
||||||
version = "2.16.1"
|
version = "2.17.0"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Mistral AI"]
|
authors = ["Mistral AI"]
|
||||||
repository = "https://github.com/mistralai/mistral-vibe"
|
repository = "https://github.com/mistralai/mistral-vibe"
|
||||||
|
|
@ -11,21 +11,21 @@ name = "Mistral Vibe"
|
||||||
icon = "./icons/mistral_vibe.svg"
|
icon = "./icons/mistral_vibe.svg"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.1/vibe-acp-darwin-aarch64-2.16.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-darwin-aarch64-2.17.0.zip"
|
||||||
cmd = "./vibe-acp"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.1/vibe-acp-darwin-x86_64-2.16.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-darwin-x86_64-2.17.0.zip"
|
||||||
cmd = "./vibe-acp"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.1/vibe-acp-linux-aarch64-2.16.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-linux-aarch64-2.17.0.zip"
|
||||||
cmd = "./vibe-acp"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.1/vibe-acp-linux-x86_64-2.16.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-linux-x86_64-2.17.0.zip"
|
||||||
cmd = "./vibe-acp"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.1/vibe-acp-windows-x86_64-2.16.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-windows-x86_64-2.17.0.zip"
|
||||||
cmd = "./vibe-acp.exe"
|
cmd = "./vibe-acp.exe"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "2.16.1"
|
version = "2.17.0"
|
||||||
description = "Minimal CLI coding agent by Mistral"
|
description = "Minimal CLI coding agent by Mistral"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
@ -38,7 +38,7 @@ dependencies = [
|
||||||
"charset-normalizer==3.4.7",
|
"charset-normalizer==3.4.7",
|
||||||
"click==8.3.3 ; sys_platform != 'emscripten'",
|
"click==8.3.3 ; sys_platform != 'emscripten'",
|
||||||
"colorama==0.4.6 ; sys_platform == 'win32'",
|
"colorama==0.4.6 ; sys_platform == 'win32'",
|
||||||
"cryptography==47.0.0",
|
"cryptography==48.0.1",
|
||||||
"eval-type-backport==0.3.1",
|
"eval-type-backport==0.3.1",
|
||||||
"gitdb==4.0.12",
|
"gitdb==4.0.12",
|
||||||
"gitpython==3.1.47",
|
"gitpython==3.1.47",
|
||||||
|
|
@ -65,10 +65,10 @@ dependencies = [
|
||||||
"linkify-it-py==2.1.0",
|
"linkify-it-py==2.1.0",
|
||||||
"markdown-it-py==4.0.0",
|
"markdown-it-py==4.0.0",
|
||||||
"markdownify==1.2.2",
|
"markdownify==1.2.2",
|
||||||
"mcp==1.27.1",
|
"mcp==1.27.2",
|
||||||
"mdit-py-plugins==0.5.0",
|
"mdit-py-plugins==0.5.0",
|
||||||
"mdurl==0.1.2",
|
"mdurl==0.1.2",
|
||||||
"mistralai==2.4.4",
|
"mistralai==2.4.9",
|
||||||
"more-itertools==11.0.2",
|
"more-itertools==11.0.2",
|
||||||
"opentelemetry-api==1.39.1",
|
"opentelemetry-api==1.39.1",
|
||||||
"opentelemetry-exporter-otlp-proto-common==1.39.1",
|
"opentelemetry-exporter-otlp-proto-common==1.39.1",
|
||||||
|
|
@ -88,11 +88,11 @@ dependencies = [
|
||||||
"pydantic-core==2.46.3",
|
"pydantic-core==2.46.3",
|
||||||
"pydantic-settings==2.14.0",
|
"pydantic-settings==2.14.0",
|
||||||
"pygments==2.20.0",
|
"pygments==2.20.0",
|
||||||
"pyjwt==2.12.1",
|
"pyjwt==2.13.0",
|
||||||
"pyperclip==1.11.0",
|
"pyperclip==1.11.0",
|
||||||
"python-dateutil==2.9.0.post0",
|
"python-dateutil==2.9.0.post0",
|
||||||
"python-dotenv==1.2.2",
|
"python-dotenv==1.2.2",
|
||||||
"python-multipart==0.0.27",
|
"python-multipart==0.0.32",
|
||||||
"pywin32==311 ; sys_platform == 'win32'",
|
"pywin32==311 ; sys_platform == 'win32'",
|
||||||
"pywin32-ctypes==0.2.3 ; sys_platform == 'win32'",
|
"pywin32-ctypes==0.2.3 ; sys_platform == 'win32'",
|
||||||
"pyyaml==6.0.3",
|
"pyyaml==6.0.3",
|
||||||
|
|
@ -116,7 +116,7 @@ dependencies = [
|
||||||
"typing-extensions==4.15.0",
|
"typing-extensions==4.15.0",
|
||||||
"typing-inspection==0.4.2",
|
"typing-inspection==0.4.2",
|
||||||
"uc-micro-py==2.0.0",
|
"uc-micro-py==2.0.0",
|
||||||
"urllib3==2.6.3",
|
"urllib3==2.7.0",
|
||||||
"uvicorn==0.46.0 ; sys_platform != 'emscripten'",
|
"uvicorn==0.46.0 ; sys_platform != 'emscripten'",
|
||||||
"watchfiles==1.1.1",
|
"watchfiles==1.1.1",
|
||||||
"websockets==16.0",
|
"websockets==16.0",
|
||||||
|
|
@ -147,7 +147,7 @@ vibe-acp = "vibe.acp.entrypoint:main"
|
||||||
|
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
exclude-newer = "7 days"
|
exclude-newer = "2 days"
|
||||||
|
|
||||||
package = true
|
package = true
|
||||||
required-version = ">=0.8.0"
|
required-version = ">=0.8.0"
|
||||||
|
|
|
||||||
|
|
@ -553,6 +553,7 @@ class TestSessionUpdates:
|
||||||
|
|
||||||
assert tool_call.params.update.session_update == "tool_call"
|
assert tool_call.params.update.session_update == "tool_call"
|
||||||
assert tool_call.params.update.kind == "search"
|
assert tool_call.params.update.kind == "search"
|
||||||
|
assert tool_call.params.update.status == "pending"
|
||||||
assert tool_call.params.update.title == "Grepping 'auth'"
|
assert tool_call.params.update.title == "Grepping 'auth'"
|
||||||
assert (
|
assert (
|
||||||
tool_call.params.update.raw_input
|
tool_call.params.update.raw_input
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from dotenv import dotenv_values
|
from dotenv import dotenv_values
|
||||||
|
import keyring
|
||||||
|
from keyring.errors import KeyringError
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||||
from vibe.acp.exceptions import InvalidRequestError
|
from vibe.acp.exceptions import InternalError, InvalidRequestError
|
||||||
from vibe.core.config import (
|
from vibe.core.config import (
|
||||||
DEFAULT_MISTRAL_API_ENV_KEY,
|
DEFAULT_MISTRAL_API_ENV_KEY,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
|
|
@ -18,6 +20,15 @@ from vibe.setup.auth import AuthStateKind
|
||||||
from vibe.setup.onboarding.context import OnboardingContext
|
from vibe.setup.onboarding.context import OnboardingContext
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def disable_keyring(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
keyring, "set_password", lambda service, username, password: None
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(keyring, "delete_password", lambda service, username: None)
|
||||||
|
|
||||||
|
|
||||||
def build_mistral_provider(
|
def build_mistral_provider(
|
||||||
*, api_key_env_var: str = DEFAULT_MISTRAL_API_ENV_KEY
|
*, api_key_env_var: str = DEFAULT_MISTRAL_API_ENV_KEY
|
||||||
) -> ProviderConfig:
|
) -> ProviderConfig:
|
||||||
|
|
@ -127,7 +138,7 @@ class TestACPAuthStatus:
|
||||||
}
|
}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_returns_combined_source_when_process_env_existed_before_dotenv(
|
async def test_returns_process_env_when_process_env_existed_before_dotenv(
|
||||||
self, config_dir: Path, monkeypatch: pytest.MonkeyPatch
|
self, config_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setenv(DEFAULT_MISTRAL_API_ENV_KEY, "process-key")
|
monkeypatch.setenv(DEFAULT_MISTRAL_API_ENV_KEY, "process-key")
|
||||||
|
|
@ -138,7 +149,25 @@ class TestACPAuthStatus:
|
||||||
|
|
||||||
assert response == {
|
assert response == {
|
||||||
"authenticated": True,
|
"authenticated": True,
|
||||||
"authState": AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV.value,
|
"authState": AuthStateKind.PROCESS_ENV.value,
|
||||||
|
"signOutAvailable": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_keyring_when_key_only_exists_in_keyring(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv(DEFAULT_MISTRAL_API_ENV_KEY, raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
keyring, "get_password", lambda service, username: "keyring-key"
|
||||||
|
)
|
||||||
|
acp_agent_loop = build_acp_agent_loop(build_mistral_provider())
|
||||||
|
|
||||||
|
response = await acp_agent_loop.ext_method("auth/status", {})
|
||||||
|
|
||||||
|
assert response == {
|
||||||
|
"authenticated": True,
|
||||||
|
"authState": AuthStateKind.OS_KEYRING.value,
|
||||||
"signOutAvailable": True,
|
"signOutAvailable": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,23 +242,58 @@ class TestACPAuthSignOut:
|
||||||
assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
|
assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_removes_dotenv_key_and_restores_process_env_key(
|
async def test_refuses_dotenv_key_when_process_env_key_exists(
|
||||||
self, config_dir: Path, monkeypatch: pytest.MonkeyPatch
|
self, config_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setenv(DEFAULT_MISTRAL_API_ENV_KEY, "process-key")
|
monkeypatch.setenv(DEFAULT_MISTRAL_API_ENV_KEY, "process-key")
|
||||||
write_env_file(config_dir, f"{DEFAULT_MISTRAL_API_ENV_KEY}=file-key\n")
|
write_env_file(config_dir, f"{DEFAULT_MISTRAL_API_ENV_KEY}=file-key\n")
|
||||||
acp_agent_loop = build_acp_agent_loop(build_mistral_provider())
|
acp_agent_loop = build_acp_agent_loop(build_mistral_provider())
|
||||||
|
|
||||||
|
with pytest.raises(InvalidRequestError, match=AuthStateKind.PROCESS_ENV.value):
|
||||||
|
await acp_agent_loop.ext_method("auth/signOut", {})
|
||||||
|
|
||||||
|
assert (
|
||||||
|
dotenv_values(config_dir / ".env")[DEFAULT_MISTRAL_API_ENV_KEY]
|
||||||
|
== "file-key"
|
||||||
|
)
|
||||||
|
assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_removes_keyring_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
deleted: list[str] = []
|
||||||
|
monkeypatch.delenv(DEFAULT_MISTRAL_API_ENV_KEY, raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
keyring, "get_password", lambda service, username: "keyring-key"
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
keyring,
|
||||||
|
"delete_password",
|
||||||
|
lambda service, username: deleted.append(username),
|
||||||
|
)
|
||||||
|
acp_agent_loop = build_acp_agent_loop(build_mistral_provider())
|
||||||
|
|
||||||
response = await acp_agent_loop.ext_method("auth/signOut", {})
|
response = await acp_agent_loop.ext_method("auth/signOut", {})
|
||||||
|
|
||||||
assert response == {}
|
assert response == {}
|
||||||
assert DEFAULT_MISTRAL_API_ENV_KEY not in dotenv_values(config_dir / ".env")
|
assert deleted == [DEFAULT_MISTRAL_API_ENV_KEY]
|
||||||
assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
|
|
||||||
assert await acp_agent_loop.ext_method("auth/status", {}) == {
|
@pytest.mark.asyncio
|
||||||
"authenticated": True,
|
async def test_surfaces_internal_error_when_keyring_delete_fails(
|
||||||
"authState": AuthStateKind.PROCESS_ENV.value,
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
"signOutAvailable": False,
|
) -> None:
|
||||||
}
|
monkeypatch.delenv(DEFAULT_MISTRAL_API_ENV_KEY, raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
keyring, "get_password", lambda service, username: "keyring-key"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _failed(service: str, username: str) -> None:
|
||||||
|
raise KeyringError("delete failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(keyring, "delete_password", _failed)
|
||||||
|
acp_agent_loop = build_acp_agent_loop(build_mistral_provider())
|
||||||
|
|
||||||
|
with pytest.raises(InternalError, match="Failed to sign out"):
|
||||||
|
await acp_agent_loop.ext_method("auth/signOut", {})
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_refuses_unsupported_provider_key(
|
async def test_refuses_unsupported_provider_key(
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ class TestCompactEventHandling:
|
||||||
cwd=str(Path.cwd()), mcp_servers=[]
|
cwd=str(Path.cwd()), mcp_servers=[]
|
||||||
)
|
)
|
||||||
session = acp_agent_loop.sessions[session_response.session_id]
|
session = acp_agent_loop.sessions[session_response.session_id]
|
||||||
|
await session.agent_loop.wait_until_ready()
|
||||||
# Need >1 message so /compact does not early-return.
|
# Need >1 message so /compact does not early-return.
|
||||||
session.agent_loop.messages.append(LLMMessage(role=Role.user, content="hello"))
|
session.agent_loop.messages.append(LLMMessage(role=Role.user, content="hello"))
|
||||||
|
|
||||||
|
|
|
||||||
113
tests/acp/test_image_blocks.py
Normal file
113
tests/acp/test_image_blocks.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from acp.helpers import ImageContentBlock, TextContentBlock
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from vibe.acp.exceptions import INVALID_IMAGE_ATTACHMENT, InvalidImageAttachmentError
|
||||||
|
from vibe.acp.image_blocks import extract_image_attachments
|
||||||
|
from vibe.core.types import (
|
||||||
|
MAX_IMAGE_BYTES,
|
||||||
|
MAX_IMAGES_PER_MESSAGE,
|
||||||
|
FileImageSource,
|
||||||
|
InlineImageSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
||||||
|
|
||||||
|
|
||||||
|
def _image_block(uri: str | None = None) -> ImageContentBlock:
|
||||||
|
return ImageContentBlock(
|
||||||
|
type="image",
|
||||||
|
data=base64.b64encode(PNG_BYTES).decode("ascii"),
|
||||||
|
mime_type="image/png",
|
||||||
|
uri=uri,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inlines_bytes_when_session_dir_is_none() -> None:
|
||||||
|
[att] = extract_image_attachments([_image_block()], session_dir=None)
|
||||||
|
|
||||||
|
assert isinstance(att.source, InlineImageSource)
|
||||||
|
assert att.source.data == base64.b64encode(PNG_BYTES).decode("ascii")
|
||||||
|
assert att.mime_type == "image/png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_writes_attachment_file_when_session_dir_is_set(tmp_path: Path) -> None:
|
||||||
|
[att] = extract_image_attachments([_image_block()], session_dir=tmp_path)
|
||||||
|
|
||||||
|
digest = hashlib.sha1(PNG_BYTES, usedforsecurity=False).hexdigest()
|
||||||
|
assert isinstance(att.source, FileImageSource)
|
||||||
|
assert att.source.path == (tmp_path / "attachments" / f"{digest}.png").resolve()
|
||||||
|
assert att.source.path.read_bytes() == PNG_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
def test_derives_alias_from_uri_basename() -> None:
|
||||||
|
[att] = extract_image_attachments([_image_block(uri="cat.png")], session_dir=None)
|
||||||
|
|
||||||
|
assert att.alias == "cat.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_falls_back_to_default_alias_without_uri() -> None:
|
||||||
|
[att] = extract_image_attachments([_image_block()], session_dir=None)
|
||||||
|
|
||||||
|
assert att.alias == "pasted-image.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ignores_non_image_blocks() -> None:
|
||||||
|
block = TextContentBlock(type="text", text="hello")
|
||||||
|
|
||||||
|
assert extract_image_attachments([block], session_dir=None) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_unsupported_mime() -> None:
|
||||||
|
block = ImageContentBlock(
|
||||||
|
type="image",
|
||||||
|
data=base64.b64encode(PNG_BYTES).decode("ascii"),
|
||||||
|
mime_type="image/tiff",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidImageAttachmentError):
|
||||||
|
extract_image_attachments([block], session_dir=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_block_error_is_structured_acp_error() -> None:
|
||||||
|
block = ImageContentBlock(
|
||||||
|
type="image",
|
||||||
|
data=base64.b64encode(PNG_BYTES).decode("ascii"),
|
||||||
|
mime_type="image/tiff",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidImageAttachmentError) as exc_info:
|
||||||
|
extract_image_attachments([block], session_dir=None)
|
||||||
|
|
||||||
|
assert exc_info.value.code == INVALID_IMAGE_ATTACHMENT
|
||||||
|
assert exc_info.value.data == {"reason": "wrong_type"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_invalid_base64() -> None:
|
||||||
|
block = ImageContentBlock(type="image", data="not base64!!!", mime_type="image/png")
|
||||||
|
|
||||||
|
with pytest.raises(InvalidImageAttachmentError):
|
||||||
|
extract_image_attachments([block], session_dir=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_too_many_images() -> None:
|
||||||
|
blocks = [_image_block() for _ in range(MAX_IMAGES_PER_MESSAGE + 1)]
|
||||||
|
|
||||||
|
with pytest.raises(InvalidImageAttachmentError):
|
||||||
|
extract_image_attachments(blocks, session_dir=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_oversized_image() -> None:
|
||||||
|
block = ImageContentBlock(
|
||||||
|
type="image",
|
||||||
|
data=base64.b64encode(b"x" * (MAX_IMAGE_BYTES + 1)).decode("ascii"),
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidImageAttachmentError):
|
||||||
|
extract_image_attachments([block], session_dir=None)
|
||||||
|
|
@ -63,7 +63,7 @@ class TestACPInitialize:
|
||||||
assert response.agent_capabilities == AgentCapabilities(
|
assert response.agent_capabilities == AgentCapabilities(
|
||||||
load_session=True,
|
load_session=True,
|
||||||
prompt_capabilities=PromptCapabilities(
|
prompt_capabilities=PromptCapabilities(
|
||||||
audio=False, embedded_context=True, image=False
|
audio=False, embedded_context=True, image=True
|
||||||
),
|
),
|
||||||
session_capabilities=SessionCapabilities(
|
session_capabilities=SessionCapabilities(
|
||||||
close=SessionCloseCapabilities(),
|
close=SessionCloseCapabilities(),
|
||||||
|
|
@ -72,7 +72,7 @@ class TestACPInitialize:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert response.agent_info == Implementation(
|
assert response.agent_info == Implementation(
|
||||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.16.1"
|
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.auth_methods is not None
|
assert response.auth_methods is not None
|
||||||
|
|
@ -163,7 +163,7 @@ class TestACPInitialize:
|
||||||
assert response.agent_capabilities == AgentCapabilities(
|
assert response.agent_capabilities == AgentCapabilities(
|
||||||
load_session=True,
|
load_session=True,
|
||||||
prompt_capabilities=PromptCapabilities(
|
prompt_capabilities=PromptCapabilities(
|
||||||
audio=False, embedded_context=True, image=False
|
audio=False, embedded_context=True, image=True
|
||||||
),
|
),
|
||||||
session_capabilities=SessionCapabilities(
|
session_capabilities=SessionCapabilities(
|
||||||
close=SessionCloseCapabilities(),
|
close=SessionCloseCapabilities(),
|
||||||
|
|
@ -172,7 +172,7 @@ class TestACPInitialize:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert response.agent_info == Implementation(
|
assert response.agent_info == Implementation(
|
||||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.16.1"
|
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.auth_methods is not None
|
assert response.auth_methods is not None
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
from acp import RequestError
|
from acp import RequestError
|
||||||
from acp.schema import (
|
from acp.schema import (
|
||||||
AgentMessageChunk,
|
AgentMessageChunk,
|
||||||
AgentThoughtChunk,
|
AgentThoughtChunk,
|
||||||
ClientCapabilities,
|
|
||||||
ToolCallProgress,
|
ToolCallProgress,
|
||||||
ToolCallStart,
|
ToolCallStart,
|
||||||
UserMessageChunk,
|
UserMessageChunk,
|
||||||
|
|
@ -17,7 +15,8 @@ import pytest
|
||||||
from tests.conftest import build_test_vibe_config
|
from tests.conftest import build_test_vibe_config
|
||||||
from tests.stubs.fake_backend import FakeBackend
|
from tests.stubs.fake_backend import FakeBackend
|
||||||
from tests.stubs.fake_client import FakeClient
|
from tests.stubs.fake_client import FakeClient
|
||||||
from vibe.acp.acp_agent_loop import WORKSPACE_TRUST_CAPABILITY, VibeAcpAgentLoop
|
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||||
|
from vibe.acp.user_display_content import USER_DISPLAY_CONTENT_META_KEY
|
||||||
from vibe.core.agent_loop import AgentLoop
|
from vibe.core.agent_loop import AgentLoop
|
||||||
from vibe.core.agents.models import BuiltinAgentName
|
from vibe.core.agents.models import BuiltinAgentName
|
||||||
from vibe.core.config import ModelConfig, SessionLoggingConfig
|
from vibe.core.config import ModelConfig, SessionLoggingConfig
|
||||||
|
|
@ -126,35 +125,42 @@ class TestLoadSession:
|
||||||
assert response.config_options[2].current_value == "off"
|
assert response.config_options[2].current_value == "off"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_load_session_resolves_workspace_trust_before_loading_config(
|
async def test_load_session_returns_trust_details_without_loading_project_docs(
|
||||||
self,
|
self,
|
||||||
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||||
temp_session_dir: Path,
|
temp_session_dir: Path,
|
||||||
create_test_session,
|
create_test_session,
|
||||||
tmp_working_directory: Path,
|
tmp_working_directory: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
acp_agent, client = acp_agent_with_session_config
|
acp_agent, _client = acp_agent_with_session_config
|
||||||
acp_agent.client_capabilities = ClientCapabilities(
|
|
||||||
field_meta={WORKSPACE_TRUST_CAPABILITY: True}
|
|
||||||
)
|
|
||||||
request_trust = AsyncMock(return_value={"decision": "trust_cwd"})
|
|
||||||
monkeypatch.setattr(client, "ext_method", request_trust)
|
|
||||||
(tmp_working_directory / "AGENTS.md").write_text(
|
(tmp_working_directory / "AGENTS.md").write_text(
|
||||||
"Loaded session project instructions", encoding="utf-8"
|
"Loaded session project instructions", encoding="utf-8"
|
||||||
)
|
)
|
||||||
session_id = "test-sess-trust"
|
session_id = "test-sess-trust"
|
||||||
create_test_session(temp_session_dir, session_id, str(tmp_working_directory))
|
create_test_session(temp_session_dir, session_id, str(tmp_working_directory))
|
||||||
|
|
||||||
await acp_agent.load_session(
|
response = await acp_agent.load_session(
|
||||||
cwd=str(tmp_working_directory), mcp_servers=[], session_id=session_id
|
cwd=str(tmp_working_directory), mcp_servers=[], session_id=session_id
|
||||||
)
|
)
|
||||||
|
|
||||||
request_trust.assert_awaited_once()
|
assert response is not None
|
||||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is True
|
payload = response.model_dump(mode="json", by_alias=True)
|
||||||
|
assert payload.get("_meta") == {
|
||||||
|
"workspace_trust": {
|
||||||
|
"status": "untrusted",
|
||||||
|
"details": {
|
||||||
|
"cwd": str(tmp_working_directory.resolve()),
|
||||||
|
"repoRoot": None,
|
||||||
|
"ignoredFiles": ["AGENTS.md"],
|
||||||
|
"availableDecisions": ["trust_cwd", "trust_session", "decline"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
||||||
|
await acp_agent.sessions[session_id].agent_loop.wait_until_ready()
|
||||||
system_prompt = acp_agent.sessions[session_id].agent_loop.messages[0].content
|
system_prompt = acp_agent.sessions[session_id].agent_loop.messages[0].content
|
||||||
assert system_prompt is not None
|
assert system_prompt is not None
|
||||||
assert "Loaded session project instructions" in system_prompt
|
assert "Loaded session project instructions" not in system_prompt
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_load_session_registers_session_with_original_id(
|
async def test_load_session_registers_session_with_original_id(
|
||||||
|
|
@ -223,6 +229,51 @@ class TestLoadSession:
|
||||||
]
|
]
|
||||||
assert len(user_updates) == 1
|
assert len(user_updates) == 1
|
||||||
assert user_updates[0].update.content.text == "Hello world"
|
assert user_updates[0].update.content.text == "Hello world"
|
||||||
|
assert user_updates[0].update.field_meta is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_session_replays_user_display_content(
|
||||||
|
self,
|
||||||
|
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||||
|
temp_session_dir: Path,
|
||||||
|
create_test_session,
|
||||||
|
) -> None:
|
||||||
|
acp_agent, client = acp_agent_with_session_config
|
||||||
|
|
||||||
|
session_id = "replay-dsp-123456"
|
||||||
|
cwd = str(Path.cwd())
|
||||||
|
user_display_content = {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"host": "mistral-vscode",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Look at "},
|
||||||
|
{
|
||||||
|
"type": "workspace_mention",
|
||||||
|
"kind": "file",
|
||||||
|
"uri": "file:///repo/src/app.ts",
|
||||||
|
"name": "app.ts",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Look at app.ts",
|
||||||
|
"user_display_content": user_display_content,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
|
||||||
|
|
||||||
|
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
|
||||||
|
|
||||||
|
user_updates = [
|
||||||
|
u for u in client._session_updates if isinstance(u.update, UserMessageChunk)
|
||||||
|
]
|
||||||
|
assert len(user_updates) == 1
|
||||||
|
assert user_updates[0].update.content.text == "Look at app.ts"
|
||||||
|
assert user_updates[0].update.field_meta == {
|
||||||
|
USER_DISPLAY_CONTENT_META_KEY: user_display_content
|
||||||
|
}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_load_session_replays_assistant_messages(
|
async def test_load_session_replays_assistant_messages(
|
||||||
|
|
@ -278,7 +329,12 @@ class TestLoadSession:
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{"role": "tool", "tool_call_id": "call_123", "content": "file contents"},
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": "call_123",
|
||||||
|
"name": "read",
|
||||||
|
"content": "file contents",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
|
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
|
||||||
|
|
||||||
|
|
@ -288,15 +344,58 @@ class TestLoadSession:
|
||||||
u for u in client._session_updates if isinstance(u.update, ToolCallStart)
|
u for u in client._session_updates if isinstance(u.update, ToolCallStart)
|
||||||
]
|
]
|
||||||
assert len(tool_call_starts) == 1
|
assert len(tool_call_starts) == 1
|
||||||
assert tool_call_starts[0].update.title == "read"
|
start = tool_call_starts[0].update
|
||||||
assert tool_call_starts[0].update.tool_call_id == "call_123"
|
assert start.tool_call_id == "call_123"
|
||||||
|
assert start.kind == "read"
|
||||||
|
# The host drops created events without a status; replayed calls are
|
||||||
|
# historical, so they must carry one.
|
||||||
|
assert start.status == "completed"
|
||||||
|
assert start.field_meta is not None
|
||||||
|
assert start.field_meta["tool_name"] == "read"
|
||||||
|
|
||||||
tool_results = [
|
tool_results = [
|
||||||
u for u in client._session_updates if isinstance(u.update, ToolCallProgress)
|
u for u in client._session_updates if isinstance(u.update, ToolCallProgress)
|
||||||
]
|
]
|
||||||
assert len(tool_results) == 1
|
assert len(tool_results) == 1
|
||||||
assert tool_results[0].update.tool_call_id == "call_123"
|
result = tool_results[0].update
|
||||||
assert tool_results[0].update.status == "completed"
|
assert result.tool_call_id == "call_123"
|
||||||
|
assert result.status == "completed"
|
||||||
|
# The host drops tool_call_update events without a kind.
|
||||||
|
assert result.kind == "read"
|
||||||
|
assert result.field_meta is not None
|
||||||
|
assert result.field_meta["tool_name"] == "read"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_session_skips_result_whose_call_was_not_replayed(
|
||||||
|
self,
|
||||||
|
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||||
|
temp_session_dir: Path,
|
||||||
|
create_test_session,
|
||||||
|
) -> None:
|
||||||
|
acp_agent, client = acp_agent_with_session_config
|
||||||
|
|
||||||
|
session_id = "replay-orphan-12345"
|
||||||
|
cwd = str(Path.cwd())
|
||||||
|
# A tool result whose call was not replayed (here no matching tool call
|
||||||
|
# at all, mirroring a hidden tool whose call replay returns None).
|
||||||
|
# Emitting it would orphan a tool_call_update with no preceding call.
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": "Do something"},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": "call_orphan",
|
||||||
|
"name": "read",
|
||||||
|
"content": "file contents",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
|
||||||
|
|
||||||
|
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
|
||||||
|
|
||||||
|
tool_results = [
|
||||||
|
u for u in client._session_updates if isinstance(u.update, ToolCallProgress)
|
||||||
|
]
|
||||||
|
assert tool_results == []
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_load_session_replays_reasoning_content(
|
async def test_load_session_replays_reasoning_content(
|
||||||
|
|
|
||||||
|
|
@ -3,29 +3,32 @@ from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from acp import RequestError
|
from acp import NewSessionResponse
|
||||||
from acp.schema import ClientCapabilities
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests.acp.conftest import _create_acp_agent
|
from tests.acp.conftest import _create_acp_agent
|
||||||
from tests.conftest import build_test_vibe_config
|
from tests.conftest import build_test_vibe_config
|
||||||
from vibe.acp.acp_agent_loop import WORKSPACE_TRUST_CAPABILITY, VibeAcpAgentLoop
|
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||||
from vibe.acp.exceptions import InvalidRequestError
|
|
||||||
from vibe.core.agent_loop import AgentLoop
|
from vibe.core.agent_loop import AgentLoop
|
||||||
from vibe.core.agents.models import BuiltinAgentName
|
from vibe.core.agents.models import BuiltinAgentName
|
||||||
from vibe.core.config import ModelConfig
|
from vibe.core.config import ModelConfig
|
||||||
|
from vibe.core.feedback import _CACHE_SECTION, _LAST_SHOWN_KEY, record_feedback_asked
|
||||||
from vibe.core.trusted_folders import trusted_folders_manager
|
from vibe.core.trusted_folders import trusted_folders_manager
|
||||||
|
|
||||||
|
|
||||||
def _system_prompt(acp_agent_loop: VibeAcpAgentLoop, session_id: str) -> str:
|
async def _system_prompt(acp_agent_loop: VibeAcpAgentLoop, session_id: str) -> str:
|
||||||
session = acp_agent_loop.sessions[session_id]
|
session = acp_agent_loop.sessions[session_id]
|
||||||
|
await session.agent_loop.wait_until_ready()
|
||||||
return session.agent_loop.messages[0].content or ""
|
return session.agent_loop.messages[0].content or ""
|
||||||
|
|
||||||
|
|
||||||
def _enable_workspace_trust(acp_agent_loop: VibeAcpAgentLoop) -> None:
|
def _workspace_trust_meta(session_response: NewSessionResponse) -> dict:
|
||||||
acp_agent_loop.client_capabilities = ClientCapabilities(
|
payload = session_response.model_dump(mode="json", by_alias=True)
|
||||||
field_meta={WORKSPACE_TRUST_CAPABILITY: True}
|
meta = payload.get("_meta")
|
||||||
)
|
assert isinstance(meta, dict)
|
||||||
|
workspace_trust = meta.get("workspace_trust")
|
||||||
|
assert isinstance(workspace_trust, dict)
|
||||||
|
return workspace_trust
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -153,7 +156,27 @@ class TestACPNewSession:
|
||||||
assert len(thinking_config.options) == 5
|
assert len(thinking_config.options) == 5
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_new_session_loads_root_agents_md_from_workspace_cwd(
|
async def test_new_session_uses_file_backed_feedback_cache(
|
||||||
|
self, acp_agent_loop: VibeAcpAgentLoop, config_dir: Path
|
||||||
|
) -> None:
|
||||||
|
session_response = await acp_agent_loop.new_session(
|
||||||
|
cwd=str(Path.cwd()), mcp_servers=[]
|
||||||
|
)
|
||||||
|
acp_session = acp_agent_loop.sessions[session_response.session_id]
|
||||||
|
|
||||||
|
record_feedback_asked(acp_session.agent_loop.cache_store)
|
||||||
|
|
||||||
|
cache_path = config_dir / "cache.toml"
|
||||||
|
assert cache_path.exists()
|
||||||
|
assert (
|
||||||
|
acp_session.agent_loop.cache_store.read_section(_CACHE_SECTION)[
|
||||||
|
_LAST_SHOWN_KEY
|
||||||
|
]
|
||||||
|
> 0
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_new_session_returns_actionable_trust_details_without_prompting(
|
||||||
self,
|
self,
|
||||||
acp_agent_loop: VibeAcpAgentLoop,
|
acp_agent_loop: VibeAcpAgentLoop,
|
||||||
tmp_working_directory: Path,
|
tmp_working_directory: Path,
|
||||||
|
|
@ -162,7 +185,6 @@ class TestACPNewSession:
|
||||||
(tmp_working_directory / "AGENTS.md").write_text(
|
(tmp_working_directory / "AGENTS.md").write_text(
|
||||||
"Root project instructions", encoding="utf-8"
|
"Root project instructions", encoding="utf-8"
|
||||||
)
|
)
|
||||||
_enable_workspace_trust(acp_agent_loop)
|
|
||||||
request_trust = AsyncMock(return_value={"decision": "trust_cwd"})
|
request_trust = AsyncMock(return_value={"decision": "trust_cwd"})
|
||||||
monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust)
|
monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust)
|
||||||
|
|
||||||
|
|
@ -171,26 +193,24 @@ class TestACPNewSession:
|
||||||
)
|
)
|
||||||
assert session_response.session_id is not None
|
assert session_response.session_id is not None
|
||||||
|
|
||||||
request_trust.assert_awaited_once()
|
request_trust.assert_not_awaited()
|
||||||
await_args = request_trust.await_args
|
assert _workspace_trust_meta(session_response) == {
|
||||||
assert await_args is not None
|
"status": "untrusted",
|
||||||
method, params = await_args.args
|
"details": {
|
||||||
assert method == "trust/request"
|
"cwd": str(tmp_working_directory.resolve()),
|
||||||
assert params["cwd"] == str(tmp_working_directory.resolve())
|
"repoRoot": None,
|
||||||
assert params["detectedFiles"] == ["AGENTS.md"]
|
"ignoredFiles": ["AGENTS.md"],
|
||||||
assert params["repoDetectedFiles"] == []
|
"availableDecisions": ["trust_cwd", "trust_session", "decline"],
|
||||||
assert params["availableDecisions"] == ["trust_cwd", "trust_session", "decline"]
|
},
|
||||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is True
|
}
|
||||||
assert "Root project instructions" in _system_prompt(
|
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
||||||
|
assert "Root project instructions" not in await _system_prompt(
|
||||||
acp_agent_loop, session_response.session_id
|
acp_agent_loop, session_response.session_id
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_new_session_can_trust_full_repo_from_subdirectory(
|
async def test_new_session_returns_repo_trust_details_from_subdirectory(
|
||||||
self,
|
self, acp_agent_loop: VibeAcpAgentLoop, tmp_path: Path
|
||||||
acp_agent_loop: VibeAcpAgentLoop,
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
repo = tmp_path / "repo"
|
repo = tmp_path / "repo"
|
||||||
cwd = repo / "src" / "pkg"
|
cwd = repo / "src" / "pkg"
|
||||||
|
|
@ -198,79 +218,68 @@ class TestACPNewSession:
|
||||||
(repo / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
|
(repo / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
|
||||||
cwd.mkdir(parents=True)
|
cwd.mkdir(parents=True)
|
||||||
(repo / "AGENTS.md").write_text("Repo instructions", encoding="utf-8")
|
(repo / "AGENTS.md").write_text("Repo instructions", encoding="utf-8")
|
||||||
_enable_workspace_trust(acp_agent_loop)
|
|
||||||
request_trust = AsyncMock(return_value={"decision": "trust_repo"})
|
|
||||||
monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust)
|
|
||||||
|
|
||||||
session_response = await acp_agent_loop.new_session(
|
session_response = await acp_agent_loop.new_session(
|
||||||
cwd=str(cwd), mcp_servers=[]
|
cwd=str(cwd), mcp_servers=[]
|
||||||
)
|
)
|
||||||
assert session_response.session_id is not None
|
assert session_response.session_id is not None
|
||||||
|
|
||||||
request_trust.assert_awaited_once()
|
assert _workspace_trust_meta(session_response) == {
|
||||||
await_args = request_trust.await_args
|
"status": "untrusted",
|
||||||
assert await_args is not None
|
"details": {
|
||||||
method, params = await_args.args
|
"cwd": str(cwd.resolve()),
|
||||||
assert method == "trust/request"
|
"repoRoot": str(repo.resolve()),
|
||||||
assert params["cwd"] == str(cwd.resolve())
|
"ignoredFiles": ["AGENTS.md"],
|
||||||
assert params["repoRoot"] == str(repo.resolve())
|
"availableDecisions": [
|
||||||
assert params["detectedFiles"] == []
|
"trust_repo",
|
||||||
assert params["repoDetectedFiles"] == ["AGENTS.md"]
|
"trust_cwd",
|
||||||
assert params["availableDecisions"] == [
|
"trust_session",
|
||||||
"trust_repo",
|
"decline",
|
||||||
"trust_cwd",
|
],
|
||||||
"trust_session",
|
},
|
||||||
"decline",
|
}
|
||||||
]
|
assert trusted_folders_manager.is_trusted(repo) is None
|
||||||
assert trusted_folders_manager.is_trusted(repo) is True
|
assert trusted_folders_manager.is_trusted(cwd) is None
|
||||||
assert trusted_folders_manager.is_trusted(cwd) is True
|
assert "Repo instructions" not in await _system_prompt(
|
||||||
assert "Repo instructions" in _system_prompt(
|
|
||||||
acp_agent_loop, session_response.session_id
|
acp_agent_loop, session_response.session_id
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_new_session_decline_skips_project_docs(
|
async def test_new_session_returns_details_after_explicit_decline(
|
||||||
self,
|
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||||
acp_agent_loop: VibeAcpAgentLoop,
|
|
||||||
tmp_working_directory: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
(tmp_working_directory / "AGENTS.md").write_text(
|
(tmp_working_directory / "AGENTS.md").write_text(
|
||||||
"Do not load this", encoding="utf-8"
|
"Do not load this", encoding="utf-8"
|
||||||
)
|
)
|
||||||
_enable_workspace_trust(acp_agent_loop)
|
trusted_folders_manager.add_untrusted(tmp_working_directory)
|
||||||
monkeypatch.setattr(
|
|
||||||
acp_agent_loop.client,
|
|
||||||
"ext_method",
|
|
||||||
AsyncMock(return_value={"decision": "decline"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
session_response = await acp_agent_loop.new_session(
|
session_response = await acp_agent_loop.new_session(
|
||||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||||
)
|
)
|
||||||
assert session_response.session_id is not None
|
assert session_response.session_id is not None
|
||||||
|
|
||||||
|
assert _workspace_trust_meta(session_response) == {
|
||||||
|
"status": "untrusted",
|
||||||
|
"details": {
|
||||||
|
"cwd": str(tmp_working_directory.resolve()),
|
||||||
|
"repoRoot": None,
|
||||||
|
"ignoredFiles": ["AGENTS.md"],
|
||||||
|
"availableDecisions": ["trust_cwd", "trust_session", "decline"],
|
||||||
|
},
|
||||||
|
}
|
||||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is False
|
assert trusted_folders_manager.is_trusted(tmp_working_directory) is False
|
||||||
assert "Do not load this" not in _system_prompt(
|
assert "Do not load this" not in await _system_prompt(
|
||||||
acp_agent_loop, session_response.session_id
|
acp_agent_loop, session_response.session_id
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_new_session_session_trust_loads_docs_without_persisting(
|
async def test_new_session_session_trust_loads_docs_without_persisting(
|
||||||
self,
|
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||||
acp_agent_loop: VibeAcpAgentLoop,
|
|
||||||
tmp_working_directory: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
(tmp_working_directory / "AGENTS.md").write_text(
|
(tmp_working_directory / "AGENTS.md").write_text(
|
||||||
"Session-only instructions", encoding="utf-8"
|
"Session-only instructions", encoding="utf-8"
|
||||||
)
|
)
|
||||||
_enable_workspace_trust(acp_agent_loop)
|
trusted_folders_manager.trust_for_session(tmp_working_directory)
|
||||||
monkeypatch.setattr(
|
|
||||||
acp_agent_loop.client,
|
|
||||||
"ext_method",
|
|
||||||
AsyncMock(return_value={"decision": "trust_session"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
session_response = await acp_agent_loop.new_session(
|
session_response = await acp_agent_loop.new_session(
|
||||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||||
|
|
@ -278,10 +287,14 @@ class TestACPNewSession:
|
||||||
assert session_response.session_id is not None
|
assert session_response.session_id is not None
|
||||||
|
|
||||||
normalized = str(tmp_working_directory.resolve())
|
normalized = str(tmp_working_directory.resolve())
|
||||||
|
assert _workspace_trust_meta(session_response) == {
|
||||||
|
"status": "session",
|
||||||
|
"details": None,
|
||||||
|
}
|
||||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is True
|
assert trusted_folders_manager.is_trusted(tmp_working_directory) is True
|
||||||
assert normalized in trusted_folders_manager._session_trusted
|
assert normalized in trusted_folders_manager._session_trusted
|
||||||
assert normalized not in trusted_folders_manager._trusted
|
assert normalized not in trusted_folders_manager._trusted
|
||||||
assert "Session-only instructions" in _system_prompt(
|
assert "Session-only instructions" in await _system_prompt(
|
||||||
acp_agent_loop, session_response.session_id
|
acp_agent_loop, session_response.session_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -292,90 +305,45 @@ class TestACPNewSession:
|
||||||
tmp_working_directory: Path,
|
tmp_working_directory: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
_enable_workspace_trust(acp_agent_loop)
|
|
||||||
request_trust = AsyncMock(return_value={"decision": "trust_cwd"})
|
|
||||||
monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust)
|
|
||||||
|
|
||||||
await acp_agent_loop.new_session(cwd=str(tmp_working_directory), mcp_servers=[])
|
|
||||||
|
|
||||||
request_trust.assert_not_awaited()
|
|
||||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_new_session_direct_client_fallback_skips_project_docs(
|
|
||||||
self,
|
|
||||||
acp_agent_loop: VibeAcpAgentLoop,
|
|
||||||
tmp_working_directory: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
(tmp_working_directory / "AGENTS.md").write_text(
|
|
||||||
"Direct client should skip this", encoding="utf-8"
|
|
||||||
)
|
|
||||||
_enable_workspace_trust(acp_agent_loop)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
acp_agent_loop.client,
|
|
||||||
"ext_method",
|
|
||||||
AsyncMock(side_effect=RequestError.method_not_found("trust/request")),
|
|
||||||
)
|
|
||||||
|
|
||||||
session_response = await acp_agent_loop.new_session(
|
|
||||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
|
||||||
)
|
|
||||||
assert session_response.session_id is not None
|
|
||||||
|
|
||||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
|
||||||
assert "Direct client should skip this" not in _system_prompt(
|
|
||||||
acp_agent_loop, session_response.session_id
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_new_session_without_workspace_trust_capability_skips_prompt(
|
|
||||||
self,
|
|
||||||
acp_agent_loop: VibeAcpAgentLoop,
|
|
||||||
tmp_working_directory: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
(tmp_working_directory / "AGENTS.md").write_text(
|
|
||||||
"Unsupported client should skip this", encoding="utf-8"
|
|
||||||
)
|
|
||||||
request_trust = AsyncMock(return_value={"decision": "trust_cwd"})
|
request_trust = AsyncMock(return_value={"decision": "trust_cwd"})
|
||||||
monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust)
|
monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust)
|
||||||
|
|
||||||
session_response = await acp_agent_loop.new_session(
|
session_response = await acp_agent_loop.new_session(
|
||||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||||
)
|
)
|
||||||
assert session_response.session_id is not None
|
|
||||||
|
|
||||||
request_trust.assert_not_awaited()
|
request_trust.assert_not_awaited()
|
||||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
assert _workspace_trust_meta(session_response) == {
|
||||||
assert "Unsupported client should skip this" not in _system_prompt(
|
"status": "untrusted",
|
||||||
acp_agent_loop, session_response.session_id
|
"details": None,
|
||||||
|
}
|
||||||
|
assert (
|
||||||
|
trusted_folders_manager.trust_status(tmp_working_directory) == "untrusted"
|
||||||
)
|
)
|
||||||
|
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_new_session_cancelled_trust_prompt_cancels_session_creation(
|
async def test_new_session_trusted_folder_loads_docs_with_no_details(
|
||||||
self,
|
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||||
acp_agent_loop: VibeAcpAgentLoop,
|
|
||||||
tmp_working_directory: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
(tmp_working_directory / "AGENTS.md").write_text(
|
(tmp_working_directory / "AGENTS.md").write_text(
|
||||||
"Cancelled prompt", encoding="utf-8"
|
"Trusted folder instructions", encoding="utf-8"
|
||||||
)
|
)
|
||||||
_enable_workspace_trust(acp_agent_loop)
|
trusted_folders_manager.add_trusted(tmp_working_directory)
|
||||||
monkeypatch.setattr(
|
|
||||||
acp_agent_loop.client,
|
session_response = await acp_agent_loop.new_session(
|
||||||
"ext_method",
|
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||||
AsyncMock(return_value={"decision": "cancelled"}),
|
|
||||||
)
|
)
|
||||||
|
assert session_response.session_id is not None
|
||||||
|
|
||||||
with pytest.raises(InvalidRequestError):
|
assert trusted_folders_manager.is_trusted(tmp_working_directory) is True
|
||||||
await acp_agent_loop.new_session(
|
assert _workspace_trust_meta(session_response) == {
|
||||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
"status": "trusted",
|
||||||
)
|
"details": None,
|
||||||
|
}
|
||||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
assert "Trusted folder instructions" in await _system_prompt(
|
||||||
assert acp_agent_loop.sessions == {}
|
acp_agent_loop, session_response.session_id
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.skip(reason="TODO: Fix this test")
|
@pytest.mark.skip(reason="TODO: Fix this test")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,7 @@ class TestACPSetModel:
|
||||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||||
)
|
)
|
||||||
assert acp_session is not None
|
assert acp_session is not None
|
||||||
|
await acp_session.agent_loop.wait_until_ready()
|
||||||
|
|
||||||
user_msg = LLMMessage(role=Role.user, content="Hello")
|
user_msg = LLMMessage(role=Role.user, content="Hello")
|
||||||
assistant_msg = LLMMessage(role=Role.assistant, content="Hi there!")
|
assistant_msg = LLMMessage(role=Role.assistant, content="Hi there!")
|
||||||
|
|
|
||||||
|
|
@ -40,3 +40,32 @@ class TestToolCallSessionUpdate:
|
||||||
assert update.tool_call_id == "test_call_123"
|
assert update.tool_call_id == "test_call_123"
|
||||||
assert update.kind == "read"
|
assert update.kind == "read"
|
||||||
assert update.raw_input is None
|
assert update.raw_input is None
|
||||||
|
|
||||||
|
def test_whole_file_read_emits_plain_file_location(self) -> None:
|
||||||
|
event = self._create_event()
|
||||||
|
|
||||||
|
update = tool_call_session_update(event)
|
||||||
|
|
||||||
|
assert isinstance(update, ToolCallStart)
|
||||||
|
assert update.locations is not None
|
||||||
|
location = update.locations[0]
|
||||||
|
assert location.field_meta == {"type": "file"}
|
||||||
|
assert location.line is None
|
||||||
|
|
||||||
|
def test_bounded_read_emits_file_range_location(self) -> None:
|
||||||
|
event = ToolCallEvent(
|
||||||
|
tool_name="read",
|
||||||
|
tool_call_id="test_call_123",
|
||||||
|
args=ReadArgs(file_path="/tmp/test.txt", offset=10, limit=20),
|
||||||
|
tool_class=Read,
|
||||||
|
)
|
||||||
|
|
||||||
|
update = tool_call_session_update(event)
|
||||||
|
|
||||||
|
assert isinstance(update, ToolCallStart)
|
||||||
|
assert update.locations is not None
|
||||||
|
location = update.locations[0]
|
||||||
|
assert location.field_meta is not None
|
||||||
|
assert location.field_meta["type"] == "file_range"
|
||||||
|
assert location.field_meta["offset"] == 10
|
||||||
|
assert location.field_meta["limit"] == 20
|
||||||
|
|
|
||||||
|
|
@ -112,14 +112,25 @@ class TestReadFieldMeta:
|
||||||
assert loc.field_meta == {"type": "file_range", "offset": 10, "limit": 50}
|
assert loc.field_meta == {"type": "file_range", "offset": 10, "limit": 50}
|
||||||
assert update.field_meta == {"tool_name": "read"}
|
assert update.field_meta == {"tool_name": "read"}
|
||||||
|
|
||||||
def test_call_defaults_offset_none_limit_default(self) -> None:
|
def test_whole_file_read_emits_plain_file_location(self) -> None:
|
||||||
event = _call_event("read", Read, ReadArgs(file_path="/tmp/f.txt"))
|
event = _call_event("read", Read, ReadArgs(file_path="/tmp/f.txt"))
|
||||||
update = tool_call_session_update(event)
|
update = tool_call_session_update(event)
|
||||||
|
|
||||||
assert isinstance(update, ToolCallStart)
|
assert isinstance(update, ToolCallStart)
|
||||||
assert update.locations is not None
|
assert update.locations is not None
|
||||||
loc = update.locations[0]
|
loc = update.locations[0]
|
||||||
assert loc.field_meta == {"type": "file_range", "offset": None, "limit": 2000}
|
assert loc.field_meta == {"type": "file"}
|
||||||
|
assert loc.line is None
|
||||||
|
|
||||||
|
def test_read_from_offset_to_end_emits_file_location_with_line(self) -> None:
|
||||||
|
event = _call_event("read", Read, ReadArgs(file_path="/tmp/f.txt", offset=42))
|
||||||
|
update = tool_call_session_update(event)
|
||||||
|
|
||||||
|
assert isinstance(update, ToolCallStart)
|
||||||
|
assert update.locations is not None
|
||||||
|
loc = update.locations[0]
|
||||||
|
assert loc.field_meta == {"type": "file"}
|
||||||
|
assert loc.line == 42
|
||||||
|
|
||||||
def test_result_location_has_start_line_and_num_lines(self) -> None:
|
def test_result_location_has_start_line_and_num_lines(self) -> None:
|
||||||
result = ReadResult(
|
result = ReadResult(
|
||||||
|
|
@ -128,6 +139,7 @@ class TestReadFieldMeta:
|
||||||
num_lines=3,
|
num_lines=3,
|
||||||
start_line=10,
|
start_line=10,
|
||||||
total_lines=20,
|
total_lines=20,
|
||||||
|
requested_limit=50,
|
||||||
)
|
)
|
||||||
event = _result_event("read", Read, result)
|
event = _result_event("read", Read, result)
|
||||||
update = tool_result_session_update(event)
|
update = tool_result_session_update(event)
|
||||||
|
|
@ -137,6 +149,77 @@ class TestReadFieldMeta:
|
||||||
loc = update.locations[0]
|
loc = update.locations[0]
|
||||||
assert loc.field_meta == {"type": "file_range", "offset": 10, "limit": 3}
|
assert loc.field_meta == {"type": "file_range", "offset": 10, "limit": 3}
|
||||||
|
|
||||||
|
def test_whole_file_result_emits_plain_file_location(self) -> None:
|
||||||
|
result = ReadResult(
|
||||||
|
file_path="/tmp/f.txt",
|
||||||
|
content=" 1→line1\n 2→line2",
|
||||||
|
num_lines=2,
|
||||||
|
start_line=1,
|
||||||
|
total_lines=2,
|
||||||
|
)
|
||||||
|
event = _result_event("read", Read, result)
|
||||||
|
update = tool_result_session_update(event)
|
||||||
|
|
||||||
|
assert isinstance(update, ToolCallProgress)
|
||||||
|
assert update.locations is not None
|
||||||
|
loc = update.locations[0]
|
||||||
|
assert loc.field_meta == {"type": "file"}
|
||||||
|
assert loc.line is None
|
||||||
|
|
||||||
|
def test_truncated_default_limit_result_emits_range(self) -> None:
|
||||||
|
# No limit given, but the file exceeded the default limit: the read was
|
||||||
|
# partial, so the chip must show the range, not imply the whole file.
|
||||||
|
result = ReadResult(
|
||||||
|
file_path="/tmp/f.txt",
|
||||||
|
content=" 1→line1",
|
||||||
|
num_lines=2000,
|
||||||
|
start_line=1,
|
||||||
|
total_lines=None,
|
||||||
|
was_truncated=True,
|
||||||
|
)
|
||||||
|
event = _result_event("read", Read, result)
|
||||||
|
update = tool_result_session_update(event)
|
||||||
|
|
||||||
|
assert isinstance(update, ToolCallProgress)
|
||||||
|
assert update.locations is not None
|
||||||
|
loc = update.locations[0]
|
||||||
|
assert loc.field_meta == {"type": "file_range", "offset": 1, "limit": 2000}
|
||||||
|
|
||||||
|
def test_offset_to_end_result_emits_file_location_with_line(self) -> None:
|
||||||
|
result = ReadResult(
|
||||||
|
file_path="/tmp/f.txt",
|
||||||
|
content=" 42→line42",
|
||||||
|
num_lines=1,
|
||||||
|
start_line=42,
|
||||||
|
total_lines=42,
|
||||||
|
requested_offset=42,
|
||||||
|
)
|
||||||
|
event = _result_event("read", Read, result)
|
||||||
|
update = tool_result_session_update(event)
|
||||||
|
|
||||||
|
assert isinstance(update, ToolCallProgress)
|
||||||
|
assert update.locations is not None
|
||||||
|
loc = update.locations[0]
|
||||||
|
assert loc.field_meta == {"type": "file"}
|
||||||
|
assert loc.line == 42
|
||||||
|
|
||||||
|
def test_bounded_result_clamps_limit_to_lines_read(self) -> None:
|
||||||
|
result = ReadResult(
|
||||||
|
file_path="/tmp/f.txt",
|
||||||
|
content=" 1→line1",
|
||||||
|
num_lines=1,
|
||||||
|
start_line=1,
|
||||||
|
total_lines=1,
|
||||||
|
requested_limit=100,
|
||||||
|
)
|
||||||
|
event = _result_event("read", Read, result)
|
||||||
|
update = tool_result_session_update(event)
|
||||||
|
|
||||||
|
assert isinstance(update, ToolCallProgress)
|
||||||
|
assert update.locations is not None
|
||||||
|
loc = update.locations[0]
|
||||||
|
assert loc.field_meta == {"type": "file_range", "offset": 1, "limit": 1}
|
||||||
|
|
||||||
|
|
||||||
class TestWebSearchFieldMeta:
|
class TestWebSearchFieldMeta:
|
||||||
def test_call_meta_contains_query(self) -> None:
|
def test_call_meta_contains_query(self) -> None:
|
||||||
|
|
|
||||||
144
tests/acp/test_user_display_content.py
Normal file
144
tests/acp/test_user_display_content.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from acp.schema import TextContentBlock
|
||||||
|
from pydantic import ValidationError
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.stubs.fake_backend import FakeBackend
|
||||||
|
from tests.stubs.fake_client import FakeClient
|
||||||
|
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||||
|
from vibe.acp.exceptions import InvalidRequestError
|
||||||
|
from vibe.acp.user_display_content import (
|
||||||
|
USER_DISPLAY_CONTENT_META_KEY,
|
||||||
|
parse_user_display_content_metadata,
|
||||||
|
)
|
||||||
|
from vibe.core.session.session_loader import SessionLoader
|
||||||
|
from vibe.core.types import Role, UserDisplayContentMetadata
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_payload() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"host": "mistral-vscode",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Look at "},
|
||||||
|
{
|
||||||
|
"type": "workspace_mention",
|
||||||
|
"kind": "file",
|
||||||
|
"uri": "file:///repo/src/app.ts",
|
||||||
|
"name": "app.ts",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_kwargs(value: object) -> dict[str, Any]:
|
||||||
|
return {USER_DISPLAY_CONTENT_META_KEY: value}
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_display_content_meta_key_is_snake_case() -> None:
|
||||||
|
assert USER_DISPLAY_CONTENT_META_KEY == "user_display_content"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_returns_none_when_metadata_is_missing() -> None:
|
||||||
|
assert parse_user_display_content_metadata(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_validates_present_metadata() -> None:
|
||||||
|
metadata = parse_user_display_content_metadata({
|
||||||
|
"version": "1.0.0",
|
||||||
|
"host": "mistral-vscode",
|
||||||
|
"content": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert metadata == UserDisplayContentMetadata(
|
||||||
|
version="1.0.0", host="mistral-vscode", content=[]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_rejects_invalid_metadata() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
parse_user_display_content_metadata({
|
||||||
|
"version": 2,
|
||||||
|
"host": "mistral-vscode",
|
||||||
|
"content": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prompt_attaches_user_display_content_to_user_message(
|
||||||
|
acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
|
||||||
|
) -> None:
|
||||||
|
session_response = await acp_agent_loop.new_session(
|
||||||
|
cwd=str(Path.cwd()), mcp_servers=[]
|
||||||
|
)
|
||||||
|
payload = _metadata_payload()
|
||||||
|
|
||||||
|
response = await acp_agent_loop.prompt(
|
||||||
|
prompt=[TextContentBlock(type="text", text="Look at app.ts")],
|
||||||
|
session_id=session_response.session_id,
|
||||||
|
**_metadata_kwargs(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.stop_reason == "end_turn"
|
||||||
|
user_message = next(
|
||||||
|
(msg for msg in backend.requests_messages[0] if msg.role == Role.user), None
|
||||||
|
)
|
||||||
|
assert user_message is not None
|
||||||
|
assert user_message.content == "Look at app.ts"
|
||||||
|
assert (
|
||||||
|
user_message.user_display_content
|
||||||
|
== UserDisplayContentMetadata.model_validate(payload)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prompt_rejects_invalid_user_display_content(
|
||||||
|
acp_agent_loop: VibeAcpAgentLoop,
|
||||||
|
) -> None:
|
||||||
|
session_response = await acp_agent_loop.new_session(
|
||||||
|
cwd=str(Path.cwd()), mcp_servers=[]
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidRequestError, match="Invalid user display content"):
|
||||||
|
await acp_agent_loop.prompt(
|
||||||
|
prompt=[TextContentBlock(type="text", text="Look at app.ts")],
|
||||||
|
session_id=session_response.session_id,
|
||||||
|
**_metadata_kwargs({"version": 2, "host": "mistral-vscode", "content": []}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prompt_persists_user_display_content(
|
||||||
|
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||||
|
temp_session_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
acp_agent, _client = acp_agent_with_session_config
|
||||||
|
session_response = await acp_agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||||
|
payload = _metadata_payload()
|
||||||
|
|
||||||
|
await acp_agent.prompt(
|
||||||
|
prompt=[TextContentBlock(type="text", text="Look at app.ts")],
|
||||||
|
session_id=session_response.session_id,
|
||||||
|
**_metadata_kwargs(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
session_dir = next(temp_session_dir.glob("session_*"))
|
||||||
|
messages_file = session_dir / "messages.jsonl"
|
||||||
|
messages = [
|
||||||
|
json.loads(line)
|
||||||
|
for line in messages_file.read_text(encoding="utf-8").splitlines()
|
||||||
|
]
|
||||||
|
user_message = next(msg for msg in messages if msg["role"] == "user")
|
||||||
|
|
||||||
|
assert user_message["user_display_content"] == payload
|
||||||
|
|
||||||
|
loaded_messages, _metadata = SessionLoader.load_session(session_dir)
|
||||||
|
loaded_user_message = next(msg for msg in loaded_messages if msg.role == Role.user)
|
||||||
|
assert loaded_user_message.user_display_content == (
|
||||||
|
UserDisplayContentMetadata.model_validate(payload)
|
||||||
|
)
|
||||||
|
|
@ -1,14 +1,32 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from acp.schema import ToolCallStart
|
||||||
|
|
||||||
|
from vibe.acp.tools.builtins.task import Task as AcpTask
|
||||||
|
from vibe.acp.tools.builtins.todo import Todo as AcpTodo, TodoArgs
|
||||||
|
from vibe.acp.user_display_content import USER_DISPLAY_CONTENT_META_KEY
|
||||||
from vibe.acp.utils import (
|
from vibe.acp.utils import (
|
||||||
TOOL_OPTIONS,
|
TOOL_OPTIONS,
|
||||||
ToolOption,
|
ToolOption,
|
||||||
build_permission_options,
|
build_permission_options,
|
||||||
|
create_tool_call_replay,
|
||||||
|
create_tool_result_replay,
|
||||||
|
create_user_message_replay,
|
||||||
get_proxy_help_text,
|
get_proxy_help_text,
|
||||||
|
tool_call_replay_update,
|
||||||
)
|
)
|
||||||
|
from vibe.core.llm.format import ResolvedToolCall
|
||||||
from vibe.core.paths import GLOBAL_ENV_FILE
|
from vibe.core.paths import GLOBAL_ENV_FILE
|
||||||
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS
|
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS
|
||||||
|
from vibe.core.tools.builtins.task import TaskArgs
|
||||||
from vibe.core.tools.permissions import PermissionScope, RequiredPermission
|
from vibe.core.tools.permissions import PermissionScope, RequiredPermission
|
||||||
|
from vibe.core.types import (
|
||||||
|
FunctionCall,
|
||||||
|
LLMMessage,
|
||||||
|
Role,
|
||||||
|
ToolCall,
|
||||||
|
UserDisplayContentMetadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _write_env_file(content: str) -> None:
|
def _write_env_file(content: str) -> None:
|
||||||
|
|
@ -124,3 +142,126 @@ class TestBuildPermissionOptions:
|
||||||
assert reject_once.name == "Deny"
|
assert reject_once.name == "Deny"
|
||||||
assert allow_once.field_meta is None
|
assert allow_once.field_meta is None
|
||||||
assert reject_once.field_meta is None
|
assert reject_once.field_meta is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateUserMessageReplay:
|
||||||
|
def test_replays_plain_text_without_field_meta(self) -> None:
|
||||||
|
replay = create_user_message_replay(
|
||||||
|
LLMMessage(role=Role.user, content="Hello", message_id="msg-1")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert replay.content.text == "Hello"
|
||||||
|
assert replay.message_id == "msg-1"
|
||||||
|
assert replay.field_meta is None
|
||||||
|
|
||||||
|
def test_replays_user_display_content_as_field_meta(self) -> None:
|
||||||
|
user_display_content = UserDisplayContentMetadata(
|
||||||
|
version="1.0.0",
|
||||||
|
host="mistral-vscode",
|
||||||
|
content=[
|
||||||
|
{"type": "text", "text": "Look at "},
|
||||||
|
{
|
||||||
|
"type": "workspace_mention",
|
||||||
|
"kind": "file",
|
||||||
|
"uri": "file:///repo/src/app.ts",
|
||||||
|
"name": "app.ts",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
replay = create_user_message_replay(
|
||||||
|
LLMMessage(
|
||||||
|
role=Role.user,
|
||||||
|
content="Look at app.ts",
|
||||||
|
message_id="msg-1",
|
||||||
|
user_display_content=user_display_content,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert replay.content.text == "Look at app.ts"
|
||||||
|
assert replay.field_meta == {
|
||||||
|
USER_DISPLAY_CONTENT_META_KEY: user_display_content.model_dump(mode="json")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateToolCallReplay:
|
||||||
|
def test_carries_status_and_resolved_kind(self) -> None:
|
||||||
|
update = create_tool_call_replay("call_1", "grep", '{"pattern": "foo"}')
|
||||||
|
|
||||||
|
assert update.status == "completed"
|
||||||
|
assert update.kind == "search"
|
||||||
|
assert update.field_meta == {"tool_name": "grep"}
|
||||||
|
assert update.raw_input == '{"pattern": "foo"}'
|
||||||
|
|
||||||
|
def test_unknown_tool_defaults_kind_to_other(self) -> None:
|
||||||
|
update = create_tool_call_replay("call_2", "mystery_tool", None)
|
||||||
|
|
||||||
|
assert update.status == "completed"
|
||||||
|
assert update.kind == "other"
|
||||||
|
assert update.field_meta == {"tool_name": "mystery_tool"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateToolResultReplay:
|
||||||
|
def test_carries_kind_and_tool_name_meta(self) -> None:
|
||||||
|
msg = LLMMessage(
|
||||||
|
role=Role.tool, tool_call_id="call_1", name="bash", content="exit 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
update = create_tool_result_replay(msg)
|
||||||
|
|
||||||
|
assert update is not None
|
||||||
|
assert update.kind == "execute"
|
||||||
|
assert update.status == "completed"
|
||||||
|
assert update.field_meta == {"tool_name": "bash"}
|
||||||
|
|
||||||
|
def test_returns_none_without_tool_call_id(self) -> None:
|
||||||
|
msg = LLMMessage(role=Role.tool, name="bash", content="exit 0")
|
||||||
|
|
||||||
|
assert create_tool_result_replay(msg) is None
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_call(call_id: str, name: str, arguments: str | None) -> ToolCall:
|
||||||
|
return ToolCall(id=call_id, function=FunctionCall(name=name, arguments=arguments))
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolCallReplayUpdate:
|
||||||
|
def test_resolved_task_carries_agent_and_task_meta(self) -> None:
|
||||||
|
tool_call = _tool_call(
|
||||||
|
"call_1", "task", '{"agent": "explore", "task": "find the bug"}'
|
||||||
|
)
|
||||||
|
resolved = ResolvedToolCall(
|
||||||
|
tool_name="task",
|
||||||
|
tool_class=AcpTask,
|
||||||
|
validated_args=TaskArgs(agent="explore", task="find the bug"),
|
||||||
|
call_id="call_1",
|
||||||
|
)
|
||||||
|
|
||||||
|
update = tool_call_replay_update(resolved, tool_call)
|
||||||
|
|
||||||
|
assert isinstance(update, ToolCallStart)
|
||||||
|
assert update.status == "completed"
|
||||||
|
assert update.field_meta is not None
|
||||||
|
assert update.field_meta["tool_name"] == "task"
|
||||||
|
assert update.field_meta["agent"] == "explore"
|
||||||
|
assert update.field_meta["task"] == "find the bug"
|
||||||
|
|
||||||
|
def test_unresolved_call_falls_back_to_generic_replay(self) -> None:
|
||||||
|
tool_call = _tool_call("call_2", "grep", '{"pattern": "foo"}')
|
||||||
|
|
||||||
|
update = tool_call_replay_update(None, tool_call)
|
||||||
|
|
||||||
|
assert isinstance(update, ToolCallStart)
|
||||||
|
assert update.status == "completed"
|
||||||
|
assert update.kind == "search"
|
||||||
|
assert update.field_meta == {"tool_name": "grep"}
|
||||||
|
|
||||||
|
def test_hidden_tool_call_is_skipped(self) -> None:
|
||||||
|
tool_call = _tool_call("call_3", "todo", "{}")
|
||||||
|
resolved = ResolvedToolCall(
|
||||||
|
tool_name="todo",
|
||||||
|
tool_class=AcpTodo,
|
||||||
|
validated_args=TodoArgs(action="read"),
|
||||||
|
call_id="call_3",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tool_call_replay_update(resolved, tool_call) is None
|
||||||
|
|
|
||||||
140
tests/acp/test_workspace_trust.py
Normal file
140
tests/acp/test_workspace_trust.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.acp.conftest import _create_acp_agent
|
||||||
|
from tests.conftest import build_test_vibe_config
|
||||||
|
from tests.stubs.fake_backend import FakeBackend
|
||||||
|
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||||
|
from vibe.acp.exceptions import InvalidRequestError, SessionNotFoundError
|
||||||
|
from vibe.core.agent_loop import AgentLoop
|
||||||
|
from vibe.core.config import ModelConfig
|
||||||
|
from vibe.core.trusted_folders import trusted_folders_manager
|
||||||
|
|
||||||
|
|
||||||
|
async def _system_prompt(acp_agent_loop: VibeAcpAgentLoop, session_id: str) -> str:
|
||||||
|
session = acp_agent_loop.sessions[session_id]
|
||||||
|
await session.agent_loop.wait_until_ready()
|
||||||
|
return session.agent_loop.messages[0].content or ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def acp_agent_loop(
|
||||||
|
backend: FakeBackend, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> VibeAcpAgentLoop:
|
||||||
|
config = build_test_vibe_config(
|
||||||
|
active_model="devstral-latest",
|
||||||
|
models=[
|
||||||
|
ModelConfig(
|
||||||
|
name="devstral-latest", provider="mistral", alias="devstral-latest"
|
||||||
|
),
|
||||||
|
ModelConfig(
|
||||||
|
name="devstral-small", provider="mistral", alias="devstral-small"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
class PatchedAgentLoop(AgentLoop):
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
super().__init__(*args, **{**kwargs, "backend": backend})
|
||||||
|
self._base_config = config
|
||||||
|
self.agent_manager.invalidate_config()
|
||||||
|
|
||||||
|
monkeypatch.setattr("vibe.acp.acp_agent_loop.AgentLoop", PatchedAgentLoop)
|
||||||
|
return _create_acp_agent()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkspaceTrustExtMethods:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_workspace_trust_status_returns_details_even_after_decline(
|
||||||
|
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||||
|
) -> None:
|
||||||
|
(tmp_working_directory / "AGENTS.md").write_text(
|
||||||
|
"Trust me later", encoding="utf-8"
|
||||||
|
)
|
||||||
|
trusted_folders_manager.add_untrusted(tmp_working_directory)
|
||||||
|
|
||||||
|
response = await acp_agent_loop.ext_method(
|
||||||
|
"trust/status", {"cwd": str(tmp_working_directory)}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response == {
|
||||||
|
"trust_status": "untrusted",
|
||||||
|
"details": {
|
||||||
|
"cwd": str(tmp_working_directory.resolve()),
|
||||||
|
"repoRoot": None,
|
||||||
|
"ignoredFiles": ["AGENTS.md"],
|
||||||
|
"availableDecisions": ["trust_cwd", "trust_session", "decline"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_workspace_trust_decision_trusts_session_and_reloads_session(
|
||||||
|
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||||
|
) -> None:
|
||||||
|
(tmp_working_directory / "AGENTS.md").write_text(
|
||||||
|
"Reloaded session instructions", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
session_response = await acp_agent_loop.new_session(
|
||||||
|
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||||
|
)
|
||||||
|
assert session_response.session_id is not None
|
||||||
|
assert "Reloaded session instructions" not in await _system_prompt(
|
||||||
|
acp_agent_loop, session_response.session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await acp_agent_loop.ext_method(
|
||||||
|
"trust/decision",
|
||||||
|
{
|
||||||
|
"cwd": str(tmp_working_directory),
|
||||||
|
"decision": "trust_session",
|
||||||
|
"session_id": session_response.session_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response == {"trust_status": "session", "details": None}
|
||||||
|
normalized = str(tmp_working_directory.resolve())
|
||||||
|
assert normalized in trusted_folders_manager._session_trusted
|
||||||
|
assert normalized not in trusted_folders_manager._trusted
|
||||||
|
assert "Reloaded session instructions" in await _system_prompt(
|
||||||
|
acp_agent_loop, session_response.session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_workspace_trust_decision_rejects_unavailable_decision(
|
||||||
|
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||||
|
) -> None:
|
||||||
|
(tmp_working_directory / "AGENTS.md").write_text(
|
||||||
|
"No repo decision here", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidRequestError):
|
||||||
|
await acp_agent_loop.ext_method(
|
||||||
|
"trust/decision",
|
||||||
|
{"cwd": str(tmp_working_directory), "decision": "trust_repo"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_workspace_trust_decision_rejects_unknown_session_id(
|
||||||
|
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||||
|
) -> None:
|
||||||
|
(tmp_working_directory / "AGENTS.md").write_text(
|
||||||
|
"Unknown session", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(SessionNotFoundError):
|
||||||
|
await acp_agent_loop.ext_method(
|
||||||
|
"trust/decision",
|
||||||
|
{
|
||||||
|
"cwd": str(tmp_working_directory),
|
||||||
|
"decision": "trust_session",
|
||||||
|
"session_id": "missing-session",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
||||||
181
tests/agent_loop/e2e/conftest.py
Normal file
181
tests/agent_loop/e2e/conftest.py
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
|
||||||
|
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.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)
|
||||||
|
def _git_offline(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# Restrict git to the local file protocol so no test can fetch/push over the
|
||||||
|
# network, even if a remote is misconfigured to a real URL.
|
||||||
|
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=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 build_e2e_agent_loop(
|
||||||
|
*, config: VibeConfig | None = None, enable_streaming: bool = False, **kwargs: Any
|
||||||
|
) -> AgentLoop:
|
||||||
|
resolved_config = config or e2e_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
|
||||||
|
return build_test_agent_loop(
|
||||||
|
config=resolved_config,
|
||||||
|
agent_name=kwargs.pop("agent_name", BuiltinAgentName.AUTO_APPROVE),
|
||||||
|
backend=BACKEND_FACTORY[provider.backend](provider=provider),
|
||||||
|
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)
|
||||||
111
tests/agent_loop/e2e/test_e2e_agent_loop.py
Normal file
111
tests/agent_loop/e2e/test_e2e_agent_loop.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.agent_loop.e2e.conftest import (
|
||||||
|
MistralAPI,
|
||||||
|
assistant_text,
|
||||||
|
build_e2e_agent_loop,
|
||||||
|
e2e_config,
|
||||||
|
)
|
||||||
|
from tests.backend.data.mistral import (
|
||||||
|
STREAMED_SIMPLE_CONVERSATION_PARAMS,
|
||||||
|
mistral_completion,
|
||||||
|
)
|
||||||
|
from vibe.core.types import (
|
||||||
|
AssistantEvent,
|
||||||
|
ToolCallEvent,
|
||||||
|
ToolResultEvent,
|
||||||
|
UserMessageEvent,
|
||||||
|
)
|
||||||
|
|
||||||
|
TODO_TOOL_CALL = [
|
||||||
|
{
|
||||||
|
"id": "call_todo_1",
|
||||||
|
"function": {"name": "todo", "arguments": '{"action": "read"}'},
|
||||||
|
"index": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _chunked_completion(reasoning: str, answer: str) -> dict[str, Any]:
|
||||||
|
completion = mistral_completion(answer)
|
||||||
|
completion["choices"][0]["message"]["content"] = [
|
||||||
|
{"type": "thinking", "thinking": [{"type": "text", "text": reasoning}]},
|
||||||
|
{"type": "text", "text": answer},
|
||||||
|
]
|
||||||
|
return completion
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_act_completes_against_real_backend(mistral_api: MistralAPI) -> None:
|
||||||
|
# A plain prompt yields a user event then the backend's assistant reply, and
|
||||||
|
# the loop accumulates prompt+completion tokens from the usage block.
|
||||||
|
mistral_api.reply(
|
||||||
|
mistral_completion("Hi there!", prompt_tokens=120, completion_tokens=30)
|
||||||
|
)
|
||||||
|
agent = build_e2e_agent_loop()
|
||||||
|
|
||||||
|
events = [event async for event in agent.act("Hello")]
|
||||||
|
|
||||||
|
assert [type(e) for e in events] == [UserMessageEvent, AssistantEvent]
|
||||||
|
assert isinstance(events[1], AssistantEvent)
|
||||||
|
assert events[1].content == "Hi there!"
|
||||||
|
assert agent.stats.context_tokens == 150
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_act_streaming(mistral_api: MistralAPI) -> None:
|
||||||
|
# Streamed SSE chunks are reassembled into the final assistant content.
|
||||||
|
_, chunks, _ = STREAMED_SIMPLE_CONVERSATION_PARAMS[0]
|
||||||
|
mistral_api.reply_stream(chunks)
|
||||||
|
agent = build_e2e_agent_loop(enable_streaming=True)
|
||||||
|
|
||||||
|
events = [event async for event in agent.act("Hi")]
|
||||||
|
|
||||||
|
assert assistant_text(events).endswith("Some content")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_act_tool_call_round_trip(mistral_api: MistralAPI) -> None:
|
||||||
|
# A tool call is parsed, executed, and its result fed back for a final reply.
|
||||||
|
mistral_api.reply(
|
||||||
|
mistral_completion("", tool_calls=TODO_TOOL_CALL),
|
||||||
|
mistral_completion("All done"),
|
||||||
|
)
|
||||||
|
agent = build_e2e_agent_loop(config=e2e_config(enabled_tools=["todo"]))
|
||||||
|
|
||||||
|
events = [event async for event in agent.act("Show my todos")]
|
||||||
|
|
||||||
|
assert any(isinstance(e, ToolCallEvent) for e in events)
|
||||||
|
assert any(isinstance(e, ToolResultEvent) for e in events)
|
||||||
|
final = [e for e in events if isinstance(e, AssistantEvent)]
|
||||||
|
assert final and final[-1].content == "All done"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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"]))
|
||||||
|
|
||||||
|
_ = [event async for event in agent.act("Hello")]
|
||||||
|
|
||||||
|
names = {t["function"]["name"] for t in mistral_api.request_json.get("tools", [])}
|
||||||
|
assert "todo" in names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_act_extracts_text_from_chunked_content_array(
|
||||||
|
mistral_api: MistralAPI,
|
||||||
|
) -> None:
|
||||||
|
# A thinking+text content array has its text part extracted as the reply.
|
||||||
|
mistral_api.reply(_chunked_completion("thinking hard", "the answer"))
|
||||||
|
agent = build_e2e_agent_loop()
|
||||||
|
|
||||||
|
events = [event async for event in agent.act("Why?")]
|
||||||
|
|
||||||
|
assistant = [e for e in events if isinstance(e, AssistantEvent)]
|
||||||
|
assert assistant and assistant[-1].content == "the answer"
|
||||||
170
tests/agent_loop/e2e/test_e2e_bash.py
Normal file
170
tests/agent_loop/e2e/test_e2e_bash.py
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
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.backend.data.mistral import mistral_completion
|
||||||
|
from vibe.core.agents.models import BuiltinAgentName
|
||||||
|
from vibe.core.tools.builtins.bash import BashResult
|
||||||
|
from vibe.core.tools.permissions import RequiredPermission
|
||||||
|
from vibe.core.types import ApprovalResponse, BaseEvent, ToolResultEvent
|
||||||
|
|
||||||
|
|
||||||
|
def _bash_call(command: str, timeout: int | None = None) -> dict[str, Any]:
|
||||||
|
arguments: dict[str, Any] = {"command": command}
|
||||||
|
if timeout is not None:
|
||||||
|
arguments["timeout"] = timeout
|
||||||
|
return mistral_completion(
|
||||||
|
"",
|
||||||
|
tool_calls=[
|
||||||
|
{
|
||||||
|
"id": "call_bash",
|
||||||
|
"function": {"name": "bash", "arguments": json.dumps(arguments)},
|
||||||
|
"index": 0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_bash(
|
||||||
|
mistral_api: MistralAPI,
|
||||||
|
command: str,
|
||||||
|
*,
|
||||||
|
timeout: int | None = None,
|
||||||
|
agent_name: str = BuiltinAgentName.AUTO_APPROVE,
|
||||||
|
approval: ApprovalResponse | None = None,
|
||||||
|
) -> 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
|
||||||
|
)
|
||||||
|
if approval is not None:
|
||||||
|
|
||||||
|
async def approval_callback(
|
||||||
|
_tool_name: str,
|
||||||
|
_args: BaseModel,
|
||||||
|
_tool_call_id: str,
|
||||||
|
_rp: list[RequiredPermission] | None = None,
|
||||||
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
|
return (approval, None)
|
||||||
|
|
||||||
|
agent.set_approval_callback(approval_callback)
|
||||||
|
|
||||||
|
events: list[BaseEvent] = [event async for event in agent.act("go")]
|
||||||
|
return next(e for e in events if isinstance(e, ToolResultEvent))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bash_captures_stdout(mistral_api: MistralAPI) -> None:
|
||||||
|
result = await _run_bash(mistral_api, "echo hello")
|
||||||
|
|
||||||
|
bash_result = cast(BashResult, result.result)
|
||||||
|
assert bash_result.returncode == 0
|
||||||
|
assert "hello" in bash_result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bash_captures_stderr(mistral_api: MistralAPI) -> None:
|
||||||
|
result = await _run_bash(mistral_api, "echo oops >&2")
|
||||||
|
|
||||||
|
bash_result = cast(BashResult, result.result)
|
||||||
|
assert "oops" in bash_result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bash_nonzero_exit_surfaces_as_error(mistral_api: MistralAPI) -> None:
|
||||||
|
result = await _run_bash(mistral_api, "exit 3")
|
||||||
|
|
||||||
|
assert result.error is not None
|
||||||
|
assert "Return code: 3" in result.error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bash_timeout_surfaces_as_error(mistral_api: MistralAPI) -> None:
|
||||||
|
result = await _run_bash(mistral_api, "sleep 5", timeout=1)
|
||||||
|
|
||||||
|
assert result.error is not None
|
||||||
|
assert "timed out" in result.error.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bash_output_truncated_to_max_bytes(mistral_api: MistralAPI) -> None:
|
||||||
|
result = await _run_bash(mistral_api, "yes x | head -c 100000")
|
||||||
|
|
||||||
|
bash_result = cast(BashResult, result.result)
|
||||||
|
assert len(bash_result.stdout) <= 16_000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bash_denylisted_command_is_skipped(mistral_api: MistralAPI) -> None:
|
||||||
|
result = await _run_bash(
|
||||||
|
mistral_api, "vim file.txt", agent_name=BuiltinAgentName.DEFAULT
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.skipped is True
|
||||||
|
assert result.skip_reason is not None
|
||||||
|
assert "denied" in result.skip_reason.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bash_allowlisted_command_runs_without_approval(
|
||||||
|
mistral_api: MistralAPI,
|
||||||
|
) -> None:
|
||||||
|
# No approval callback registered; an allowlisted command must run anyway.
|
||||||
|
result = await _run_bash(
|
||||||
|
mistral_api, "echo allowed", agent_name=BuiltinAgentName.DEFAULT
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.skipped is False
|
||||||
|
assert "allowed" in cast(BashResult, result.result).stdout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bash_non_allowlisted_command_requires_approval(
|
||||||
|
mistral_api: MistralAPI,
|
||||||
|
) -> None:
|
||||||
|
result = await _run_bash(
|
||||||
|
mistral_api,
|
||||||
|
"touch newfile.txt",
|
||||||
|
agent_name=BuiltinAgentName.DEFAULT,
|
||||||
|
approval=ApprovalResponse.YES,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.skipped is False
|
||||||
|
assert (Path.cwd() / "newfile.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bash_non_allowlisted_command_denied_at_prompt_is_skipped(
|
||||||
|
mistral_api: MistralAPI,
|
||||||
|
) -> None:
|
||||||
|
result = await _run_bash(
|
||||||
|
mistral_api,
|
||||||
|
"touch denied.txt",
|
||||||
|
agent_name=BuiltinAgentName.DEFAULT,
|
||||||
|
approval=ApprovalResponse.NO,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.skipped is True
|
||||||
|
assert not (Path.cwd() / "denied.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bash_command_touching_outside_workdir_requires_approval(
|
||||||
|
mistral_api: MistralAPI, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
outside = tmp_path / "outside.txt"
|
||||||
|
result = await _run_bash(
|
||||||
|
mistral_api,
|
||||||
|
f"touch {outside}",
|
||||||
|
agent_name=BuiltinAgentName.DEFAULT,
|
||||||
|
approval=ApprovalResponse.NO,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.skipped is True
|
||||||
|
assert not outside.exists()
|
||||||
136
tests/agent_loop/e2e/test_e2e_connectors.py
Normal file
136
tests/agent_loop/e2e/test_e2e_connectors.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop, e2e_config
|
||||||
|
from tests.backend.data.mistral import mistral_completion
|
||||||
|
from tests.constants import CONNECTORS_BOOTSTRAP_PATH
|
||||||
|
from vibe.core.config import ConnectorConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _connector(
|
||||||
|
*,
|
||||||
|
connector_id: str = "conn-1",
|
||||||
|
name: str = "wiki",
|
||||||
|
is_ready: bool = True,
|
||||||
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
auth_action: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": connector_id,
|
||||||
|
"name": name,
|
||||||
|
"status": {"is_ready": is_ready},
|
||||||
|
"tools": tools or [],
|
||||||
|
"auth_action": auth_action,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tool(name: str = "search", description: str = "Search docs") -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _set_bootstrap(router: respx.MockRouter, connectors: list[dict[str, Any]]) -> None:
|
||||||
|
router.get(CONNECTORS_BOOTSTRAP_PATH).mock(
|
||||||
|
return_value=httpx.Response(200, json={"connectors": connectors})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _connectors_config(*aliases: str) -> Any:
|
||||||
|
return e2e_config(
|
||||||
|
connectors=[ConnectorConfig(name=alias, disabled=False) for alias in aliases]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _offered_tool_names(
|
||||||
|
mistral_api: MistralAPI, *enabled_connectors: str
|
||||||
|
) -> set[str]:
|
||||||
|
# Drive one turn and read which tools the AgentLoop serialized into the
|
||||||
|
# outgoing chat-completions request — the only connector-visible surface.
|
||||||
|
mistral_api.reply(mistral_completion("ok"))
|
||||||
|
agent = build_e2e_agent_loop(config=_connectors_config(*enabled_connectors))
|
||||||
|
_ = [event async for event in agent.act("hello")]
|
||||||
|
return {t["function"]["name"] for t in mistral_api.request_json.get("tools", [])}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connector_tools_are_offered_to_the_model(
|
||||||
|
mistral_api: MistralAPI, mock_mistral: respx.MockRouter
|
||||||
|
) -> None:
|
||||||
|
_set_bootstrap(
|
||||||
|
mock_mistral, [_connector(name="wiki", tools=[_tool("search"), _tool("read")])]
|
||||||
|
)
|
||||||
|
|
||||||
|
names = await _offered_tool_names(mistral_api, "wiki")
|
||||||
|
|
||||||
|
assert "connector_wiki_search" in names
|
||||||
|
assert "connector_wiki_read" in names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_colliding_connector_aliases_are_disambiguated(
|
||||||
|
mistral_api: MistralAPI, mock_mistral: respx.MockRouter
|
||||||
|
) -> None:
|
||||||
|
_set_bootstrap(
|
||||||
|
mock_mistral,
|
||||||
|
[
|
||||||
|
_connector(connector_id="c-1", name="mcp", tools=[_tool("a")]),
|
||||||
|
_connector(connector_id="c-2", name="mcp", tools=[_tool("b")]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
names = await _offered_tool_names(mistral_api, "mcp", "mcp_2")
|
||||||
|
|
||||||
|
assert "connector_mcp_a" in names
|
||||||
|
assert "connector_mcp_2_b" in names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_not_ready_connector_offers_no_tools(
|
||||||
|
mistral_api: MistralAPI, mock_mistral: respx.MockRouter
|
||||||
|
) -> None:
|
||||||
|
_set_bootstrap(
|
||||||
|
mock_mistral,
|
||||||
|
[
|
||||||
|
_connector(
|
||||||
|
name="linear",
|
||||||
|
is_ready=False,
|
||||||
|
tools=[_tool("search")],
|
||||||
|
auth_action={"type": "oauth"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
names = await _offered_tool_names(mistral_api, "linear")
|
||||||
|
|
||||||
|
assert not any(name.startswith("connector_linear") for name in names)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disabled_connector_tools_are_withheld(
|
||||||
|
mistral_api: MistralAPI, mock_mistral: respx.MockRouter
|
||||||
|
) -> None:
|
||||||
|
_set_bootstrap(mock_mistral, [_connector(name="wiki", tools=[_tool("search")])])
|
||||||
|
|
||||||
|
# No ConnectorConfig entry → the connector stays disabled by default.
|
||||||
|
names = await _offered_tool_names(mistral_api)
|
||||||
|
|
||||||
|
assert "connector_wiki_search" not in names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bootstrap_failure_still_lets_the_agent_run(
|
||||||
|
mistral_api: MistralAPI, mock_mistral: respx.MockRouter
|
||||||
|
) -> None:
|
||||||
|
mock_mistral.get(CONNECTORS_BOOTSTRAP_PATH).mock(return_value=httpx.Response(500))
|
||||||
|
|
||||||
|
names = await _offered_tool_names(mistral_api, "wiki")
|
||||||
|
|
||||||
|
assert not any(name.startswith("connector_") for name in names)
|
||||||
144
tests/agent_loop/e2e/test_e2e_fork_and_plan.py
Normal file
144
tests/agent_loop/e2e/test_e2e_fork_and_plan.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop, e2e_config
|
||||||
|
from tests.backend.data.mistral import mistral_completion
|
||||||
|
from vibe.core.agents.models import BuiltinAgentName
|
||||||
|
from vibe.core.tools.builtins.ask_user_question import (
|
||||||
|
AskUserQuestionArgs,
|
||||||
|
AskUserQuestionResult,
|
||||||
|
)
|
||||||
|
from vibe.core.types import PlanReviewEndedEvent, PlanReviewRequestedEvent, Role
|
||||||
|
from vibe.core.utils.tags import VIBE_WARNING_TAG
|
||||||
|
|
||||||
|
EXIT_PLAN_TOOL_CALL = [
|
||||||
|
{
|
||||||
|
"id": "call_exit_plan_1",
|
||||||
|
"function": {"name": "exit_plan_mode", "arguments": "{}"},
|
||||||
|
"index": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _user_message_ids(agent: Any) -> list[str]:
|
||||||
|
return [m.message_id for m in agent.messages if m.role == Role.user]
|
||||||
|
|
||||||
|
|
||||||
|
def _assistant_message_id(agent: Any) -> str:
|
||||||
|
return next(m.message_id for m in agent.messages if m.role == Role.assistant)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fork_copies_all_non_system_messages(mistral_api: MistralAPI) -> None:
|
||||||
|
# fork() with no anchor clones every non-system message into the child loop.
|
||||||
|
mistral_api.reply(mistral_completion("Hi there!"))
|
||||||
|
agent = build_e2e_agent_loop()
|
||||||
|
_ = [event async for event in agent.act("Hello")]
|
||||||
|
|
||||||
|
forked = await agent.fork()
|
||||||
|
|
||||||
|
non_system = [m for m in forked.messages if m.role != Role.system]
|
||||||
|
assert [m.role for m in non_system] == [Role.user, Role.assistant]
|
||||||
|
assert forked.parent_session_id == agent.session_id
|
||||||
|
assert forked.session_id != agent.session_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fork_from_message_id_truncates_at_next_user_turn(
|
||||||
|
mistral_api: MistralAPI,
|
||||||
|
) -> None:
|
||||||
|
# Forking from a user message keeps that turn but drops everything from the next one.
|
||||||
|
mistral_api.reply(mistral_completion("First"), mistral_completion("Second"))
|
||||||
|
agent = build_e2e_agent_loop()
|
||||||
|
_ = [event async for event in agent.act("Turn one")]
|
||||||
|
_ = [event async for event in agent.act("Turn two")]
|
||||||
|
|
||||||
|
first_user_id = _user_message_ids(agent)[0]
|
||||||
|
forked = await agent.fork(first_user_id)
|
||||||
|
|
||||||
|
contents = [m.content for m in forked.messages if m.role != Role.system]
|
||||||
|
assert contents == ["Turn one", "First"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fork_from_unknown_message_id_raises(mistral_api: MistralAPI) -> None:
|
||||||
|
# An unknown anchor id is rejected rather than silently forking everything.
|
||||||
|
mistral_api.reply(mistral_completion("Hi"))
|
||||||
|
agent = build_e2e_agent_loop()
|
||||||
|
_ = [event async for event in agent.act("Hello")]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="unknown message_id"):
|
||||||
|
await agent.fork("does-not-exist")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fork_from_assistant_message_id_raises(mistral_api: MistralAPI) -> None:
|
||||||
|
# Forking is only allowed from user turns; an assistant anchor is rejected.
|
||||||
|
mistral_api.reply(mistral_completion("Hi"))
|
||||||
|
agent = build_e2e_agent_loop()
|
||||||
|
_ = [event async for event in agent.act("Hello")]
|
||||||
|
|
||||||
|
assistant_id = _assistant_message_id(agent)
|
||||||
|
with pytest.raises(ValueError, match="only supported for user messages"):
|
||||||
|
await agent.fork(assistant_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_agent(mistral_api: MistralAPI) -> Any:
|
||||||
|
mistral_api.reply(
|
||||||
|
mistral_completion("", tool_calls=EXIT_PLAN_TOOL_CALL),
|
||||||
|
mistral_completion("Staying in plan mode."),
|
||||||
|
)
|
||||||
|
return build_e2e_agent_loop(
|
||||||
|
config=e2e_config(enabled_tools=["exit_plan_mode"]),
|
||||||
|
agent_name=BuiltinAgentName.PLAN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_plan_mode_emits_review_requested_and_ended_events(
|
||||||
|
mistral_api: MistralAPI,
|
||||||
|
) -> None:
|
||||||
|
# An exit_plan_mode round trip brackets the tool with review-requested/ended events.
|
||||||
|
agent = _plan_agent(mistral_api)
|
||||||
|
|
||||||
|
async def stay_in_plan(_: AskUserQuestionArgs) -> AskUserQuestionResult:
|
||||||
|
return AskUserQuestionResult(cancelled=True, answers=[])
|
||||||
|
|
||||||
|
agent.set_user_input_callback(stay_in_plan)
|
||||||
|
|
||||||
|
events = [event async for event in agent.act("Make a plan")]
|
||||||
|
|
||||||
|
assert any(isinstance(e, PlanReviewRequestedEvent) for e in events)
|
||||||
|
assert any(isinstance(e, PlanReviewEndedEvent) for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_plan_mode_injects_updated_plan_when_file_changed(
|
||||||
|
mistral_api: MistralAPI,
|
||||||
|
) -> None:
|
||||||
|
# If the plan file changes during review, its new content is injected back as context.
|
||||||
|
agent = _plan_agent(mistral_api)
|
||||||
|
|
||||||
|
plan_path: Path | None = None
|
||||||
|
|
||||||
|
async def edit_plan_then_decline(_: AskUserQuestionArgs) -> AskUserQuestionResult:
|
||||||
|
assert plan_path is not None
|
||||||
|
plan_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
plan_path.write_text("# Updated plan\nStep 1")
|
||||||
|
return AskUserQuestionResult(cancelled=True, answers=[])
|
||||||
|
|
||||||
|
agent.set_user_input_callback(edit_plan_then_decline)
|
||||||
|
|
||||||
|
async for event in agent.act("Make a plan"):
|
||||||
|
if isinstance(event, PlanReviewRequestedEvent):
|
||||||
|
plan_path = event.file_path
|
||||||
|
|
||||||
|
injected = [m for m in agent.messages if getattr(m, "injected", False)]
|
||||||
|
assert any(
|
||||||
|
m.content and VIBE_WARNING_TAG in m.content and "# Updated plan" in m.content
|
||||||
|
for m in injected
|
||||||
|
)
|
||||||
178
tests/agent_loop/e2e/test_e2e_provider_apis.py
Normal file
178
tests/agent_loop/e2e/test_e2e_provider_apis.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.agent_loop.e2e.conftest import (
|
||||||
|
ProviderAPI,
|
||||||
|
anthropic_e2e_config,
|
||||||
|
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 vibe.core.types import ToolResultEvent
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnthropic:
|
||||||
|
@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())
|
||||||
|
|
||||||
|
events = [event async for event in agent.act("Reply with exactly: pong")]
|
||||||
|
|
||||||
|
assert assistant_text(events) == "pong"
|
||||||
|
assert agent.stats.context_tokens == 15
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
|
||||||
|
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"])
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
) -> 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())
|
||||||
|
|
||||||
|
events = [event async for event in agent.act("Reply with exactly: pong")]
|
||||||
|
|
||||||
|
assert assistant_text(events) == "pong"
|
||||||
|
assert any(
|
||||||
|
m.reasoning_content and "Let me think." in m.reasoning_content
|
||||||
|
for m in agent.messages
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_agent_executes_tool_call(
|
||||||
|
self, openai_responses_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.")]),
|
||||||
|
)
|
||||||
|
agent = build_e2e_agent_loop(
|
||||||
|
config=openai_responses_e2e_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, openai_responses_api: ProviderAPI
|
||||||
|
) -> 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
224
tests/agent_loop/e2e/test_e2e_teleport.py
Normal file
224
tests/agent_loop/e2e/test_e2e_teleport.py
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from git import Repo
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from tests.agent_loop.e2e.conftest import build_e2e_agent_loop
|
||||||
|
from tests.constants import (
|
||||||
|
CONNECTORS_BOOTSTRAP_PATH,
|
||||||
|
MISTRAL_BASE_URL,
|
||||||
|
TELEPORT_COMPLETE_URL,
|
||||||
|
TELEPORT_SESSIONS_PATH,
|
||||||
|
)
|
||||||
|
from vibe.core.agent_loop import AgentLoop, TeleportError
|
||||||
|
from vibe.core.teleport.types import (
|
||||||
|
TeleportCheckingGitEvent,
|
||||||
|
TeleportCompleteEvent,
|
||||||
|
TeleportPushingEvent,
|
||||||
|
TeleportPushRequiredEvent,
|
||||||
|
TeleportPushResponseEvent,
|
||||||
|
TeleportStartingWorkflowEvent,
|
||||||
|
)
|
||||||
|
|
||||||
|
# vibe_code_sessions_base_url is an exclude=True field, so it is dropped when the
|
||||||
|
# agent manager re-derives config via model_dump(); the default url is unavoidable.
|
||||||
|
SESSIONS_BASE_URL = "https://chat.mistral.ai"
|
||||||
|
SESSIONS_URL = f"{SESSIONS_BASE_URL}{TELEPORT_SESSIONS_PATH}"
|
||||||
|
|
||||||
|
|
||||||
|
def _sessions_ok() -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"sessionId": "controller-session-id",
|
||||||
|
"webSessionId": "web-session-id",
|
||||||
|
"projectId": "project-id",
|
||||||
|
"status": "running",
|
||||||
|
"url": TELEPORT_COMPLETE_URL,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _commit(repo: Repo, message: str) -> str:
|
||||||
|
(Path(repo.working_dir) / "file.txt").write_text(f"{message}\n")
|
||||||
|
repo.index.add(["file.txt"])
|
||||||
|
repo.index.commit(message)
|
||||||
|
return repo.head.commit.hexsha
|
||||||
|
|
||||||
|
|
||||||
|
def _init_repo(workdir: Path) -> Repo:
|
||||||
|
# origin is a local bare repo so fetch/push stay offline and instant; a
|
||||||
|
# separate github-url remote satisfies Teleport's GitHub-only detection.
|
||||||
|
bare = Repo.init(workdir.with_name(f"{workdir.name}_origin.git"), bare=True)
|
||||||
|
repo = Repo.init(workdir, initial_branch="work")
|
||||||
|
repo.config_writer().set_value("user", "name", "Tester").release()
|
||||||
|
repo.config_writer().set_value("user", "email", "t@example.com").release()
|
||||||
|
repo.create_remote("origin", str(bare.git_dir))
|
||||||
|
repo.create_remote("hub", "https://github.com/owner/repo.git")
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_with_pushed_branch(workdir: Path) -> Repo:
|
||||||
|
repo = _init_repo(workdir)
|
||||||
|
_commit(repo, "initial")
|
||||||
|
repo.git.push("origin", "work")
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_sessions() -> Iterator[respx.MockRouter]:
|
||||||
|
# One router stubs both hosts: the connector bootstrap (api.mistral.ai, hit on
|
||||||
|
# agent init) and the teleport sessions endpoint (chat.mistral.ai).
|
||||||
|
with respx.mock(assert_all_called=False) as router:
|
||||||
|
router.get(f"{MISTRAL_BASE_URL}{CONNECTORS_BOOTSTRAP_PATH}").mock(
|
||||||
|
return_value=httpx.Response(200, json={"connectors": []})
|
||||||
|
)
|
||||||
|
router.post(SESSIONS_URL).mock(return_value=_sessions_ok())
|
||||||
|
yield router
|
||||||
|
|
||||||
|
|
||||||
|
async def _drain(
|
||||||
|
agent: AgentLoop, prompt: str | None, *, approve: bool | None = None
|
||||||
|
) -> list[object]:
|
||||||
|
gen = agent.teleport_to_vibe_code(prompt)
|
||||||
|
events: list[object] = []
|
||||||
|
response: TeleportPushResponseEvent | None = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event = await gen.asend(response)
|
||||||
|
except StopAsyncIteration:
|
||||||
|
break
|
||||||
|
events.append(event)
|
||||||
|
response = None
|
||||||
|
if isinstance(event, TeleportPushRequiredEvent) and approve is not None:
|
||||||
|
response = TeleportPushResponseEvent(approved=approve)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_teleport_completes_when_branch_already_pushed(
|
||||||
|
tmp_working_directory: Path, mock_sessions: respx.MockRouter
|
||||||
|
) -> None:
|
||||||
|
_repo_with_pushed_branch(tmp_working_directory)
|
||||||
|
|
||||||
|
events = await _drain(build_e2e_agent_loop(), "do the thing")
|
||||||
|
|
||||||
|
assert [type(e) for e in events] == [
|
||||||
|
TeleportCheckingGitEvent,
|
||||||
|
TeleportStartingWorkflowEvent,
|
||||||
|
TeleportCompleteEvent,
|
||||||
|
]
|
||||||
|
assert isinstance(events[-1], TeleportCompleteEvent)
|
||||||
|
assert events[-1].url == TELEPORT_COMPLETE_URL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_teleport_sends_repo_metadata_and_diff(
|
||||||
|
tmp_working_directory: Path, mock_sessions: respx.MockRouter
|
||||||
|
) -> None:
|
||||||
|
repo = _repo_with_pushed_branch(tmp_working_directory)
|
||||||
|
commit = repo.head.commit.hexsha
|
||||||
|
(tmp_working_directory / "file.txt").write_text("uncommitted change\n")
|
||||||
|
|
||||||
|
await _drain(build_e2e_agent_loop(), "ship it")
|
||||||
|
|
||||||
|
payload = mock_sessions.calls.last.request.read().decode()
|
||||||
|
assert "https://github.com/owner/repo.git" in payload
|
||||||
|
assert "work" in payload
|
||||||
|
assert commit in payload
|
||||||
|
assert "zstd" in payload
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_teleport_pushes_then_completes_when_approved(
|
||||||
|
tmp_working_directory: Path, mock_sessions: respx.MockRouter
|
||||||
|
) -> None:
|
||||||
|
repo = _repo_with_pushed_branch(tmp_working_directory)
|
||||||
|
head = _commit(repo, "second")
|
||||||
|
|
||||||
|
events = await _drain(build_e2e_agent_loop(), "ship it", approve=True)
|
||||||
|
|
||||||
|
assert [type(e) for e in events] == [
|
||||||
|
TeleportCheckingGitEvent,
|
||||||
|
TeleportPushRequiredEvent,
|
||||||
|
TeleportPushingEvent,
|
||||||
|
TeleportStartingWorkflowEvent,
|
||||||
|
TeleportCompleteEvent,
|
||||||
|
]
|
||||||
|
assert repo.remote("origin").refs["work"].commit.hexsha == head
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_teleport_aborts_when_push_declined(
|
||||||
|
tmp_working_directory: Path, mock_sessions: respx.MockRouter
|
||||||
|
) -> None:
|
||||||
|
repo = _repo_with_pushed_branch(tmp_working_directory)
|
||||||
|
_commit(repo, "second")
|
||||||
|
|
||||||
|
with pytest.raises(TeleportError, match="not pushed"):
|
||||||
|
await _drain(build_e2e_agent_loop(), "ship it", approve=False)
|
||||||
|
|
||||||
|
assert not mock_sessions.post(SESSIONS_URL).called
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_teleport_fails_when_push_fails(
|
||||||
|
tmp_working_directory: Path, mock_sessions: respx.MockRouter
|
||||||
|
) -> None:
|
||||||
|
repo = _repo_with_pushed_branch(tmp_working_directory)
|
||||||
|
_commit(repo, "second")
|
||||||
|
repo.git.remote("set-url", "--push", "origin", "/nonexistent/repo.git")
|
||||||
|
|
||||||
|
with pytest.raises(TeleportError, match="Failed to push"):
|
||||||
|
await _drain(build_e2e_agent_loop(), "ship it", approve=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_teleport_requires_a_branch(
|
||||||
|
tmp_working_directory: Path, mock_sessions: respx.MockRouter
|
||||||
|
) -> None:
|
||||||
|
repo = _repo_with_pushed_branch(tmp_working_directory)
|
||||||
|
repo.git.checkout(repo.head.commit.hexsha) # detach HEAD
|
||||||
|
|
||||||
|
with pytest.raises(TeleportError, match="checked-out branch"):
|
||||||
|
await _drain(build_e2e_agent_loop(), "ship it")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_teleport_rejects_empty_prompt(
|
||||||
|
tmp_working_directory: Path, mock_sessions: respx.MockRouter
|
||||||
|
) -> None:
|
||||||
|
repo = _init_repo(tmp_working_directory)
|
||||||
|
_commit(repo, "initial")
|
||||||
|
|
||||||
|
with pytest.raises(TeleportError, match="non-empty prompt"):
|
||||||
|
await _drain(build_e2e_agent_loop(), None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_teleport_surfaces_http_error(
|
||||||
|
tmp_working_directory: Path, mock_sessions: respx.MockRouter
|
||||||
|
) -> None:
|
||||||
|
_repo_with_pushed_branch(tmp_working_directory)
|
||||||
|
mock_sessions.post(SESSIONS_URL).mock(return_value=httpx.Response(500, text="boom"))
|
||||||
|
|
||||||
|
with pytest.raises(TeleportError, match="start failed"):
|
||||||
|
await _drain(build_e2e_agent_loop(), "ship it")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_teleport_unsupported_without_github_remote(
|
||||||
|
tmp_working_directory: Path, mock_sessions: respx.MockRouter
|
||||||
|
) -> None:
|
||||||
|
repo = Repo.init(tmp_working_directory, initial_branch="work")
|
||||||
|
repo.config_writer().set_value("user", "name", "Tester").release()
|
||||||
|
repo.config_writer().set_value("user", "email", "t@example.com").release()
|
||||||
|
_commit(repo, "initial")
|
||||||
|
|
||||||
|
with pytest.raises(TeleportError, match="GitHub"):
|
||||||
|
await _drain(build_e2e_agent_loop(), "ship it")
|
||||||
93
tests/agent_loop/e2e/test_e2e_tools.py
Normal file
93
tests/agent_loop/e2e/test_e2e_tools.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop, e2e_config
|
||||||
|
from tests.backend.data.mistral import mistral_completion
|
||||||
|
from vibe.core.tools.builtins.grep import GrepResult
|
||||||
|
from vibe.core.tools.builtins.read import ReadResult
|
||||||
|
from vibe.core.tools.builtins.todo import TodoResult
|
||||||
|
from vibe.core.types import BaseEvent, ToolResultEvent
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_call(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return mistral_completion(
|
||||||
|
"",
|
||||||
|
tool_calls=[
|
||||||
|
{
|
||||||
|
"id": f"call_{name}",
|
||||||
|
"function": {"name": name, "arguments": json.dumps(arguments)},
|
||||||
|
"index": 0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_tool(
|
||||||
|
mistral_api: MistralAPI, name: str, arguments: dict[str, Any]
|
||||||
|
) -> ToolResultEvent:
|
||||||
|
# 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]))
|
||||||
|
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
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_file_tool_creates_file(mistral_api: MistralAPI) -> None:
|
||||||
|
target = Path.cwd() / "note.txt"
|
||||||
|
|
||||||
|
await _run_tool(mistral_api, "write_file", {"path": str(target), "content": "hi\n"})
|
||||||
|
|
||||||
|
assert target.read_text() == "hi\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_tool_returns_file_content(mistral_api: MistralAPI) -> None:
|
||||||
|
target = Path.cwd() / "note.txt"
|
||||||
|
target.write_text("hello world\n")
|
||||||
|
|
||||||
|
result = await _run_tool(mistral_api, "read", {"file_path": str(target)})
|
||||||
|
|
||||||
|
assert "hello world" in cast(ReadResult, result.result).content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_edit_tool_replaces_text(mistral_api: MistralAPI) -> None:
|
||||||
|
target = Path.cwd() / "note.txt"
|
||||||
|
target.write_text("hello world\n")
|
||||||
|
|
||||||
|
await _run_tool(
|
||||||
|
mistral_api,
|
||||||
|
"edit",
|
||||||
|
{"file_path": str(target), "old_string": "hello", "new_string": "goodbye"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert target.read_text() == "goodbye world\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grep_tool_finds_matches(mistral_api: MistralAPI) -> None:
|
||||||
|
(Path.cwd() / "note.txt").write_text("needle here\n")
|
||||||
|
|
||||||
|
result = await _run_tool(mistral_api, "grep", {"pattern": "needle", "path": "."})
|
||||||
|
|
||||||
|
assert cast(GrepResult, result.result).match_count >= 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_todo_tool_writes_items(mistral_api: MistralAPI) -> None:
|
||||||
|
result = await _run_tool(
|
||||||
|
mistral_api,
|
||||||
|
"todo",
|
||||||
|
{"action": "write", "todos": [{"id": "1", "content": "ship it"}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cast(TodoResult, result.result).total_count == 1
|
||||||
|
|
@ -9,7 +9,7 @@ from tests.mock.utils import mock_llm_chunk
|
||||||
from tests.stubs.fake_backend import FakeBackend
|
from tests.stubs.fake_backend import FakeBackend
|
||||||
from vibe.core.agent_loop import ImagesNotSupportedError
|
from vibe.core.agent_loop import ImagesNotSupportedError
|
||||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||||
from vibe.core.types import Backend, ImageAttachment, LLMMessage, Role
|
from vibe.core.types import Backend, FileImageSource, ImageAttachment, LLMMessage, Role
|
||||||
|
|
||||||
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
||||||
|
|
||||||
|
|
@ -68,7 +68,9 @@ def _config_with_both_models() -> VibeConfig:
|
||||||
def png_attachment(tmp_path: Path) -> ImageAttachment:
|
def png_attachment(tmp_path: Path) -> ImageAttachment:
|
||||||
p = tmp_path / "x.png"
|
p = tmp_path / "x.png"
|
||||||
p.write_bytes(PNG_BYTES)
|
p.write_bytes(PNG_BYTES)
|
||||||
return ImageAttachment(path=p, alias="x.png", mime_type="image/png")
|
return ImageAttachment(
|
||||||
|
source=FileImageSource(path=p), alias="x.png", mime_type="image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -18,6 +18,7 @@ from vibe.core.agent_loop import AgentLoop
|
||||||
from vibe.core.config import MCPStdio
|
from vibe.core.config import MCPStdio
|
||||||
from vibe.core.telemetry.types import TerminalEmulator
|
from vibe.core.telemetry.types import TerminalEmulator
|
||||||
from vibe.core.tools.manager import ToolManager
|
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.mcp.tools import RemoteTool
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -157,6 +158,23 @@ class TestIntegrateMcpIdempotency:
|
||||||
# so a future call with servers would still run discovery.
|
# so a future call with servers would still run discovery.
|
||||||
assert not manager._mcp_integrated
|
assert not manager._mcp_integrated
|
||||||
|
|
||||||
|
def test_no_servers_syncs_shared_registry_status(self) -> None:
|
||||||
|
config = build_test_vibe_config(
|
||||||
|
mcp_servers=[MCPStdio(name="srv", transport="stdio", command="echo")]
|
||||||
|
)
|
||||||
|
registry = FakeMCPRegistry()
|
||||||
|
manager = ToolManager(lambda: config, mcp_registry=registry, defer_mcp=True)
|
||||||
|
|
||||||
|
manager.integrate_mcp()
|
||||||
|
assert registry.status() == {"srv": AuthStatus.STDIO}
|
||||||
|
|
||||||
|
config = build_test_vibe_config(mcp_servers=[])
|
||||||
|
manager = ToolManager(lambda: config, mcp_registry=registry, defer_mcp=True)
|
||||||
|
manager.integrate_mcp()
|
||||||
|
|
||||||
|
assert registry.status() == {}
|
||||||
|
assert not manager._mcp_integrated
|
||||||
|
|
||||||
|
|
||||||
class TestRefreshRemoteTools:
|
class TestRefreshRemoteTools:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -24,7 +24,9 @@ class StubView(CompletionView):
|
||||||
def clear_completion_suggestions(self) -> None:
|
def clear_completion_suggestions(self) -> None:
|
||||||
self.clears += 1
|
self.clears += 1
|
||||||
|
|
||||||
def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
|
def replace_completion_range(
|
||||||
|
self, start: int, end: int, replacement: str, *, suppress_update: bool = False
|
||||||
|
) -> None:
|
||||||
self.replacements.append((start, end, replacement))
|
self.replacements.append((start, end, replacement))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,9 @@ class StubView(CompletionView):
|
||||||
def clear_completion_suggestions(self) -> None:
|
def clear_completion_suggestions(self) -> None:
|
||||||
self.reset_count += 1
|
self.reset_count += 1
|
||||||
|
|
||||||
def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
|
def replace_completion_range(
|
||||||
|
self, start: int, end: int, replacement: str, *, suppress_update: bool = False
|
||||||
|
) -> None:
|
||||||
self.replacements.append(Replacement(start, end, replacement))
|
self.replacements.append(Replacement(start, end, replacement))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,13 @@ from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from textual.content import Content
|
|
||||||
from textual.style import Style
|
|
||||||
from textual.widgets import Markdown
|
from textual.widgets import Markdown
|
||||||
|
|
||||||
from vibe.cli.textual_ui.app import VibeApp
|
from vibe.cli.textual_ui.app import VibeApp
|
||||||
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import (
|
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import (
|
||||||
CompletionPopup,
|
CompletionPopup,
|
||||||
_CompletionItem,
|
_CompletionItem,
|
||||||
|
_CompletionRow,
|
||||||
)
|
)
|
||||||
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
|
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
|
||||||
|
|
||||||
|
|
@ -42,7 +41,7 @@ async def test_popup_hides_when_input_cleared(vibe_app: VibeApp) -> None:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pressing_tab_writes_selected_command_and_leaves_popup_visible(
|
async def test_pressing_tab_completes_command_and_hides_popup_when_exact_match(
|
||||||
vibe_app: VibeApp,
|
vibe_app: VibeApp,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with vibe_app.run_test() as pilot:
|
async with vibe_app.run_test() as pilot:
|
||||||
|
|
@ -53,24 +52,18 @@ async def test_pressing_tab_writes_selected_command_and_leaves_popup_visible(
|
||||||
await pilot.press("tab")
|
await pilot.press("tab")
|
||||||
|
|
||||||
assert chat_input.value == "/config"
|
assert chat_input.value == "/config"
|
||||||
assert popup.styles.display == "block"
|
assert popup.styles.display == "none"
|
||||||
|
|
||||||
|
|
||||||
def ensure_selected_command(popup: CompletionPopup, expected_alias: str) -> None:
|
def ensure_selected_command(popup: CompletionPopup, expected_alias: str) -> None:
|
||||||
selected_aliases: list[str] = []
|
selected_rows = [
|
||||||
for item in popup.query(_CompletionItem):
|
row
|
||||||
renderable = item.render()
|
for row in popup.query(_CompletionRow)
|
||||||
assert isinstance(renderable, Content)
|
if row.has_class("completion-selected")
|
||||||
content = str(renderable)
|
]
|
||||||
for span in renderable.spans:
|
assert len(selected_rows) == 1
|
||||||
style = span.style
|
command = selected_rows[0].query_one(".completion-command", _CompletionItem)
|
||||||
if isinstance(style, Style) and style.reverse:
|
assert str(command.render()).strip() == expected_alias
|
||||||
alias_text = content[span.start : span.end].strip()
|
|
||||||
alias = alias_text.split()[0] if alias_text else ""
|
|
||||||
selected_aliases.append(alias)
|
|
||||||
|
|
||||||
assert len(selected_aliases) == 1
|
|
||||||
assert selected_aliases[0] == expected_alias
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -297,20 +290,6 @@ async def test_finds_files_recursively_with_partial_path(
|
||||||
assert popup.styles.display == "block"
|
assert popup.styles.display == "block"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_popup_is_positioned_near_cursor(vibe_app: VibeApp) -> None:
|
|
||||||
async with vibe_app.run_test() as pilot:
|
|
||||||
popup = vibe_app.query_one(CompletionPopup)
|
|
||||||
|
|
||||||
await pilot.press(*"/com")
|
|
||||||
|
|
||||||
assert popup.styles.display == "block"
|
|
||||||
offset = popup.styles.offset
|
|
||||||
# The popup should have an explicit offset set by _position_popup
|
|
||||||
assert offset.x is not None
|
|
||||||
assert offset.y is not None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_does_not_trigger_completion_when_navigating_history(
|
async def test_does_not_trigger_completion_when_navigating_history(
|
||||||
file_tree: Path, vibe_app: VibeApp
|
file_tree: Path, vibe_app: VibeApp
|
||||||
|
|
|
||||||
132
tests/backend/data/anthropic.py
Normal file
132
tests/backend/data/anthropic.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from tests.backend.data import Chunk, JsonResponse
|
||||||
|
|
||||||
|
|
||||||
|
def _sse_event(data: dict[str, Any]) -> Chunk:
|
||||||
|
return f"data: {json.dumps(data, separators=(',', ':'))}".encode()
|
||||||
|
|
||||||
|
|
||||||
|
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).
|
||||||
|
return [
|
||||||
|
block
|
||||||
|
for message in payload["messages"]
|
||||||
|
if isinstance(message["content"], list)
|
||||||
|
for block in message["content"]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def anthropic_message(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
input_tokens: int = 12,
|
||||||
|
output_tokens: int = 3,
|
||||||
|
stop_reason: str = "end_turn",
|
||||||
|
) -> JsonResponse:
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": text}],
|
||||||
|
"usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
|
||||||
|
"stop_reason": stop_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def anthropic_tool_use(
|
||||||
|
name: str,
|
||||||
|
tool_input: dict[str, Any],
|
||||||
|
*,
|
||||||
|
tool_id: str = "toolu_1",
|
||||||
|
input_tokens: int = 20,
|
||||||
|
output_tokens: int = 5,
|
||||||
|
) -> JsonResponse:
|
||||||
|
return {
|
||||||
|
"content": [
|
||||||
|
{"type": "tool_use", "id": tool_id, "name": name, "input": tool_input}
|
||||||
|
],
|
||||||
|
"usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
|
||||||
|
"stop_reason": "tool_use",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def anthropic_reasoning_tool_use_stream(
|
||||||
|
name: str,
|
||||||
|
arguments: str,
|
||||||
|
*,
|
||||||
|
reasoning: str = "thinking...",
|
||||||
|
signature: str = "sig",
|
||||||
|
tool_id: str = "toolu_1",
|
||||||
|
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},
|
||||||
|
},
|
||||||
|
{"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},
|
||||||
|
},
|
||||||
|
{"type": "content_block_stop", "index": 1},
|
||||||
|
{
|
||||||
|
"type": "message_delta",
|
||||||
|
"delta": {"stop_reason": "tool_use"},
|
||||||
|
"usage": {"output_tokens": output_tokens},
|
||||||
|
},
|
||||||
|
{"type": "message_stop"},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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},
|
||||||
|
},
|
||||||
|
{"type": "content_block_stop", "index": 0},
|
||||||
|
{
|
||||||
|
"type": "message_delta",
|
||||||
|
"delta": {"stop_reason": "end_turn"},
|
||||||
|
"usage": {"output_tokens": output_tokens},
|
||||||
|
},
|
||||||
|
{"type": "message_stop"},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
@ -1,7 +1,41 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from tests.backend.data import Chunk, JsonResponse, ResultData, Url
|
from tests.backend.data import Chunk, JsonResponse, ResultData, Url
|
||||||
|
|
||||||
|
|
||||||
|
def mistral_completion(
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
prompt_tokens: int = 100,
|
||||||
|
completion_tokens: int = 50,
|
||||||
|
tool_calls: list[dict[str, Any]] | None = None,
|
||||||
|
) -> JsonResponse:
|
||||||
|
return {
|
||||||
|
"id": "cmpl_test",
|
||||||
|
"created": 1234567890,
|
||||||
|
"model": "devstral-latest",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": prompt_tokens,
|
||||||
|
"completion_tokens": completion_tokens,
|
||||||
|
"total_tokens": prompt_tokens + completion_tokens,
|
||||||
|
},
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"finish_reason": "tool_calls" if tool_calls else "stop",
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": content,
|
||||||
|
"tool_calls": tool_calls,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [
|
SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [
|
||||||
(
|
(
|
||||||
"https://api.mistral.ai",
|
"https://api.mistral.ai",
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@ import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from tests.backend.data import Chunk, JsonResponse, ResultData, Url
|
from tests.backend.data import Chunk, JsonResponse, ResultData, Url
|
||||||
|
from tests.constants import OPENAI_BASE_URL
|
||||||
OPENAI_RESPONSES_TEST_BASE_URL = "https://api.openai.com"
|
|
||||||
|
|
||||||
|
|
||||||
def _sse_event(data: dict[str, Any] | str) -> Chunk:
|
def _sse_event(data: dict[str, Any] | str) -> Chunk:
|
||||||
|
|
@ -90,9 +89,134 @@ def _function_call_item(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def openai_message_item(
|
||||||
|
text: str, *, phase: str | None = None, message_id: str = "msg_1"
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return _message_output_item(message_id, text, phase=phase)
|
||||||
|
|
||||||
|
|
||||||
|
def openai_function_call_item(
|
||||||
|
name: str, arguments: str, *, item_id: str = "fc_1", call_id: str = "call_1"
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return _function_call_item(item_id, call_id, name, arguments)
|
||||||
|
|
||||||
|
|
||||||
|
def openai_response(
|
||||||
|
output: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
input_tokens: int = 10,
|
||||||
|
output_tokens: int = 2,
|
||||||
|
response_id: str = "resp_1",
|
||||||
|
model: str = "gpt-test",
|
||||||
|
) -> JsonResponse:
|
||||||
|
return {
|
||||||
|
"id": response_id,
|
||||||
|
"object": "response",
|
||||||
|
"created_at": 1234567890,
|
||||||
|
"model": model,
|
||||||
|
"output": output,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": input_tokens,
|
||||||
|
"output_tokens": output_tokens,
|
||||||
|
"total_tokens": input_tokens + output_tokens,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def openai_sse(*events: dict[str, Any] | str) -> list[Chunk]:
|
||||||
|
return [_sse_event(event) for event in events]
|
||||||
|
|
||||||
|
|
||||||
|
def openai_reasoning_tool_call_stream(
|
||||||
|
name: str,
|
||||||
|
arguments: str,
|
||||||
|
*,
|
||||||
|
reasoning: str = "thinking...",
|
||||||
|
call_id: str = "call_1",
|
||||||
|
item_id: str = "fc_1",
|
||||||
|
input_tokens: int = 20,
|
||||||
|
output_tokens: int = 5,
|
||||||
|
) -> list[Chunk]:
|
||||||
|
return openai_sse(
|
||||||
|
{"type": "response.created", "response": {"id": "resp_1", "output": []}},
|
||||||
|
{
|
||||||
|
"type": "response.output_item.added",
|
||||||
|
"output_index": 0,
|
||||||
|
"item": _stream_message_item("msg_r", phase="commentary"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.reasoning_summary_text.delta",
|
||||||
|
"output_index": 0,
|
||||||
|
"delta": reasoning,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.output_item.added",
|
||||||
|
"output_index": 1,
|
||||||
|
"item": _function_call_item(
|
||||||
|
item_id, call_id, name, "", status="in_progress"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.function_call_arguments.delta",
|
||||||
|
"output_index": 1,
|
||||||
|
"call_id": call_id,
|
||||||
|
"delta": arguments,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.function_call_arguments.done",
|
||||||
|
"output_index": 1,
|
||||||
|
"call_id": call_id,
|
||||||
|
"name": name,
|
||||||
|
"arguments": arguments,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.output_item.done",
|
||||||
|
"output_index": 1,
|
||||||
|
"item": _function_call_item(item_id, call_id, name, arguments),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.completed",
|
||||||
|
"response": openai_response(
|
||||||
|
[_function_call_item(item_id, call_id, name, arguments)],
|
||||||
|
input_tokens=input_tokens,
|
||||||
|
output_tokens=output_tokens,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"[DONE]",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def openai_text_stream(
|
||||||
|
text: str, *, input_tokens: int = 10, output_tokens: int = 2
|
||||||
|
) -> list[Chunk]:
|
||||||
|
return openai_sse(
|
||||||
|
{"type": "response.created", "response": {"id": "resp_1", "output": []}},
|
||||||
|
{
|
||||||
|
"type": "response.output_item.added",
|
||||||
|
"output_index": 0,
|
||||||
|
"item": _stream_message_item("msg_1"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.output_text.delta",
|
||||||
|
"output_index": 0,
|
||||||
|
"content_index": 0,
|
||||||
|
"delta": text,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "response.completed",
|
||||||
|
"response": openai_response(
|
||||||
|
[openai_message_item(text)],
|
||||||
|
input_tokens=input_tokens,
|
||||||
|
output_tokens=output_tokens,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"[DONE]",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [
|
SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [
|
||||||
(
|
(
|
||||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
OPENAI_BASE_URL,
|
||||||
{
|
{
|
||||||
"id": "resp_fake_id_1234",
|
"id": "resp_fake_id_1234",
|
||||||
"object": "response",
|
"object": "response",
|
||||||
|
|
@ -113,7 +237,7 @@ SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [
|
||||||
|
|
||||||
TOOL_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [
|
TOOL_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [
|
||||||
(
|
(
|
||||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
OPENAI_BASE_URL,
|
||||||
{
|
{
|
||||||
"id": "resp_fake_id_9012",
|
"id": "resp_fake_id_9012",
|
||||||
"object": "response",
|
"object": "response",
|
||||||
|
|
@ -144,7 +268,7 @@ TOOL_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [
|
||||||
|
|
||||||
STREAMED_SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, list[Chunk], list[ResultData]]] = [
|
STREAMED_SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, list[Chunk], list[ResultData]]] = [
|
||||||
(
|
(
|
||||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
OPENAI_BASE_URL,
|
||||||
[
|
[
|
||||||
_sse_event({
|
_sse_event({
|
||||||
"type": "response.created",
|
"type": "response.created",
|
||||||
|
|
@ -235,7 +359,7 @@ STREAMED_SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, list[Chunk], list[ResultDat
|
||||||
|
|
||||||
COMMENTARY_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [
|
COMMENTARY_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [
|
||||||
(
|
(
|
||||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
OPENAI_BASE_URL,
|
||||||
{
|
{
|
||||||
"id": "resp_thinking_1234",
|
"id": "resp_thinking_1234",
|
||||||
"object": "response",
|
"object": "response",
|
||||||
|
|
@ -268,7 +392,7 @@ STREAMED_COMMENTARY_CONVERSATION_PARAMS: list[
|
||||||
tuple[Url, list[Chunk], list[ResultData]]
|
tuple[Url, list[Chunk], list[ResultData]]
|
||||||
] = [
|
] = [
|
||||||
(
|
(
|
||||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
OPENAI_BASE_URL,
|
||||||
[
|
[
|
||||||
_sse_event({
|
_sse_event({
|
||||||
"type": "response.created",
|
"type": "response.created",
|
||||||
|
|
@ -419,7 +543,7 @@ STREAMED_COMMENTARY_CONVERSATION_PARAMS: list[
|
||||||
|
|
||||||
STREAMED_TOOL_CONVERSATION_PARAMS: list[tuple[Url, list[Chunk], list[ResultData]]] = [
|
STREAMED_TOOL_CONVERSATION_PARAMS: list[tuple[Url, list[Chunk], list[ResultData]]] = [
|
||||||
(
|
(
|
||||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
OPENAI_BASE_URL,
|
||||||
[
|
[
|
||||||
_sse_event({
|
_sse_event({
|
||||||
"type": "response.created",
|
"type": "response.created",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from tests.constants import ANTHROPIC_BASE_URL, ANTHROPIC_MESSAGES_PATH
|
||||||
from vibe.core.config import ProviderConfig
|
from vibe.core.config import ProviderConfig
|
||||||
from vibe.core.llm.backend.anthropic import AnthropicAdapter, AnthropicMapper
|
from vibe.core.llm.backend.anthropic import AnthropicAdapter, AnthropicMapper
|
||||||
from vibe.core.types import (
|
from vibe.core.types import (
|
||||||
|
|
@ -30,7 +31,7 @@ def adapter():
|
||||||
def provider():
|
def provider():
|
||||||
return ProviderConfig(
|
return ProviderConfig(
|
||||||
name="anthropic",
|
name="anthropic",
|
||||||
api_base="https://api.anthropic.com",
|
api_base=ANTHROPIC_BASE_URL,
|
||||||
api_key_env_var="ANTHROPIC_API_KEY",
|
api_key_env_var="ANTHROPIC_API_KEY",
|
||||||
api_style="anthropic",
|
api_style="anthropic",
|
||||||
)
|
)
|
||||||
|
|
@ -229,75 +230,6 @@ class TestMapperParseResponse:
|
||||||
assert chunk.usage.completion_tokens == 7
|
assert chunk.usage.completion_tokens == 7
|
||||||
|
|
||||||
|
|
||||||
class TestMapperStreamingEvents:
|
|
||||||
def test_text_delta(self, mapper):
|
|
||||||
chunk, idx = mapper.parse_streaming_event(
|
|
||||||
"content_block_delta",
|
|
||||||
{"delta": {"type": "text_delta", "text": "hi"}, "index": 0},
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
assert chunk.message.content == "hi"
|
|
||||||
|
|
||||||
def test_thinking_delta(self, mapper):
|
|
||||||
chunk, _ = mapper.parse_streaming_event(
|
|
||||||
"content_block_delta",
|
|
||||||
{"delta": {"type": "thinking_delta", "thinking": "hmm"}, "index": 0},
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
assert chunk.message.reasoning_content == "hmm"
|
|
||||||
|
|
||||||
def test_tool_use_start(self, mapper):
|
|
||||||
chunk, idx = mapper.parse_streaming_event(
|
|
||||||
"content_block_start",
|
|
||||||
{
|
|
||||||
"content_block": {"type": "tool_use", "id": "t1", "name": "search"},
|
|
||||||
"index": 2,
|
|
||||||
},
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
assert chunk.message.tool_calls[0].id == "t1"
|
|
||||||
assert idx == 2
|
|
||||||
|
|
||||||
def test_input_json_delta(self, mapper):
|
|
||||||
chunk, _ = mapper.parse_streaming_event(
|
|
||||||
"content_block_delta",
|
|
||||||
{
|
|
||||||
"delta": {"type": "input_json_delta", "partial_json": '{"q":'},
|
|
||||||
"index": 1,
|
|
||||||
},
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
assert chunk.message.tool_calls[0].function.arguments == '{"q":'
|
|
||||||
|
|
||||||
def test_message_start_usage(self, mapper):
|
|
||||||
chunk, _ = mapper.parse_streaming_event(
|
|
||||||
"message_start",
|
|
||||||
{"message": {"usage": {"input_tokens": 50, "cache_read_input_tokens": 10}}},
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
assert chunk.usage.prompt_tokens == 60
|
|
||||||
|
|
||||||
def test_message_delta_usage(self, mapper):
|
|
||||||
chunk, _ = mapper.parse_streaming_event(
|
|
||||||
"message_delta", {"usage": {"output_tokens": 42}}, 0
|
|
||||||
)
|
|
||||||
assert chunk.usage.completion_tokens == 42
|
|
||||||
|
|
||||||
def test_unknown_event(self, mapper):
|
|
||||||
chunk, idx = mapper.parse_streaming_event("ping", {}, 5)
|
|
||||||
assert chunk is None
|
|
||||||
assert idx == 5
|
|
||||||
|
|
||||||
def test_signature_delta(self, mapper):
|
|
||||||
chunk, _ = mapper.parse_streaming_event(
|
|
||||||
"content_block_delta",
|
|
||||||
{"delta": {"type": "signature_delta", "signature": "sig"}, "index": 0},
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
assert chunk is not None
|
|
||||||
assert chunk.message.reasoning_signature == "sig"
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdapterPrepareRequest:
|
class TestAdapterPrepareRequest:
|
||||||
def test_basic(self, adapter, provider):
|
def test_basic(self, adapter, provider):
|
||||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||||
|
|
@ -316,7 +248,7 @@ class TestAdapterPrepareRequest:
|
||||||
assert payload["model"] == "claude-sonnet-4-20250514"
|
assert payload["model"] == "claude-sonnet-4-20250514"
|
||||||
assert payload["max_tokens"] == 1024
|
assert payload["max_tokens"] == 1024
|
||||||
assert "temperature" not in payload
|
assert "temperature" not in payload
|
||||||
assert req.endpoint == "/v1/messages"
|
assert req.endpoint == ANTHROPIC_MESSAGES_PATH
|
||||||
assert req.headers["anthropic-version"] == "2023-06-01"
|
assert req.headers["anthropic-version"] == "2023-06-01"
|
||||||
|
|
||||||
def test_beta_features(self, adapter, provider):
|
def test_beta_features(self, adapter, provider):
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,9 @@ from tests.backend.data.mistral import (
|
||||||
STREAMED_TOOL_CONVERSATION_PARAMS as MISTRAL_STREAMED_TOOL_CONVERSATION_PARAMS,
|
STREAMED_TOOL_CONVERSATION_PARAMS as MISTRAL_STREAMED_TOOL_CONVERSATION_PARAMS,
|
||||||
TOOL_CONVERSATION_PARAMS as MISTRAL_TOOL_CONVERSATION_PARAMS,
|
TOOL_CONVERSATION_PARAMS as MISTRAL_TOOL_CONVERSATION_PARAMS,
|
||||||
)
|
)
|
||||||
|
from tests.constants import CHAT_COMPLETIONS_PATH
|
||||||
from vibe.core.config import ModelConfig, ProviderConfig
|
from vibe.core.config import ModelConfig, ProviderConfig
|
||||||
from vibe.core.llm.backend.factory import BACKEND_FACTORY
|
from vibe.core.llm.backend.factory import BACKEND_FACTORY, create_backend
|
||||||
from vibe.core.llm.backend.generic import GenericBackend
|
from vibe.core.llm.backend.generic import GenericBackend
|
||||||
from vibe.core.llm.backend.mistral import MistralBackend, MistralMapper
|
from vibe.core.llm.backend.mistral import MistralBackend, MistralMapper
|
||||||
from vibe.core.llm.exceptions import BackendError, BackendErrorBuilder
|
from vibe.core.llm.exceptions import BackendError, BackendErrorBuilder
|
||||||
|
|
@ -71,7 +72,7 @@ class TestBackend:
|
||||||
self, base_url: Url, json_response: JsonResponse, result_data: ResultData
|
self, base_url: Url, json_response: JsonResponse, result_data: ResultData
|
||||||
):
|
):
|
||||||
with respx.mock(base_url=base_url) as mock_api:
|
with respx.mock(base_url=base_url) as mock_api:
|
||||||
mock_api.post("/v1/chat/completions").mock(
|
mock_api.post(CHAT_COMPLETIONS_PATH).mock(
|
||||||
return_value=httpx.Response(status_code=200, json=json_response)
|
return_value=httpx.Response(status_code=200, json=json_response)
|
||||||
)
|
)
|
||||||
provider = ProviderConfig(
|
provider = ProviderConfig(
|
||||||
|
|
@ -139,7 +140,7 @@ class TestBackend:
|
||||||
self, base_url: Url, chunks: list[Chunk], result_data: list[ResultData]
|
self, base_url: Url, chunks: list[Chunk], result_data: list[ResultData]
|
||||||
):
|
):
|
||||||
with respx.mock(base_url=base_url) as mock_api:
|
with respx.mock(base_url=base_url) as mock_api:
|
||||||
mock_api.post("/v1/chat/completions").mock(
|
mock_api.post(CHAT_COMPLETIONS_PATH).mock(
|
||||||
return_value=httpx.Response(
|
return_value=httpx.Response(
|
||||||
status_code=200,
|
status_code=200,
|
||||||
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
|
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
|
||||||
|
|
@ -291,7 +292,7 @@ class TestBackend:
|
||||||
response: httpx.Response,
|
response: httpx.Response,
|
||||||
):
|
):
|
||||||
with respx.mock(base_url=base_url) as mock_api:
|
with respx.mock(base_url=base_url) as mock_api:
|
||||||
mock_api.post("/v1/chat/completions").mock(return_value=response)
|
mock_api.post(CHAT_COMPLETIONS_PATH).mock(return_value=response)
|
||||||
provider = ProviderConfig(
|
provider = ProviderConfig(
|
||||||
name="provider_name",
|
name="provider_name",
|
||||||
api_base=f"{base_url}/v1",
|
api_base=f"{base_url}/v1",
|
||||||
|
|
@ -335,7 +336,7 @@ class TestBackend:
|
||||||
self, base_url: Url, provider_name: str, expected_stream_options: dict
|
self, base_url: Url, provider_name: str, expected_stream_options: dict
|
||||||
):
|
):
|
||||||
with respx.mock(base_url=base_url) as mock_api:
|
with respx.mock(base_url=base_url) as mock_api:
|
||||||
route = mock_api.post("/v1/chat/completions").mock(
|
route = mock_api.post(CHAT_COMPLETIONS_PATH).mock(
|
||||||
return_value=httpx.Response(
|
return_value=httpx.Response(
|
||||||
status_code=200,
|
status_code=200,
|
||||||
stream=httpx.ByteStream(
|
stream=httpx.ByteStream(
|
||||||
|
|
@ -399,7 +400,7 @@ class TestBackend:
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
with respx.mock(base_url=base_url) as mock_api:
|
with respx.mock(base_url=base_url) as mock_api:
|
||||||
mock_api.post("/v1/chat/completions").mock(
|
mock_api.post(CHAT_COMPLETIONS_PATH).mock(
|
||||||
return_value=httpx.Response(status_code=200, json=json_response)
|
return_value=httpx.Response(status_code=200, json=json_response)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -441,7 +442,7 @@ class TestBackend:
|
||||||
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
|
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
|
||||||
headers={"Content-Type": "text/event-stream"},
|
headers={"Content-Type": "text/event-stream"},
|
||||||
)
|
)
|
||||||
mock_api.post("/v1/chat/completions").mock(return_value=mock_response)
|
mock_api.post(CHAT_COMPLETIONS_PATH).mock(return_value=mock_response)
|
||||||
|
|
||||||
provider = ProviderConfig(
|
provider = ProviderConfig(
|
||||||
name="provider_name",
|
name="provider_name",
|
||||||
|
|
@ -470,13 +471,29 @@ class TestBackend:
|
||||||
|
|
||||||
class TestMistralRetry:
|
class TestMistralRetry:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _create_test_backend() -> MistralBackend:
|
def _create_test_backend(
|
||||||
|
timeout: float = 720.0, retry_max_elapsed_time: float = 300.0
|
||||||
|
) -> MistralBackend:
|
||||||
provider = ProviderConfig(
|
provider = ProviderConfig(
|
||||||
name="test_provider",
|
name="test_provider",
|
||||||
api_base="https://api.mistral.ai/v1",
|
api_base="https://api.mistral.ai/v1",
|
||||||
api_key_env_var="API_KEY",
|
api_key_env_var="API_KEY",
|
||||||
)
|
)
|
||||||
return MistralBackend(provider=provider)
|
return MistralBackend(
|
||||||
|
provider=provider,
|
||||||
|
timeout=timeout,
|
||||||
|
retry_max_elapsed_time=retry_max_elapsed_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_fast_http_retry_config() -> RetryConfig:
|
||||||
|
return RetryConfig(
|
||||||
|
strategy="backoff",
|
||||||
|
backoff=BackoffStrategy(
|
||||||
|
initial_interval=1, max_interval=1, exponent=1, max_elapsed_time=10000
|
||||||
|
),
|
||||||
|
retry_connection_errors=True,
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_client_creation_includes_timeout_and_retry_config(self):
|
async def test_client_creation_includes_timeout_and_retry_config(self):
|
||||||
|
|
@ -492,6 +509,61 @@ class TestMistralRetry:
|
||||||
assert call_kwargs["retry_config"] is backend._retry_config
|
assert call_kwargs["retry_config"] is backend._retry_config
|
||||||
assert "async_client" in call_kwargs
|
assert "async_client" in call_kwargs
|
||||||
|
|
||||||
|
def test_retry_budget_uses_explicit_config(self):
|
||||||
|
backend = self._create_test_backend(
|
||||||
|
timeout=7200.0, retry_max_elapsed_time=1234.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert backend._timeout == 7200.0
|
||||||
|
assert backend._retry_config.backoff.max_elapsed_time == 1234000
|
||||||
|
|
||||||
|
def test_create_backend_passes_retry_budget(self):
|
||||||
|
provider = ProviderConfig(
|
||||||
|
name="test_provider",
|
||||||
|
api_base="https://api.mistral.ai/v1",
|
||||||
|
api_key_env_var="API_KEY",
|
||||||
|
backend=Backend.MISTRAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = create_backend(
|
||||||
|
provider=provider, timeout=7200.0, retry_max_elapsed_time=1234.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(backend, MistralBackend)
|
||||||
|
assert backend._timeout == 7200.0
|
||||||
|
assert backend._retry_config.backoff.max_elapsed_time == 1234000
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_complete_retries_retryable_http_error(self):
|
||||||
|
with respx.mock(base_url="https://api.mistral.ai") as mock_api:
|
||||||
|
route = mock_api.post("/v1/chat/completions").mock(
|
||||||
|
side_effect=[
|
||||||
|
httpx.Response(status_code=502, text="Bad Gateway"),
|
||||||
|
httpx.Response(
|
||||||
|
status_code=200, json=MISTRAL_SIMPLE_CONVERSATION_PARAMS[0][1]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
backend = self._create_test_backend()
|
||||||
|
backend._retry_config = self._build_fast_http_retry_config()
|
||||||
|
model = ModelConfig(
|
||||||
|
name="model_name", provider="test_provider", alias="model_alias"
|
||||||
|
)
|
||||||
|
messages = [LLMMessage(role=Role.user, content="Just say hi")]
|
||||||
|
|
||||||
|
result = await backend.complete(
|
||||||
|
model=model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=0.2,
|
||||||
|
tools=None,
|
||||||
|
max_tokens=None,
|
||||||
|
tool_choice=None,
|
||||||
|
extra_headers=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.message.content == "Some content"
|
||||||
|
assert route.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
class TestMistralMapperPrepareMessage:
|
class TestMistralMapperPrepareMessage:
|
||||||
"""Tests for MistralMapper.prepare_message thinking-block handling.
|
"""Tests for MistralMapper.prepare_message thinking-block handling.
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from vibe.core.llm.backend._image import (
|
||||||
to_base64,
|
to_base64,
|
||||||
to_data_uri,
|
to_data_uri,
|
||||||
)
|
)
|
||||||
from vibe.core.types import ImageAttachment
|
from vibe.core.types import FileImageSource, ImageAttachment, InlineImageSource
|
||||||
|
|
||||||
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
||||||
|
|
||||||
|
|
@ -24,7 +24,9 @@ def _clear_cache() -> None:
|
||||||
def _att(tmp_path: Path, name: str = "shot.png") -> ImageAttachment:
|
def _att(tmp_path: Path, name: str = "shot.png") -> ImageAttachment:
|
||||||
p = tmp_path / name
|
p = tmp_path / name
|
||||||
p.write_bytes(PNG_BYTES)
|
p.write_bytes(PNG_BYTES)
|
||||||
return ImageAttachment(path=p, alias=name, mime_type="image/png")
|
return ImageAttachment(
|
||||||
|
source=FileImageSource(path=p), alias=name, mime_type="image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_repeated_calls_hit_cache_and_skip_disk(tmp_path: Path) -> None:
|
def test_repeated_calls_hit_cache_and_skip_disk(tmp_path: Path) -> None:
|
||||||
|
|
@ -46,13 +48,14 @@ def test_cache_invalidates_when_file_mtime_changes(tmp_path: Path) -> None:
|
||||||
to_base64(att)
|
to_base64(att)
|
||||||
assert _encode_cached.cache_info().misses == 1
|
assert _encode_cached.cache_info().misses == 1
|
||||||
|
|
||||||
|
assert isinstance(att.source, FileImageSource)
|
||||||
new_bytes = PNG_BYTES + b"\x01"
|
new_bytes = PNG_BYTES + b"\x01"
|
||||||
att.path.write_bytes(new_bytes)
|
att.source.path.write_bytes(new_bytes)
|
||||||
# Force a distinct mtime even on coarse-resolution filesystems.
|
# Force a distinct mtime even on coarse-resolution filesystems.
|
||||||
import os
|
import os
|
||||||
|
|
||||||
stat = att.path.stat()
|
stat = att.source.path.stat()
|
||||||
os.utime(att.path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000))
|
os.utime(att.source.path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000))
|
||||||
|
|
||||||
refreshed = to_base64(att)
|
refreshed = to_base64(att)
|
||||||
assert refreshed == base64.b64encode(new_bytes).decode("ascii")
|
assert refreshed == base64.b64encode(new_bytes).decode("ascii")
|
||||||
|
|
@ -61,8 +64,23 @@ def test_cache_invalidates_when_file_mtime_changes(tmp_path: Path) -> None:
|
||||||
|
|
||||||
def test_missing_file_raises_image_read_error(tmp_path: Path) -> None:
|
def test_missing_file_raises_image_read_error(tmp_path: Path) -> None:
|
||||||
att = ImageAttachment(
|
att = ImageAttachment(
|
||||||
path=tmp_path / "nope.png", alias="nope.png", mime_type="image/png"
|
source=FileImageSource(path=tmp_path / "nope.png"),
|
||||||
|
alias="nope.png",
|
||||||
|
mime_type="image/png",
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ImageReadError):
|
with pytest.raises(ImageReadError):
|
||||||
to_data_uri(att)
|
to_data_uri(att)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_data_is_returned_without_touching_disk() -> None:
|
||||||
|
encoded = base64.b64encode(PNG_BYTES).decode("ascii")
|
||||||
|
att = ImageAttachment(
|
||||||
|
source=InlineImageSource(data=encoded),
|
||||||
|
alias="pasted.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert to_base64(att) == encoded
|
||||||
|
assert to_data_uri(att) == f"data:image/png;base64,{encoded}"
|
||||||
|
assert _encode_cached.cache_info().misses == 0
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from vibe.core.llm.backend.generic import OpenAIAdapter
|
||||||
from vibe.core.llm.backend.mistral import MistralMapper
|
from vibe.core.llm.backend.mistral import MistralMapper
|
||||||
from vibe.core.llm.backend.openai_responses import OpenAIResponsesAdapter
|
from vibe.core.llm.backend.openai_responses import OpenAIResponsesAdapter
|
||||||
from vibe.core.llm.backend.reasoning_adapter import ReasoningAdapter
|
from vibe.core.llm.backend.reasoning_adapter import ReasoningAdapter
|
||||||
from vibe.core.types import ImageAttachment, LLMMessage, Role
|
from vibe.core.types import FileImageSource, ImageAttachment, LLMMessage, Role
|
||||||
|
|
||||||
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
||||||
EXPECTED_B64 = base64.b64encode(PNG_BYTES).decode("ascii")
|
EXPECTED_B64 = base64.b64encode(PNG_BYTES).decode("ascii")
|
||||||
|
|
@ -25,7 +25,9 @@ EXPECTED_DATA_URI = f"data:image/png;base64,{EXPECTED_B64}"
|
||||||
def image_attachment(tmp_path: Path) -> ImageAttachment:
|
def image_attachment(tmp_path: Path) -> ImageAttachment:
|
||||||
path = tmp_path / "shot.png"
|
path = tmp_path / "shot.png"
|
||||||
path.write_bytes(PNG_BYTES)
|
path.write_bytes(PNG_BYTES)
|
||||||
return ImageAttachment(path=path, alias="shot.png", mime_type="image/png")
|
return ImageAttachment(
|
||||||
|
source=FileImageSource(path=path), alias="shot.png", mime_type="image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _user_message(image: ImageAttachment) -> LLMMessage:
|
def _user_message(image: ImageAttachment) -> LLMMessage:
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,13 @@ import respx
|
||||||
from tests.backend.data import Chunk, JsonResponse, ResultData, Url
|
from tests.backend.data import Chunk, JsonResponse, ResultData, Url
|
||||||
from tests.backend.data.openai_responses import (
|
from tests.backend.data.openai_responses import (
|
||||||
COMMENTARY_CONVERSATION_PARAMS,
|
COMMENTARY_CONVERSATION_PARAMS,
|
||||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
|
||||||
SIMPLE_CONVERSATION_PARAMS,
|
SIMPLE_CONVERSATION_PARAMS,
|
||||||
STREAMED_COMMENTARY_CONVERSATION_PARAMS,
|
STREAMED_COMMENTARY_CONVERSATION_PARAMS,
|
||||||
STREAMED_SIMPLE_CONVERSATION_PARAMS,
|
STREAMED_SIMPLE_CONVERSATION_PARAMS,
|
||||||
STREAMED_TOOL_CONVERSATION_PARAMS,
|
STREAMED_TOOL_CONVERSATION_PARAMS,
|
||||||
TOOL_CONVERSATION_PARAMS,
|
TOOL_CONVERSATION_PARAMS,
|
||||||
)
|
)
|
||||||
|
from tests.constants import OPENAI_BASE_URL, OPENAI_RESPONSES_PATH
|
||||||
from vibe.core.config import ModelConfig, ProviderConfig
|
from vibe.core.config import ModelConfig, ProviderConfig
|
||||||
from vibe.core.llm.backend.generic import GenericBackend
|
from vibe.core.llm.backend.generic import GenericBackend
|
||||||
from vibe.core.llm.backend.openai_responses import OpenAIResponsesAdapter
|
from vibe.core.llm.backend.openai_responses import OpenAIResponsesAdapter
|
||||||
|
|
@ -55,7 +55,7 @@ def model():
|
||||||
return _make_model()
|
return _make_model()
|
||||||
|
|
||||||
|
|
||||||
def _make_provider(base_url: Url = OPENAI_RESPONSES_TEST_BASE_URL) -> ProviderConfig:
|
def _make_provider(base_url: Url = OPENAI_BASE_URL) -> ProviderConfig:
|
||||||
return ProviderConfig(
|
return ProviderConfig(
|
||||||
name="openai",
|
name="openai",
|
||||||
api_base=f"{base_url}/v1",
|
api_base=f"{base_url}/v1",
|
||||||
|
|
@ -68,7 +68,7 @@ def _make_model() -> ModelConfig:
|
||||||
return ModelConfig(name="gpt-4o", provider="openai", alias="gpt-4o")
|
return ModelConfig(name="gpt-4o", provider="openai", alias="gpt-4o")
|
||||||
|
|
||||||
|
|
||||||
def _make_backend(base_url: Url = OPENAI_RESPONSES_TEST_BASE_URL) -> GenericBackend:
|
def _make_backend(base_url: Url = OPENAI_BASE_URL) -> GenericBackend:
|
||||||
return GenericBackend(provider=_make_provider(base_url))
|
return GenericBackend(provider=_make_provider(base_url))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1229,7 +1229,7 @@ class TestGenericBackendIntegration:
|
||||||
self, base_url: Url, json_response: JsonResponse, result_data: ResultData
|
self, base_url: Url, json_response: JsonResponse, result_data: ResultData
|
||||||
):
|
):
|
||||||
with respx.mock(base_url=base_url) as mock_api:
|
with respx.mock(base_url=base_url) as mock_api:
|
||||||
mock_api.post("/v1/responses").mock(
|
mock_api.post(OPENAI_RESPONSES_PATH).mock(
|
||||||
return_value=httpx.Response(status_code=200, json=json_response)
|
return_value=httpx.Response(status_code=200, json=json_response)
|
||||||
)
|
)
|
||||||
backend = _make_backend(base_url)
|
backend = _make_backend(base_url)
|
||||||
|
|
@ -1261,7 +1261,7 @@ class TestGenericBackendIntegration:
|
||||||
self, base_url: Url, chunks: list[Chunk], result_data: list[ResultData]
|
self, base_url: Url, chunks: list[Chunk], result_data: list[ResultData]
|
||||||
):
|
):
|
||||||
with respx.mock(base_url=base_url) as mock_api:
|
with respx.mock(base_url=base_url) as mock_api:
|
||||||
mock_api.post("/v1/responses").mock(
|
mock_api.post(OPENAI_RESPONSES_PATH).mock(
|
||||||
return_value=httpx.Response(
|
return_value=httpx.Response(
|
||||||
status_code=200,
|
status_code=200,
|
||||||
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
|
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
|
||||||
|
|
@ -1289,9 +1289,9 @@ class TestGenericBackendIntegration:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_streaming_payload_includes_stream_flag(self):
|
async def test_streaming_payload_includes_stream_flag(self):
|
||||||
base_url = OPENAI_RESPONSES_TEST_BASE_URL
|
base_url = OPENAI_BASE_URL
|
||||||
with respx.mock(base_url=base_url) as mock_api:
|
with respx.mock(base_url=base_url) as mock_api:
|
||||||
route = mock_api.post("/v1/responses").mock(
|
route = mock_api.post(OPENAI_RESPONSES_PATH).mock(
|
||||||
return_value=httpx.Response(
|
return_value=httpx.Response(
|
||||||
status_code=200,
|
status_code=200,
|
||||||
stream=httpx.ByteStream(
|
stream=httpx.ByteStream(
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,27 @@ def test_resolve_api_key_for_plan_with_missing_env_var() -> None:
|
||||||
environ["MISTRAL_API_KEY"] = previous_api_key
|
environ["MISTRAL_API_KEY"] = previous_api_key
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_api_key_for_plan_falls_back_to_keyring(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||||
|
keyring_api_key = "keyring_mistral_api_key"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"vibe.core.config._settings.keyring.get_password",
|
||||||
|
lambda service, username: keyring_api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = ProviderConfig(
|
||||||
|
name="test_mistral",
|
||||||
|
api_base="https://api.mistral.ai",
|
||||||
|
backend=Backend.MISTRAL,
|
||||||
|
api_key_env_var="MISTRAL_API_KEY",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = resolve_api_key_for_plan(provider)
|
||||||
|
assert result == keyring_api_key
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("plan_info", "expected_cta"),
|
("plan_info", "expected_cta"),
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import tomllib
|
|
||||||
|
|
||||||
from vibe.cli.cache import read_cache, write_cache
|
|
||||||
|
|
||||||
|
|
||||||
class TestReadCache:
|
|
||||||
def test_reads_valid_toml(self, tmp_path: Path) -> None:
|
|
||||||
cache_path = tmp_path / "cache.toml"
|
|
||||||
cache_path.write_text('[update_cache]\nlatest_version = "1.0.0"\n')
|
|
||||||
|
|
||||||
result = read_cache(cache_path)
|
|
||||||
|
|
||||||
assert result["update_cache"]["latest_version"] == "1.0.0"
|
|
||||||
|
|
||||||
def test_returns_empty_dict_when_missing(self, tmp_path: Path) -> None:
|
|
||||||
assert read_cache(tmp_path / "missing.toml") == {}
|
|
||||||
|
|
||||||
def test_returns_empty_dict_when_corrupted(self, tmp_path: Path) -> None:
|
|
||||||
cache_path = tmp_path / "cache.toml"
|
|
||||||
cache_path.write_text("{bad toml")
|
|
||||||
|
|
||||||
assert read_cache(cache_path) == {}
|
|
||||||
|
|
||||||
|
|
||||||
class TestWriteCache:
|
|
||||||
def test_writes_new_file(self, tmp_path: Path) -> None:
|
|
||||||
cache_path = tmp_path / "cache.toml"
|
|
||||||
|
|
||||||
write_cache(cache_path, "feedback", {"last_shown_at": 100.0})
|
|
||||||
|
|
||||||
with cache_path.open("rb") as f:
|
|
||||||
data = tomllib.load(f)
|
|
||||||
assert data["feedback"]["last_shown_at"] == 100.0
|
|
||||||
|
|
||||||
def test_merges_with_existing(self, tmp_path: Path) -> None:
|
|
||||||
cache_path = tmp_path / "cache.toml"
|
|
||||||
cache_path.write_text('[update_cache]\nlatest_version = "1.0.0"\n')
|
|
||||||
|
|
||||||
write_cache(cache_path, "feedback", {"last_shown_at": 200.0})
|
|
||||||
|
|
||||||
with cache_path.open("rb") as f:
|
|
||||||
data = tomllib.load(f)
|
|
||||||
assert data["update_cache"]["latest_version"] == "1.0.0"
|
|
||||||
assert data["feedback"]["last_shown_at"] == 200.0
|
|
||||||
|
|
||||||
def test_merges_within_section_and_leaves_other_sections_alone(
|
|
||||||
self, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
cache_path = tmp_path / "cache.toml"
|
|
||||||
cache_path.write_text(
|
|
||||||
"[update_cache]\n"
|
|
||||||
'latest_version = "1.0.0"\n'
|
|
||||||
"stored_at_timestamp = 1\n"
|
|
||||||
'seen_whats_new_version = "1.0.0"\n\n'
|
|
||||||
"[feedback]\n"
|
|
||||||
"last_shown_at = 100.0\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
write_cache(
|
|
||||||
cache_path,
|
|
||||||
"update_cache",
|
|
||||||
{"latest_version": "2.0.0", "stored_at_timestamp": 2},
|
|
||||||
)
|
|
||||||
|
|
||||||
with cache_path.open("rb") as f:
|
|
||||||
data = tomllib.load(f)
|
|
||||||
assert data["update_cache"]["latest_version"] == "2.0.0"
|
|
||||||
assert data["update_cache"]["stored_at_timestamp"] == 2
|
|
||||||
assert data["update_cache"]["seen_whats_new_version"] == "1.0.0"
|
|
||||||
assert data["feedback"]["last_shown_at"] == 100.0
|
|
||||||
|
|
@ -128,6 +128,14 @@ class TestCommandRegistry:
|
||||||
result = registry.parse_command("/connectors filesystem")
|
result = registry.parse_command("/connectors filesystem")
|
||||||
assert result == ("mcp", registry.commands["mcp"], "filesystem")
|
assert result == ("mcp", registry.commands["mcp"], "filesystem")
|
||||||
|
|
||||||
|
def test_mcp_command_description_surfaces_auth_subcommands(self) -> None:
|
||||||
|
registry = CommandRegistry()
|
||||||
|
command = registry.commands["mcp"]
|
||||||
|
|
||||||
|
assert "status" in command.description
|
||||||
|
assert "login <alias>" in command.description
|
||||||
|
assert "logout <alias>" in command.description
|
||||||
|
|
||||||
def test_data_retention_command_registration(self) -> None:
|
def test_data_retention_command_registration(self) -> None:
|
||||||
registry = CommandRegistry()
|
registry = CommandRegistry()
|
||||||
result = registry.parse_command("/data-retention")
|
result = registry.parse_command("/data-retention")
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import tomllib
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from vibe.cli.textual_ui.widgets.feedback_bar_manager import FeedbackBarManager
|
from vibe.cli.textual_ui.widgets.feedback_bar_manager import FeedbackBarManager
|
||||||
|
from vibe.core.cache_store import FileSystemVibeCodeCacheStore
|
||||||
from vibe.core.feedback import (
|
from vibe.core.feedback import (
|
||||||
_CACHE_SECTION,
|
_CACHE_SECTION,
|
||||||
_LAST_SHOWN_KEY,
|
_LAST_SHOWN_KEY,
|
||||||
|
|
@ -15,24 +16,18 @@ from vibe.core.feedback import (
|
||||||
from vibe.core.types import LLMMessage, Role
|
from vibe.core.types import LLMMessage, Role
|
||||||
|
|
||||||
|
|
||||||
def _patch_cache_file(tmp_path: Path):
|
|
||||||
from vibe.core.paths._vibe_home import GlobalPath
|
|
||||||
|
|
||||||
return patch(
|
|
||||||
"vibe.core.feedback.CACHE_FILE", GlobalPath(lambda: tmp_path / "cache.toml")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _patch_probability(value: float):
|
def _patch_probability(value: float):
|
||||||
return patch("vibe.core.feedback.FEEDBACK_PROBABILITY", value)
|
return patch("vibe.core.feedback.FEEDBACK_PROBABILITY", value)
|
||||||
|
|
||||||
|
|
||||||
def _make_agent_loop(
|
def _make_agent_loop(
|
||||||
|
cache_path: Path,
|
||||||
user_message_count: int = MIN_USER_MESSAGES_FOR_FEEDBACK,
|
user_message_count: int = MIN_USER_MESSAGES_FOR_FEEDBACK,
|
||||||
telemetry_active: bool = True,
|
telemetry_active: bool = True,
|
||||||
) -> MagicMock:
|
) -> MagicMock:
|
||||||
loop = MagicMock()
|
loop = MagicMock()
|
||||||
loop.telemetry_client.is_active.return_value = telemetry_active
|
loop.telemetry_client.is_active.return_value = telemetry_active
|
||||||
|
loop.cache_store = FileSystemVibeCodeCacheStore(cache_path)
|
||||||
messages = [
|
messages = [
|
||||||
LLMMessage(role=Role.user, content=f"msg {i}")
|
LLMMessage(role=Role.user, content=f"msg {i}")
|
||||||
for i in range(user_message_count)
|
for i in range(user_message_count)
|
||||||
|
|
@ -45,20 +40,22 @@ class TestShouldShow:
|
||||||
def test_shows_when_conditions_met(self, tmp_path: Path) -> None:
|
def test_shows_when_conditions_met(self, tmp_path: Path) -> None:
|
||||||
manager = FeedbackBarManager()
|
manager = FeedbackBarManager()
|
||||||
with (
|
with (
|
||||||
_patch_cache_file(tmp_path),
|
|
||||||
_patch_probability(0.2),
|
_patch_probability(0.2),
|
||||||
patch("vibe.core.feedback.random.random", return_value=0.0),
|
patch("vibe.core.feedback.random.random", return_value=0.0),
|
||||||
):
|
):
|
||||||
assert manager.should_show(_make_agent_loop()) is True
|
assert (
|
||||||
|
manager.should_show(_make_agent_loop(tmp_path / "cache.toml")) is True
|
||||||
|
)
|
||||||
|
|
||||||
def test_does_not_show_when_random_misses(self, tmp_path: Path) -> None:
|
def test_does_not_show_when_random_misses(self, tmp_path: Path) -> None:
|
||||||
manager = FeedbackBarManager()
|
manager = FeedbackBarManager()
|
||||||
with (
|
with (
|
||||||
_patch_cache_file(tmp_path),
|
|
||||||
_patch_probability(0.2),
|
_patch_probability(0.2),
|
||||||
patch("vibe.core.feedback.random.random", return_value=1.0),
|
patch("vibe.core.feedback.random.random", return_value=1.0),
|
||||||
):
|
):
|
||||||
assert manager.should_show(_make_agent_loop()) is False
|
assert (
|
||||||
|
manager.should_show(_make_agent_loop(tmp_path / "cache.toml")) is False
|
||||||
|
)
|
||||||
|
|
||||||
def test_does_not_show_within_cooldown(self, tmp_path: Path) -> None:
|
def test_does_not_show_within_cooldown(self, tmp_path: Path) -> None:
|
||||||
(tmp_path / "cache.toml").write_text(
|
(tmp_path / "cache.toml").write_text(
|
||||||
|
|
@ -66,11 +63,12 @@ class TestShouldShow:
|
||||||
)
|
)
|
||||||
manager = FeedbackBarManager()
|
manager = FeedbackBarManager()
|
||||||
with (
|
with (
|
||||||
_patch_cache_file(tmp_path),
|
|
||||||
_patch_probability(0.2),
|
_patch_probability(0.2),
|
||||||
patch("vibe.core.feedback.random.random", return_value=0.0),
|
patch("vibe.core.feedback.random.random", return_value=0.0),
|
||||||
):
|
):
|
||||||
assert manager.should_show(_make_agent_loop()) is False
|
assert (
|
||||||
|
manager.should_show(_make_agent_loop(tmp_path / "cache.toml")) is False
|
||||||
|
)
|
||||||
|
|
||||||
def test_shows_after_cooldown_expires(self, tmp_path: Path) -> None:
|
def test_shows_after_cooldown_expires(self, tmp_path: Path) -> None:
|
||||||
(tmp_path / "cache.toml").write_text(
|
(tmp_path / "cache.toml").write_text(
|
||||||
|
|
@ -78,30 +76,38 @@ class TestShouldShow:
|
||||||
)
|
)
|
||||||
manager = FeedbackBarManager()
|
manager = FeedbackBarManager()
|
||||||
with (
|
with (
|
||||||
_patch_cache_file(tmp_path),
|
|
||||||
_patch_probability(0.2),
|
_patch_probability(0.2),
|
||||||
patch("vibe.core.feedback.random.random", return_value=0.0),
|
patch("vibe.core.feedback.random.random", return_value=0.0),
|
||||||
):
|
):
|
||||||
assert manager.should_show(_make_agent_loop()) is True
|
assert (
|
||||||
|
manager.should_show(_make_agent_loop(tmp_path / "cache.toml")) is True
|
||||||
|
)
|
||||||
|
|
||||||
def test_does_not_show_when_telemetry_inactive(self, tmp_path: Path) -> None:
|
def test_does_not_show_when_telemetry_inactive(self, tmp_path: Path) -> None:
|
||||||
manager = FeedbackBarManager()
|
manager = FeedbackBarManager()
|
||||||
with _patch_cache_file(tmp_path), _patch_probability(0.2):
|
with _patch_probability(0.2):
|
||||||
assert (
|
assert (
|
||||||
manager.should_show(_make_agent_loop(telemetry_active=False)) is False
|
manager.should_show(
|
||||||
|
_make_agent_loop(tmp_path / "cache.toml", telemetry_active=False)
|
||||||
|
)
|
||||||
|
is False
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_does_not_show_when_too_few_user_messages(self, tmp_path: Path) -> None:
|
def test_does_not_show_when_too_few_user_messages(self, tmp_path: Path) -> None:
|
||||||
manager = FeedbackBarManager()
|
manager = FeedbackBarManager()
|
||||||
with (
|
with (
|
||||||
_patch_cache_file(tmp_path),
|
|
||||||
_patch_probability(0.2),
|
_patch_probability(0.2),
|
||||||
patch("vibe.core.feedback.random.random", return_value=0.0),
|
patch("vibe.core.feedback.random.random", return_value=0.0),
|
||||||
):
|
):
|
||||||
assert manager.should_show(_make_agent_loop(user_message_count=1)) is False
|
assert (
|
||||||
|
manager.should_show(
|
||||||
|
_make_agent_loop(tmp_path / "cache.toml", user_message_count=1)
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
def test_skips_injected_messages_in_count(self, tmp_path: Path) -> None:
|
def test_skips_injected_messages_in_count(self, tmp_path: Path) -> None:
|
||||||
loop = _make_agent_loop(user_message_count=0)
|
loop = _make_agent_loop(tmp_path / "cache.toml", user_message_count=0)
|
||||||
loop.messages = [
|
loop.messages = [
|
||||||
LLMMessage(role=Role.user, content="real"),
|
LLMMessage(role=Role.user, content="real"),
|
||||||
LLMMessage(role=Role.user, content="injected", injected=True),
|
LLMMessage(role=Role.user, content="injected", injected=True),
|
||||||
|
|
@ -109,7 +115,6 @@ class TestShouldShow:
|
||||||
]
|
]
|
||||||
manager = FeedbackBarManager()
|
manager = FeedbackBarManager()
|
||||||
with (
|
with (
|
||||||
_patch_cache_file(tmp_path),
|
|
||||||
_patch_probability(0.2),
|
_patch_probability(0.2),
|
||||||
patch("vibe.core.feedback.random.random", return_value=0.0),
|
patch("vibe.core.feedback.random.random", return_value=0.0),
|
||||||
):
|
):
|
||||||
|
|
@ -121,8 +126,7 @@ class TestRecordFeedbackAsked:
|
||||||
def test_writes_timestamp_to_cache(self, tmp_path: Path) -> None:
|
def test_writes_timestamp_to_cache(self, tmp_path: Path) -> None:
|
||||||
manager = FeedbackBarManager()
|
manager = FeedbackBarManager()
|
||||||
before = int(time.time())
|
before = int(time.time())
|
||||||
with _patch_cache_file(tmp_path):
|
manager.record_feedback_asked(_make_agent_loop(tmp_path / "cache.toml"))
|
||||||
manager.record_feedback_asked()
|
with (tmp_path / "cache.toml").open("rb") as f:
|
||||||
with (tmp_path / "cache.toml").open("rb") as f:
|
data = tomllib.load(f)
|
||||||
data = tomllib.load(f)
|
|
||||||
assert data[_CACHE_SECTION][_LAST_SHOWN_KEY] >= before
|
assert data[_CACHE_SECTION][_LAST_SHOWN_KEY] >= before
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,41 @@ def test_help_shows_auto_approve_flag(
|
||||||
assert exc_info.value.code == 0
|
assert exc_info.value.code == 0
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
assert "--auto-approve" in output
|
assert "--auto-approve" in output
|
||||||
|
assert "--yolo" in output
|
||||||
assert "Shortcut for --agent auto-approve" in output
|
assert "Shortcut for --agent auto-approve" in output
|
||||||
|
|
||||||
|
|
||||||
def test_auto_approve_conflicts_with_agent(
|
def test_help_shows_check_upgrade_flag(
|
||||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr("sys.argv", ["vibe", "--agent", "plan", "--auto-approve"])
|
monkeypatch.setattr("sys.argv", ["vibe", "--help"])
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
parse_arguments()
|
||||||
|
|
||||||
|
assert exc_info.value.code == 0
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "--check-upgrade" in output
|
||||||
|
assert "Check for a Vibe update now" in output
|
||||||
|
|
||||||
|
|
||||||
|
# def test_auto_approve_conflicts_with_agent(
|
||||||
|
# monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
|
||||||
|
|
||||||
|
def test_yolo_alias_selects_auto_approve(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr("sys.argv", ["vibe", "--yolo"])
|
||||||
|
|
||||||
|
args = parse_arguments()
|
||||||
|
|
||||||
|
assert args.auto_approve is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("flag", ["--auto-approve", "--yolo"])
|
||||||
|
def test_auto_approve_aliases_conflict_with_agent(
|
||||||
|
flag: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("sys.argv", ["vibe", "--agent", "plan", flag])
|
||||||
|
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
parse_arguments()
|
parse_arguments()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -21,6 +22,7 @@ def _make_args(**overrides: object) -> argparse.Namespace:
|
||||||
"enabled_tools": None,
|
"enabled_tools": None,
|
||||||
"output": "text",
|
"output": "text",
|
||||||
"agent": "default",
|
"agent": "default",
|
||||||
|
"check_upgrade": False,
|
||||||
"setup": False,
|
"setup": False,
|
||||||
"workdir": None,
|
"workdir": None,
|
||||||
"add_dir": [],
|
"add_dir": [],
|
||||||
|
|
@ -134,16 +136,15 @@ def test_trust_flag_trusts_cwd_for_session_only(
|
||||||
|
|
||||||
args = _make_args(trust=True, prompt=None)
|
args = _make_args(trust=True, prompt=None)
|
||||||
monkeypatch.setattr(entrypoint_mod, "parse_arguments", lambda: args)
|
monkeypatch.setattr(entrypoint_mod, "parse_arguments", lambda: args)
|
||||||
monkeypatch.setattr(
|
|
||||||
entrypoint_mod, "check_and_resolve_trusted_folder", lambda _cwd: None
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None
|
entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stop main() before it runs the actual CLI.
|
# Stop main() before it runs the actual CLI.
|
||||||
monkeypatch.setattr(
|
def fake_run_cli(_args: argparse.Namespace, **_kwargs: object) -> None:
|
||||||
"vibe.cli.cli.run_cli", lambda _args: (_ for _ in ()).throw(SystemExit(0))
|
raise SystemExit(0)
|
||||||
)
|
|
||||||
|
monkeypatch.setattr("vibe.cli.cli.run_cli", fake_run_cli)
|
||||||
|
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
entrypoint_mod.main()
|
entrypoint_mod.main()
|
||||||
|
|
@ -172,9 +173,11 @@ def test_trust_flag_works_in_programmatic_mode(
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None
|
entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
|
||||||
"vibe.cli.cli.run_cli", lambda _args: (_ for _ in ()).throw(SystemExit(0))
|
def fake_run_cli(_args: argparse.Namespace, **_kwargs: object) -> None:
|
||||||
)
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr("vibe.cli.cli.run_cli", fake_run_cli)
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
entrypoint_mod.main()
|
entrypoint_mod.main()
|
||||||
|
|
@ -183,6 +186,78 @@ def test_trust_flag_works_in_programmatic_mode(
|
||||||
assert trusted_folders_manager._trusted == []
|
assert trusted_folders_manager._trusted == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_upgrade_does_not_pass_trust_resolver(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
project = tmp_path / "proj"
|
||||||
|
project.mkdir()
|
||||||
|
(project / "AGENTS.md").write_text("hello", encoding="utf-8")
|
||||||
|
monkeypatch.chdir(project)
|
||||||
|
|
||||||
|
args = _make_args(prompt=None, check_upgrade=True)
|
||||||
|
monkeypatch.setattr(entrypoint_mod, "parse_arguments", lambda: args)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
entrypoint_mod,
|
||||||
|
"check_and_resolve_trusted_folder",
|
||||||
|
lambda _cwd: pytest.fail("check-upgrade must not prompt for trust"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_run_cli(
|
||||||
|
_args: argparse.Namespace,
|
||||||
|
*,
|
||||||
|
resolve_trusted_folder: Callable[[], None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
assert resolve_trusted_folder is None
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr("vibe.cli.cli.run_cli", fake_run_cli)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
entrypoint_mod.main()
|
||||||
|
|
||||||
|
assert exc_info.value.code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_interactive_start_passes_trust_resolver_to_cli(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
project = tmp_path / "proj"
|
||||||
|
project.mkdir()
|
||||||
|
monkeypatch.chdir(project)
|
||||||
|
|
||||||
|
args = _make_args(prompt=None)
|
||||||
|
calls: list[str] = []
|
||||||
|
monkeypatch.setattr(entrypoint_mod, "parse_arguments", lambda: args)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
entrypoint_mod,
|
||||||
|
"check_and_resolve_trusted_folder",
|
||||||
|
lambda _cwd: calls.append("trust"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_run_cli(
|
||||||
|
_args: argparse.Namespace,
|
||||||
|
*,
|
||||||
|
resolve_trusted_folder: Callable[[], None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
assert callable(resolve_trusted_folder)
|
||||||
|
resolve_trusted_folder()
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr("vibe.cli.cli.run_cli", fake_run_cli)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
entrypoint_mod.main()
|
||||||
|
|
||||||
|
assert exc_info.value.code == 0
|
||||||
|
assert calls == ["trust"]
|
||||||
|
|
||||||
|
|
||||||
def test_session_trust_does_not_write_to_disk(
|
def test_session_trust_does_not_write_to_disk(
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -224,3 +299,60 @@ def test_run_cli_passes_max_tokens_to_run_programmatic(
|
||||||
|
|
||||||
assert exc_info.value.code == 0
|
assert exc_info.value.code == 0
|
||||||
assert call["max_session_tokens"] == 123
|
assert call["max_session_tokens"] == 123
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cli_runs_update_prompt_before_trust_resolver(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
args = _make_args(prompt=None)
|
||||||
|
config = build_test_vibe_config()
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli_mod, "bootstrap_config_files", lambda: None)
|
||||||
|
monkeypatch.setattr(cli_mod, "load_config_or_exit", lambda interactive: config)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_mod,
|
||||||
|
"_maybe_run_startup_update_prompt",
|
||||||
|
lambda _config, _repository: calls.append("update"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_trusted_folder() -> None:
|
||||||
|
calls.append("trust")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
cli_mod.run_cli(args, resolve_trusted_folder=resolve_trusted_folder)
|
||||||
|
|
||||||
|
assert exc_info.value.code == 0
|
||||||
|
assert calls == ["update", "trust"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cli_check_upgrade_exits_before_loading_config(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
args = _make_args(prompt=None, check_upgrade=True)
|
||||||
|
call: dict[str, object] = {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli_mod, "bootstrap_config_files", lambda: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_mod,
|
||||||
|
"load_config_or_exit",
|
||||||
|
lambda interactive: pytest.fail("check-upgrade should not load config"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(cli_mod, "load_update_prompt_theme", lambda: "dracula")
|
||||||
|
|
||||||
|
def fake_run_check_upgrade(_repository: object, *, theme: str | None) -> None:
|
||||||
|
call["theme"] = theme
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli_mod, "_run_check_upgrade", fake_run_check_upgrade)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
cli_mod.run_cli(
|
||||||
|
args,
|
||||||
|
resolve_trusted_folder=lambda: pytest.fail(
|
||||||
|
"check-upgrade should not prompt for trust"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.code == 0
|
||||||
|
assert call["theme"] == "dracula"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -114,7 +113,7 @@ async def test_resume_picker_shows_renamed_session_title(
|
||||||
|
|
||||||
assert handled is True
|
assert handled is True
|
||||||
assert captured_picker is not None
|
assert captured_picker is not None
|
||||||
assert captured_picker._latest_messages[f"local:{logger.session_id}"] == "New title"
|
assert captured_picker._latest_messages[logger.session_id] == "New title"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -129,34 +128,3 @@ async def test_rename_command_requires_title(tmp_path: Path) -> None:
|
||||||
assert any(error._error == "Usage: /rename <title>" for error in errors)
|
assert any(error._error == "Usage: /rename <title>" for error in errors)
|
||||||
|
|
||||||
assert handled is True
|
assert handled is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_rename_command_is_intercepted_for_remote_sessions(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
config = build_test_vibe_config(session_logging=_enabled_session_config(tmp_path))
|
|
||||||
app = build_test_vibe_app(config=config)
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
with patch(
|
|
||||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
|
||||||
) as MockSource:
|
|
||||||
remote_source = MagicMock()
|
|
||||||
remote_source.session_id = "remote-session-id"
|
|
||||||
remote_source.is_terminated = False
|
|
||||||
remote_source.is_waiting_for_input = False
|
|
||||||
MockSource.return_value = remote_source
|
|
||||||
|
|
||||||
await app._remote_manager.attach(
|
|
||||||
session_id="remote-session-id", config=config
|
|
||||||
)
|
|
||||||
handled = await app._handle_command("/rename Remote title")
|
|
||||||
await pilot.pause()
|
|
||||||
errors = app.query(ErrorMessage)
|
|
||||||
assert any(
|
|
||||||
error._error == "Renaming is only supported for local sessions."
|
|
||||||
for error in errors
|
|
||||||
)
|
|
||||||
|
|
||||||
assert handled is True
|
|
||||||
|
|
|
||||||
|
|
@ -57,13 +57,11 @@ async def test_session_delete_request_deletes_local_session(
|
||||||
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
|
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
|
||||||
|
|
||||||
await app.on_session_picker_app_session_delete_requested(
|
await app.on_session_picker_app_session_delete_requested(
|
||||||
SessionPickerApp.SessionDeleteRequested(
|
SessionPickerApp.SessionDeleteRequested("deleted-session", "deleted-session")
|
||||||
"local:deleted-session", "local", "deleted-session"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert deleted_sessions == [("deleted-session", config.session_logging)]
|
assert deleted_sessions == [("deleted-session", config.session_logging)]
|
||||||
assert picker.removed_option_ids == ["local:deleted-session"]
|
assert picker.removed_option_ids == ["deleted-session"]
|
||||||
assert any(
|
assert any(
|
||||||
isinstance(widget, UserCommandMessage)
|
isinstance(widget, UserCommandMessage)
|
||||||
and widget._content
|
and widget._content
|
||||||
|
|
@ -96,13 +94,11 @@ async def test_session_delete_request_keeps_picker_on_delete_error(
|
||||||
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
|
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
|
||||||
|
|
||||||
await app.on_session_picker_app_session_delete_requested(
|
await app.on_session_picker_app_session_delete_requested(
|
||||||
SessionPickerApp.SessionDeleteRequested(
|
SessionPickerApp.SessionDeleteRequested("deleted-session", "deleted-session")
|
||||||
"local:deleted-session", "local", "deleted-session"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert picker.removed_option_ids == []
|
assert picker.removed_option_ids == []
|
||||||
assert picker.cleared_pending_option_ids == ["local:deleted-session"]
|
assert picker.cleared_pending_option_ids == ["deleted-session"]
|
||||||
assert any(
|
assert any(
|
||||||
isinstance(widget, ErrorMessage)
|
isinstance(widget, ErrorMessage)
|
||||||
and widget._error == "Failed to delete session: disk said no"
|
and widget._error == "Failed to delete session: disk said no"
|
||||||
|
|
@ -140,13 +136,11 @@ async def test_session_delete_request_returns_to_input_when_picker_becomes_empty
|
||||||
monkeypatch.setattr(app, "_switch_to_input_app", switch_to_input)
|
monkeypatch.setattr(app, "_switch_to_input_app", switch_to_input)
|
||||||
|
|
||||||
await app.on_session_picker_app_session_delete_requested(
|
await app.on_session_picker_app_session_delete_requested(
|
||||||
SessionPickerApp.SessionDeleteRequested(
|
SessionPickerApp.SessionDeleteRequested("deleted-session", "deleted-session")
|
||||||
"local:deleted-session", "local", "deleted-session"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert switched_to_input is True
|
assert switched_to_input is True
|
||||||
assert picker.removed_option_ids == ["local:deleted-session"]
|
assert picker.removed_option_ids == ["deleted-session"]
|
||||||
assert [
|
assert [
|
||||||
widget._content
|
widget._content
|
||||||
for widget in mounted_widgets
|
for widget in mounted_widgets
|
||||||
|
|
@ -157,40 +151,6 @@ async def test_session_delete_request_returns_to_input_when_picker_becomes_empty
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_session_delete_request_rejects_remote_sessions(
|
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
config = build_test_vibe_config(
|
|
||||||
session_logging=_enabled_session_config(tmp_path), enable_connectors=False
|
|
||||||
)
|
|
||||||
app = build_test_vibe_app(config=config)
|
|
||||||
mounted_widgets: list[object] = []
|
|
||||||
|
|
||||||
async def delete_session(
|
|
||||||
session_id: str, session_config: SessionLoggingConfig
|
|
||||||
) -> None:
|
|
||||||
pytest.fail("remote sessions should not be deleted")
|
|
||||||
|
|
||||||
async def mount_and_scroll(widget: object, after: object | None = None) -> None:
|
|
||||||
mounted_widgets.append(widget)
|
|
||||||
|
|
||||||
monkeypatch.setattr("vibe.cli.textual_ui.app.delete_saved_session", delete_session)
|
|
||||||
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
|
|
||||||
|
|
||||||
await app.on_session_picker_app_session_delete_requested(
|
|
||||||
SessionPickerApp.SessionDeleteRequested(
|
|
||||||
"remote:remote-session", "remote", "remote-session"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert any(
|
|
||||||
isinstance(widget, ErrorMessage)
|
|
||||||
and widget._error == "Deleting remote sessions is not supported."
|
|
||||||
for widget in mounted_widgets
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_delete_request_rejects_current_session(
|
async def test_session_delete_request_rejects_current_session(
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
|
@ -217,12 +177,12 @@ async def test_session_delete_request_rejects_current_session(
|
||||||
|
|
||||||
await app.on_session_picker_app_session_delete_requested(
|
await app.on_session_picker_app_session_delete_requested(
|
||||||
SessionPickerApp.SessionDeleteRequested(
|
SessionPickerApp.SessionDeleteRequested(
|
||||||
f"local:{app.agent_loop.session_id}", "local", app.agent_loop.session_id
|
app.agent_loop.session_id, app.agent_loop.session_id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert deleted_sessions == []
|
assert deleted_sessions == []
|
||||||
assert picker.cleared_pending_option_ids == [f"local:{app.agent_loop.session_id}"]
|
assert picker.cleared_pending_option_ids == [app.agent_loop.session_id]
|
||||||
assert any(
|
assert any(
|
||||||
isinstance(widget, ErrorMessage)
|
isinstance(widget, ErrorMessage)
|
||||||
and widget._error == "Deleting the current session is not supported."
|
and widget._error == "Deleting the current session is not supported."
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,20 @@ from unittest.mock import patch
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests.conftest import build_test_vibe_config
|
from tests.conftest import build_test_vibe_config
|
||||||
from vibe.cli.cli import _maybe_run_startup_update_prompt
|
from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
|
||||||
from vibe.cli.update_notifier import FileSystemUpdateCacheRepository, UpdateCache
|
from vibe import __version__
|
||||||
from vibe.setup.update_prompt.update_prompt_dialog import UpdatePromptResult
|
from vibe.cli.cli import _maybe_run_startup_update_prompt, _run_check_upgrade
|
||||||
|
from vibe.cli.update_notifier import (
|
||||||
|
FileSystemUpdateCacheRepository,
|
||||||
|
Update,
|
||||||
|
UpdateCache,
|
||||||
|
UpdateGatewayCause,
|
||||||
|
UpdateGatewayError,
|
||||||
|
)
|
||||||
|
from vibe.setup.update_prompt.update_prompt_dialog import (
|
||||||
|
UpdatePromptMode,
|
||||||
|
UpdatePromptResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _BrokenRepository:
|
class _BrokenRepository:
|
||||||
|
|
@ -129,6 +140,76 @@ def test_no_op_when_cache_read_raises_oserror() -> None:
|
||||||
mock_ask.assert_not_called()
|
mock_ask.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_upgrade_fetches_and_prompts_when_update_is_available(
|
||||||
|
repository: FileSystemUpdateCacheRepository,
|
||||||
|
) -> None:
|
||||||
|
notifier = FakeUpdateGateway(update=Update(latest_version="999.0.0"))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE
|
||||||
|
) as mock_ask:
|
||||||
|
_run_check_upgrade(repository, update_notifier=notifier, theme="textual-light")
|
||||||
|
|
||||||
|
mock_ask.assert_called_once_with(
|
||||||
|
__version__,
|
||||||
|
"999.0.0",
|
||||||
|
theme="textual-light",
|
||||||
|
prompt_mode=UpdatePromptMode.CHECK_UPGRADE,
|
||||||
|
)
|
||||||
|
assert notifier.fetch_update_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_upgrade_cancel_does_not_dismiss_update(
|
||||||
|
repository: FileSystemUpdateCacheRepository,
|
||||||
|
) -> None:
|
||||||
|
config = build_test_vibe_config(enable_update_checks=True)
|
||||||
|
notifier = FakeUpdateGateway(update=Update(latest_version="999.0.0"))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE
|
||||||
|
):
|
||||||
|
_run_check_upgrade(repository, update_notifier=notifier)
|
||||||
|
|
||||||
|
cache = asyncio.run(repository.get())
|
||||||
|
assert cache is not None
|
||||||
|
assert cache.dismissed_version is None
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE
|
||||||
|
) as mock_ask:
|
||||||
|
_maybe_run_startup_update_prompt(config, repository)
|
||||||
|
|
||||||
|
mock_ask.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_upgrade_prints_up_to_date_when_no_update_exists(
|
||||||
|
repository: FileSystemUpdateCacheRepository, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
notifier = FakeUpdateGateway(update=None)
|
||||||
|
|
||||||
|
with patch("vibe.cli.cli.ask_update_prompt") as mock_ask:
|
||||||
|
_run_check_upgrade(repository, update_notifier=notifier)
|
||||||
|
|
||||||
|
mock_ask.assert_not_called()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "already up to date" in out
|
||||||
|
assert __version__ in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_upgrade_exits_one_when_gateway_errors(
|
||||||
|
repository: FileSystemUpdateCacheRepository, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
notifier = FakeUpdateGateway(
|
||||||
|
error=UpdateGatewayError(cause=UpdateGatewayCause.REQUEST_FAILED)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as excinfo:
|
||||||
|
_run_check_upgrade(repository, update_notifier=notifier)
|
||||||
|
|
||||||
|
assert excinfo.value.code == 1
|
||||||
|
assert "Update check failed" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
def test_continue_marks_version_as_dismissed_and_prevents_reprompt(
|
def test_continue_marks_version_as_dismissed_and_prevents_reprompt(
|
||||||
repository: FileSystemUpdateCacheRepository,
|
repository: FileSystemUpdateCacheRepository,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,11 @@ import pytest
|
||||||
|
|
||||||
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
|
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
|
||||||
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
||||||
|
from tests.constants import OPENAI_BASE_URL
|
||||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse
|
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.chat_input import ChatInputContainer
|
||||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||||
from vibe.core.types import Backend, LLMMessage, Role
|
from vibe.core.types import Backend
|
||||||
|
|
||||||
|
|
||||||
def _chat_plan_gateway(*, prompt_switching_to_pro_plan: bool) -> FakeWhoAmIGateway:
|
def _chat_plan_gateway(*, prompt_switching_to_pro_plan: bool) -> FakeWhoAmIGateway:
|
||||||
|
|
@ -100,46 +101,6 @@ async def test_teleport_command_without_history_sends_early_failure_telemetry(
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_teleport_command_in_remote_session_sends_early_failure_telemetry(
|
|
||||||
telemetry_events: list[dict[str, Any]],
|
|
||||||
) -> None:
|
|
||||||
app = build_test_vibe_app(
|
|
||||||
config=_vibe_code_enabled_config(),
|
|
||||||
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=False),
|
|
||||||
)
|
|
||||||
app.agent_loop.messages.append(LLMMessage(role=Role.user, content="hello"))
|
|
||||||
app.agent_loop.messages.append(LLMMessage(role=Role.assistant, content="hi"))
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
await _wait_until(
|
|
||||||
pilot.pause,
|
|
||||||
lambda: app.commands.get_command_name("/teleport") == "teleport",
|
|
||||||
)
|
|
||||||
|
|
||||||
await app._remote_manager.attach(session_id="remote-session", config=app.config)
|
|
||||||
await app.on_chat_input_container_submitted(
|
|
||||||
ChatInputContainer.Submitted("/teleport")
|
|
||||||
)
|
|
||||||
await _wait_until(
|
|
||||||
pilot.pause, lambda: len(_teleport_failed_events(telemetry_events)) == 1
|
|
||||||
)
|
|
||||||
await app._remote_manager.detach()
|
|
||||||
|
|
||||||
assert _teleport_failed_events(telemetry_events) == [
|
|
||||||
{
|
|
||||||
"event_name": "vibe.teleport_failed",
|
|
||||||
"properties": {
|
|
||||||
"stage": "remote_session",
|
|
||||||
"error_class": "TeleportRemoteSessionError",
|
|
||||||
"push_required": False,
|
|
||||||
"nb_session_messages": 2,
|
|
||||||
"session_id": app.agent_loop.session_id,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_teleport_command_hidden_when_current_key_is_not_eligible() -> None:
|
async def test_teleport_command_hidden_when_current_key_is_not_eligible() -> None:
|
||||||
app = build_test_vibe_app(
|
app = build_test_vibe_app(
|
||||||
|
|
@ -215,7 +176,7 @@ async def test_teleport_command_hides_after_switching_to_non_mistral_model(
|
||||||
),
|
),
|
||||||
ProviderConfig(
|
ProviderConfig(
|
||||||
name="openai",
|
name="openai",
|
||||||
api_base="https://api.openai.com/v1",
|
api_base=f"{OPENAI_BASE_URL}/v1",
|
||||||
api_key_env_var="OPENAI_API_KEY",
|
api_key_env_var="OPENAI_API_KEY",
|
||||||
backend=Backend.GENERIC,
|
backend=Backend.GENERIC,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
|
|
||||||
|
|
||||||
|
|
||||||
def test_rendered_text_length_uses_terminal_cell_width() -> None:
|
|
||||||
# "你" and "🙂" both occupy 2 terminal cells in Rich (+2 for separator).
|
|
||||||
assert CompletionPopup.rendered_text_length("@你", "🙂") == 6
|
|
||||||
|
|
||||||
|
|
||||||
def test_rendered_text_length_keeps_description_separator() -> None:
|
|
||||||
assert CompletionPopup.rendered_text_length("@abc", "def") == 8
|
|
||||||
|
|
@ -22,6 +22,11 @@ def _build(
|
||||||
|
|
||||||
def _render(*args, **kwargs):
|
def _render(*args, **kwargs):
|
||||||
kwargs.setdefault("dark", True)
|
kwargs.setdefault("dark", True)
|
||||||
|
args = list(args)
|
||||||
|
# Tests pass a single start line as an int for readability; the renderer
|
||||||
|
# expects a list of occurrences.
|
||||||
|
if len(args) >= 4 and isinstance(args[3], int):
|
||||||
|
args[3] = [args[3]]
|
||||||
return render_edit_diff(*args, **kwargs)
|
return render_edit_diff(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -156,6 +161,32 @@ class TestRenderEditDiff:
|
||||||
assert any(_plain(w).rstrip().endswith("d") for w in removed)
|
assert any(_plain(w).rstrip().endswith("d") for w in removed)
|
||||||
assert any(_plain(w).rstrip().endswith("Z") for w in added)
|
assert any(_plain(w).rstrip().endswith("Z") for w in added)
|
||||||
|
|
||||||
|
def test_replace_all_renders_each_occurrence(self) -> None:
|
||||||
|
widgets = render_edit_diff(
|
||||||
|
"foo", "bar", "py", [3, 10, 25], ansi=False, dark=True
|
||||||
|
)
|
||||||
|
removed = [w for w in widgets if "diff-removed" in w.classes]
|
||||||
|
added = [w for w in widgets if "diff-added" in w.classes]
|
||||||
|
assert len(removed) == 3
|
||||||
|
assert len(added) == 3
|
||||||
|
|
||||||
|
def test_replace_all_uses_each_start_line(self) -> None:
|
||||||
|
widgets = render_edit_diff(
|
||||||
|
"foo", "bar", "py", [3, 10, 25], ansi=False, dark=True
|
||||||
|
)
|
||||||
|
joined = "\n".join(_plain(w) for w in widgets)
|
||||||
|
assert "3" in joined
|
||||||
|
assert "10" in joined
|
||||||
|
assert "25" in joined
|
||||||
|
|
||||||
|
def test_replace_all_separates_occurrences_with_gap(self) -> None:
|
||||||
|
widgets = render_edit_diff("foo", "bar", "py", [3, 10], ansi=False, dark=True)
|
||||||
|
assert sum("diff-gap" in w.classes for w in widgets) == 1
|
||||||
|
|
||||||
|
def test_single_occurrence_has_no_gap(self) -> None:
|
||||||
|
widgets = render_edit_diff("foo", "bar", "py", [3], ansi=False, dark=True)
|
||||||
|
assert all("diff-gap" not in w.classes for w in widgets)
|
||||||
|
|
||||||
|
|
||||||
class TestBorderColors:
|
class TestBorderColors:
|
||||||
def test_keys_index_into_widgets(self) -> None:
|
def test_keys_index_into_widgets(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,12 @@ async def test_no_queue_header_when_empty(vibe_app: VibeApp) -> None:
|
||||||
async def test_bash_submitted_during_running_bash_is_queued(vibe_app: VibeApp) -> None:
|
async def test_bash_submitted_during_running_bash_is_queued(vibe_app: VibeApp) -> None:
|
||||||
async with vibe_app.run_test() as pilot:
|
async with vibe_app.run_test() as pilot:
|
||||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||||
chat_input.value = "!sleep 0.3"
|
chat_input.value = "!sleep 1"
|
||||||
await pilot.press("enter")
|
await pilot.press("enter")
|
||||||
|
|
||||||
await _wait_until(pilot, lambda: vibe_app._bash_task is not None, timeout=1.0)
|
assert await _wait_until(
|
||||||
|
pilot, lambda: vibe_app._bash_task is not None, timeout=2.0
|
||||||
|
)
|
||||||
|
|
||||||
chat_input.value = "!echo queued"
|
chat_input.value = "!echo queued"
|
||||||
await pilot.press("enter")
|
await pilot.press("enter")
|
||||||
|
|
@ -56,6 +58,15 @@ async def test_bash_submitted_during_running_bash_is_queued(vibe_app: VibeApp) -
|
||||||
queued_bashes = [w for w in vibe_app.query(BashOutputMessage) if w._queued]
|
queued_bashes = [w for w in vibe_app.query(BashOutputMessage) if w._queued]
|
||||||
assert len(queued_bashes) == 1
|
assert len(queued_bashes) == 1
|
||||||
|
|
||||||
|
await pilot.press("ctrl+c")
|
||||||
|
assert await _wait_until(
|
||||||
|
pilot, lambda: len(vibe_app._input_queue) == 0, timeout=2.0
|
||||||
|
)
|
||||||
|
await pilot.press("escape")
|
||||||
|
assert await _wait_until(
|
||||||
|
pilot, lambda: vibe_app._bash_task is None, timeout=5.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_slash_command_rejected_with_warning_when_busy(vibe_app: VibeApp) -> None:
|
async def test_slash_command_rejected_with_warning_when_busy(vibe_app: VibeApp) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests.conftest import build_test_vibe_app
|
from tests.conftest import build_test_vibe_app
|
||||||
from vibe.cli.textual_ui.app import VibeApp
|
from vibe.cli.textual_ui.app import VibeApp, _run_app_with_cleanup
|
||||||
from vibe.cli.textual_ui.quit_manager import QUIT_CONFIRM_DELAY, QuitManager
|
from vibe.cli.textual_ui.quit_manager import QUIT_CONFIRM_DELAY, QuitManager
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -182,3 +183,69 @@ class TestActionDeleteRightOrQuit:
|
||||||
mock_confirm.assert_called_once_with(
|
mock_confirm.assert_called_once_with(
|
||||||
"Ctrl+D", "1 queued message will be discarded"
|
"Ctrl+D", "1 queued message will be discarded"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shutdown_cleanup_cancels_in_flight_tasks(app: VibeApp) -> None:
|
||||||
|
async def _pending() -> None:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
agent_task = asyncio.create_task(_pending())
|
||||||
|
bash_task = asyncio.create_task(_pending())
|
||||||
|
app._agent_task = agent_task
|
||||||
|
app._bash_task = bash_task
|
||||||
|
|
||||||
|
await asyncio.wait_for(app.shutdown_cleanup(), timeout=1.0)
|
||||||
|
|
||||||
|
assert agent_task.cancelled()
|
||||||
|
assert bash_task.cancelled()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shutdown_disables_future_queue_drains(app: VibeApp) -> None:
|
||||||
|
app._input_queue.append_prompt("queued")
|
||||||
|
|
||||||
|
await app._begin_shutdown()
|
||||||
|
|
||||||
|
with patch("vibe.cli.textual_ui.message_queue.asyncio.create_task") as create_task:
|
||||||
|
app._queue.start_drain_if_needed()
|
||||||
|
|
||||||
|
create_task.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_begin_shutdown_stops_scheduled_loop_runner(app: VibeApp) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(app._queue, "shutdown", new_callable=AsyncMock) as queue_shutdown,
|
||||||
|
patch.object(app._loop_runner, "stop", new_callable=AsyncMock) as loop_stop,
|
||||||
|
):
|
||||||
|
await app._begin_shutdown()
|
||||||
|
|
||||||
|
queue_shutdown.assert_awaited_once()
|
||||||
|
loop_stop.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shutdown_cleanup_flushes_telemetry(app: VibeApp) -> None:
|
||||||
|
with patch.object(
|
||||||
|
app.agent_loop.telemetry_client, "aclose", new_callable=AsyncMock
|
||||||
|
) as telemetry_aclose:
|
||||||
|
await app.shutdown_cleanup()
|
||||||
|
|
||||||
|
telemetry_aclose.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_app_with_cleanup_runs_cleanup_when_run_async_raises(
|
||||||
|
app: VibeApp,
|
||||||
|
) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
app, "run_async", new_callable=AsyncMock, side_effect=RuntimeError("boom")
|
||||||
|
),
|
||||||
|
patch.object(app, "shutdown_cleanup", new_callable=AsyncMock) as cleanup,
|
||||||
|
):
|
||||||
|
with pytest.raises(RuntimeError, match="boom"):
|
||||||
|
await _run_app_with_cleanup(app)
|
||||||
|
|
||||||
|
cleanup.assert_awaited_once()
|
||||||
|
|
|
||||||
|
|
@ -1,286 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from vibe.cli.textual_ui.remote.remote_session_manager import RemoteSessionManager
|
|
||||||
from vibe.core.tools.builtins.ask_user_question import AskUserQuestionArgs
|
|
||||||
from vibe.core.types import WaitingForInputEvent
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def manager() -> RemoteSessionManager:
|
|
||||||
return RemoteSessionManager()
|
|
||||||
|
|
||||||
|
|
||||||
class TestProperties:
|
|
||||||
def test_is_active_false_by_default(self, manager: RemoteSessionManager) -> None:
|
|
||||||
assert manager.is_active is False
|
|
||||||
|
|
||||||
def test_is_terminated_false_when_inactive(
|
|
||||||
self, manager: RemoteSessionManager
|
|
||||||
) -> None:
|
|
||||||
assert manager.is_terminated is False
|
|
||||||
|
|
||||||
def test_is_waiting_for_input_false_when_inactive(
|
|
||||||
self, manager: RemoteSessionManager
|
|
||||||
) -> None:
|
|
||||||
assert manager.is_waiting_for_input is False
|
|
||||||
|
|
||||||
def test_has_pending_input_false_by_default(
|
|
||||||
self, manager: RemoteSessionManager
|
|
||||||
) -> None:
|
|
||||||
assert manager.has_pending_input is False
|
|
||||||
|
|
||||||
def test_session_id_none_when_inactive(self, manager: RemoteSessionManager) -> None:
|
|
||||||
assert manager.session_id is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestAttachDetach:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_attach_activates_manager(
|
|
||||||
self, manager: RemoteSessionManager
|
|
||||||
) -> None:
|
|
||||||
with patch(
|
|
||||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
|
||||||
) as MockSource:
|
|
||||||
mock_source = MagicMock()
|
|
||||||
mock_source.session_id = "test-session-id"
|
|
||||||
MockSource.return_value = mock_source
|
|
||||||
|
|
||||||
config = MagicMock()
|
|
||||||
await manager.attach(session_id="test-session-id", config=config)
|
|
||||||
|
|
||||||
assert manager.is_active is True
|
|
||||||
assert manager.session_id == "test-session-id"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_detach_cleans_up(self, manager: RemoteSessionManager) -> None:
|
|
||||||
with patch(
|
|
||||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
|
||||||
) as MockSource:
|
|
||||||
mock_source = AsyncMock()
|
|
||||||
mock_source.session_id = "test-id"
|
|
||||||
MockSource.return_value = mock_source
|
|
||||||
|
|
||||||
config = MagicMock()
|
|
||||||
await manager.attach(session_id="test-id", config=config)
|
|
||||||
await manager.detach()
|
|
||||||
|
|
||||||
assert manager.is_active is False
|
|
||||||
assert manager.session_id is None
|
|
||||||
assert manager.has_pending_input is False
|
|
||||||
mock_source.close.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_attach_detaches_previous_session(
|
|
||||||
self, manager: RemoteSessionManager
|
|
||||||
) -> None:
|
|
||||||
with patch(
|
|
||||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
|
||||||
) as MockSource:
|
|
||||||
first_source = AsyncMock()
|
|
||||||
second_source = MagicMock()
|
|
||||||
second_source.session_id = "second-id"
|
|
||||||
MockSource.side_effect = [first_source, second_source]
|
|
||||||
|
|
||||||
config = MagicMock()
|
|
||||||
await manager.attach(session_id="first-id", config=config)
|
|
||||||
await manager.attach(session_id="second-id", config=config)
|
|
||||||
|
|
||||||
first_source.close.assert_called_once()
|
|
||||||
assert manager.session_id == "second-id"
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidateInput:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_returns_none_when_waiting_for_input(
|
|
||||||
self, manager: RemoteSessionManager
|
|
||||||
) -> None:
|
|
||||||
with patch(
|
|
||||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
|
||||||
) as MockSource:
|
|
||||||
mock_source = MagicMock()
|
|
||||||
mock_source.is_terminated = False
|
|
||||||
mock_source.is_waiting_for_input = True
|
|
||||||
MockSource.return_value = mock_source
|
|
||||||
|
|
||||||
config = MagicMock()
|
|
||||||
await manager.attach(session_id="id", config=config)
|
|
||||||
|
|
||||||
assert manager.validate_input() is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_returns_warning_when_terminated(
|
|
||||||
self, manager: RemoteSessionManager
|
|
||||||
) -> None:
|
|
||||||
with patch(
|
|
||||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
|
||||||
) as MockSource:
|
|
||||||
mock_source = MagicMock()
|
|
||||||
mock_source.is_terminated = True
|
|
||||||
MockSource.return_value = mock_source
|
|
||||||
|
|
||||||
config = MagicMock()
|
|
||||||
await manager.attach(session_id="id", config=config)
|
|
||||||
|
|
||||||
result = manager.validate_input()
|
|
||||||
assert result is not None
|
|
||||||
assert "ended" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_returns_warning_when_not_waiting_for_input(
|
|
||||||
self, manager: RemoteSessionManager
|
|
||||||
) -> None:
|
|
||||||
with patch(
|
|
||||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
|
||||||
) as MockSource:
|
|
||||||
mock_source = MagicMock()
|
|
||||||
mock_source.is_terminated = False
|
|
||||||
mock_source.is_waiting_for_input = False
|
|
||||||
MockSource.return_value = mock_source
|
|
||||||
|
|
||||||
config = MagicMock()
|
|
||||||
await manager.attach(session_id="id", config=config)
|
|
||||||
|
|
||||||
result = manager.validate_input()
|
|
||||||
assert result is not None
|
|
||||||
assert "not waiting" in result
|
|
||||||
|
|
||||||
|
|
||||||
class TestSendPrompt:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_raises_when_inactive_and_required(
|
|
||||||
self, manager: RemoteSessionManager
|
|
||||||
) -> None:
|
|
||||||
with pytest.raises(RuntimeError, match="No active remote session"):
|
|
||||||
await manager.send_prompt("hello")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_returns_silently_when_inactive_and_not_required(
|
|
||||||
self, manager: RemoteSessionManager
|
|
||||||
) -> None:
|
|
||||||
await manager.send_prompt("hello", require_source=False)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_restores_pending_on_error(
|
|
||||||
self, manager: RemoteSessionManager
|
|
||||||
) -> None:
|
|
||||||
with patch(
|
|
||||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
|
||||||
) as MockSource:
|
|
||||||
mock_source = AsyncMock()
|
|
||||||
mock_source.send_prompt.side_effect = Exception("connection error")
|
|
||||||
MockSource.return_value = mock_source
|
|
||||||
|
|
||||||
config = MagicMock()
|
|
||||||
await manager.attach(session_id="id", config=config)
|
|
||||||
|
|
||||||
event = WaitingForInputEvent(task_id="t1", label="test")
|
|
||||||
manager.set_pending_input(event)
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match="connection error"):
|
|
||||||
await manager.send_prompt("hello")
|
|
||||||
|
|
||||||
assert manager.has_pending_input is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestPendingInput:
|
|
||||||
def test_set_and_cancel_pending_input(self, manager: RemoteSessionManager) -> None:
|
|
||||||
event = WaitingForInputEvent(task_id="t1", label="test")
|
|
||||||
manager.set_pending_input(event)
|
|
||||||
assert manager.has_pending_input is True
|
|
||||||
|
|
||||||
manager.cancel_pending_input()
|
|
||||||
assert manager.has_pending_input is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildQuestionArgs:
|
|
||||||
def test_returns_none_with_no_predefined_answers(
|
|
||||||
self, manager: RemoteSessionManager
|
|
||||||
) -> None:
|
|
||||||
event = WaitingForInputEvent(task_id="t1", label="test")
|
|
||||||
assert manager.build_question_args(event) is None
|
|
||||||
|
|
||||||
def test_returns_none_with_one_predefined_answer(
|
|
||||||
self, manager: RemoteSessionManager
|
|
||||||
) -> None:
|
|
||||||
event = WaitingForInputEvent(
|
|
||||||
task_id="t1", label="test", predefined_answers=["only one"]
|
|
||||||
)
|
|
||||||
assert manager.build_question_args(event) is None
|
|
||||||
|
|
||||||
def test_returns_args_with_two_predefined_answers(
|
|
||||||
self, manager: RemoteSessionManager
|
|
||||||
) -> None:
|
|
||||||
event = WaitingForInputEvent(
|
|
||||||
task_id="t1", label="Pick one", predefined_answers=["yes", "no"]
|
|
||||||
)
|
|
||||||
result = manager.build_question_args(event)
|
|
||||||
assert result is not None
|
|
||||||
assert isinstance(result, AskUserQuestionArgs)
|
|
||||||
assert len(result.questions) == 1
|
|
||||||
assert result.questions[0].question == "Pick one"
|
|
||||||
assert len(result.questions[0].options) == 2
|
|
||||||
|
|
||||||
def test_caps_at_four_predefined_answers(
|
|
||||||
self, manager: RemoteSessionManager
|
|
||||||
) -> None:
|
|
||||||
event = WaitingForInputEvent(
|
|
||||||
task_id="t1",
|
|
||||||
label="Pick",
|
|
||||||
predefined_answers=["a", "b", "c", "d", "e", "f"],
|
|
||||||
)
|
|
||||||
result = manager.build_question_args(event)
|
|
||||||
assert result is not None
|
|
||||||
assert len(result.questions[0].options) == 4
|
|
||||||
|
|
||||||
def test_uses_default_question_when_no_label(
|
|
||||||
self, manager: RemoteSessionManager
|
|
||||||
) -> None:
|
|
||||||
event = WaitingForInputEvent(task_id="t1", predefined_answers=["a", "b"])
|
|
||||||
result = manager.build_question_args(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result.questions[0].question == "Choose an answer"
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildTerminalMessage:
|
|
||||||
def test_completed_when_no_source(self, manager: RemoteSessionManager) -> None:
|
|
||||||
msg_type, text = manager.build_terminal_message()
|
|
||||||
assert msg_type == "info"
|
|
||||||
assert "completed" in text
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_failed_state(self, manager: RemoteSessionManager) -> None:
|
|
||||||
with patch(
|
|
||||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
|
||||||
) as MockSource:
|
|
||||||
mock_source = MagicMock()
|
|
||||||
mock_source.is_failed = True
|
|
||||||
mock_source.is_canceled = False
|
|
||||||
MockSource.return_value = mock_source
|
|
||||||
|
|
||||||
config = MagicMock()
|
|
||||||
await manager.attach(session_id="id", config=config)
|
|
||||||
|
|
||||||
msg_type, text = manager.build_terminal_message()
|
|
||||||
assert msg_type == "error"
|
|
||||||
assert "failed" in text.lower()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_canceled_state(self, manager: RemoteSessionManager) -> None:
|
|
||||||
with patch(
|
|
||||||
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
|
|
||||||
) as MockSource:
|
|
||||||
mock_source = MagicMock()
|
|
||||||
mock_source.is_failed = False
|
|
||||||
mock_source.is_canceled = True
|
|
||||||
MockSource.return_value = mock_source
|
|
||||||
|
|
||||||
config = MagicMock()
|
|
||||||
await manager.attach(session_id="id", config=config)
|
|
||||||
|
|
||||||
msg_type, text = manager.build_terminal_message()
|
|
||||||
assert msg_type == "warning"
|
|
||||||
assert "canceled" in text.lower()
|
|
||||||
|
|
@ -19,25 +19,21 @@ def sample_sessions() -> list[ResumeSessionInfo]:
|
||||||
return [
|
return [
|
||||||
ResumeSessionInfo(
|
ResumeSessionInfo(
|
||||||
session_id="session-a",
|
session_id="session-a",
|
||||||
source="local",
|
|
||||||
cwd="/test",
|
cwd="/test",
|
||||||
title="Session A",
|
title="Session A",
|
||||||
end_time=(datetime.now(UTC) - timedelta(minutes=5)).isoformat(),
|
end_time=(datetime.now(UTC) - timedelta(minutes=5)).isoformat(),
|
||||||
),
|
),
|
||||||
ResumeSessionInfo(
|
ResumeSessionInfo(
|
||||||
session_id="session-b",
|
session_id="session-b",
|
||||||
source="local",
|
|
||||||
cwd="/test",
|
cwd="/test",
|
||||||
title="Session B",
|
title="Session B",
|
||||||
end_time=(datetime.now(UTC) - timedelta(hours=1)).isoformat(),
|
end_time=(datetime.now(UTC) - timedelta(hours=1)).isoformat(),
|
||||||
),
|
),
|
||||||
ResumeSessionInfo(
|
ResumeSessionInfo(
|
||||||
session_id="session-c",
|
session_id="session-c",
|
||||||
source="remote",
|
|
||||||
cwd="/test",
|
cwd="/test",
|
||||||
title="Session C",
|
title="Session C",
|
||||||
end_time=(datetime.now(UTC) - timedelta(days=1)).isoformat(),
|
end_time=(datetime.now(UTC) - timedelta(days=1)).isoformat(),
|
||||||
status="RUNNING",
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -45,9 +41,9 @@ def sample_sessions() -> list[ResumeSessionInfo]:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_latest_messages() -> dict[str, str]:
|
def sample_latest_messages() -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"local:session-a": "Help me fix this bug",
|
"session-a": "Help me fix this bug",
|
||||||
"local:session-b": "Refactor the authentication module",
|
"session-b": "Refactor the authentication module",
|
||||||
"remote:session-c": "Add unit tests for the API",
|
"session-c": "Add unit tests for the API",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -143,31 +139,19 @@ class TestSessionPickerAppInit:
|
||||||
|
|
||||||
class TestSessionPickerMessages:
|
class TestSessionPickerMessages:
|
||||||
def test_session_selected_stores_option_id(self) -> None:
|
def test_session_selected_stores_option_id(self) -> None:
|
||||||
msg = SessionPickerApp.SessionSelected(
|
msg = SessionPickerApp.SessionSelected("test-session-id", "test-session-id")
|
||||||
"local:test-session-id", "local", "test-session-id"
|
assert msg.option_id == "test-session-id"
|
||||||
)
|
|
||||||
assert msg.option_id == "local:test-session-id"
|
|
||||||
assert msg.source == "local"
|
|
||||||
assert msg.session_id == "test-session-id"
|
assert msg.session_id == "test-session-id"
|
||||||
|
|
||||||
def test_session_selected_with_full_uuid(self) -> None:
|
|
||||||
session_id = "abc12345-6789-0123-4567-89abcdef0123"
|
|
||||||
option_id = f"remote:{session_id}"
|
|
||||||
msg = SessionPickerApp.SessionSelected(option_id, "remote", session_id)
|
|
||||||
assert msg.option_id == option_id
|
|
||||||
assert msg.source == "remote"
|
|
||||||
assert msg.session_id == session_id
|
|
||||||
|
|
||||||
def test_cancelled_can_be_instantiated(self) -> None:
|
def test_cancelled_can_be_instantiated(self) -> None:
|
||||||
msg = SessionPickerApp.Cancelled()
|
msg = SessionPickerApp.Cancelled()
|
||||||
assert isinstance(msg, SessionPickerApp.Cancelled)
|
assert isinstance(msg, SessionPickerApp.Cancelled)
|
||||||
|
|
||||||
def test_session_delete_requested_stores_session_info(self) -> None:
|
def test_session_delete_requested_stores_session_info(self) -> None:
|
||||||
msg = SessionPickerApp.SessionDeleteRequested(
|
msg = SessionPickerApp.SessionDeleteRequested(
|
||||||
"local:test-session-id", "local", "test-session-id"
|
"test-session-id", "test-session-id"
|
||||||
)
|
)
|
||||||
assert msg.option_id == "local:test-session-id"
|
assert msg.option_id == "test-session-id"
|
||||||
assert msg.source == "local"
|
|
||||||
assert msg.session_id == "test-session-id"
|
assert msg.session_id == "test-session-id"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -199,15 +183,15 @@ class TestSessionPickerSessionRemoval:
|
||||||
picker = SessionPickerApp(
|
picker = SessionPickerApp(
|
||||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||||
)
|
)
|
||||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
option_list = FakeOptionList(highlighted_option_id="session-a")
|
||||||
posted_messages: list[object] = []
|
posted_messages: list[object] = []
|
||||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||||
|
|
||||||
picker.action_request_delete()
|
picker.action_request_delete()
|
||||||
|
|
||||||
assert_delete_state(picker, kind="confirmation", option_id="local:session-a")
|
assert_delete_state(picker, kind="confirmation", option_id="session-a")
|
||||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
assert option_list.replaced_prompts[-1].option_id == "session-a"
|
||||||
assert (
|
assert (
|
||||||
"Press D again to delete" in option_list.replaced_prompts[-1].prompt.plain
|
"Press D again to delete" in option_list.replaced_prompts[-1].prompt.plain
|
||||||
)
|
)
|
||||||
|
|
@ -222,7 +206,7 @@ class TestSessionPickerSessionRemoval:
|
||||||
picker = SessionPickerApp(
|
picker = SessionPickerApp(
|
||||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||||
)
|
)
|
||||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
option_list = FakeOptionList(highlighted_option_id="session-a")
|
||||||
posted_messages: list[object] = []
|
posted_messages: list[object] = []
|
||||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||||
|
|
@ -230,14 +214,13 @@ class TestSessionPickerSessionRemoval:
|
||||||
picker.action_request_delete()
|
picker.action_request_delete()
|
||||||
picker.action_request_delete()
|
picker.action_request_delete()
|
||||||
|
|
||||||
assert_delete_state(picker, kind="pending", option_id="local:session-a")
|
assert_delete_state(picker, kind="pending", option_id="session-a")
|
||||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
assert option_list.replaced_prompts[-1].option_id == "session-a"
|
||||||
assert "Deleting..." in option_list.replaced_prompts[-1].prompt.plain
|
assert "Deleting..." in option_list.replaced_prompts[-1].prompt.plain
|
||||||
assert len(posted_messages) == 1
|
assert len(posted_messages) == 1
|
||||||
message = posted_messages[0]
|
message = posted_messages[0]
|
||||||
assert isinstance(message, SessionPickerApp.SessionDeleteRequested)
|
assert isinstance(message, SessionPickerApp.SessionDeleteRequested)
|
||||||
assert message.option_id == "local:session-a"
|
assert message.option_id == "session-a"
|
||||||
assert message.source == "local"
|
|
||||||
assert message.session_id == "session-a"
|
assert message.session_id == "session-a"
|
||||||
|
|
||||||
def test_delete_confirmation_is_consumed_after_request(
|
def test_delete_confirmation_is_consumed_after_request(
|
||||||
|
|
@ -249,7 +232,7 @@ class TestSessionPickerSessionRemoval:
|
||||||
picker = SessionPickerApp(
|
picker = SessionPickerApp(
|
||||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||||
)
|
)
|
||||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
option_list = FakeOptionList(highlighted_option_id="session-a")
|
||||||
posted_messages: list[object] = []
|
posted_messages: list[object] = []
|
||||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||||
|
|
@ -259,34 +242,10 @@ class TestSessionPickerSessionRemoval:
|
||||||
picker.action_request_delete()
|
picker.action_request_delete()
|
||||||
|
|
||||||
assert len(posted_messages) == 1
|
assert len(posted_messages) == 1
|
||||||
assert_delete_state(picker, kind="pending", option_id="local:session-a")
|
assert_delete_state(picker, kind="pending", option_id="session-a")
|
||||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
assert option_list.replaced_prompts[-1].option_id == "session-a"
|
||||||
assert "Deleting..." in option_list.replaced_prompts[-1].prompt.plain
|
assert "Deleting..." in option_list.replaced_prompts[-1].prompt.plain
|
||||||
|
|
||||||
def test_delete_request_shows_feedback_for_remote_sessions(
|
|
||||||
self,
|
|
||||||
sample_sessions: list[ResumeSessionInfo],
|
|
||||||
sample_latest_messages: dict[str, str],
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
picker = SessionPickerApp(
|
|
||||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
|
||||||
)
|
|
||||||
option_list = FakeOptionList(highlighted_option_id="remote:session-c")
|
|
||||||
posted_messages: list[object] = []
|
|
||||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
|
||||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
|
||||||
|
|
||||||
picker.action_request_delete()
|
|
||||||
|
|
||||||
assert_delete_state(picker, kind="feedback", option_id="remote:session-c")
|
|
||||||
assert option_list.replaced_prompts[-1].option_id == "remote:session-c"
|
|
||||||
assert (
|
|
||||||
"Can't delete remote session"
|
|
||||||
in option_list.replaced_prompts[-1].prompt.plain
|
|
||||||
)
|
|
||||||
assert posted_messages == []
|
|
||||||
|
|
||||||
def test_delete_request_shows_feedback_for_current_session(
|
def test_delete_request_shows_feedback_for_current_session(
|
||||||
self,
|
self,
|
||||||
sample_sessions: list[ResumeSessionInfo],
|
sample_sessions: list[ResumeSessionInfo],
|
||||||
|
|
@ -298,15 +257,15 @@ class TestSessionPickerSessionRemoval:
|
||||||
latest_messages=sample_latest_messages,
|
latest_messages=sample_latest_messages,
|
||||||
current_session_id="session-a",
|
current_session_id="session-a",
|
||||||
)
|
)
|
||||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
option_list = FakeOptionList(highlighted_option_id="session-a")
|
||||||
posted_messages: list[object] = []
|
posted_messages: list[object] = []
|
||||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||||
|
|
||||||
picker.action_request_delete()
|
picker.action_request_delete()
|
||||||
|
|
||||||
assert_delete_state(picker, kind="feedback", option_id="local:session-a")
|
assert_delete_state(picker, kind="feedback", option_id="session-a")
|
||||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
assert option_list.replaced_prompts[-1].option_id == "session-a"
|
||||||
assert (
|
assert (
|
||||||
"Can't delete current session"
|
"Can't delete current session"
|
||||||
in option_list.replaced_prompts[-1].prompt.plain
|
in option_list.replaced_prompts[-1].prompt.plain
|
||||||
|
|
@ -315,7 +274,7 @@ class TestSessionPickerSessionRemoval:
|
||||||
|
|
||||||
picker.action_request_delete()
|
picker.action_request_delete()
|
||||||
|
|
||||||
assert_delete_state(picker, kind="feedback", option_id="local:session-a")
|
assert_delete_state(picker, kind="feedback", option_id="session-a")
|
||||||
assert posted_messages == []
|
assert posted_messages == []
|
||||||
|
|
||||||
def test_pending_delete_blocks_resume_selection(
|
def test_pending_delete_blocks_resume_selection(
|
||||||
|
|
@ -327,7 +286,7 @@ class TestSessionPickerSessionRemoval:
|
||||||
picker = SessionPickerApp(
|
picker = SessionPickerApp(
|
||||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||||
)
|
)
|
||||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
option_list = FakeOptionList(highlighted_option_id="session-a")
|
||||||
posted_messages: list[object] = []
|
posted_messages: list[object] = []
|
||||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||||
|
|
@ -335,15 +294,15 @@ class TestSessionPickerSessionRemoval:
|
||||||
picker.action_request_delete()
|
picker.action_request_delete()
|
||||||
picker.action_request_delete()
|
picker.action_request_delete()
|
||||||
picker.on_option_list_option_selected(
|
picker.on_option_list_option_selected(
|
||||||
cast(OptionList.OptionSelected, FakeOptionEvent("local:session-a"))
|
cast(OptionList.OptionSelected, FakeOptionEvent("session-a"))
|
||||||
)
|
)
|
||||||
picker.on_option_list_option_selected(
|
picker.on_option_list_option_selected(
|
||||||
cast(OptionList.OptionSelected, FakeOptionEvent("local:session-b"))
|
cast(OptionList.OptionSelected, FakeOptionEvent("session-b"))
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(posted_messages) == 1
|
assert len(posted_messages) == 1
|
||||||
assert isinstance(posted_messages[0], SessionPickerApp.SessionDeleteRequested)
|
assert isinstance(posted_messages[0], SessionPickerApp.SessionDeleteRequested)
|
||||||
assert_delete_state(picker, kind="pending", option_id="local:session-a")
|
assert_delete_state(picker, kind="pending", option_id="session-a")
|
||||||
|
|
||||||
def test_clear_pending_delete_restores_session_option(
|
def test_clear_pending_delete_restores_session_option(
|
||||||
self,
|
self,
|
||||||
|
|
@ -354,7 +313,7 @@ class TestSessionPickerSessionRemoval:
|
||||||
picker = SessionPickerApp(
|
picker = SessionPickerApp(
|
||||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||||
)
|
)
|
||||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
option_list = FakeOptionList(highlighted_option_id="session-a")
|
||||||
posted_messages: list[object] = []
|
posted_messages: list[object] = []
|
||||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||||
|
|
@ -362,9 +321,9 @@ class TestSessionPickerSessionRemoval:
|
||||||
picker.action_request_delete()
|
picker.action_request_delete()
|
||||||
picker.action_request_delete()
|
picker.action_request_delete()
|
||||||
|
|
||||||
assert picker.clear_pending_delete("local:session-a") is True
|
assert picker.clear_pending_delete("session-a") is True
|
||||||
assert picker._delete_state is None
|
assert picker._delete_state is None
|
||||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
assert option_list.replaced_prompts[-1].option_id == "session-a"
|
||||||
assert "Help me fix this bug" in option_list.replaced_prompts[-1].prompt.plain
|
assert "Help me fix this bug" in option_list.replaced_prompts[-1].prompt.plain
|
||||||
|
|
||||||
def test_highlighting_another_session_clears_confirmation(
|
def test_highlighting_another_session_clears_confirmation(
|
||||||
|
|
@ -376,16 +335,16 @@ class TestSessionPickerSessionRemoval:
|
||||||
picker = SessionPickerApp(
|
picker = SessionPickerApp(
|
||||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||||
)
|
)
|
||||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
option_list = FakeOptionList(highlighted_option_id="session-a")
|
||||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||||
picker.action_request_delete()
|
picker.action_request_delete()
|
||||||
|
|
||||||
picker.on_option_list_option_highlighted(
|
picker.on_option_list_option_highlighted(
|
||||||
cast(OptionList.OptionHighlighted, FakeOptionEvent("local:session-b"))
|
cast(OptionList.OptionHighlighted, FakeOptionEvent("session-b"))
|
||||||
)
|
)
|
||||||
|
|
||||||
assert picker._delete_state is None
|
assert picker._delete_state is None
|
||||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
assert option_list.replaced_prompts[-1].option_id == "session-a"
|
||||||
assert "Help me fix this bug" in option_list.replaced_prompts[-1].prompt.plain
|
assert "Help me fix this bug" in option_list.replaced_prompts[-1].prompt.plain
|
||||||
|
|
||||||
def test_highlighting_another_session_clears_delete_feedback(
|
def test_highlighting_another_session_clears_delete_feedback(
|
||||||
|
|
@ -395,18 +354,20 @@ class TestSessionPickerSessionRemoval:
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
picker = SessionPickerApp(
|
picker = SessionPickerApp(
|
||||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
sessions=sample_sessions,
|
||||||
|
latest_messages=sample_latest_messages,
|
||||||
|
current_session_id="session-c",
|
||||||
)
|
)
|
||||||
option_list = FakeOptionList(highlighted_option_id="remote:session-c")
|
option_list = FakeOptionList(highlighted_option_id="session-c")
|
||||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||||
picker.action_request_delete()
|
picker.action_request_delete()
|
||||||
|
|
||||||
picker.on_option_list_option_highlighted(
|
picker.on_option_list_option_highlighted(
|
||||||
cast(OptionList.OptionHighlighted, FakeOptionEvent("local:session-b"))
|
cast(OptionList.OptionHighlighted, FakeOptionEvent("session-b"))
|
||||||
)
|
)
|
||||||
|
|
||||||
assert picker._delete_state is None
|
assert picker._delete_state is None
|
||||||
assert option_list.replaced_prompts[-1].option_id == "remote:session-c"
|
assert option_list.replaced_prompts[-1].option_id == "session-c"
|
||||||
assert (
|
assert (
|
||||||
"Add unit tests for the API"
|
"Add unit tests for the API"
|
||||||
in option_list.replaced_prompts[-1].prompt.plain
|
in option_list.replaced_prompts[-1].prompt.plain
|
||||||
|
|
@ -421,7 +382,7 @@ class TestSessionPickerSessionRemoval:
|
||||||
picker = SessionPickerApp(
|
picker = SessionPickerApp(
|
||||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||||
)
|
)
|
||||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
option_list = FakeOptionList(highlighted_option_id="session-a")
|
||||||
posted_messages: list[object] = []
|
posted_messages: list[object] = []
|
||||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||||
|
|
@ -444,9 +405,11 @@ class TestSessionPickerSessionRemoval:
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
picker = SessionPickerApp(
|
picker = SessionPickerApp(
|
||||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
sessions=sample_sessions,
|
||||||
|
latest_messages=sample_latest_messages,
|
||||||
|
current_session_id="session-c",
|
||||||
)
|
)
|
||||||
option_list = FakeOptionList(highlighted_option_id="remote:session-c")
|
option_list = FakeOptionList(highlighted_option_id="session-c")
|
||||||
posted_messages: list[object] = []
|
posted_messages: list[object] = []
|
||||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||||
|
|
@ -471,20 +434,20 @@ class TestSessionPickerSessionRemoval:
|
||||||
picker = SessionPickerApp(
|
picker = SessionPickerApp(
|
||||||
sessions=sample_sessions, latest_messages=sample_latest_messages
|
sessions=sample_sessions, latest_messages=sample_latest_messages
|
||||||
)
|
)
|
||||||
option_list = FakeOptionList(highlighted_option_id="local:session-a")
|
option_list = FakeOptionList(highlighted_option_id="session-a")
|
||||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||||
picker.action_request_delete()
|
picker.action_request_delete()
|
||||||
assert_delete_state(picker, kind="confirmation", option_id="local:session-a")
|
assert_delete_state(picker, kind="confirmation", option_id="session-a")
|
||||||
|
|
||||||
assert picker.remove_session("local:session-a") is True
|
assert picker.remove_session("session-a") is True
|
||||||
|
|
||||||
assert [session.option_id for session in picker._sessions] == [
|
assert [session.option_id for session in picker._sessions] == [
|
||||||
"local:session-b",
|
"session-b",
|
||||||
"remote:session-c",
|
"session-c",
|
||||||
]
|
]
|
||||||
assert "local:session-a" not in picker._latest_messages
|
assert "session-a" not in picker._latest_messages
|
||||||
assert picker._delete_state is None
|
assert picker._delete_state is None
|
||||||
assert option_list.removed_option_ids == ["local:session-a"]
|
assert option_list.removed_option_ids == ["session-a"]
|
||||||
|
|
||||||
def test_remove_missing_session_returns_false(
|
def test_remove_missing_session_returns_false(
|
||||||
self,
|
self,
|
||||||
|
|
@ -498,7 +461,7 @@ class TestSessionPickerSessionRemoval:
|
||||||
option_list = FakeOptionList()
|
option_list = FakeOptionList()
|
||||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||||
|
|
||||||
assert picker.remove_session("local:missing") is False
|
assert picker.remove_session("missing") is False
|
||||||
|
|
||||||
assert picker._sessions == sample_sessions
|
assert picker._sessions == sample_sessions
|
||||||
assert picker._latest_messages == sample_latest_messages
|
assert picker._latest_messages == sample_latest_messages
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@ from weakref import WeakKeyDictionary
|
||||||
|
|
||||||
from vibe.cli.textual_ui.widgets.messages import UserMessage
|
from vibe.cli.textual_ui.widgets.messages import UserMessage
|
||||||
from vibe.cli.textual_ui.windowing.history import build_history_widgets
|
from vibe.cli.textual_ui.windowing.history import build_history_widgets
|
||||||
from vibe.core.types import ImageAttachment, LLMMessage, Role
|
from vibe.core.types import FileImageSource, ImageAttachment, LLMMessage, Role
|
||||||
|
|
||||||
|
|
||||||
def _att(path: Path, alias: str) -> ImageAttachment:
|
def _att(path: Path, alias: str) -> ImageAttachment:
|
||||||
path.write_bytes(b"\x89PNG")
|
path.write_bytes(b"\x89PNG")
|
||||||
return ImageAttachment(path=path, alias=alias, mime_type="image/png")
|
return ImageAttachment(
|
||||||
|
source=FileImageSource(path=path), alias=alias, mime_type="image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_attachments_footer_singular(tmp_path: Path) -> None:
|
def test_attachments_footer_singular(tmp_path: Path) -> None:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import keyring
|
||||||
|
from keyring.backend import KeyringBackend
|
||||||
|
import keyring.errors
|
||||||
import pytest
|
import pytest
|
||||||
import tomli_w
|
import tomli_w
|
||||||
|
|
||||||
|
|
@ -34,6 +37,39 @@ from vibe.core.config.harness_files import (
|
||||||
from vibe.core.llm.types import BackendLike
|
from vibe.core.llm.types import BackendLike
|
||||||
|
|
||||||
|
|
||||||
|
class _EmptyKeyring(KeyringBackend):
|
||||||
|
"""A keyring backend that stores nothing, used to keep tests off the real OS keyring."""
|
||||||
|
|
||||||
|
priority = 1 # type: ignore[assignment]
|
||||||
|
|
||||||
|
def get_password(self, service: str, username: str) -> str | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_password(self, service: str, username: str, password: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete_password(self, service: str, username: str) -> None:
|
||||||
|
raise keyring.errors.PasswordDeleteError(username)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _disable_os_keyring() -> 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
|
||||||
|
without this every config construction would touch the real Keychain. We install an
|
||||||
|
empty backend (rather than patching ``keyring.get_password``) so tests that swap in
|
||||||
|
their own backend via ``keyring.set_keyring`` still work. Tests that exercise keyring
|
||||||
|
behaviour opt in by patching ``keyring.get_password`` / ``set_password`` directly.
|
||||||
|
"""
|
||||||
|
original = keyring.get_keyring()
|
||||||
|
keyring.set_keyring(_EmptyKeyring())
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
keyring.set_keyring(original)
|
||||||
|
|
||||||
|
|
||||||
def get_base_config() -> dict[str, Any]:
|
def get_base_config() -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"active_model": "devstral-latest",
|
"active_model": "devstral-latest",
|
||||||
|
|
@ -143,6 +179,8 @@ def _scratchpad_dir(
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _mock_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
def _mock_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setenv("MISTRAL_API_KEY", "mock")
|
monkeypatch.setenv("MISTRAL_API_KEY", "mock")
|
||||||
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "mock")
|
||||||
|
monkeypatch.setenv("OPENAI_API_KEY", "mock")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|
@ -262,6 +300,9 @@ def build_test_vibe_config(**kwargs) -> VibeConfig:
|
||||||
)
|
)
|
||||||
if kwargs.get("models"):
|
if kwargs.get("models"):
|
||||||
kwargs.setdefault("active_model", kwargs["models"][0].alias)
|
kwargs.setdefault("active_model", kwargs["models"][0].alias)
|
||||||
|
# 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)
|
||||||
return VibeConfig(
|
return VibeConfig(
|
||||||
session_logging=resolved_session_logging,
|
session_logging=resolved_session_logging,
|
||||||
enable_update_checks=resolved_enable_update_checks,
|
enable_update_checks=resolved_enable_update_checks,
|
||||||
|
|
|
||||||
14
tests/constants.py
Normal file
14
tests/constants.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
MISTRAL_BASE_URL = "https://api.mistral.ai"
|
||||||
|
CHAT_COMPLETIONS_PATH = "/v1/chat/completions"
|
||||||
|
CONNECTORS_BOOTSTRAP_PATH = "/v1/connectors/bootstrap"
|
||||||
|
|
||||||
|
ANTHROPIC_BASE_URL = "https://api.anthropic.com"
|
||||||
|
ANTHROPIC_MESSAGES_PATH = "/v1/messages"
|
||||||
|
|
||||||
|
OPENAI_BASE_URL = "https://api.openai.com"
|
||||||
|
OPENAI_RESPONSES_PATH = "/v1/responses"
|
||||||
|
|
||||||
|
TELEPORT_SESSIONS_PATH = "/api/v1/code/sessions"
|
||||||
|
TELEPORT_COMPLETE_URL = "https://chat.example.com/code/project-id/web-session-id"
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ValidationError
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from tests.conftest import build_test_vibe_config
|
|
||||||
from vibe.core.agent_loop import AgentLoopStateError
|
|
||||||
from vibe.core.nuage.exceptions import ErrorCode, WorkflowsException
|
|
||||||
from vibe.core.nuage.remote_events_source import RemoteEventsSource
|
|
||||||
from vibe.core.nuage.streaming import StreamEvent
|
|
||||||
|
|
||||||
_SESSION_ID = "test-session"
|
|
||||||
|
|
||||||
|
|
||||||
def _make_source(**kwargs) -> RemoteEventsSource:
|
|
||||||
config = build_test_vibe_config(enabled_tools=kwargs.pop("enabled_tools", []))
|
|
||||||
return RemoteEventsSource(session_id=_SESSION_ID, config=config, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_retryable_exc(msg: str) -> WorkflowsException:
|
|
||||||
return WorkflowsException(message=msg, code=ErrorCode.GET_EVENTS_STREAM_ERROR)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_stream_event(
|
|
||||||
broker_sequence: int | None = None, data: dict | None = None
|
|
||||||
) -> StreamEvent:
|
|
||||||
return StreamEvent(data=data or {}, broker_sequence=broker_sequence)
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsRetryableStreamDisconnect:
|
|
||||||
def test_peer_closed_connection(self) -> None:
|
|
||||||
source = _make_source()
|
|
||||||
exc = _make_retryable_exc("Peer closed connection without response")
|
|
||||||
assert source._is_retryable_stream_disconnect(exc) is True
|
|
||||||
|
|
||||||
def test_incomplete_chunked_read(self) -> None:
|
|
||||||
source = _make_source()
|
|
||||||
exc = _make_retryable_exc("Incomplete chunked read during streaming")
|
|
||||||
assert source._is_retryable_stream_disconnect(exc) is True
|
|
||||||
|
|
||||||
def test_non_retryable_message(self) -> None:
|
|
||||||
source = _make_source()
|
|
||||||
exc = WorkflowsException(
|
|
||||||
message="some other error", code=ErrorCode.GET_EVENTS_STREAM_ERROR
|
|
||||||
)
|
|
||||||
assert source._is_retryable_stream_disconnect(exc) is False
|
|
||||||
|
|
||||||
def test_wrong_error_code(self) -> None:
|
|
||||||
source = _make_source()
|
|
||||||
exc = WorkflowsException(
|
|
||||||
message="peer closed connection",
|
|
||||||
code=ErrorCode.POST_EXECUTIONS_SIGNALS_ERROR,
|
|
||||||
)
|
|
||||||
assert source._is_retryable_stream_disconnect(exc) is False
|
|
||||||
|
|
||||||
|
|
||||||
async def _async_gen_from_list(items):
|
|
||||||
for item in items:
|
|
||||||
yield item
|
|
||||||
|
|
||||||
|
|
||||||
async def _async_gen_raise(items, exc):
|
|
||||||
for item in items:
|
|
||||||
yield item
|
|
||||||
raise exc
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeStream:
|
|
||||||
def __init__(self, payloads=None, exc=None):
|
|
||||||
self._payloads = payloads or []
|
|
||||||
self._exc = exc
|
|
||||||
self._closed = False
|
|
||||||
|
|
||||||
def __aiter__(self):
|
|
||||||
return self._iterate().__aiter__()
|
|
||||||
|
|
||||||
async def _iterate(self):
|
|
||||||
for p in self._payloads:
|
|
||||||
yield p
|
|
||||||
if self._exc is not None:
|
|
||||||
raise self._exc
|
|
||||||
|
|
||||||
async def aclose(self):
|
|
||||||
self._closed = True
|
|
||||||
|
|
||||||
|
|
||||||
class TestStreamRemoteEventsRetry:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_retries_on_retryable_disconnect(self) -> None:
|
|
||||||
source = _make_source()
|
|
||||||
exc = _make_retryable_exc("peer closed connection")
|
|
||||||
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
def make_stream(_params):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
return _FakeStream(exc=exc)
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.stream_events = make_stream
|
|
||||||
source._client = mock_client
|
|
||||||
|
|
||||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
|
||||||
events = [e async for e in source._stream_remote_events()]
|
|
||||||
|
|
||||||
assert events == []
|
|
||||||
assert call_count == 4 # 1 initial + 3 retries
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stops_after_max_retry_count(self) -> None:
|
|
||||||
source = _make_source()
|
|
||||||
exc = _make_retryable_exc("incomplete chunked read")
|
|
||||||
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
def make_stream(_params):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
return _FakeStream(exc=exc)
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.stream_events = make_stream
|
|
||||||
source._client = mock_client
|
|
||||||
|
|
||||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
|
||||||
events = [e async for e in source._stream_remote_events()]
|
|
||||||
|
|
||||||
assert events == []
|
|
||||||
assert call_count == 4
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resets_retry_count_on_successful_event(self) -> None:
|
|
||||||
source = _make_source()
|
|
||||||
exc = _make_retryable_exc("peer closed connection")
|
|
||||||
|
|
||||||
successful_event = _make_stream_event(broker_sequence=0, data={})
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
def make_stream(_params):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
if call_count <= 2:
|
|
||||||
return _FakeStream(payloads=[successful_event], exc=exc)
|
|
||||||
return _FakeStream(exc=exc)
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.stream_events = make_stream
|
|
||||||
source._client = mock_client
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("asyncio.sleep", new_callable=AsyncMock),
|
|
||||||
patch.object(source, "_normalize_stream_event", return_value=None),
|
|
||||||
):
|
|
||||||
events = [e async for e in source._stream_remote_events()]
|
|
||||||
|
|
||||||
assert events == []
|
|
||||||
# call 1: success + exc -> retry_count = 1
|
|
||||||
# call 2: success (reset) + exc -> retry_count = 1
|
|
||||||
# call 3: exc -> retry_count = 2
|
|
||||||
# call 4: exc -> retry_count = 3
|
|
||||||
# call 5: exc -> retry_count = 4 > 3 -> break
|
|
||||||
assert call_count == 5
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_non_retryable_raises_agent_loop_state_error(self) -> None:
|
|
||||||
source = _make_source()
|
|
||||||
exc = WorkflowsException(
|
|
||||||
message="something bad", code=ErrorCode.TEMPORAL_CONNECTION_ERROR
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.stream_events = lambda _: _FakeStream(exc=exc)
|
|
||||||
source._client = mock_client
|
|
||||||
|
|
||||||
with pytest.raises(AgentLoopStateError):
|
|
||||||
async for _ in source._stream_remote_events():
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class TestStreamRemoteEventsIdleBoundary:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stops_on_idle_boundary(self) -> None:
|
|
||||||
source = _make_source()
|
|
||||||
event_data = _make_stream_event(broker_sequence=0, data={})
|
|
||||||
sentinel_event = MagicMock()
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.stream_events = lambda _: _FakeStream(payloads=[event_data])
|
|
||||||
source._client = mock_client
|
|
||||||
|
|
||||||
workflow_event = MagicMock()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(
|
|
||||||
source, "_normalize_stream_event", return_value=workflow_event
|
|
||||||
),
|
|
||||||
patch.object(
|
|
||||||
source, "_consume_workflow_event", return_value=[sentinel_event]
|
|
||||||
),
|
|
||||||
patch.object(source, "_is_idle_boundary", return_value=True) as mock_idle,
|
|
||||||
):
|
|
||||||
events = [
|
|
||||||
e
|
|
||||||
async for e in source._stream_remote_events(stop_on_idle_boundary=True)
|
|
||||||
]
|
|
||||||
|
|
||||||
assert events == [sentinel_event]
|
|
||||||
mock_idle.assert_called_once_with(workflow_event)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_continues_past_idle_boundary_when_disabled(self) -> None:
|
|
||||||
source = _make_source()
|
|
||||||
event1 = _make_stream_event(broker_sequence=0, data={})
|
|
||||||
event2 = _make_stream_event(broker_sequence=1, data={})
|
|
||||||
sentinel1 = MagicMock()
|
|
||||||
sentinel2 = MagicMock()
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.stream_events = lambda _: _FakeStream(payloads=[event1, event2])
|
|
||||||
source._client = mock_client
|
|
||||||
|
|
||||||
workflow_event = MagicMock()
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
def consume_side_effect(_evt):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
return [sentinel1] if call_count == 1 else [sentinel2]
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(
|
|
||||||
source, "_normalize_stream_event", return_value=workflow_event
|
|
||||||
),
|
|
||||||
patch.object(
|
|
||||||
source, "_consume_workflow_event", side_effect=consume_side_effect
|
|
||||||
),
|
|
||||||
patch.object(source, "_is_idle_boundary", return_value=True),
|
|
||||||
):
|
|
||||||
events = [
|
|
||||||
e
|
|
||||||
async for e in source._stream_remote_events(stop_on_idle_boundary=False)
|
|
||||||
]
|
|
||||||
|
|
||||||
assert events == [sentinel1, sentinel2]
|
|
||||||
|
|
||||||
|
|
||||||
class TestBrokerSequenceTracking:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_next_start_seq_updated(self) -> None:
|
|
||||||
source = _make_source()
|
|
||||||
assert source._next_start_seq == 0
|
|
||||||
|
|
||||||
event1 = _make_stream_event(broker_sequence=5, data={})
|
|
||||||
event2 = _make_stream_event(broker_sequence=10, data={})
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.stream_events = lambda _: _FakeStream(payloads=[event1, event2])
|
|
||||||
source._client = mock_client
|
|
||||||
|
|
||||||
with patch.object(source, "_normalize_stream_event", return_value=None):
|
|
||||||
events = [e async for e in source._stream_remote_events()]
|
|
||||||
|
|
||||||
assert events == []
|
|
||||||
assert source._next_start_seq == 11
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_none_broker_sequence_not_updated(self) -> None:
|
|
||||||
source = _make_source()
|
|
||||||
source._next_start_seq = 5
|
|
||||||
|
|
||||||
event = _make_stream_event(broker_sequence=None, data={})
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.stream_events = lambda _: _FakeStream(payloads=[event])
|
|
||||||
source._client = mock_client
|
|
||||||
|
|
||||||
with patch.object(source, "_normalize_stream_event", return_value=None):
|
|
||||||
events = [e async for e in source._stream_remote_events()]
|
|
||||||
|
|
||||||
assert events == []
|
|
||||||
assert source._next_start_seq == 5
|
|
||||||
|
|
||||||
|
|
||||||
def test_consume_workflow_event_validation_error_is_logged_and_ignored() -> None:
|
|
||||||
class _InvalidPayload(BaseModel):
|
|
||||||
required: str
|
|
||||||
|
|
||||||
source = _make_source()
|
|
||||||
with pytest.raises(ValidationError) as exc_info:
|
|
||||||
_InvalidPayload.model_validate({})
|
|
||||||
|
|
||||||
source._translator.consume_workflow_event = MagicMock(side_effect=exc_info.value)
|
|
||||||
|
|
||||||
with patch("vibe.core.nuage.remote_events_source.logger.warning") as mock_warning:
|
|
||||||
events = source._consume_workflow_event(MagicMock())
|
|
||||||
|
|
||||||
assert events == []
|
|
||||||
mock_warning.assert_called_once()
|
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from vibe.core.nuage.client import WorkflowsClient
|
|
||||||
from vibe.core.nuage.exceptions import ErrorCode, WorkflowsException
|
|
||||||
from vibe.core.nuage.streaming import StreamEvent, StreamEventsQueryParams
|
|
||||||
from vibe.core.nuage.workflow import WorkflowExecutionStatus
|
|
||||||
|
|
||||||
|
|
||||||
def _make_client() -> WorkflowsClient:
|
|
||||||
return WorkflowsClient(base_url="http://localhost:8080", api_key="test-key")
|
|
||||||
|
|
||||||
|
|
||||||
def _valid_event_payload() -> dict:
|
|
||||||
return {
|
|
||||||
"stream": "test-stream",
|
|
||||||
"timestamp_unix_nano": 1000000,
|
|
||||||
"data": {"key": "value"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseSSEData:
|
|
||||||
def test_valid_json_returns_stream_event(self) -> None:
|
|
||||||
client = _make_client()
|
|
||||||
payload = _valid_event_payload()
|
|
||||||
result = client._parse_sse_data(json.dumps(payload), event_type=None)
|
|
||||||
assert isinstance(result, StreamEvent)
|
|
||||||
assert result.stream == "test-stream"
|
|
||||||
assert result.data == {"key": "value"}
|
|
||||||
|
|
||||||
def test_error_event_type_raises(self) -> None:
|
|
||||||
client = _make_client()
|
|
||||||
payload = {"some": "data"}
|
|
||||||
with pytest.raises(WorkflowsException) as exc_info:
|
|
||||||
client._parse_sse_data(json.dumps(payload), event_type="error")
|
|
||||||
assert exc_info.value.code == ErrorCode.GET_EVENTS_STREAM_ERROR
|
|
||||||
assert "Stream error from server" in exc_info.value.message
|
|
||||||
|
|
||||||
def test_error_key_in_json_raises(self) -> None:
|
|
||||||
client = _make_client()
|
|
||||||
payload = {"error": "something went wrong"}
|
|
||||||
with pytest.raises(WorkflowsException) as exc_info:
|
|
||||||
client._parse_sse_data(json.dumps(payload), event_type=None)
|
|
||||||
assert exc_info.value.code == ErrorCode.GET_EVENTS_STREAM_ERROR
|
|
||||||
assert "something went wrong" in exc_info.value.message
|
|
||||||
|
|
||||||
def test_error_event_type_with_non_dict_parsed(self) -> None:
|
|
||||||
client = _make_client()
|
|
||||||
with pytest.raises(WorkflowsException) as exc_info:
|
|
||||||
client._parse_sse_data(json.dumps("a plain string"), event_type="error")
|
|
||||||
assert "a plain string" in exc_info.value.message
|
|
||||||
|
|
||||||
def test_malformed_json_raises(self) -> None:
|
|
||||||
client = _make_client()
|
|
||||||
with pytest.raises(json.JSONDecodeError):
|
|
||||||
client._parse_sse_data("{not valid json", event_type=None)
|
|
||||||
|
|
||||||
|
|
||||||
class TestIterSSEEvents:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_parses_data_lines(self) -> None:
|
|
||||||
client = _make_client()
|
|
||||||
payload = _valid_event_payload()
|
|
||||||
lines = [f"data: {json.dumps(payload)}"]
|
|
||||||
response = AsyncMock()
|
|
||||||
response.aiter_bytes = _async_byte_iter(lines)
|
|
||||||
|
|
||||||
events = [e async for e in client._iter_sse_events(response)]
|
|
||||||
assert len(events) == 1
|
|
||||||
assert events[0].stream == "test-stream"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_skips_empty_lines_and_comments(self) -> None:
|
|
||||||
client = _make_client()
|
|
||||||
payload = _valid_event_payload()
|
|
||||||
lines = ["", ": this is a comment", f"data: {json.dumps(payload)}", ""]
|
|
||||||
response = AsyncMock()
|
|
||||||
response.aiter_bytes = _async_byte_iter(lines)
|
|
||||||
|
|
||||||
events = [e async for e in client._iter_sse_events(response)]
|
|
||||||
assert len(events) == 1
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_parses_event_type_and_passes_to_parse(self) -> None:
|
|
||||||
client = _make_client()
|
|
||||||
payload = {"error": "server broke"}
|
|
||||||
lines = ["event: error", f"data: {json.dumps(payload)}"]
|
|
||||||
response = AsyncMock()
|
|
||||||
response.aiter_bytes = _async_byte_iter(lines)
|
|
||||||
|
|
||||||
with pytest.raises(WorkflowsException) as exc_info:
|
|
||||||
_ = [e async for e in client._iter_sse_events(response)]
|
|
||||||
assert exc_info.value.code == ErrorCode.GET_EVENTS_STREAM_ERROR
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resets_event_type_after_data_line(self) -> None:
|
|
||||||
client = _make_client()
|
|
||||||
payload = _valid_event_payload()
|
|
||||||
lines = [
|
|
||||||
"event: custom_type",
|
|
||||||
f"data: {json.dumps(payload)}",
|
|
||||||
f"data: {json.dumps(payload)}",
|
|
||||||
]
|
|
||||||
response = AsyncMock()
|
|
||||||
response.aiter_bytes = _async_byte_iter(lines)
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
client, "_parse_sse_data", wraps=client._parse_sse_data
|
|
||||||
) as mock_parse:
|
|
||||||
events = [e async for e in client._iter_sse_events(response)]
|
|
||||||
assert len(events) == 2
|
|
||||||
assert mock_parse.call_args_list[0].args[1] == "custom_type"
|
|
||||||
assert mock_parse.call_args_list[1].args[1] is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_skips_non_data_non_event_lines(self) -> None:
|
|
||||||
client = _make_client()
|
|
||||||
payload = _valid_event_payload()
|
|
||||||
lines = ["id: 123", "retry: 5000", f"data: {json.dumps(payload)}"]
|
|
||||||
response = AsyncMock()
|
|
||||||
response.aiter_bytes = _async_byte_iter(lines)
|
|
||||||
|
|
||||||
events = [e async for e in client._iter_sse_events(response)]
|
|
||||||
assert len(events) == 1
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_parse_failure_logs_warning_and_continues(self) -> None:
|
|
||||||
client = _make_client()
|
|
||||||
payload = _valid_event_payload()
|
|
||||||
lines = ["data: {not valid json}", f"data: {json.dumps(payload)}"]
|
|
||||||
response = AsyncMock()
|
|
||||||
response.aiter_bytes = _async_byte_iter(lines)
|
|
||||||
|
|
||||||
with patch("vibe.core.nuage.client.logger") as mock_logger:
|
|
||||||
events = [e async for e in client._iter_sse_events(response)]
|
|
||||||
assert len(events) == 1
|
|
||||||
mock_logger.warning.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
def _setup_mock_client(client: WorkflowsClient, mock_response: AsyncMock) -> None:
|
|
||||||
mock_stream = AsyncMock()
|
|
||||||
mock_stream.__aenter__ = AsyncMock(return_value=mock_response)
|
|
||||||
mock_stream.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
mock_http = AsyncMock()
|
|
||||||
mock_http.stream = lambda *args, **kwargs: mock_stream
|
|
||||||
client._client = mock_http
|
|
||||||
|
|
||||||
|
|
||||||
class TestStreamEvents:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_yields_stream_events(self) -> None:
|
|
||||||
client = _make_client()
|
|
||||||
payload = _valid_event_payload()
|
|
||||||
lines = [f"data: {json.dumps(payload)}"]
|
|
||||||
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.raise_for_status = lambda: None
|
|
||||||
mock_response.aiter_bytes = _async_byte_iter(lines)
|
|
||||||
_setup_mock_client(client, mock_response)
|
|
||||||
|
|
||||||
params = StreamEventsQueryParams(workflow_exec_id="wf-1", start_seq=0)
|
|
||||||
events = [e async for e in client.stream_events(params)]
|
|
||||||
assert len(events) == 1
|
|
||||||
assert isinstance(events[0], StreamEvent)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reraises_workflows_exception(self) -> None:
|
|
||||||
client = _make_client()
|
|
||||||
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.raise_for_status = lambda: None
|
|
||||||
mock_response.aiter_bytes = _async_byte_iter([
|
|
||||||
"event: error",
|
|
||||||
'data: {"error": "stream error"}',
|
|
||||||
])
|
|
||||||
_setup_mock_client(client, mock_response)
|
|
||||||
|
|
||||||
params = StreamEventsQueryParams(workflow_exec_id="wf-1")
|
|
||||||
with pytest.raises(WorkflowsException) as exc_info:
|
|
||||||
_ = [e async for e in client.stream_events(params)]
|
|
||||||
assert exc_info.value.code == ErrorCode.GET_EVENTS_STREAM_ERROR
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wraps_other_exceptions_in_workflows_exception(self) -> None:
|
|
||||||
client = _make_client()
|
|
||||||
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.raise_for_status.side_effect = RuntimeError("connection lost")
|
|
||||||
_setup_mock_client(client, mock_response)
|
|
||||||
|
|
||||||
params = StreamEventsQueryParams(workflow_exec_id="wf-1")
|
|
||||||
with pytest.raises(WorkflowsException) as exc_info:
|
|
||||||
_ = [e async for e in client.stream_events(params)]
|
|
||||||
assert exc_info.value.code == ErrorCode.GET_EVENTS_STREAM_ERROR
|
|
||||||
assert "Failed to stream events" in exc_info.value.message
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetWorkflowRuns:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sends_current_user_filter_by_default(self) -> None:
|
|
||||||
client = _make_client()
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.raise_for_status.return_value = None
|
|
||||||
mock_response.json.return_value = {"executions": [], "next_page_token": None}
|
|
||||||
|
|
||||||
mock_http = AsyncMock()
|
|
||||||
mock_http.get.return_value = mock_response
|
|
||||||
client._client = mock_http
|
|
||||||
|
|
||||||
await client.get_workflow_runs(
|
|
||||||
workflow_identifier="workflow-1",
|
|
||||||
page_size=10,
|
|
||||||
status=[WorkflowExecutionStatus.RUNNING],
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_http.get.assert_awaited_once()
|
|
||||||
call_params = mock_http.get.call_args.kwargs["params"]
|
|
||||||
assert call_params["user_id"] == "current"
|
|
||||||
assert call_params["workflow_identifier"] == "workflow-1"
|
|
||||||
assert call_params["page_size"] == 10
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_allows_overriding_user_id(self) -> None:
|
|
||||||
client = _make_client()
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.raise_for_status.return_value = None
|
|
||||||
mock_response.json.return_value = {"executions": [], "next_page_token": None}
|
|
||||||
|
|
||||||
mock_http = AsyncMock()
|
|
||||||
mock_http.get.return_value = mock_response
|
|
||||||
client._client = mock_http
|
|
||||||
|
|
||||||
await client.get_workflow_runs(user_id="user-123")
|
|
||||||
|
|
||||||
call_params = mock_http.get.call_args.kwargs["params"]
|
|
||||||
assert call_params["user_id"] == "user-123"
|
|
||||||
|
|
||||||
|
|
||||||
def _async_byte_iter(lines: list[str]):
|
|
||||||
async def _iter():
|
|
||||||
for line in lines:
|
|
||||||
yield f"{line}\n".encode()
|
|
||||||
|
|
||||||
return _iter
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from vibe.core.session.image_snapshot import ImageSnapshotError, snapshot_image
|
from vibe.core.session.image_snapshot import (
|
||||||
|
ImageSnapshotError,
|
||||||
|
snapshot_image,
|
||||||
|
snapshot_image_bytes,
|
||||||
|
)
|
||||||
|
from vibe.core.types import MAX_IMAGE_BYTES, FileImageSource, InlineImageSource
|
||||||
|
|
||||||
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
||||||
|
|
||||||
|
|
@ -19,10 +25,11 @@ def test_snapshot_image_copies_to_attachments_dir(tmp_path: Path) -> None:
|
||||||
att = snapshot_image(src, alias="screenshot.png", session_dir=session_dir)
|
att = snapshot_image(src, alias="screenshot.png", session_dir=session_dir)
|
||||||
|
|
||||||
digest = hashlib.sha1(PNG_BYTES, usedforsecurity=False).hexdigest()
|
digest = hashlib.sha1(PNG_BYTES, usedforsecurity=False).hexdigest()
|
||||||
assert att.path == (session_dir / "attachments" / f"{digest}.png").resolve()
|
assert isinstance(att.source, FileImageSource)
|
||||||
|
assert att.source.path == (session_dir / "attachments" / f"{digest}.png").resolve()
|
||||||
assert att.alias == "screenshot.png"
|
assert att.alias == "screenshot.png"
|
||||||
assert att.mime_type == "image/png"
|
assert att.mime_type == "image/png"
|
||||||
assert att.path.read_bytes() == PNG_BYTES
|
assert att.source.path.read_bytes() == PNG_BYTES
|
||||||
|
|
||||||
|
|
||||||
def test_snapshot_image_is_idempotent_on_same_bytes(tmp_path: Path) -> None:
|
def test_snapshot_image_is_idempotent_on_same_bytes(tmp_path: Path) -> None:
|
||||||
|
|
@ -35,7 +42,9 @@ def test_snapshot_image_is_idempotent_on_same_bytes(tmp_path: Path) -> None:
|
||||||
att_a = snapshot_image(src_a, alias="a.png", session_dir=session_dir)
|
att_a = snapshot_image(src_a, alias="a.png", session_dir=session_dir)
|
||||||
att_b = snapshot_image(src_b, alias="b.png", session_dir=session_dir)
|
att_b = snapshot_image(src_b, alias="b.png", session_dir=session_dir)
|
||||||
|
|
||||||
assert att_a.path == att_b.path
|
assert isinstance(att_a.source, FileImageSource)
|
||||||
|
assert isinstance(att_b.source, FileImageSource)
|
||||||
|
assert att_a.source.path == att_b.source.path
|
||||||
assert sum(1 for _ in (session_dir / "attachments").iterdir()) == 1
|
assert sum(1 for _ in (session_dir / "attachments").iterdir()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -45,7 +54,8 @@ def test_snapshot_image_returns_source_when_session_dir_is_none(tmp_path: Path)
|
||||||
|
|
||||||
att = snapshot_image(src, alias="screenshot.png", session_dir=None)
|
att = snapshot_image(src, alias="screenshot.png", session_dir=None)
|
||||||
|
|
||||||
assert att.path == src.resolve()
|
assert isinstance(att.source, FileImageSource)
|
||||||
|
assert att.source.path == src.resolve()
|
||||||
assert att.alias == "screenshot.png"
|
assert att.alias == "screenshot.png"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -69,3 +79,54 @@ def test_snapshot_image_normalizes_jpg_to_jpeg_mime(tmp_path: Path) -> None:
|
||||||
att = snapshot_image(src, alias="photo.jpg", session_dir=None)
|
att = snapshot_image(src, alias="photo.jpg", session_dir=None)
|
||||||
|
|
||||||
assert att.mime_type == "image/jpeg"
|
assert att.mime_type == "image/jpeg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_image_bytes_inlines_when_session_dir_is_none() -> None:
|
||||||
|
att = snapshot_image_bytes(
|
||||||
|
PNG_BYTES, alias="pasted.png", mime_type="image/png", session_dir=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(att.source, InlineImageSource)
|
||||||
|
assert att.source.data == base64.b64encode(PNG_BYTES).decode("ascii")
|
||||||
|
assert att.alias == "pasted.png"
|
||||||
|
assert att.mime_type == "image/png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_image_bytes_writes_file_when_session_dir_is_set(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
session_dir = tmp_path / "session"
|
||||||
|
|
||||||
|
att = snapshot_image_bytes(
|
||||||
|
PNG_BYTES, alias="pasted.png", mime_type="image/png", session_dir=session_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
digest = hashlib.sha1(PNG_BYTES, usedforsecurity=False).hexdigest()
|
||||||
|
assert isinstance(att.source, FileImageSource)
|
||||||
|
assert att.source.path == (session_dir / "attachments" / f"{digest}.png").resolve()
|
||||||
|
assert att.source.path.read_bytes() == PNG_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_image_bytes_rejects_unsupported_mime() -> None:
|
||||||
|
with pytest.raises(ImageSnapshotError):
|
||||||
|
snapshot_image_bytes(
|
||||||
|
PNG_BYTES, alias="x", mime_type="image/tiff", session_dir=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_image_rejects_oversized_file(tmp_path: Path) -> None:
|
||||||
|
src = tmp_path / "big.png"
|
||||||
|
src.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * MAX_IMAGE_BYTES)
|
||||||
|
|
||||||
|
with pytest.raises(ImageSnapshotError):
|
||||||
|
snapshot_image(src, alias="big.png", session_dir=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_image_bytes_rejects_oversized_data() -> None:
|
||||||
|
with pytest.raises(ImageSnapshotError):
|
||||||
|
snapshot_image_bytes(
|
||||||
|
b"x" * (MAX_IMAGE_BYTES + 1),
|
||||||
|
alias="big.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
session_dir=None,
|
||||||
|
)
|
||||||
|
|
|
||||||
47
tests/core/test_agent_loop_refresh_config.py
Normal file
47
tests/core/test_agent_loop_refresh_config.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import build_test_agent_loop, build_test_vibe_config
|
||||||
|
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
|
||||||
|
from vibe.core.config import MCPHttp, MCPOAuth, MCPStreamableHttp, VibeConfig
|
||||||
|
from vibe.core.tools.mcp import AuthStatus
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_config_reconciles_mcp_registry_status(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
kept = MCPHttp(name="kept", transport="http", url="http://kept:1")
|
||||||
|
removed = MCPHttp(name="removed", transport="http", url="http://removed:1")
|
||||||
|
registry = FakeMCPRegistry()
|
||||||
|
agent_loop = build_test_agent_loop(
|
||||||
|
config=build_test_vibe_config(mcp_servers=[kept, removed]),
|
||||||
|
mcp_registry=registry,
|
||||||
|
)
|
||||||
|
refreshed_config = build_test_vibe_config(mcp_servers=[kept])
|
||||||
|
|
||||||
|
monkeypatch.setattr(VibeConfig, "load", staticmethod(lambda: refreshed_config))
|
||||||
|
agent_loop.refresh_config()
|
||||||
|
|
||||||
|
assert registry.status() == {"kept": AuthStatus.STATIC}
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_config_does_not_mark_undiscovered_oauth_server_ok(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
oauth = MCPStreamableHttp(
|
||||||
|
name="linear",
|
||||||
|
transport="streamable-http",
|
||||||
|
url="https://mcp.example.com/mcp",
|
||||||
|
auth=MCPOAuth(type="oauth", scopes=["read"]),
|
||||||
|
)
|
||||||
|
registry = FakeMCPRegistry()
|
||||||
|
agent_loop = build_test_agent_loop(
|
||||||
|
config=build_test_vibe_config(mcp_servers=[]), mcp_registry=registry
|
||||||
|
)
|
||||||
|
refreshed_config = build_test_vibe_config(mcp_servers=[oauth])
|
||||||
|
|
||||||
|
monkeypatch.setattr(VibeConfig, "load", staticmethod(lambda: refreshed_config))
|
||||||
|
agent_loop.refresh_config()
|
||||||
|
|
||||||
|
assert registry.status() == {"linear": AuthStatus.NEEDS_AUTH}
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from tests.constants import CHAT_COMPLETIONS_PATH
|
||||||
from vibe.core.llm.exceptions import BackendError, PayloadSummary
|
from vibe.core.llm.exceptions import BackendError, PayloadSummary
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -24,7 +25,7 @@ def _make_error(
|
||||||
) -> BackendError:
|
) -> BackendError:
|
||||||
return BackendError(
|
return BackendError(
|
||||||
provider="test-provider",
|
provider="test-provider",
|
||||||
endpoint="/v1/chat/completions",
|
endpoint=CHAT_COMPLETIONS_PATH,
|
||||||
status=status,
|
status=status,
|
||||||
reason="some reason",
|
reason="some reason",
|
||||||
headers=headers or {},
|
headers=headers or {},
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from pathlib import Path
|
||||||
|
|
||||||
from vibe.core.telemetry.build_metadata import build_attachment_counts
|
from vibe.core.telemetry.build_metadata import build_attachment_counts
|
||||||
from vibe.core.telemetry.types import AttachmentKind
|
from vibe.core.telemetry.types import AttachmentKind
|
||||||
from vibe.core.types import ImageAttachment, LLMMessage, Role
|
from vibe.core.types import FileImageSource, ImageAttachment, LLMMessage, Role
|
||||||
|
|
||||||
|
|
||||||
def _msg(images: list[ImageAttachment] | None) -> LLMMessage:
|
def _msg(images: list[ImageAttachment] | None) -> LLMMessage:
|
||||||
|
|
@ -13,7 +13,9 @@ def _msg(images: list[ImageAttachment] | None) -> LLMMessage:
|
||||||
|
|
||||||
def _image() -> ImageAttachment:
|
def _image() -> ImageAttachment:
|
||||||
return ImageAttachment(
|
return ImageAttachment(
|
||||||
path=Path("/tmp/x.png"), alias="x.png", mime_type="image/png"
|
source=FileImageSource(path=Path("/tmp/x.png")),
|
||||||
|
alias="x.png",
|
||||||
|
mime_type="image/png",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
109
tests/core/test_cache_store.py
Normal file
109
tests/core/test_cache_store.py
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
from vibe.core.cache_store import FileSystemVibeCodeCacheStore
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileSystemVibeCodeCacheStore:
|
||||||
|
def test_reads_valid_toml_section(self, tmp_path: Path) -> None:
|
||||||
|
cache_path = tmp_path / "cache.toml"
|
||||||
|
cache_path.write_text('[update_cache]\nlatest_version = "1.0.0"\n')
|
||||||
|
store = FileSystemVibeCodeCacheStore(cache_path)
|
||||||
|
|
||||||
|
result = store.read_section("update_cache")
|
||||||
|
|
||||||
|
assert result["latest_version"] == "1.0.0"
|
||||||
|
|
||||||
|
def test_returns_empty_dict_when_file_is_missing(self, tmp_path: Path) -> None:
|
||||||
|
store = FileSystemVibeCodeCacheStore(tmp_path / "missing.toml")
|
||||||
|
|
||||||
|
assert store.read_section("update_cache") == {}
|
||||||
|
|
||||||
|
def test_returns_empty_dict_when_file_is_corrupted(self, tmp_path: Path) -> None:
|
||||||
|
cache_path = tmp_path / "cache.toml"
|
||||||
|
cache_path.write_text("{bad toml")
|
||||||
|
store = FileSystemVibeCodeCacheStore(cache_path)
|
||||||
|
|
||||||
|
assert store.read_section("update_cache") == {}
|
||||||
|
|
||||||
|
def test_writes_new_file(self, tmp_path: Path) -> None:
|
||||||
|
cache_path = tmp_path / "cache.toml"
|
||||||
|
store = FileSystemVibeCodeCacheStore(cache_path)
|
||||||
|
|
||||||
|
store.write_section("feedback", {"last_shown_at": 100.0})
|
||||||
|
|
||||||
|
with cache_path.open("rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
assert data["feedback"]["last_shown_at"] == 100.0
|
||||||
|
|
||||||
|
def test_merges_with_existing(self, tmp_path: Path) -> None:
|
||||||
|
cache_path = tmp_path / "cache.toml"
|
||||||
|
cache_path.write_text('[update_cache]\nlatest_version = "1.0.0"\n')
|
||||||
|
store = FileSystemVibeCodeCacheStore(cache_path)
|
||||||
|
|
||||||
|
store.write_section("feedback", {"last_shown_at": 200.0})
|
||||||
|
|
||||||
|
with cache_path.open("rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
assert data["update_cache"]["latest_version"] == "1.0.0"
|
||||||
|
assert data["feedback"]["last_shown_at"] == 200.0
|
||||||
|
|
||||||
|
def test_merges_within_section_and_leaves_other_sections_alone(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
cache_path = tmp_path / "cache.toml"
|
||||||
|
cache_path.write_text(
|
||||||
|
"[update_cache]\n"
|
||||||
|
'latest_version = "1.0.0"\n'
|
||||||
|
"stored_at_timestamp = 1\n"
|
||||||
|
'seen_whats_new_version = "1.0.0"\n\n'
|
||||||
|
"[feedback]\n"
|
||||||
|
"last_shown_at = 100.0\n"
|
||||||
|
)
|
||||||
|
store = FileSystemVibeCodeCacheStore(cache_path)
|
||||||
|
|
||||||
|
store.write_section(
|
||||||
|
"update_cache", {"latest_version": "2.0.0", "stored_at_timestamp": 2}
|
||||||
|
)
|
||||||
|
|
||||||
|
with cache_path.open("rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
assert data["update_cache"]["latest_version"] == "2.0.0"
|
||||||
|
assert data["update_cache"]["stored_at_timestamp"] == 2
|
||||||
|
assert data["update_cache"]["seen_whats_new_version"] == "1.0.0"
|
||||||
|
assert data["feedback"]["last_shown_at"] == 100.0
|
||||||
|
|
||||||
|
def test_reads_section(self, tmp_path: Path) -> None:
|
||||||
|
cache_path = tmp_path / "cache.toml"
|
||||||
|
cache_path.write_text("[feedback]\nlast_shown_at = 100\n")
|
||||||
|
store = FileSystemVibeCodeCacheStore(cache_path)
|
||||||
|
|
||||||
|
assert store.read_section("feedback") == {"last_shown_at": 100}
|
||||||
|
|
||||||
|
def test_returns_empty_dict_for_missing_section(self, tmp_path: Path) -> None:
|
||||||
|
store = FileSystemVibeCodeCacheStore(tmp_path / "cache.toml")
|
||||||
|
|
||||||
|
assert store.read_section("feedback") == {}
|
||||||
|
|
||||||
|
def test_writes_section(self, tmp_path: Path) -> None:
|
||||||
|
cache_path = tmp_path / "cache.toml"
|
||||||
|
store = FileSystemVibeCodeCacheStore(cache_path)
|
||||||
|
|
||||||
|
store.write_section("feedback", {"last_shown_at": 100})
|
||||||
|
|
||||||
|
with cache_path.open("rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
assert data["feedback"]["last_shown_at"] == 100
|
||||||
|
|
||||||
|
def test_replaces_non_table_section_when_writing(self, tmp_path: Path) -> None:
|
||||||
|
cache_path = tmp_path / "cache.toml"
|
||||||
|
cache_path.write_text('feedback = "not-a-table"\n')
|
||||||
|
store = FileSystemVibeCodeCacheStore(cache_path)
|
||||||
|
|
||||||
|
store.write_section("feedback", {"last_shown_at": 100})
|
||||||
|
|
||||||
|
with cache_path.open("rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
assert data["feedback"]["last_shown_at"] == 100
|
||||||
|
|
@ -31,6 +31,9 @@ class FakeLayer(ConfigLayer[RawConfig]):
|
||||||
async def _build_config_snapshot(self) -> LayerConfigSnapshot:
|
async def _build_config_snapshot(self) -> LayerConfigSnapshot:
|
||||||
return LayerConfigSnapshot(data=dict(self._data), fingerprint="fp")
|
return LayerConfigSnapshot(data=dict(self._data), fingerprint="fp")
|
||||||
|
|
||||||
|
async def _save_to_store(self, _next_config: RawConfig) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class UntrustedFakeLayer(FakeLayer):
|
class UntrustedFakeLayer(FakeLayer):
|
||||||
async def _check_trust(self) -> bool:
|
async def _check_trust(self) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,24 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from jsonpointer import JsonPointerException
|
||||||
from pydantic import BaseModel, ValidationError
|
from pydantic import BaseModel, ValidationError
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from vibe.core.config.layer import (
|
from vibe.core.config.layer import (
|
||||||
ConfigLayer,
|
ConfigLayer,
|
||||||
|
ConfigPatchApplicationError,
|
||||||
LayerImplementationError,
|
LayerImplementationError,
|
||||||
RawConfig,
|
RawConfig,
|
||||||
TrustNotResolvedError,
|
TrustNotResolvedError,
|
||||||
UntrustedLayerError,
|
UntrustedLayerError,
|
||||||
)
|
)
|
||||||
from vibe.core.config.patch import ConfigPatch
|
from vibe.core.config.patch import AddOperationPatch, ConfigPatch, ReplaceOperationPatch
|
||||||
from vibe.core.config.types import ConcurrencyConflictError, LayerConfigSnapshot
|
from vibe.core.config.types import (
|
||||||
|
ConcurrencyConflictError,
|
||||||
|
ConflictStrategy,
|
||||||
|
LayerConfigSnapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StubLayer(ConfigLayer[BaseModel]):
|
class StubLayer(ConfigLayer[BaseModel]):
|
||||||
|
|
@ -45,6 +51,9 @@ class StubLayer(ConfigLayer[BaseModel]):
|
||||||
data=dict(self._data), fingerprint=f"fp-{self.read_count}"
|
data=dict(self._data), fingerprint=f"fp-{self.read_count}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _save_to_store(self, _next_config: BaseModel) -> str:
|
||||||
|
raise NotImplementedError("StubLayer.apply() is not implemented")
|
||||||
|
|
||||||
|
|
||||||
class ObservableStubLayer(StubLayer):
|
class ObservableStubLayer(StubLayer):
|
||||||
"""Stub that records _on_trust_changed calls."""
|
"""Stub that records _on_trust_changed calls."""
|
||||||
|
|
@ -57,6 +66,18 @@ class ObservableStubLayer(StubLayer):
|
||||||
self.trust_changes.append((old, new))
|
self.trust_changes.append((old, new))
|
||||||
|
|
||||||
|
|
||||||
|
class WritableStubLayer(StubLayer):
|
||||||
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.writes: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def _save_to_store(self, _next_config: BaseModel) -> str:
|
||||||
|
next_data = _next_config.model_dump()
|
||||||
|
self.writes.append(next_data)
|
||||||
|
self._data = next_data
|
||||||
|
return f"write-fp-{len(self.writes)}"
|
||||||
|
|
||||||
|
|
||||||
class SampleSchema(BaseModel):
|
class SampleSchema(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
count: int = 0
|
count: int = 0
|
||||||
|
|
@ -91,6 +112,9 @@ async def test_default_check_trust_returns_false() -> None:
|
||||||
async def _build_config_snapshot(self) -> LayerConfigSnapshot:
|
async def _build_config_snapshot(self) -> LayerConfigSnapshot:
|
||||||
return LayerConfigSnapshot(data={}, fingerprint="fp")
|
return LayerConfigSnapshot(data={}, fingerprint="fp")
|
||||||
|
|
||||||
|
async def _save_to_store(self, _next_config: BaseModel) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
layer = DefaultTrustLayer(name="default")
|
layer = DefaultTrustLayer(name="default")
|
||||||
result = await layer.resolve_trust()
|
result = await layer.resolve_trust()
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
@ -236,7 +260,7 @@ async def test_load_returns_data() -> None:
|
||||||
layer = StubLayer(data={"key": "value"})
|
layer = StubLayer(data={"key": "value"})
|
||||||
result = await layer.load()
|
result = await layer.load()
|
||||||
assert isinstance(result, RawConfig)
|
assert isinstance(result, RawConfig)
|
||||||
assert result.model_extra == {"key": "value"}
|
assert result.model_dump() == {"key": "value"}
|
||||||
assert layer.fingerprint == "fp-1"
|
assert layer.fingerprint == "fp-1"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -246,7 +270,7 @@ async def test_load_auto_resolves_trust() -> None:
|
||||||
assert layer.is_trusted is None
|
assert layer.is_trusted is None
|
||||||
result = await layer.load()
|
result = await layer.load()
|
||||||
assert layer.is_trusted is True
|
assert layer.is_trusted is True
|
||||||
assert result.model_extra == {"a": 1}
|
assert result.model_dump() == {"a": 1}
|
||||||
assert layer.fingerprint == "fp-1"
|
assert layer.fingerprint == "fp-1"
|
||||||
|
|
||||||
await layer.resolve_trust()
|
await layer.resolve_trust()
|
||||||
|
|
@ -311,21 +335,21 @@ async def test_invalidate_cache_causes_reload() -> None:
|
||||||
async def test_revoke_grant_cycle_refreshes_data() -> None:
|
async def test_revoke_grant_cycle_refreshes_data() -> None:
|
||||||
layer = StubLayer(data={"v": 1})
|
layer = StubLayer(data={"v": 1})
|
||||||
result1 = await layer.load()
|
result1 = await layer.load()
|
||||||
assert result1.model_extra == {"v": 1}
|
assert result1.model_dump() == {"v": 1}
|
||||||
|
|
||||||
await layer.revoke_trust()
|
await layer.revoke_trust()
|
||||||
layer._data = {"v": 2}
|
layer._data = {"v": 2}
|
||||||
await layer.grant_trust()
|
await layer.grant_trust()
|
||||||
|
|
||||||
result2 = await layer.load()
|
result2 = await layer.load()
|
||||||
assert result2.model_extra == {"v": 2}
|
assert result2.model_dump() == {"v": 2}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_trust_clears_data_on_revocation() -> None:
|
async def test_resolve_trust_clears_data_on_revocation() -> None:
|
||||||
layer = StubLayer(data={"v": 1})
|
layer = StubLayer(data={"v": 1})
|
||||||
result1 = await layer.load()
|
result1 = await layer.load()
|
||||||
assert result1.model_extra == {"v": 1}
|
assert result1.model_dump() == {"v": 1}
|
||||||
|
|
||||||
# External revocation via resolve_trust (not revoke_trust)
|
# External revocation via resolve_trust (not revoke_trust)
|
||||||
layer._stub_trusted = False
|
layer._stub_trusted = False
|
||||||
|
|
@ -339,7 +363,7 @@ async def test_resolve_trust_clears_data_on_revocation() -> None:
|
||||||
await layer.resolve_trust()
|
await layer.resolve_trust()
|
||||||
|
|
||||||
result2 = await layer.load()
|
result2 = await layer.load()
|
||||||
assert result2.model_extra == {"v": 2}
|
assert result2.model_dump() == {"v": 2}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -350,7 +374,7 @@ async def test_load_returns_deep_copy() -> None:
|
||||||
result1.model_extra["items"].append("mutated")
|
result1.model_extra["items"].append("mutated")
|
||||||
|
|
||||||
result2 = await layer.load()
|
result2 = await layer.load()
|
||||||
assert result2.model_extra == {"items": ["a", "b"]}
|
assert result2.model_dump() == {"items": ["a", "b"]}
|
||||||
assert layer.read_count == 1
|
assert layer.read_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -384,7 +408,7 @@ async def test_default_schema_preserves_extras() -> None:
|
||||||
layer = StubLayer(data={"anything": "goes"})
|
layer = StubLayer(data={"anything": "goes"})
|
||||||
result = await layer.load()
|
result = await layer.load()
|
||||||
assert isinstance(result, RawConfig)
|
assert isinstance(result, RawConfig)
|
||||||
assert result.model_extra == {"anything": "goes"}
|
assert result.model_dump() == {"anything": "goes"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -423,6 +447,9 @@ async def test_concurrent_loads_serialize() -> None:
|
||||||
data={"v": self.read_count}, fingerprint=f"fp-{self.read_count}"
|
data={"v": self.read_count}, fingerprint=f"fp-{self.read_count}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _save_to_store(self, _next_config: BaseModel) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
layer = SlowLayer()
|
layer = SlowLayer()
|
||||||
results = await asyncio.gather(layer.load(), layer.load(), layer.load())
|
results = await asyncio.gather(layer.load(), layer.load(), layer.load())
|
||||||
assert layer.read_count == 1
|
assert layer.read_count == 1
|
||||||
|
|
@ -438,8 +465,155 @@ async def test_fingerprint_returns_none_before_load() -> None:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_apply_not_implemented() -> None:
|
async def test_apply_not_implemented() -> None:
|
||||||
layer = StubLayer()
|
layer = StubLayer()
|
||||||
|
await layer.load()
|
||||||
|
fingerprint = layer.fingerprint
|
||||||
|
assert isinstance(fingerprint, str)
|
||||||
|
|
||||||
with pytest.raises(NotImplementedError):
|
with pytest.raises(NotImplementedError):
|
||||||
await layer.apply(ConfigPatch(fingerprint="fp-1"))
|
await layer.apply(ConfigPatch(fingerprint=fingerprint))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_operation_failure_wrapped() -> None:
|
||||||
|
original_data = {"tools": {"disabled_tools": ["bash"]}}
|
||||||
|
layer = WritableStubLayer(data=original_data)
|
||||||
|
await layer.load()
|
||||||
|
fingerprint = layer.fingerprint
|
||||||
|
assert isinstance(fingerprint, str)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ConfigPatchApplicationError, match="Layer 'stub': failed to apply patch"
|
||||||
|
) as exc_info:
|
||||||
|
await layer.apply(
|
||||||
|
ConfigPatch(
|
||||||
|
AddOperationPatch(path="/tools/enabled_tools/-", value="read"),
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.layer_name == "stub"
|
||||||
|
assert isinstance(exc_info.value.__cause__, JsonPointerException)
|
||||||
|
assert "enabled_tools" in str(exc_info.value.__cause__)
|
||||||
|
assert layer.fingerprint == fingerprint
|
||||||
|
assert (await layer.load()).model_dump() == original_data
|
||||||
|
assert layer.writes == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_operation_failure_after_successful_operation_is_atomic() -> None:
|
||||||
|
original_data = {"active_model": "old", "tools": {"disabled_tools": ["bash"]}}
|
||||||
|
layer = WritableStubLayer(data=original_data)
|
||||||
|
await layer.load()
|
||||||
|
fingerprint = layer.fingerprint
|
||||||
|
assert isinstance(fingerprint, str)
|
||||||
|
|
||||||
|
with pytest.raises(ConfigPatchApplicationError):
|
||||||
|
await layer.apply(
|
||||||
|
ConfigPatch(
|
||||||
|
ReplaceOperationPatch(path="/active_model", value="new"),
|
||||||
|
AddOperationPatch(path="/tools/enabled_tools/-", value="read"),
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert layer.fingerprint == fingerprint
|
||||||
|
assert (await layer.load()).model_dump() == original_data
|
||||||
|
assert layer.writes == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_schema_validation_failure_wrapped() -> None:
|
||||||
|
original_data = {"name": "test", "count": 1}
|
||||||
|
layer = WritableStubLayer(output_schema=SampleSchema, data=original_data)
|
||||||
|
await layer.load()
|
||||||
|
fingerprint = layer.fingerprint
|
||||||
|
assert isinstance(fingerprint, str)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ConfigPatchApplicationError, match="Layer 'stub': failed to apply patch"
|
||||||
|
) as exc_info:
|
||||||
|
await layer.apply(
|
||||||
|
ConfigPatch(
|
||||||
|
ReplaceOperationPatch(path="/count", value={"bad": "value"}),
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.layer_name == "stub"
|
||||||
|
assert isinstance(exc_info.value.__cause__, ValidationError)
|
||||||
|
assert layer.fingerprint == fingerprint
|
||||||
|
assert (await layer.load()).model_dump() == original_data
|
||||||
|
assert layer.writes == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_cancel_rejects_stale_fingerprint() -> None:
|
||||||
|
layer = WritableStubLayer(data={"key": "old"})
|
||||||
|
await layer.load()
|
||||||
|
fingerprint = layer.fingerprint
|
||||||
|
assert isinstance(fingerprint, str)
|
||||||
|
|
||||||
|
await layer.apply(
|
||||||
|
ConfigPatch(
|
||||||
|
ReplaceOperationPatch(path="/key", value="first"), fingerprint=fingerprint
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ConcurrencyConflictError) as exc_info:
|
||||||
|
await layer.apply(
|
||||||
|
ConfigPatch(
|
||||||
|
ReplaceOperationPatch(path="/key", value="second"),
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.expected_fp == fingerprint
|
||||||
|
assert exc_info.value.actual_fp == layer.fingerprint
|
||||||
|
assert (await layer.load()).model_dump() == {"key": "first"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_replace_accepts_stale_fingerprint() -> None:
|
||||||
|
layer = WritableStubLayer(data={"key": "old"})
|
||||||
|
await layer.load()
|
||||||
|
fingerprint = layer.fingerprint
|
||||||
|
assert isinstance(fingerprint, str)
|
||||||
|
|
||||||
|
await layer.apply(
|
||||||
|
ConfigPatch(
|
||||||
|
ReplaceOperationPatch(path="/key", value="first"), fingerprint=fingerprint
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await layer.apply(
|
||||||
|
ConfigPatch(
|
||||||
|
ReplaceOperationPatch(path="/key", value="second"), fingerprint=fingerprint
|
||||||
|
),
|
||||||
|
on_conflict=ConflictStrategy.REPLACE,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (await layer.load()).model_dump() == {"key": "second"}
|
||||||
|
assert layer.writes == [{"key": "first"}, {"key": "second"}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_to_store_failure_wrapped() -> None:
|
||||||
|
class BrokenApplyLayer(StubLayer):
|
||||||
|
async def _save_to_store(self, _next_config: BaseModel) -> str:
|
||||||
|
raise OSError("disk full")
|
||||||
|
|
||||||
|
layer = BrokenApplyLayer(data={"key": "old"})
|
||||||
|
await layer.load()
|
||||||
|
fingerprint = layer.fingerprint
|
||||||
|
assert isinstance(fingerprint, str)
|
||||||
|
|
||||||
|
with pytest.raises(LayerImplementationError, match="_save_to_store") as exc_info:
|
||||||
|
await layer.apply(
|
||||||
|
ConfigPatch(
|
||||||
|
ReplaceOperationPatch(path="/key", value="new"), fingerprint=fingerprint
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(exc_info.value.__cause__, OSError)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -499,6 +673,9 @@ class FakeLocalUserLayer(ConfigLayer[UserConfigSchema]):
|
||||||
async def _build_config_snapshot(self) -> LayerConfigSnapshot:
|
async def _build_config_snapshot(self) -> LayerConfigSnapshot:
|
||||||
return LayerConfigSnapshot(data=dict(self._data), fingerprint="user-fp")
|
return LayerConfigSnapshot(data=dict(self._data), fingerprint="user-fp")
|
||||||
|
|
||||||
|
async def _save_to_store(self, _next_config: UserConfigSchema) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scenario_local_user_layer_always_trusted() -> None:
|
async def test_scenario_local_user_layer_always_trusted() -> None:
|
||||||
|
|
@ -544,6 +721,9 @@ class FakeLocalProjectLayer(ConfigLayer[BaseModel]):
|
||||||
async def _build_config_snapshot(self) -> LayerConfigSnapshot:
|
async def _build_config_snapshot(self) -> LayerConfigSnapshot:
|
||||||
return LayerConfigSnapshot(data=dict(self._data), fingerprint="project-fp")
|
return LayerConfigSnapshot(data=dict(self._data), fingerprint="project-fp")
|
||||||
|
|
||||||
|
async def _save_to_store(self, _next_config: BaseModel) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scenario_local_project_layer_trust_lifecycle() -> None:
|
async def test_scenario_local_project_layer_trust_lifecycle() -> None:
|
||||||
|
|
@ -561,7 +741,7 @@ async def test_scenario_local_project_layer_trust_lifecycle() -> None:
|
||||||
await layer.grant_trust()
|
await layer.grant_trust()
|
||||||
assert trust_store == {"/tmp/my-project": True}
|
assert trust_store == {"/tmp/my-project": True}
|
||||||
result = await layer.load()
|
result = await layer.load()
|
||||||
assert result.model_extra == project_data
|
assert result.model_dump() == project_data
|
||||||
|
|
||||||
# 3. New instance with same trust store — loads directly
|
# 3. New instance with same trust store — loads directly
|
||||||
layer2 = FakeLocalProjectLayer(
|
layer2 = FakeLocalProjectLayer(
|
||||||
|
|
@ -570,7 +750,7 @@ async def test_scenario_local_project_layer_trust_lifecycle() -> None:
|
||||||
assert layer2.is_trusted is None
|
assert layer2.is_trusted is None
|
||||||
result2 = await layer2.load()
|
result2 = await layer2.load()
|
||||||
assert layer2.is_trusted is True
|
assert layer2.is_trusted is True
|
||||||
assert result2.model_extra == project_data
|
assert result2.model_dump() == project_data
|
||||||
|
|
||||||
# 4. Revoke trust — removed from store, load raises
|
# 4. Revoke trust — removed from store, load raises
|
||||||
await layer2.revoke_trust()
|
await layer2.revoke_trust()
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ def test_skips_missing_file(tmp_path: Path) -> None:
|
||||||
assert environ == {"EXISTING": "1"}
|
assert environ == {"EXISTING": "1"}
|
||||||
|
|
||||||
|
|
||||||
def test_sets_and_overrides_values(tmp_path: Path) -> None:
|
def test_adds_missing_values_without_overriding_existing(tmp_path: Path) -> None:
|
||||||
env_path = tmp_path / ".env"
|
env_path = tmp_path / ".env"
|
||||||
_write_env_file(
|
_write_env_file(
|
||||||
env_path,
|
env_path,
|
||||||
|
|
@ -42,11 +42,23 @@ def test_sets_and_overrides_values(tmp_path: Path) -> None:
|
||||||
|
|
||||||
load_dotenv_values(env_path=env_path, environ=environ)
|
load_dotenv_values(env_path=env_path, environ=environ)
|
||||||
|
|
||||||
assert environ["MISTRAL_API_KEY"] == "new-key"
|
# An explicit process/shell value wins over the .env file.
|
||||||
assert environ["HTTPS_PROXY"] == "https://local-proxy:8080"
|
assert environ["MISTRAL_API_KEY"] == "old-key"
|
||||||
assert environ["OTHER"] == "from-env"
|
assert environ["HTTPS_PROXY"] == "old-https"
|
||||||
|
assert environ["OTHER"] == "keep"
|
||||||
|
assert environ["FOO"] == "keep"
|
||||||
|
# Keys absent from the process env are still loaded from the .env file.
|
||||||
assert environ["NEW_KEY"] == "added"
|
assert environ["NEW_KEY"] == "added"
|
||||||
assert environ["FOO"] == "replace"
|
|
||||||
|
|
||||||
|
def test_adds_dotenv_value_when_process_env_is_empty(tmp_path: Path) -> None:
|
||||||
|
env_path = tmp_path / ".env"
|
||||||
|
_write_env_file(env_path, "MISTRAL_API_KEY=file-key\n")
|
||||||
|
environ = {"MISTRAL_API_KEY": ""}
|
||||||
|
|
||||||
|
load_dotenv_values(env_path=env_path, environ=environ)
|
||||||
|
|
||||||
|
assert environ["MISTRAL_API_KEY"] == "file-key"
|
||||||
|
|
||||||
|
|
||||||
def test_ignores_empty_values(tmp_path: Path) -> None:
|
def test_ignores_empty_values(tmp_path: Path) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from vibe.core.config import MCPHttp, MCPOAuth, MCPStaticAuth, MCPStreamableHttp
|
from vibe.core.config import MCPHttp, MCPOAuth, MCPStaticAuth, MCPStreamableHttp
|
||||||
|
from vibe.core.tools.mcp import AuthStatus
|
||||||
from vibe.core.tools.mcp.registry import MCPRegistry
|
from vibe.core.tools.mcp.registry import MCPRegistry
|
||||||
|
|
||||||
HTTP_TRANSPORTS = [
|
HTTP_TRANSPORTS = [
|
||||||
|
|
@ -183,10 +184,8 @@ def test_oauth_scopes_empty_list_allowed() -> None:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(("cls", "transport"), HTTP_TRANSPORTS)
|
@pytest.mark.parametrize(("cls", "transport"), HTTP_TRANSPORTS)
|
||||||
async def test_registry_skips_oauth_servers_with_gated_warning(
|
async def test_registry_marks_oauth_servers_without_tokens_as_needing_auth(
|
||||||
cls: type[MCPHttp | MCPStreamableHttp],
|
cls: type[MCPHttp | MCPStreamableHttp], transport: str
|
||||||
transport: str,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
srv = cls.model_validate({
|
srv = cls.model_validate({
|
||||||
"name": "linear",
|
"name": "linear",
|
||||||
|
|
@ -195,19 +194,23 @@ async def test_registry_skips_oauth_servers_with_gated_warning(
|
||||||
"auth": {"type": "oauth", "scopes": ["read"]},
|
"auth": {"type": "oauth", "scopes": ["read"]},
|
||||||
})
|
})
|
||||||
registry = MCPRegistry()
|
registry = MCPRegistry()
|
||||||
|
storage = MagicMock()
|
||||||
|
storage.get_tokens = AsyncMock(return_value=None)
|
||||||
|
storage.delete_tokens = AsyncMock()
|
||||||
|
storage.delete_client_info = AsyncMock()
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="vibe"):
|
with (
|
||||||
|
patch("vibe.core.tools.mcp.registry.KeyringTokenStorage", return_value=storage),
|
||||||
|
patch(
|
||||||
|
"vibe.core.tools.mcp.registry.Fingerprint.load",
|
||||||
|
new=AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
|
patch("vibe.core.tools.mcp.registry.Fingerprint.delete", new=AsyncMock()),
|
||||||
|
):
|
||||||
first = await registry.get_tools_async([srv])
|
first = await registry.get_tools_async([srv])
|
||||||
|
|
||||||
assert first == {}
|
|
||||||
assert (
|
|
||||||
"OAuth support for MCP servers is not yet enabled; coming in a future release"
|
|
||||||
in caplog.text
|
|
||||||
)
|
|
||||||
|
|
||||||
caplog.clear()
|
|
||||||
with caplog.at_level(logging.WARNING, logger="vibe"):
|
|
||||||
second = await registry.get_tools_async([srv])
|
second = await registry.get_tools_async([srv])
|
||||||
|
|
||||||
|
assert first == {}
|
||||||
assert second == {}
|
assert second == {}
|
||||||
assert "OAuth support" not in caplog.text
|
assert registry.needs_auth == {"linear"}
|
||||||
|
assert registry.status()["linear"] == AuthStatus.NEEDS_AUTH
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ class FakeLayer(ConfigLayer[RawConfig]):
|
||||||
async def _build_config_snapshot(self) -> LayerConfigSnapshot:
|
async def _build_config_snapshot(self) -> LayerConfigSnapshot:
|
||||||
return LayerConfigSnapshot(data=dict(self._data), fingerprint="fp")
|
return LayerConfigSnapshot(data=dict(self._data), fingerprint="fp")
|
||||||
|
|
||||||
|
async def _save_to_store(self, _next_config: RawConfig) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class SimpleSchema(ConfigSchema):
|
class SimpleSchema(ConfigSchema):
|
||||||
value: Annotated[str, WithReplaceMerge()] = "default"
|
value: Annotated[str, WithReplaceMerge()] = "default"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from tests.constants import ANTHROPIC_BASE_URL
|
||||||
from vibe.core.config import OtelSpanExporterConfig, ProviderConfig, VibeConfig
|
from vibe.core.config import OtelSpanExporterConfig, ProviderConfig, VibeConfig
|
||||||
from vibe.core.types import Backend
|
from vibe.core.types import Backend
|
||||||
|
|
||||||
|
|
@ -62,7 +63,7 @@ class TestOtelSpanExporterConfig:
|
||||||
update={
|
update={
|
||||||
"providers": [
|
"providers": [
|
||||||
ProviderConfig(
|
ProviderConfig(
|
||||||
name="anthropic", api_base="https://api.anthropic.com/v1"
|
name="anthropic", api_base=f"{ANTHROPIC_BASE_URL}/v1"
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -80,6 +81,18 @@ class TestOtelSpanExporterConfig:
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces"
|
assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces"
|
||||||
|
|
||||||
|
def test_resolves_api_key_from_keyring(
|
||||||
|
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
# Key stored only in the OS keyring (no env var) must still authenticate OTEL.
|
||||||
|
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"keyring.get_password", lambda service, username: "sk-keyring"
|
||||||
|
)
|
||||||
|
result = vibe_config.otel_span_exporter_config
|
||||||
|
assert result is not None
|
||||||
|
assert result.headers == {"Authorization": "Bearer sk-keyring"}
|
||||||
|
|
||||||
def test_returns_none_and_warns_when_api_key_missing(
|
def test_returns_none_and_warns_when_api_key_missing(
|
||||||
self,
|
self,
|
||||||
vibe_config: VibeConfig,
|
vibe_config: VibeConfig,
|
||||||
|
|
|
||||||
|
|
@ -1,135 +1,84 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import FrozenInstanceError
|
|
||||||
from typing import Any, get_args
|
from typing import Any, get_args
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from vibe.core.config import (
|
from vibe.core.config import (
|
||||||
AppendToList,
|
AddOperationPatch,
|
||||||
DeleteField,
|
RemoveOperationPatch,
|
||||||
PatchOp,
|
ReplaceOperationPatch,
|
||||||
RemoveFromList,
|
|
||||||
SetField,
|
|
||||||
)
|
)
|
||||||
from vibe.core.config.patch import ConfigPatch
|
from vibe.core.config.patch import ConfigPatch, PatchOp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("operation", "expected"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
AddOperationPatch(path="/tools/disabled_tools/0", value="bash"),
|
||||||
|
{"op": "add", "path": "/tools/disabled_tools/0", "value": "bash"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReplaceOperationPatch(path="/active_model", value="devstral-small"),
|
||||||
|
{"op": "replace", "path": "/active_model", "value": "devstral-small"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RemoveOperationPatch(path="/tools/deprecated_setting"),
|
||||||
|
{"op": "remove", "path": "/tools/deprecated_setting"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_json_patch_operations_convert_to_json_patch_payload(
|
||||||
|
operation: AddOperationPatch | ReplaceOperationPatch | RemoveOperationPatch,
|
||||||
|
expected: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
assert operation.to_json_patch() == expected
|
||||||
|
|
||||||
|
|
||||||
def test_patch_op_union_contains_all_operations() -> None:
|
def test_patch_op_union_contains_all_operations() -> None:
|
||||||
assert get_args(PatchOp) == (SetField, AppendToList, RemoveFromList, DeleteField)
|
assert set(get_args(PatchOp.__value__)) == {
|
||||||
|
AddOperationPatch,
|
||||||
|
ReplaceOperationPatch,
|
||||||
def test_set_field_accepts_top_level_key() -> None:
|
RemoveOperationPatch,
|
||||||
op = SetField("active_model", "devstral-small")
|
}
|
||||||
|
|
||||||
assert op.key == "active_model"
|
|
||||||
assert op.value == "devstral-small"
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_field_accepts_nested_key() -> None:
|
|
||||||
op = SetField("models.providers", {"mistral": {"region": "eu"}})
|
|
||||||
|
|
||||||
assert op.key == "models.providers"
|
|
||||||
|
|
||||||
|
|
||||||
def test_append_to_list_accepts_nested_key() -> None:
|
|
||||||
op = AppendToList("tools.disabled_tools", ("bash", "python"))
|
|
||||||
|
|
||||||
assert op.key == "tools.disabled_tools"
|
|
||||||
assert op.items == ("bash", "python")
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove_from_list_accepts_nested_key() -> None:
|
|
||||||
op = RemoveFromList("models.available_models", ("codestral-latest",))
|
|
||||||
|
|
||||||
assert op.key == "models.available_models"
|
|
||||||
assert op.values == ("codestral-latest",)
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_field_accepts_nested_key() -> None:
|
|
||||||
op = DeleteField("tools.deprecated_setting")
|
|
||||||
|
|
||||||
assert op.key == "tools.deprecated_setting"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"factory",
|
"factory",
|
||||||
[
|
[
|
||||||
lambda key: SetField(key, "value"),
|
lambda path: AddOperationPatch(path=path, value="value"),
|
||||||
lambda key: AppendToList(key, ("value",)),
|
lambda path: ReplaceOperationPatch(path=path, value="value"),
|
||||||
lambda key: RemoveFromList(key, ("value",)),
|
lambda path: RemoveOperationPatch(path=path),
|
||||||
lambda key: DeleteField(key),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize(
|
def test_json_patch_operations_reject_non_pointer_paths(factory: Any) -> None:
|
||||||
"invalid_key", ["", ".active_model", "active_model.", "tools..bash"]
|
with pytest.raises(ValidationError, match="valid JSON Pointer"):
|
||||||
)
|
factory("tools.disabled_tools")
|
||||||
def test_patch_operations_reject_invalid_key_paths(
|
|
||||||
factory: Callable[[str], object], invalid_key: str
|
|
||||||
) -> None:
|
|
||||||
with pytest.raises(ValueError, match="dot-separated path|must not be empty"):
|
|
||||||
factory(invalid_key)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"factory",
|
"factory",
|
||||||
[
|
[
|
||||||
lambda key: SetField(key, "value"),
|
lambda path: AddOperationPatch(path=path, value="value"),
|
||||||
lambda key: AppendToList(key, ("value",)),
|
lambda path: ReplaceOperationPatch(path=path, value="value"),
|
||||||
lambda key: RemoveFromList(key, ("value",)),
|
lambda path: RemoveOperationPatch(path=path),
|
||||||
lambda key: DeleteField(key),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_patch_operations_reject_non_string_keys(
|
def test_json_patch_operations_reject_invalid_escapes(factory: Any) -> None:
|
||||||
factory: Callable[[Any], object],
|
with pytest.raises(ValidationError, match="valid JSON Pointer"):
|
||||||
) -> None:
|
factory("/tools/~2")
|
||||||
with pytest.raises(TypeError, match="Patch operation key must be a string"):
|
|
||||||
factory(1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_append_to_list_rejects_non_tuple_items() -> None:
|
def test_json_patch_operations_accept_slash_prefixed_paths() -> None:
|
||||||
bad_items: Any = ["bash"]
|
op = ReplaceOperationPatch(path="/", value={"active_model": "devstral-small"})
|
||||||
|
|
||||||
with pytest.raises(TypeError, match="AppendToList.items must be a tuple"):
|
assert op.path == "/"
|
||||||
AppendToList("tools.disabled_tools", bad_items)
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove_from_list_rejects_non_tuple_values() -> None:
|
|
||||||
bad_values: Any = ["bash"]
|
|
||||||
|
|
||||||
with pytest.raises(TypeError, match="RemoveFromList.values must be a tuple"):
|
|
||||||
RemoveFromList("tools.disabled_tools", bad_values)
|
|
||||||
|
|
||||||
|
|
||||||
def test_patch_operations_are_frozen() -> None:
|
|
||||||
op = SetField("active_model", "devstral-small")
|
|
||||||
|
|
||||||
with pytest.raises(FrozenInstanceError):
|
|
||||||
op.__setattr__("key", "models.active_model")
|
|
||||||
|
|
||||||
|
|
||||||
def test_scenario_mini_vibe_patch_operations() -> None:
|
|
||||||
operations: list[PatchOp] = [
|
|
||||||
SetField("active_model", "devstral-small"),
|
|
||||||
AppendToList("tools.disabled_tools", ("bash",)),
|
|
||||||
RemoveFromList("models.available_models", ("codestral-latest",)),
|
|
||||||
DeleteField("tools.deprecated_setting"),
|
|
||||||
]
|
|
||||||
|
|
||||||
assert operations == [
|
|
||||||
SetField("active_model", "devstral-small"),
|
|
||||||
AppendToList("tools.disabled_tools", ("bash",)),
|
|
||||||
RemoveFromList("models.available_models", ("codestral-latest",)),
|
|
||||||
DeleteField("tools.deprecated_setting"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# --- ConfigPatch ---
|
|
||||||
|
|
||||||
|
|
||||||
def test_config_patch_stores_operations_and_metadata() -> None:
|
def test_config_patch_stores_operations_and_metadata() -> None:
|
||||||
op = SetField("active_model", "devstral-small")
|
op = ReplaceOperationPatch(path="/active_model", value="devstral-small")
|
||||||
patch = ConfigPatch(op, fingerprint="fp-1", reason="test")
|
patch = ConfigPatch(op, fingerprint="fp-1", reason="test")
|
||||||
|
|
||||||
assert patch.operations == [op]
|
assert patch.operations == [op]
|
||||||
|
|
@ -145,9 +94,9 @@ def test_config_patch_defaults() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_config_patch_accepts_multiple_operations() -> None:
|
def test_config_patch_accepts_multiple_operations() -> None:
|
||||||
ops: list[PatchOp] = [
|
ops = [
|
||||||
SetField("active_model", "devstral-small"),
|
ReplaceOperationPatch(path="/active_model", value="devstral-small"),
|
||||||
AppendToList("tools.disabled_tools", ("bash",)),
|
AddOperationPatch(path="/tools/disabled_tools/-", value="bash"),
|
||||||
]
|
]
|
||||||
patch = ConfigPatch(*ops, fingerprint="fp-1")
|
patch = ConfigPatch(*ops, fingerprint="fp-1")
|
||||||
|
|
||||||
|
|
@ -155,18 +104,23 @@ def test_config_patch_accepts_multiple_operations() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_config_patch_add_appends_operations() -> None:
|
def test_config_patch_add_appends_operations() -> None:
|
||||||
patch = ConfigPatch(SetField("active_model", "devstral-small"), fingerprint="fp-1")
|
patch = ConfigPatch(
|
||||||
patch.add(DeleteField("tools.deprecated_setting"))
|
ReplaceOperationPatch(path="/active_model", value="devstral-small"),
|
||||||
|
fingerprint="fp-1",
|
||||||
|
)
|
||||||
|
patch.add(RemoveOperationPatch(path="/tools/deprecated_setting"))
|
||||||
|
|
||||||
assert patch.operations == [
|
assert patch.operations == [
|
||||||
SetField("active_model", "devstral-small"),
|
ReplaceOperationPatch(path="/active_model", value="devstral-small"),
|
||||||
DeleteField("tools.deprecated_setting"),
|
RemoveOperationPatch(path="/tools/deprecated_setting"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_config_patch_add_returns_self() -> None:
|
def test_config_patch_add_returns_self() -> None:
|
||||||
patch = ConfigPatch(fingerprint="fp-1")
|
patch = ConfigPatch(fingerprint="fp-1")
|
||||||
result = patch.add(SetField("active_model", "devstral-small"))
|
result = patch.add(
|
||||||
|
ReplaceOperationPatch(path="/active_model", value="devstral-small")
|
||||||
|
)
|
||||||
|
|
||||||
assert result is patch
|
assert result is patch
|
||||||
|
|
||||||
|
|
@ -174,8 +128,8 @@ def test_config_patch_add_returns_self() -> None:
|
||||||
def test_config_patch_add_accepts_multiple_operations() -> None:
|
def test_config_patch_add_accepts_multiple_operations() -> None:
|
||||||
patch = ConfigPatch(fingerprint="fp-1")
|
patch = ConfigPatch(fingerprint="fp-1")
|
||||||
patch.add(
|
patch.add(
|
||||||
SetField("active_model", "devstral-small"),
|
ReplaceOperationPatch(path="/active_model", value="devstral-small"),
|
||||||
AppendToList("tools.disabled_tools", ("bash",)),
|
AddOperationPatch(path="/tools/disabled_tools/-", value="bash"),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(patch.operations) == 2
|
assert len(patch.operations) == 2
|
||||||
|
|
@ -184,42 +138,52 @@ def test_config_patch_add_accepts_multiple_operations() -> None:
|
||||||
def test_config_patch_add_is_chainable() -> None:
|
def test_config_patch_add_is_chainable() -> None:
|
||||||
patch = (
|
patch = (
|
||||||
ConfigPatch(fingerprint="fp-1")
|
ConfigPatch(fingerprint="fp-1")
|
||||||
.add(SetField("active_model", "devstral-small"))
|
.add(ReplaceOperationPatch(path="/active_model", value="devstral-small"))
|
||||||
.add(DeleteField("tools.deprecated_setting"))
|
.add(RemoveOperationPatch(path="/tools/deprecated_setting"))
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(patch.operations) == 2
|
assert len(patch.operations) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_config_patch_describe_set_field() -> None:
|
def test_config_patch_to_json_patch_from_wrappers() -> None:
|
||||||
patch = ConfigPatch(SetField("active_model", "devstral-small"), fingerprint="fp-1")
|
|
||||||
|
|
||||||
assert patch.describe() == ["set 'active_model' = 'devstral-small'"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_config_patch_describe_append_to_list() -> None:
|
|
||||||
patch = ConfigPatch(
|
patch = ConfigPatch(
|
||||||
AppendToList("tools.disabled_tools", ("bash", "python")), fingerprint="fp-1"
|
ReplaceOperationPatch(path="/active_model", value="devstral-small"),
|
||||||
)
|
AddOperationPatch(path="/tools/disabled_tools/-", value="bash"),
|
||||||
|
RemoveOperationPatch(path="/tools/deprecated_setting"),
|
||||||
assert patch.describe() == ["append to 'tools.disabled_tools': ['bash', 'python']"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_config_patch_describe_remove_from_list() -> None:
|
|
||||||
patch = ConfigPatch(
|
|
||||||
RemoveFromList("models.available_models", ("codestral-latest",)),
|
|
||||||
fingerprint="fp-1",
|
fingerprint="fp-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert patch.describe() == [
|
assert patch.to_json_patch() == [
|
||||||
"remove from 'models.available_models': ['codestral-latest']"
|
{"op": "replace", "path": "/active_model", "value": "devstral-small"},
|
||||||
|
{"op": "add", "path": "/tools/disabled_tools/-", "value": "bash"},
|
||||||
|
{"op": "remove", "path": "/tools/deprecated_setting"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_config_patch_describe_delete_field() -> None:
|
def test_config_patch_describe_add_operation() -> None:
|
||||||
patch = ConfigPatch(DeleteField("tools.deprecated_setting"), fingerprint="fp-1")
|
patch = ConfigPatch(
|
||||||
|
AddOperationPatch(path="/tools/disabled_tools/-", value="bash"),
|
||||||
|
fingerprint="fp-1",
|
||||||
|
)
|
||||||
|
|
||||||
assert patch.describe() == ["delete 'tools.deprecated_setting'"]
|
assert patch.describe() == ["add '/tools/disabled_tools/-' = 'bash'"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_patch_describe_replace_operation() -> None:
|
||||||
|
patch = ConfigPatch(
|
||||||
|
ReplaceOperationPatch(path="/active_model", value="devstral-small"),
|
||||||
|
fingerprint="fp-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert patch.describe() == ["replace '/active_model' = 'devstral-small'"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_patch_describe_remove_operation() -> None:
|
||||||
|
patch = ConfigPatch(
|
||||||
|
RemoveOperationPatch(path="/tools/deprecated_setting"), fingerprint="fp-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert patch.describe() == ["remove '/tools/deprecated_setting'"]
|
||||||
|
|
||||||
|
|
||||||
def test_config_patch_describe_empty_returns_empty_list() -> None:
|
def test_config_patch_describe_empty_returns_empty_list() -> None:
|
||||||
|
|
@ -230,28 +194,28 @@ def test_config_patch_describe_empty_returns_empty_list() -> None:
|
||||||
|
|
||||||
def test_config_patch_describe_multiple_operations() -> None:
|
def test_config_patch_describe_multiple_operations() -> None:
|
||||||
patch = ConfigPatch(
|
patch = ConfigPatch(
|
||||||
SetField("active_model", "devstral-small"),
|
ReplaceOperationPatch(path="/active_model", value="devstral-small"),
|
||||||
AppendToList("tools.disabled_tools", ("bash",)),
|
AddOperationPatch(path="/tools/disabled_tools/-", value="bash"),
|
||||||
DeleteField("tools.deprecated_setting"),
|
RemoveOperationPatch(path="/tools/deprecated_setting"),
|
||||||
fingerprint="fp-1",
|
fingerprint="fp-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert patch.describe() == [
|
assert patch.describe() == [
|
||||||
"set 'active_model' = 'devstral-small'",
|
"replace '/active_model' = 'devstral-small'",
|
||||||
"append to 'tools.disabled_tools': ['bash']",
|
"add '/tools/disabled_tools/-' = 'bash'",
|
||||||
"delete 'tools.deprecated_setting'",
|
"remove '/tools/deprecated_setting'",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_scenario_build_patch_incrementally() -> None:
|
def test_scenario_build_patch_incrementally() -> None:
|
||||||
patch = ConfigPatch(fingerprint="fp-abc", reason="/model command")
|
patch = ConfigPatch(fingerprint="fp-abc", reason="/model command")
|
||||||
patch.add(SetField("active_model", "devstral-small"))
|
patch.add(ReplaceOperationPatch(path="/active_model", value="devstral-small"))
|
||||||
patch.add(AppendToList("tools.disabled_tools", ("bash",)))
|
patch.add(AddOperationPatch(path="/tools/disabled_tools/-", value="bash"))
|
||||||
|
|
||||||
assert patch.fingerprint == "fp-abc"
|
assert patch.fingerprint == "fp-abc"
|
||||||
assert patch.reason == "/model command"
|
assert patch.reason == "/model command"
|
||||||
assert len(patch.operations) == 2
|
assert len(patch.operations) == 2
|
||||||
assert patch.describe() == [
|
assert patch.describe() == [
|
||||||
"set 'active_model' = 'devstral-small'",
|
"replace '/active_model' = 'devstral-small'",
|
||||||
"append to 'tools.disabled_tools': ['bash']",
|
"add '/tools/disabled_tools/-' = 'bash'",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@ from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from vibe.core.config.fingerprint import capture_stable_file, create_dict_fingerprint
|
from vibe.core.config.fingerprint import (
|
||||||
|
capture_stable_file,
|
||||||
|
create_dict_fingerprint,
|
||||||
|
create_file_fingerprint,
|
||||||
|
)
|
||||||
from vibe.core.config.types import ConcurrencyConflictError
|
from vibe.core.config.types import ConcurrencyConflictError
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -67,6 +71,32 @@ class TestCaptureStableFile:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateFileFingerprint:
|
||||||
|
def test_captures_file_state(self, tmp_working_directory: Path) -> None:
|
||||||
|
path = tmp_working_directory / "config.toml"
|
||||||
|
path.write_text("key = 1")
|
||||||
|
|
||||||
|
with path.open("rb") as file:
|
||||||
|
first_fingerprint = create_file_fingerprint(file)
|
||||||
|
with path.open("rb") as file:
|
||||||
|
second_fingerprint = create_file_fingerprint(file)
|
||||||
|
|
||||||
|
assert isinstance(first_fingerprint, str)
|
||||||
|
assert first_fingerprint
|
||||||
|
assert first_fingerprint == second_fingerprint
|
||||||
|
|
||||||
|
def test_changes_when_file_changes(self, tmp_working_directory: Path) -> None:
|
||||||
|
path = tmp_working_directory / "config.toml"
|
||||||
|
path.write_text("key = 1")
|
||||||
|
with path.open("rb") as file:
|
||||||
|
first_fingerprint = create_file_fingerprint(file)
|
||||||
|
|
||||||
|
path.write_text("key = 2")
|
||||||
|
|
||||||
|
with path.open("rb") as file:
|
||||||
|
assert create_file_fingerprint(file) != first_fingerprint
|
||||||
|
|
||||||
|
|
||||||
class TestCreateDictFingerprint:
|
class TestCreateDictFingerprint:
|
||||||
def test_empty_dict_returns_stable_non_empty_token(self) -> None:
|
def test_empty_dict_returns_stable_non_empty_token(self) -> None:
|
||||||
first_fingerprint = create_dict_fingerprint({})
|
first_fingerprint = create_dict_fingerprint({})
|
||||||
|
|
|
||||||
58
tests/core/test_image_attachment.py
Normal file
58
tests/core/test_image_attachment.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from vibe.core.types import FileImageSource, ImageAttachment, InlineImageSource
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrates_legacy_flat_path_shape() -> None:
|
||||||
|
att = ImageAttachment.model_validate({
|
||||||
|
"path": "/tmp/a.png",
|
||||||
|
"alias": "a.png",
|
||||||
|
"mime_type": "image/png",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert isinstance(att.source, FileImageSource)
|
||||||
|
assert att.source.path == Path("/tmp/a.png")
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrates_legacy_flat_data_shape() -> None:
|
||||||
|
att = ImageAttachment.model_validate({
|
||||||
|
"data": "Zm9v",
|
||||||
|
"alias": "pasted.png",
|
||||||
|
"mime_type": "image/png",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert isinstance(att.source, InlineImageSource)
|
||||||
|
assert att.source.data == "Zm9v"
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_path_construction() -> None:
|
||||||
|
att = ImageAttachment(
|
||||||
|
source=FileImageSource(path=Path("/tmp/a.png")),
|
||||||
|
alias="a.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(att.source, FileImageSource)
|
||||||
|
assert att.source.path == Path("/tmp/a.png")
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_source_round_trips_through_json() -> None:
|
||||||
|
att = ImageAttachment(
|
||||||
|
source=FileImageSource(path=Path("/tmp/a.png")),
|
||||||
|
alias="a.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
|
||||||
|
dumped = att.model_dump(exclude_none=True, mode="json")
|
||||||
|
assert dumped["source"] == {"kind": "file", "path": "/tmp/a.png"}
|
||||||
|
assert ImageAttachment.model_validate(dumped) == att
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_attachment_without_source() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ImageAttachment.model_validate({"alias": "a.png", "mime_type": "image/png"})
|
||||||
|
|
@ -4,27 +4,43 @@ from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from vibe.core.types import ImageAttachment, LLMMessage, Role
|
from vibe.core.types import (
|
||||||
|
FileImageSource,
|
||||||
|
ImageAttachment,
|
||||||
|
LLMMessage,
|
||||||
|
Role,
|
||||||
|
UserDisplayContentMetadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def image_a(tmp_path: Path) -> ImageAttachment:
|
def image_a(tmp_path: Path) -> ImageAttachment:
|
||||||
p = tmp_path / "a.png"
|
p = tmp_path / "a.png"
|
||||||
p.write_bytes(b"\x89PNG")
|
p.write_bytes(b"\x89PNG")
|
||||||
return ImageAttachment(path=p, alias="a.png", mime_type="image/png")
|
return ImageAttachment(
|
||||||
|
source=FileImageSource(path=p), alias="a.png", mime_type="image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def image_b(tmp_path: Path) -> ImageAttachment:
|
def image_b(tmp_path: Path) -> ImageAttachment:
|
||||||
p = tmp_path / "b.png"
|
p = tmp_path / "b.png"
|
||||||
p.write_bytes(b"\x89PNG")
|
p.write_bytes(b"\x89PNG")
|
||||||
return ImageAttachment(path=p, alias="b.png", mime_type="image/png")
|
return ImageAttachment(
|
||||||
|
source=FileImageSource(path=p), alias="b.png", mime_type="image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _msg(content: str, images: list[ImageAttachment] | None = None) -> LLMMessage:
|
def _msg(content: str, images: list[ImageAttachment] | None = None) -> LLMMessage:
|
||||||
return LLMMessage(role=Role.assistant, content=content, images=images)
|
return LLMMessage(role=Role.assistant, content=content, images=images)
|
||||||
|
|
||||||
|
|
||||||
|
def _display_content(host: str) -> UserDisplayContentMetadata:
|
||||||
|
return UserDisplayContentMetadata(
|
||||||
|
version="1.0.0", host=host, content=[{"type": "text", "text": host}]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_merge_prefers_self_images_when_present(
|
def test_merge_prefers_self_images_when_present(
|
||||||
image_a: ImageAttachment, image_b: ImageAttachment
|
image_a: ImageAttachment, image_b: ImageAttachment
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -49,3 +65,26 @@ def test_merge_preserves_explicit_empty_self_images_over_other(
|
||||||
def test_merge_yields_none_when_both_sides_are_none() -> None:
|
def test_merge_yields_none_when_both_sides_are_none() -> None:
|
||||||
merged = _msg("hi") + _msg(" there")
|
merged = _msg("hi") + _msg(" there")
|
||||||
assert merged.images is None
|
assert merged.images is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_prefers_self_user_display_content_when_present() -> None:
|
||||||
|
self_display_content = _display_content("mistral-vscode")
|
||||||
|
other_display_content = _display_content("mistral-jetbrains")
|
||||||
|
|
||||||
|
merged = LLMMessage(
|
||||||
|
role=Role.user, content="hi", user_display_content=self_display_content
|
||||||
|
) + LLMMessage(
|
||||||
|
role=Role.user, content=" there", user_display_content=other_display_content
|
||||||
|
)
|
||||||
|
|
||||||
|
assert merged.user_display_content == self_display_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_falls_back_to_other_user_display_content() -> None:
|
||||||
|
other_display_content = _display_content("mistral-vscode")
|
||||||
|
|
||||||
|
merged = LLMMessage(role=Role.user, content="hi") + LLMMessage(
|
||||||
|
role=Role.user, content=" there", user_display_content=other_display_content
|
||||||
|
)
|
||||||
|
|
||||||
|
assert merged.user_display_content == other_display_content
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import asyncio
|
||||||
from collections.abc import Callable, Iterator
|
from collections.abc import Callable, Iterator
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
import socket
|
import socket
|
||||||
|
from types import TracebackType
|
||||||
|
from unittest.mock import patch
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -11,6 +13,7 @@ import keyring
|
||||||
from keyring.backend import KeyringBackend
|
from keyring.backend import KeyringBackend
|
||||||
import keyring.backends.fail
|
import keyring.backends.fail
|
||||||
import keyring.errors
|
import keyring.errors
|
||||||
|
from mcp.client.auth import OAuthFlowError
|
||||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||||
import pytest
|
import pytest
|
||||||
import respx
|
import respx
|
||||||
|
|
@ -21,6 +24,7 @@ from vibe.core.auth.mcp_oauth import (
|
||||||
LoopbackCallbackHandler,
|
LoopbackCallbackHandler,
|
||||||
MCPOAuthError,
|
MCPOAuthError,
|
||||||
MCPOAuthHeadlessError,
|
MCPOAuthHeadlessError,
|
||||||
|
MCPOAuthLoginFailed,
|
||||||
MCPOAuthPortInUse,
|
MCPOAuthPortInUse,
|
||||||
build_oauth_provider,
|
build_oauth_provider,
|
||||||
perform_oauth_login,
|
perform_oauth_login,
|
||||||
|
|
@ -366,6 +370,26 @@ class TestBuildOAuthProvider:
|
||||||
)
|
)
|
||||||
assert provider.context.client_metadata_url == "https://vibe.example/cm.json"
|
assert provider.context.client_metadata_url == "https://vibe.example/cm.json"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_client_id_is_exposed_as_fallback_client_info(
|
||||||
|
self, memory_keyring: _MemoryKeyring
|
||||||
|
) -> None:
|
||||||
|
srv = _oauth_server(client_id="pre-registered-client")
|
||||||
|
|
||||||
|
async def on_url(_url: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def cb() -> tuple[str, str | None]:
|
||||||
|
return "code", None
|
||||||
|
|
||||||
|
provider = build_oauth_provider(
|
||||||
|
srv, redirect_handler=on_url, callback_handler=cb
|
||||||
|
)
|
||||||
|
client_info = await provider.context.storage.get_client_info()
|
||||||
|
|
||||||
|
assert client_info is not None
|
||||||
|
assert client_info.client_id == "pre-registered-client"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_rejects_static_auth(self, memory_keyring: _MemoryKeyring) -> None:
|
async def test_rejects_static_auth(self, memory_keyring: _MemoryKeyring) -> None:
|
||||||
from vibe.core.config import MCPStaticAuth
|
from vibe.core.config import MCPStaticAuth
|
||||||
|
|
@ -388,6 +412,39 @@ class TestBuildOAuthProvider:
|
||||||
|
|
||||||
|
|
||||||
class TestPerformOAuthLogin:
|
class TestPerformOAuthLogin:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oauth_flow_error_becomes_login_failed(
|
||||||
|
self, memory_keyring: _MemoryKeyring
|
||||||
|
) -> None:
|
||||||
|
srv = _oauth_server(name="demo")
|
||||||
|
|
||||||
|
class OAuthFlowFailingClient:
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self) -> OAuthFlowFailingClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc: BaseException | None,
|
||||||
|
traceback: TracebackType | None,
|
||||||
|
) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get(self, _url: str) -> None:
|
||||||
|
raise OAuthFlowError("cancelled")
|
||||||
|
|
||||||
|
async def on_url(_url: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"vibe.core.auth.mcp_oauth.httpx.AsyncClient", new=OAuthFlowFailingClient
|
||||||
|
):
|
||||||
|
with pytest.raises(MCPOAuthLoginFailed, match="cancelled"):
|
||||||
|
await perform_oauth_login(srv, on_url=on_url)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_full_flow_persists_tokens_and_fingerprint(
|
async def test_full_flow_persists_tokens_and_fingerprint(
|
||||||
self, memory_keyring: _MemoryKeyring
|
self, memory_keyring: _MemoryKeyring
|
||||||
|
|
|
||||||
56
tests/core/test_message_list.py
Normal file
56
tests/core/test_message_list.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from vibe.core.types import LLMMessage, MessageList, Role
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_system_prompt_replaces_existing_system_slot() -> None:
|
||||||
|
messages = MessageList(
|
||||||
|
initial=[
|
||||||
|
LLMMessage(role=Role.system, content="old"),
|
||||||
|
LLMMessage(role=Role.user, content="hi"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.update_system_prompt("new")
|
||||||
|
|
||||||
|
assert len(messages) == 2
|
||||||
|
assert messages[0].role == Role.system
|
||||||
|
assert messages[0].content == "new"
|
||||||
|
assert messages[1].content == "hi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_system_prompt_inserts_without_clobbering_when_no_system() -> None:
|
||||||
|
messages = MessageList(
|
||||||
|
initial=[
|
||||||
|
LLMMessage(role=Role.user, content="Hello"),
|
||||||
|
LLMMessage(role=Role.assistant, content="Hi there!"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.update_system_prompt("system prompt")
|
||||||
|
|
||||||
|
assert len(messages) == 3
|
||||||
|
assert messages[0].role == Role.system
|
||||||
|
assert messages[1].content == "Hello"
|
||||||
|
assert messages[2].content == "Hi there!"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_system_prompt_inserts_into_empty_list() -> None:
|
||||||
|
messages = MessageList()
|
||||||
|
|
||||||
|
messages.update_system_prompt("system prompt")
|
||||||
|
|
||||||
|
assert len(messages) == 1
|
||||||
|
assert messages[0].role == Role.system
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_system_prompt_notifies_only_when_requested() -> None:
|
||||||
|
observed: list[LLMMessage] = []
|
||||||
|
messages = MessageList(observer=observed.append)
|
||||||
|
|
||||||
|
messages.update_system_prompt("silent")
|
||||||
|
assert observed == []
|
||||||
|
|
||||||
|
messages.update_system_prompt("loud", notify=True)
|
||||||
|
assert len(observed) == 1
|
||||||
|
assert observed[0].content == "loud"
|
||||||
|
|
@ -3,7 +3,6 @@ from __future__ import annotations
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from vibe.core.config.layers.overrides import OverridesLayer
|
from vibe.core.config.layers.overrides import OverridesLayer
|
||||||
from vibe.core.config.patch import ConfigPatch
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -20,13 +19,6 @@ async def test_always_trusted() -> None:
|
||||||
assert await layer.resolve_trust() is True
|
assert await layer.resolve_trust() is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_apply_raises_not_implemented() -> None:
|
|
||||||
layer = OverridesLayer(data={})
|
|
||||||
with pytest.raises(NotImplementedError, match="M2"):
|
|
||||||
await layer.apply(ConfigPatch(fingerprint="fp-1"))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_default_name() -> None:
|
async def test_default_name() -> None:
|
||||||
layer = OverridesLayer(data={})
|
layer = OverridesLayer(data={})
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import pytest
|
||||||
|
|
||||||
from vibe.core.config.layer import UntrustedLayerError
|
from vibe.core.config.layer import UntrustedLayerError
|
||||||
from vibe.core.config.layers.project import ProjectConfigLayer
|
from vibe.core.config.layers.project import ProjectConfigLayer
|
||||||
from vibe.core.config.patch import ConfigPatch
|
|
||||||
from vibe.core.config.types import MISSING_CONFIG_FILE_FINGERPRINT
|
from vibe.core.config.types import MISSING_CONFIG_FILE_FINGERPRINT
|
||||||
from vibe.core.paths._vibe_home import GlobalPath
|
from vibe.core.paths._vibe_home import GlobalPath
|
||||||
from vibe.core.trusted_folders import trusted_folders_manager
|
from vibe.core.trusted_folders import trusted_folders_manager
|
||||||
|
|
@ -73,13 +72,6 @@ async def test_default_name(tmp_working_directory: Path) -> None:
|
||||||
assert layer.name == "project-toml"
|
assert layer.name == "project-toml"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_apply_raises_not_implemented(tmp_working_directory: Path) -> None:
|
|
||||||
layer = ProjectConfigLayer(path=tmp_working_directory)
|
|
||||||
with pytest.raises(NotImplementedError, match="M2"):
|
|
||||||
await layer.apply(ConfigPatch(fingerprint="fp-1"))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_trust_uses_path_parent_for_resolution(
|
async def test_trust_uses_path_parent_for_resolution(
|
||||||
tmp_working_directory: Path,
|
tmp_working_directory: Path,
|
||||||
|
|
|
||||||
|
|
@ -1,937 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from tests.conftest import build_test_vibe_config
|
|
||||||
from vibe.core.nuage.events import (
|
|
||||||
CustomTaskCanceled,
|
|
||||||
CustomTaskCanceledAttributes,
|
|
||||||
CustomTaskCompleted,
|
|
||||||
CustomTaskCompletedAttributes,
|
|
||||||
CustomTaskInProgress,
|
|
||||||
CustomTaskInProgressAttributes,
|
|
||||||
CustomTaskStarted,
|
|
||||||
CustomTaskStartedAttributes,
|
|
||||||
JSONPatchAdd,
|
|
||||||
JSONPatchAppend,
|
|
||||||
JSONPatchPayload,
|
|
||||||
JSONPatchReplace,
|
|
||||||
JSONPayload,
|
|
||||||
)
|
|
||||||
from vibe.core.nuage.remote_events_source import RemoteEventsSource
|
|
||||||
from vibe.core.types import (
|
|
||||||
AssistantEvent,
|
|
||||||
ReasoningEvent,
|
|
||||||
Role,
|
|
||||||
ToolCallEvent,
|
|
||||||
ToolResultEvent,
|
|
||||||
ToolStreamEvent,
|
|
||||||
UserMessageEvent,
|
|
||||||
WaitingForInputEvent,
|
|
||||||
)
|
|
||||||
|
|
||||||
_EXEC_ID = "session-123"
|
|
||||||
|
|
||||||
|
|
||||||
def _make_loop(enabled_tools: list[str] | None = None) -> RemoteEventsSource:
|
|
||||||
config = build_test_vibe_config(enabled_tools=enabled_tools or [])
|
|
||||||
return RemoteEventsSource(session_id=_EXEC_ID, config=config)
|
|
||||||
|
|
||||||
|
|
||||||
def _started(
|
|
||||||
task_id: str, task_type: str, payload: dict[str, Any]
|
|
||||||
) -> CustomTaskStarted:
|
|
||||||
return CustomTaskStarted(
|
|
||||||
event_id=f"evt-{task_id}-start",
|
|
||||||
workflow_exec_id=_EXEC_ID,
|
|
||||||
attributes=CustomTaskStartedAttributes(
|
|
||||||
custom_task_id=task_id,
|
|
||||||
custom_task_type=task_type,
|
|
||||||
payload=JSONPayload(value=payload),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _completed(
|
|
||||||
task_id: str, task_type: str, payload: dict[str, Any]
|
|
||||||
) -> CustomTaskCompleted:
|
|
||||||
return CustomTaskCompleted(
|
|
||||||
event_id=f"evt-{task_id}-done",
|
|
||||||
workflow_exec_id=_EXEC_ID,
|
|
||||||
attributes=CustomTaskCompletedAttributes(
|
|
||||||
custom_task_id=task_id,
|
|
||||||
custom_task_type=task_type,
|
|
||||||
payload=JSONPayload(value=payload),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _in_progress(
|
|
||||||
task_id: str, task_type: str, patches: list[Any]
|
|
||||||
) -> CustomTaskInProgress:
|
|
||||||
return CustomTaskInProgress(
|
|
||||||
event_id=f"evt-{task_id}-progress",
|
|
||||||
workflow_exec_id=_EXEC_ID,
|
|
||||||
attributes=CustomTaskInProgressAttributes(
|
|
||||||
custom_task_id=task_id,
|
|
||||||
custom_task_type=task_type,
|
|
||||||
payload=JSONPatchPayload(value=patches),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _canceled(task_id: str, task_type: str, reason: str = "") -> CustomTaskCanceled:
|
|
||||||
return CustomTaskCanceled(
|
|
||||||
event_id=f"evt-{task_id}-cancel",
|
|
||||||
workflow_exec_id=_EXEC_ID,
|
|
||||||
attributes=CustomTaskCanceledAttributes(
|
|
||||||
custom_task_id=task_id, custom_task_type=task_type, reason=reason
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_consume_wait_for_input_event_emits_waiting_event() -> None:
|
|
||||||
loop = _make_loop()
|
|
||||||
event = _started(
|
|
||||||
"wait-task-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{
|
|
||||||
"task_id": "wait-task-1",
|
|
||||||
"input_schema": {"title": "ChatInput"},
|
|
||||||
"label": "What next?",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
emitted_events = loop._consume_workflow_event(event)
|
|
||||||
|
|
||||||
assert len(emitted_events) == 2
|
|
||||||
assistant_event = emitted_events[0]
|
|
||||||
waiting_event = emitted_events[1]
|
|
||||||
assert isinstance(assistant_event, AssistantEvent)
|
|
||||||
assert assistant_event.content == "What next?"
|
|
||||||
assert isinstance(waiting_event, WaitingForInputEvent)
|
|
||||||
assert waiting_event.task_id == "wait-task-1"
|
|
||||||
assert waiting_event.label == "What next?"
|
|
||||||
assert waiting_event.predefined_answers is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_consume_agent_input_keeps_repeated_text_across_distinct_turns() -> None:
|
|
||||||
loop = _make_loop()
|
|
||||||
first_event = _completed(
|
|
||||||
"input-1", "AgentInputState", {"input": {"message": [{"text": "continue"}]}}
|
|
||||||
)
|
|
||||||
second_event = _completed(
|
|
||||||
"input-2", "AgentInputState", {"input": {"message": [{"text": "continue"}]}}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert loop._consume_workflow_event(first_event) == []
|
|
||||||
assert loop._consume_workflow_event(second_event) == []
|
|
||||||
|
|
||||||
assert [msg.content for msg in loop.messages if msg.role == Role.user] == [
|
|
||||||
"continue",
|
|
||||||
"continue",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_wait_for_input_emits_predefined_answers_and_user_message() -> None:
|
|
||||||
loop = _make_loop()
|
|
||||||
started = _started(
|
|
||||||
"wait-task-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{
|
|
||||||
"input_schema": {
|
|
||||||
"title": "ChatInput",
|
|
||||||
"properties": {
|
|
||||||
"message": {
|
|
||||||
"examples": [
|
|
||||||
[{"type": "text", "text": "Python"}],
|
|
||||||
[{"type": "text", "text": "JavaScript"}],
|
|
||||||
[{"type": "text", "text": "Other"}],
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"label": "Which language?",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
completed = _completed(
|
|
||||||
"wait-task-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{"input": {"message": [{"type": "text", "text": "Python"}]}},
|
|
||||||
)
|
|
||||||
|
|
||||||
started_events = loop._consume_workflow_event(started)
|
|
||||||
completed_events = loop._consume_workflow_event(completed)
|
|
||||||
|
|
||||||
assistant_event = next(
|
|
||||||
event for event in started_events if isinstance(event, AssistantEvent)
|
|
||||||
)
|
|
||||||
waiting_event = next(
|
|
||||||
event for event in started_events if isinstance(event, WaitingForInputEvent)
|
|
||||||
)
|
|
||||||
assert assistant_event.content == "Which language?"
|
|
||||||
assert waiting_event.predefined_answers == ["Python", "JavaScript"]
|
|
||||||
user_event = next(
|
|
||||||
event for event in completed_events if isinstance(event, UserMessageEvent)
|
|
||||||
)
|
|
||||||
assert user_event.content == "Python"
|
|
||||||
|
|
||||||
|
|
||||||
def test_tool_events_update_stats_and_messages() -> None:
|
|
||||||
loop = _make_loop(enabled_tools=["todo"])
|
|
||||||
started = _started(
|
|
||||||
"tool-task-1",
|
|
||||||
"AgentToolCallState",
|
|
||||||
{"name": "todo", "tool_call_id": "call-1", "kwargs": {"action": "read"}},
|
|
||||||
)
|
|
||||||
completed = _completed(
|
|
||||||
"tool-task-1",
|
|
||||||
"AgentToolCallState",
|
|
||||||
{
|
|
||||||
"name": "todo",
|
|
||||||
"tool_call_id": "call-1",
|
|
||||||
"kwargs": {"action": "read"},
|
|
||||||
"output": {"total_count": 0},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
started_events = loop._consume_workflow_event(started)
|
|
||||||
completed_events = loop._consume_workflow_event(completed)
|
|
||||||
|
|
||||||
assert any(isinstance(event, ToolCallEvent) for event in started_events)
|
|
||||||
result_event = next(
|
|
||||||
event for event in completed_events if isinstance(event, ToolResultEvent)
|
|
||||||
)
|
|
||||||
assert result_event.error is None
|
|
||||||
assert result_event.cancelled is False
|
|
||||||
assert result_event.tool_call_id == "call-1"
|
|
||||||
assert loop.stats.tool_calls_agreed == 1
|
|
||||||
assert loop.stats.tool_calls_succeeded == 1
|
|
||||||
assert loop.stats.tool_calls_failed == 0
|
|
||||||
tool_messages = [msg for msg in loop.messages if msg.role == Role.tool]
|
|
||||||
assert len(tool_messages) == 1
|
|
||||||
assert tool_messages[0].tool_call_id == "call-1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ask_user_question_tool_emits_assistant_question() -> None:
|
|
||||||
loop = _make_loop(enabled_tools=["ask_user_question"])
|
|
||||||
started = _started(
|
|
||||||
"tool-task-question",
|
|
||||||
"AgentToolCallState",
|
|
||||||
{
|
|
||||||
"name": "ask_user_question",
|
|
||||||
"tool_call_id": "call-question",
|
|
||||||
"kwargs": {
|
|
||||||
"questions": [
|
|
||||||
{
|
|
||||||
"question": "Which file type should I create?",
|
|
||||||
"options": [{"label": "Python"}, {"label": "JavaScript"}],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
events = loop._consume_workflow_event(started)
|
|
||||||
|
|
||||||
assistant_event = next(
|
|
||||||
event for event in events if isinstance(event, AssistantEvent)
|
|
||||||
)
|
|
||||||
tool_call_event = next(
|
|
||||||
event for event in events if isinstance(event, ToolCallEvent)
|
|
||||||
)
|
|
||||||
assert assistant_event.content == "Which file type should I create?"
|
|
||||||
assert tool_call_event.tool_call_id == "call-question"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ask_user_question_invalid_args_are_logged_and_ignored() -> None:
|
|
||||||
loop = _make_loop(enabled_tools=["ask_user_question"])
|
|
||||||
started = _started(
|
|
||||||
"tool-task-question",
|
|
||||||
"AgentToolCallState",
|
|
||||||
{
|
|
||||||
"name": "ask_user_question",
|
|
||||||
"tool_call_id": "call-question",
|
|
||||||
"kwargs": {"questions": [{}]},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"vibe.core.nuage.remote_workflow_event_translator.logger.warning"
|
|
||||||
) as mock_warning:
|
|
||||||
events = loop._consume_workflow_event(started)
|
|
||||||
|
|
||||||
assert any(isinstance(event, ToolCallEvent) for event in events)
|
|
||||||
assert not any(isinstance(event, AssistantEvent) for event in events)
|
|
||||||
mock_warning.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
def test_ask_user_question_wait_for_input_completion_emits_tool_result() -> None:
|
|
||||||
loop = _make_loop(enabled_tools=["ask_user_question"])
|
|
||||||
ask_started = _started(
|
|
||||||
"tool-task-question",
|
|
||||||
"AgentToolCallState",
|
|
||||||
{
|
|
||||||
"name": "ask_user_question",
|
|
||||||
"tool_call_id": "call-question",
|
|
||||||
"kwargs": {
|
|
||||||
"questions": [{"question": "Which type of file?", "options": []}]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
wait_started = _started(
|
|
||||||
"wait-task-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{"input_schema": {"title": "ChatInput"}, "label": "Which type of file?"},
|
|
||||||
)
|
|
||||||
wait_completed = _completed(
|
|
||||||
"wait-task-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{
|
|
||||||
"input_schema": {"title": "ChatInput"},
|
|
||||||
"label": "Which type of file?",
|
|
||||||
"input": {"message": [{"type": "text", "text": "Python"}]},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
loop._consume_workflow_event(ask_started)
|
|
||||||
loop._consume_workflow_event(wait_started)
|
|
||||||
completed_events = loop._consume_workflow_event(wait_completed)
|
|
||||||
|
|
||||||
tool_result = next(
|
|
||||||
(e for e in completed_events if isinstance(e, ToolResultEvent)), None
|
|
||||||
)
|
|
||||||
assert tool_result is not None
|
|
||||||
assert tool_result.tool_call_id == "call-question"
|
|
||||||
user_message = next(e for e in completed_events if isinstance(e, UserMessageEvent))
|
|
||||||
assert user_message.content == "Python"
|
|
||||||
|
|
||||||
|
|
||||||
def test_working_events_without_tool_call_id_render_remote_progress_row() -> None:
|
|
||||||
loop = _make_loop()
|
|
||||||
started = _started(
|
|
||||||
"working-1",
|
|
||||||
"working",
|
|
||||||
{"title": "Creating sandbox", "content": "initializing", "toolUIState": None},
|
|
||||||
)
|
|
||||||
completed = _completed(
|
|
||||||
"working-1",
|
|
||||||
"working",
|
|
||||||
{
|
|
||||||
"title": "Creating sandbox",
|
|
||||||
"content": "sandbox created",
|
|
||||||
"toolUIState": None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
started_events = loop._consume_workflow_event(started)
|
|
||||||
completed_events = loop._consume_workflow_event(completed)
|
|
||||||
|
|
||||||
assert started_events == []
|
|
||||||
assert any(isinstance(event, ToolCallEvent) for event in completed_events)
|
|
||||||
assert any(isinstance(event, ToolStreamEvent) for event in completed_events)
|
|
||||||
result_event = next(
|
|
||||||
event for event in completed_events if isinstance(event, ToolResultEvent)
|
|
||||||
)
|
|
||||||
assert result_event.tool_name == "Creating sandbox"
|
|
||||||
assert result_event.tool_call_id == "working-1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_working_events_with_tool_call_id_wait_for_real_tool_call() -> None:
|
|
||||||
loop = _make_loop()
|
|
||||||
working_started = _started(
|
|
||||||
"working-tool-1",
|
|
||||||
"working",
|
|
||||||
{
|
|
||||||
"title": "Executing write_file",
|
|
||||||
"content": "writing file",
|
|
||||||
"toolUIState": {"toolCallId": "call-write"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
tool_started = _started(
|
|
||||||
"tool-task-1",
|
|
||||||
"AgentToolCallState",
|
|
||||||
{
|
|
||||||
"name": "write_file",
|
|
||||||
"tool_call_id": "call-write",
|
|
||||||
"kwargs": {
|
|
||||||
"path": "hello_world.js",
|
|
||||||
"content": "console.log('Hello, World!');",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
working_events = loop._consume_workflow_event(working_started)
|
|
||||||
tool_events = loop._consume_workflow_event(tool_started)
|
|
||||||
|
|
||||||
assert any(isinstance(event, ToolCallEvent) for event in working_events)
|
|
||||||
assert any(isinstance(event, ToolStreamEvent) for event in working_events)
|
|
||||||
assert not any(isinstance(event, ToolCallEvent) for event in tool_events)
|
|
||||||
assert not any(isinstance(event, ToolResultEvent) for event in tool_events)
|
|
||||||
|
|
||||||
|
|
||||||
def test_working_task_promoted_to_real_tool_call_does_not_create_duplicate_row() -> (
|
|
||||||
None
|
|
||||||
):
|
|
||||||
loop = _make_loop(enabled_tools=["write_file"])
|
|
||||||
working_started = _started(
|
|
||||||
"working-tool-1",
|
|
||||||
"working",
|
|
||||||
{
|
|
||||||
"title": "Writing file",
|
|
||||||
"content": '# hello.py\n\nprint("Hello, World!")',
|
|
||||||
"toolUIState": None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
working_promoted = _completed(
|
|
||||||
"working-tool-1",
|
|
||||||
"working",
|
|
||||||
{
|
|
||||||
"title": "Executing write_file",
|
|
||||||
"content": "",
|
|
||||||
"toolUIState": {
|
|
||||||
"type": "file",
|
|
||||||
"toolCallId": "call-write",
|
|
||||||
"operations": [
|
|
||||||
{
|
|
||||||
"type": "create",
|
|
||||||
"uri": "/workspace/hello.py",
|
|
||||||
"content": 'print("Hello, World!")',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
agent_tool_completed = _completed(
|
|
||||||
"tool-task-1",
|
|
||||||
"AgentToolCallState",
|
|
||||||
{
|
|
||||||
"name": "write_file",
|
|
||||||
"tool_call_id": "call-write",
|
|
||||||
"kwargs": {"path": "hello.py", "content": 'print("Hello, World!")'},
|
|
||||||
"output": {
|
|
||||||
"path": "/workspace/hello.py",
|
|
||||||
"bytes_written": 22,
|
|
||||||
"content": 'print("Hello, World!")',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert loop._consume_workflow_event(working_started) == []
|
|
||||||
|
|
||||||
promoted_events = loop._consume_workflow_event(working_promoted)
|
|
||||||
assert len([e for e in promoted_events if isinstance(e, ToolCallEvent)]) == 1
|
|
||||||
assert not any(isinstance(e, ToolStreamEvent) for e in promoted_events)
|
|
||||||
assert any(isinstance(e, ToolResultEvent) for e in promoted_events)
|
|
||||||
|
|
||||||
completed_events = loop._consume_workflow_event(agent_tool_completed)
|
|
||||||
assert not any(isinstance(e, ToolCallEvent) for e in completed_events)
|
|
||||||
assert not any(isinstance(e, ToolResultEvent) for e in completed_events)
|
|
||||||
|
|
||||||
|
|
||||||
def test_idle_boundary_waits_for_open_tool_results() -> None:
|
|
||||||
loop = _make_loop(enabled_tools=["write_file"])
|
|
||||||
working_started = _started(
|
|
||||||
"working-tool-1",
|
|
||||||
"working",
|
|
||||||
{
|
|
||||||
"title": "Executing write_file",
|
|
||||||
"content": "writing file",
|
|
||||||
"toolUIState": {"toolCallId": "call-write"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
idle_candidate = _completed("input-task-1", "AgentInputState", {"input": None})
|
|
||||||
tool_completed = _completed(
|
|
||||||
"tool-task-1",
|
|
||||||
"AgentToolCallState",
|
|
||||||
{
|
|
||||||
"name": "write_file",
|
|
||||||
"tool_call_id": "call-write",
|
|
||||||
"kwargs": {
|
|
||||||
"path": "hello_world.js",
|
|
||||||
"content": "console.log('Hello, World!');",
|
|
||||||
},
|
|
||||||
"output": {
|
|
||||||
"path": "/workspace/hello_world.js",
|
|
||||||
"bytes_written": 29,
|
|
||||||
"content": "console.log('Hello, World!');",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
idle_after_tool = _completed("input-task-2", "AgentInputState", {"input": None})
|
|
||||||
|
|
||||||
working_events = loop._consume_workflow_event(working_started)
|
|
||||||
assert any(isinstance(event, ToolCallEvent) for event in working_events)
|
|
||||||
|
|
||||||
loop._consume_workflow_event(idle_candidate)
|
|
||||||
assert loop._is_idle_boundary(idle_candidate) is False
|
|
||||||
|
|
||||||
tool_events = loop._consume_workflow_event(tool_completed)
|
|
||||||
assert not any(isinstance(event, ToolCallEvent) for event in tool_events)
|
|
||||||
assert any(isinstance(event, ToolResultEvent) for event in tool_events)
|
|
||||||
|
|
||||||
loop._consume_workflow_event(idle_after_tool)
|
|
||||||
assert loop._is_idle_boundary(idle_after_tool) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_send_user_message_tool_is_not_rendered() -> None:
|
|
||||||
loop = _make_loop()
|
|
||||||
started = _started(
|
|
||||||
"tool-task-send-user-message",
|
|
||||||
"AgentToolCallState",
|
|
||||||
{
|
|
||||||
"name": "send_user_message",
|
|
||||||
"tool_call_id": "call-send",
|
|
||||||
"kwargs": {"message": "hello"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
completed = _completed(
|
|
||||||
"tool-task-send-user-message",
|
|
||||||
"AgentToolCallState",
|
|
||||||
{
|
|
||||||
"name": "send_user_message",
|
|
||||||
"tool_call_id": "call-send",
|
|
||||||
"kwargs": {"message": "hello"},
|
|
||||||
"output": {"success": True, "error": None},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert loop._consume_workflow_event(started) == []
|
|
||||||
assert loop._consume_workflow_event(completed) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_send_user_message_working_events_are_not_rendered() -> None:
|
|
||||||
loop = _make_loop()
|
|
||||||
started = _started(
|
|
||||||
"working-send-user-message",
|
|
||||||
"working",
|
|
||||||
{
|
|
||||||
"title": "Executing send_user_message",
|
|
||||||
"content": "Hello!",
|
|
||||||
"toolUIState": {"toolCallId": "call-send-working"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
completed = _completed(
|
|
||||||
"working-send-user-message",
|
|
||||||
"working",
|
|
||||||
{
|
|
||||||
"title": "Executing send_user_message",
|
|
||||||
"content": "Hello!",
|
|
||||||
"toolUIState": {"toolCallId": "call-send-working"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert loop._consume_workflow_event(started) == []
|
|
||||||
assert loop._consume_workflow_event(completed) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_remote_bash_uses_known_tool_display_even_when_disabled_locally() -> None:
|
|
||||||
loop = _make_loop(enabled_tools=["write_file"])
|
|
||||||
started = _started(
|
|
||||||
"tool-task-bash",
|
|
||||||
"AgentToolCallState",
|
|
||||||
{
|
|
||||||
"name": "bash",
|
|
||||||
"tool_call_id": "call-bash",
|
|
||||||
"kwargs": {"command": "cat hello.py | wc -c"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
completed = _completed(
|
|
||||||
"tool-task-bash",
|
|
||||||
"AgentToolCallState",
|
|
||||||
{
|
|
||||||
"name": "bash",
|
|
||||||
"tool_call_id": "call-bash",
|
|
||||||
"kwargs": {"command": "cat hello.py | wc -c"},
|
|
||||||
"output": {
|
|
||||||
"command": "cat hello.py | wc -c",
|
|
||||||
"stdout": "22\n",
|
|
||||||
"stderr": "",
|
|
||||||
"returncode": 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
started_events = loop._consume_workflow_event(started)
|
|
||||||
completed_events = loop._consume_workflow_event(completed)
|
|
||||||
|
|
||||||
tool_call_event = next(
|
|
||||||
event for event in started_events if isinstance(event, ToolCallEvent)
|
|
||||||
)
|
|
||||||
result_event = next(
|
|
||||||
event for event in completed_events if isinstance(event, ToolResultEvent)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert tool_call_event.tool_name == "bash"
|
|
||||||
assert tool_call_event.tool_class.get_name() == "bash"
|
|
||||||
assert tool_call_event.args is not None
|
|
||||||
assert tool_call_event.args.command == "cat hello.py | wc -c" # type: ignore[attr-defined]
|
|
||||||
assert result_event.result is not None
|
|
||||||
assert result_event.result.command == "cat hello.py | wc -c" # type: ignore[attr-defined]
|
|
||||||
assert result_event.result.stdout == "22\n" # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
|
|
||||||
def test_canceled_tool_marks_cancelled_and_failed_stats() -> None:
|
|
||||||
loop = _make_loop(enabled_tools=["todo"])
|
|
||||||
loop._task_state["tool-task-2"] = {
|
|
||||||
"name": "todo",
|
|
||||||
"tool_call_id": "call-2",
|
|
||||||
"kwargs": {"action": "read"},
|
|
||||||
}
|
|
||||||
canceled = _canceled(
|
|
||||||
"tool-task-2", "AgentToolCallState", reason="user interrupted tool"
|
|
||||||
)
|
|
||||||
|
|
||||||
events = loop._consume_workflow_event(canceled)
|
|
||||||
|
|
||||||
result_event = next(event for event in events if isinstance(event, ToolResultEvent))
|
|
||||||
assert result_event.cancelled is True
|
|
||||||
assert result_event.error == "Canceled: user interrupted tool"
|
|
||||||
assert loop.stats.tool_calls_failed == 1
|
|
||||||
assert loop.stats.tool_calls_succeeded == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_working_thinking_type_emits_assistant_events() -> None:
|
|
||||||
loop = _make_loop()
|
|
||||||
started = _started(
|
|
||||||
"thinking-1",
|
|
||||||
"working",
|
|
||||||
{"type": "thinking", "title": "Thinking", "content": "", "toolUIState": None},
|
|
||||||
)
|
|
||||||
in_progress = _in_progress(
|
|
||||||
"thinking-1", "working", [JSONPatchAppend(path="/content", value="Hello!")]
|
|
||||||
)
|
|
||||||
completed = _completed(
|
|
||||||
"thinking-1",
|
|
||||||
"working",
|
|
||||||
{
|
|
||||||
"type": "thinking",
|
|
||||||
"title": "Thinking",
|
|
||||||
"content": "Hello!",
|
|
||||||
"toolUIState": None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
started_events = loop._consume_workflow_event(started)
|
|
||||||
progress_events = loop._consume_workflow_event(in_progress)
|
|
||||||
completed_events = loop._consume_workflow_event(completed)
|
|
||||||
|
|
||||||
assert started_events == []
|
|
||||||
assert len(progress_events) == 1
|
|
||||||
assert isinstance(progress_events[0], ReasoningEvent)
|
|
||||||
assert progress_events[0].content == "Hello!"
|
|
||||||
assert completed_events == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_working_bash_progress_without_tool_call_id_streams_command_output() -> None:
|
|
||||||
loop = _make_loop(enabled_tools=["write_file"])
|
|
||||||
started = _started(
|
|
||||||
"working-bash-1",
|
|
||||||
"working",
|
|
||||||
{"type": "tool", "title": "Planning", "content": "", "toolUIState": None},
|
|
||||||
)
|
|
||||||
in_progress = _in_progress(
|
|
||||||
"working-bash-1",
|
|
||||||
"working",
|
|
||||||
[
|
|
||||||
JSONPatchAdd(
|
|
||||||
path="/toolUIState",
|
|
||||||
value={
|
|
||||||
"type": "command",
|
|
||||||
"command": "ls -la /workspace",
|
|
||||||
"result": {
|
|
||||||
"status": "success",
|
|
||||||
"output": "total 4\ndrwxrwxrwx 2 root root 4096 Mar 20 10:18 .\ndrwxr-xr-x 1 root root 80 Mar 20 10:18 ..\n",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
JSONPatchReplace(path="/title", value="Executing bash"),
|
|
||||||
JSONPatchReplace(path="/content", value=""),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
started_events = loop._consume_workflow_event(started)
|
|
||||||
progress_events = loop._consume_workflow_event(in_progress)
|
|
||||||
|
|
||||||
assert started_events == []
|
|
||||||
tool_call_event = next(
|
|
||||||
event for event in progress_events if isinstance(event, ToolCallEvent)
|
|
||||||
)
|
|
||||||
tool_stream_event = next(
|
|
||||||
event for event in progress_events if isinstance(event, ToolStreamEvent)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert tool_call_event.tool_name == "bash"
|
|
||||||
assert tool_call_event.tool_class.get_name() == "bash"
|
|
||||||
assert tool_call_event.tool_call_id == "working-bash-1"
|
|
||||||
assert tool_stream_event.tool_name == "bash"
|
|
||||||
assert tool_stream_event.tool_call_id == "working-bash-1"
|
|
||||||
assert "command: ls -la /workspace" in tool_stream_event.message
|
|
||||||
assert "total 4" in tool_stream_event.message
|
|
||||||
assert "drwxrwxrwx 2 root root 4096" in tool_stream_event.message
|
|
||||||
|
|
||||||
|
|
||||||
def test_working_completed_with_tool_call_id_emits_tool_result() -> None:
|
|
||||||
loop = _make_loop(enabled_tools=["write_file"])
|
|
||||||
working_started = _started(
|
|
||||||
"working-tool-1",
|
|
||||||
"working",
|
|
||||||
{
|
|
||||||
"title": "Executing write_file",
|
|
||||||
"content": "",
|
|
||||||
"toolUIState": {"toolCallId": "call-write-solo"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
working_completed = _completed(
|
|
||||||
"working-tool-1",
|
|
||||||
"working",
|
|
||||||
{
|
|
||||||
"title": "Executing write_file",
|
|
||||||
"content": "",
|
|
||||||
"toolUIState": {
|
|
||||||
"type": "file",
|
|
||||||
"toolCallId": "call-write-solo",
|
|
||||||
"operations": [
|
|
||||||
{
|
|
||||||
"type": "create",
|
|
||||||
"uri": "/workspace/hello.py",
|
|
||||||
"content": 'print("Hello, World!")',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
started_events = loop._consume_workflow_event(working_started)
|
|
||||||
assert any(isinstance(e, ToolCallEvent) for e in started_events)
|
|
||||||
|
|
||||||
completed_events = loop._consume_workflow_event(working_completed)
|
|
||||||
result_events = [e for e in completed_events if isinstance(e, ToolResultEvent)]
|
|
||||||
assert len(result_events) == 1
|
|
||||||
assert result_events[0].error is None
|
|
||||||
assert result_events[0].tool_call_id == "call-write-solo"
|
|
||||||
|
|
||||||
|
|
||||||
def test_working_completed_with_tool_call_id_emits_error_result() -> None:
|
|
||||||
loop = _make_loop(enabled_tools=["write_file"])
|
|
||||||
working_started = _started(
|
|
||||||
"working-tool-2",
|
|
||||||
"working",
|
|
||||||
{
|
|
||||||
"title": "Executing write_file",
|
|
||||||
"content": "",
|
|
||||||
"toolUIState": {"toolCallId": "call-write-err"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
working_completed = _completed(
|
|
||||||
"working-tool-2",
|
|
||||||
"working",
|
|
||||||
{
|
|
||||||
"title": "Executing write_file",
|
|
||||||
"content": "Error: Permission denied.",
|
|
||||||
"toolUIState": {
|
|
||||||
"type": "file",
|
|
||||||
"toolCallId": "call-write-err",
|
|
||||||
"operations": [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
loop._consume_workflow_event(working_started)
|
|
||||||
completed_events = loop._consume_workflow_event(working_completed)
|
|
||||||
|
|
||||||
result_events = [e for e in completed_events if isinstance(e, ToolResultEvent)]
|
|
||||||
assert len(result_events) == 1
|
|
||||||
assert result_events[0].error is not None
|
|
||||||
assert result_events[0].tool_call_id == "call-write-err"
|
|
||||||
|
|
||||||
|
|
||||||
def test_json_patch_with_array_index_preserves_list_structure() -> None:
|
|
||||||
loop = _make_loop()
|
|
||||||
started = _started(
|
|
||||||
"msg-1",
|
|
||||||
"assistant_message",
|
|
||||||
{"contentChunks": [{"type": "text", "text": "Hello"}]},
|
|
||||||
)
|
|
||||||
in_progress = _in_progress(
|
|
||||||
"msg-1",
|
|
||||||
"assistant_message",
|
|
||||||
[JSONPatchReplace(path="/contentChunks/0/text", value="Hello world")],
|
|
||||||
)
|
|
||||||
|
|
||||||
loop._consume_workflow_event(started)
|
|
||||||
progress_events = loop._consume_workflow_event(in_progress)
|
|
||||||
|
|
||||||
assert len(progress_events) == 1
|
|
||||||
assert isinstance(progress_events[0], AssistantEvent)
|
|
||||||
assert progress_events[0].content == " world"
|
|
||||||
|
|
||||||
|
|
||||||
def test_steer_input_events_are_suppressed() -> None:
|
|
||||||
loop = _make_loop()
|
|
||||||
steer_started = _started(
|
|
||||||
"steer-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{"input_schema": {"title": "ChatInput"}, "label": "Send a message to steer..."},
|
|
||||||
)
|
|
||||||
steer_completed = _completed(
|
|
||||||
"steer-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{
|
|
||||||
"input_schema": {"title": "ChatInput"},
|
|
||||||
"label": "Send a message to steer...",
|
|
||||||
"input": None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert loop._consume_workflow_event(steer_started) == []
|
|
||||||
assert loop._translator.pending_input_request is not None
|
|
||||||
assert loop._translator.pending_input_request.task_id == "steer-1"
|
|
||||||
assert loop._consume_workflow_event(steer_completed) == []
|
|
||||||
assert loop._translator.pending_input_request is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_steer_input_allows_user_submission() -> None:
|
|
||||||
loop = _make_loop()
|
|
||||||
steer_started = _started(
|
|
||||||
"steer-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{"input_schema": {"title": "ChatInput"}, "label": "Send a message to steer..."},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert loop._consume_workflow_event(steer_started) == []
|
|
||||||
assert loop.is_waiting_for_input
|
|
||||||
|
|
||||||
loop._translator.pending_input_request = None
|
|
||||||
|
|
||||||
steer_completed = _completed(
|
|
||||||
"steer-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{
|
|
||||||
"input_schema": {"title": "ChatInput"},
|
|
||||||
"label": "Send a message to steer...",
|
|
||||||
"input": {"message": [{"type": "text", "text": "do X instead"}]},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
events = loop._consume_workflow_event(steer_completed)
|
|
||||||
assert any(isinstance(e, UserMessageEvent) for e in events)
|
|
||||||
user_event = next(e for e in events if isinstance(e, UserMessageEvent))
|
|
||||||
assert user_event.content == "do X instead"
|
|
||||||
|
|
||||||
|
|
||||||
def test_steer_does_not_overwrite_regular_pending_input() -> None:
|
|
||||||
loop = _make_loop()
|
|
||||||
regular_started = _started(
|
|
||||||
"regular-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{"input_schema": {"title": "ChatInput"}, "label": "Enter your message"},
|
|
||||||
)
|
|
||||||
loop._consume_workflow_event(regular_started)
|
|
||||||
assert loop._translator.pending_input_request is not None
|
|
||||||
assert loop._translator.pending_input_request.task_id == "regular-1"
|
|
||||||
|
|
||||||
steer_started = _started(
|
|
||||||
"steer-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{"input_schema": {"title": "ChatInput"}, "label": "Send a message to steer..."},
|
|
||||||
)
|
|
||||||
loop._consume_workflow_event(steer_started)
|
|
||||||
assert loop._translator.pending_input_request.task_id == "regular-1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_steer_start_registers_task_for_terminal_handling() -> None:
|
|
||||||
loop = _make_loop()
|
|
||||||
steer_started = _started(
|
|
||||||
"steer-1", "wait_for_input", {"label": "Send a message to steer..."}
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("vibe.core.nuage.remote_events_source.logger.warning") as mock_warning:
|
|
||||||
assert loop._consume_workflow_event(steer_started) == []
|
|
||||||
|
|
||||||
mock_warning.assert_called_once()
|
|
||||||
assert "steer-1" in loop._translator._steer_task_ids
|
|
||||||
assert "steer-1" in loop._translator._invalid_steer_task_ids
|
|
||||||
assert loop._translator.pending_input_request is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_steer_completion_does_not_clear_regular_prompt() -> None:
|
|
||||||
loop = _make_loop()
|
|
||||||
regular_started = _started(
|
|
||||||
"regular-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{"input_schema": {"title": "ChatInput"}, "label": "Pick an option"},
|
|
||||||
)
|
|
||||||
loop._consume_workflow_event(regular_started)
|
|
||||||
|
|
||||||
steer_started = _started(
|
|
||||||
"steer-1", "wait_for_input", {"label": "Send a message to steer..."}
|
|
||||||
)
|
|
||||||
assert loop._consume_workflow_event(steer_started) == []
|
|
||||||
assert loop._translator.pending_input_request is not None
|
|
||||||
assert loop._translator.pending_input_request.task_id == "regular-1"
|
|
||||||
|
|
||||||
steer_completed = _completed(
|
|
||||||
"steer-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{"label": "Send a message to steer...", "input": None},
|
|
||||||
)
|
|
||||||
events = loop._consume_workflow_event(steer_completed)
|
|
||||||
assert events == []
|
|
||||||
assert loop._translator.pending_input_request is not None
|
|
||||||
assert loop._translator.pending_input_request.task_id == "regular-1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_steer_cancellation_does_not_clear_regular_prompt() -> None:
|
|
||||||
loop = _make_loop()
|
|
||||||
regular_started = _started(
|
|
||||||
"regular-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{"input_schema": {"title": "ChatInput"}, "label": "Pick an option"},
|
|
||||||
)
|
|
||||||
loop._consume_workflow_event(regular_started)
|
|
||||||
|
|
||||||
steer_started = _started(
|
|
||||||
"steer-1", "wait_for_input", {"label": "Send a message to steer..."}
|
|
||||||
)
|
|
||||||
assert loop._consume_workflow_event(steer_started) == []
|
|
||||||
assert loop._translator.pending_input_request is not None
|
|
||||||
assert loop._translator.pending_input_request.task_id == "regular-1"
|
|
||||||
|
|
||||||
events = loop._consume_workflow_event(_canceled("steer-1", "wait_for_input"))
|
|
||||||
assert events == []
|
|
||||||
assert loop._translator.pending_input_request is not None
|
|
||||||
assert loop._translator.pending_input_request.task_id == "regular-1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_steer_completion_is_preserved_while_regular_prompt_pending() -> None:
|
|
||||||
loop = _make_loop()
|
|
||||||
regular_started = _started(
|
|
||||||
"regular-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{"input_schema": {"title": "ChatInput"}, "label": "Pick an option"},
|
|
||||||
)
|
|
||||||
loop._consume_workflow_event(regular_started)
|
|
||||||
|
|
||||||
steer_started = _started(
|
|
||||||
"steer-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{"input_schema": {"title": "ChatInput"}, "label": "Send a message to steer..."},
|
|
||||||
)
|
|
||||||
loop._consume_workflow_event(steer_started)
|
|
||||||
|
|
||||||
steer_completed = _completed(
|
|
||||||
"steer-1",
|
|
||||||
"wait_for_input",
|
|
||||||
{
|
|
||||||
"input_schema": {"title": "ChatInput"},
|
|
||||||
"label": "Send a message to steer...",
|
|
||||||
"input": {"message": [{"type": "text", "text": "user steer msg"}]},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
events = loop._consume_workflow_event(steer_completed)
|
|
||||||
|
|
||||||
user_event = next(e for e in events if isinstance(e, UserMessageEvent))
|
|
||||||
assert user_event.content == "user steer msg"
|
|
||||||
assert loop._translator.pending_input_request is not None
|
|
||||||
assert loop._translator.pending_input_request.task_id == "regular-1"
|
|
||||||
138
tests/core/test_resolve_api_key.py
Normal file
138
tests/core/test_resolve_api_key.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import keyring
|
||||||
|
from keyring.errors import KeyringError
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_returns_env_value_without_consulting_keyring(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("CUSTOM_API_KEY", "env-key")
|
||||||
|
|
||||||
|
def _fail(service: str, username: str) -> str | None:
|
||||||
|
raise AssertionError("keyring must not be consulted when env is set")
|
||||||
|
|
||||||
|
monkeypatch.setattr(keyring, "get_password", _fail)
|
||||||
|
|
||||||
|
assert resolve_api_key("CUSTOM_API_KEY") == "env-key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_falls_back_to_keyring_when_env_unset(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("CUSTOM_API_KEY", raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
keyring, "get_password", lambda service, username: "keyring-key"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolve_api_key("CUSTOM_API_KEY") == "keyring-key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_returns_none_when_env_unset_and_keyring_empty(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("CUSTOM_API_KEY", raising=False)
|
||||||
|
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||||
|
|
||||||
|
assert resolve_api_key("CUSTOM_API_KEY") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_returns_none_when_keyring_raises(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("CUSTOM_API_KEY", raising=False)
|
||||||
|
|
||||||
|
def _unavailable(service: str, username: str) -> str | None:
|
||||||
|
raise KeyringError("no keyring")
|
||||||
|
|
||||||
|
monkeypatch.setattr(keyring, "get_password", _unavailable)
|
||||||
|
|
||||||
|
assert resolve_api_key("CUSTOM_API_KEY") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_returns_none_for_empty_env_key(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def _fail(service: str, username: str) -> str | None:
|
||||||
|
raise AssertionError("keyring must not be consulted for an empty env key")
|
||||||
|
|
||||||
|
monkeypatch.setattr(keyring, "get_password", _fail)
|
||||||
|
|
||||||
|
assert resolve_api_key("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_api_key_accepts_keyring_only_key(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
keyring, "get_password", lambda service, username: "keyring-key"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should not raise MissingAPIKeyError despite the env var being unset.
|
||||||
|
config = build_test_vibe_config()
|
||||||
|
|
||||||
|
assert config.get_active_provider().api_key_env_var == "MISTRAL_API_KEY"
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_api_key_raises_when_neither_env_nor_keyring(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||||
|
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||||
|
|
||||||
|
with pytest.raises(MissingAPIKeyError):
|
||||||
|
build_test_vibe_config()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mistral_backend_reads_keyring_only_key(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
keyring, "get_password", lambda service, username: "keyring-key"
|
||||||
|
)
|
||||||
|
provider = ProviderConfig(
|
||||||
|
name="mistral",
|
||||||
|
api_base="https://api.mistral.ai/v1",
|
||||||
|
api_key_env_var="MISTRAL_API_KEY",
|
||||||
|
backend=Backend.MISTRAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = MistralBackend(provider)
|
||||||
|
|
||||||
|
assert backend._api_key == "keyring-key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vibe_code_api_key_resolves_from_keyring(
|
||||||
|
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()
|
||||||
|
|
||||||
|
assert config.vibe_code_api_key == "keyring-key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vibe_code_api_key_empty_when_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()
|
||||||
|
|
||||||
|
# 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 == ""
|
||||||
|
|
@ -510,16 +510,6 @@ class TestTelemetryClient:
|
||||||
"nb_session_messages": 4,
|
"nb_session_messages": 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_send_remote_resume_requested_payload(
|
|
||||||
self, telemetry_events: list[dict[str, Any]]
|
|
||||||
) -> None:
|
|
||||||
config = build_test_vibe_config(enable_telemetry=True)
|
|
||||||
client = TelemetryClient(config_getter=lambda: config)
|
|
||||||
client.send_remote_resume_requested(session_id="remote-123")
|
|
||||||
assert len(telemetry_events) == 1
|
|
||||||
assert telemetry_events[0]["event_name"] == "vibe.remote_resume_requested"
|
|
||||||
assert telemetry_events[0]["properties"] == {"session_id": "remote-123"}
|
|
||||||
|
|
||||||
def test_send_teleport_failed_payload_includes_error_details(
|
def test_send_teleport_failed_payload_includes_error_details(
|
||||||
self, telemetry_events: list[dict[str, Any]]
|
self, telemetry_events: list[dict[str, Any]]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import json
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from tests.constants import TELEPORT_SESSIONS_PATH
|
||||||
from vibe.core.teleport.errors import ServiceTeleportError
|
from vibe.core.teleport.errors import ServiceTeleportError
|
||||||
from vibe.core.teleport.nuage import (
|
from vibe.core.teleport.nuage import (
|
||||||
NuageClient,
|
NuageClient,
|
||||||
|
|
@ -52,7 +53,7 @@ async def test_start_posts_nuage_request() -> None:
|
||||||
response = await nuage.start(_request())
|
response = await nuage.start(_request())
|
||||||
|
|
||||||
assert seen_request is not None
|
assert seen_request is not None
|
||||||
assert str(seen_request.url) == "https://chat.example.com/api/v1/code/sessions"
|
assert str(seen_request.url) == f"https://chat.example.com{TELEPORT_SESSIONS_PATH}"
|
||||||
assert seen_request.headers["authorization"] == "Bearer api-key"
|
assert seen_request.headers["authorization"] == "Bearer api-key"
|
||||||
assert seen_request.headers["content-type"] == "application/json"
|
assert seen_request.headers["content-type"] == "application/json"
|
||||||
assert json.loads(seen_request.content) == {
|
assert json.loads(seen_request.content) == {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import pytest
|
||||||
import zstandard
|
import zstandard
|
||||||
|
|
||||||
from tests.conftest import build_test_vibe_config
|
from tests.conftest import build_test_vibe_config
|
||||||
|
from tests.constants import TELEPORT_COMPLETE_URL, TELEPORT_SESSIONS_PATH
|
||||||
from vibe.core.teleport.errors import (
|
from vibe.core.teleport.errors import (
|
||||||
ServiceTeleportError,
|
ServiceTeleportError,
|
||||||
ServiceTeleportNotSupportedError,
|
ServiceTeleportNotSupportedError,
|
||||||
|
|
@ -59,7 +60,7 @@ def _mock_handler() -> Any:
|
||||||
"webSessionId": "web-session-id",
|
"webSessionId": "web-session-id",
|
||||||
"projectId": "project-id",
|
"projectId": "project-id",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"url": "https://chat.example.com/code/project-id/web-session-id",
|
"url": TELEPORT_COMPLETE_URL,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -218,7 +219,7 @@ class TestTeleportServiceExecute:
|
||||||
"webSessionId": "web-session-id",
|
"webSessionId": "web-session-id",
|
||||||
"projectId": "project-id",
|
"projectId": "project-id",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"url": "https://chat.example.com/code/project-id/web-session-id",
|
"url": TELEPORT_COMPLETE_URL,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -247,10 +248,8 @@ class TestTeleportServiceExecute:
|
||||||
assert isinstance(events[0], TeleportCheckingGitEvent)
|
assert isinstance(events[0], TeleportCheckingGitEvent)
|
||||||
assert isinstance(events[1], TeleportStartingWorkflowEvent)
|
assert isinstance(events[1], TeleportStartingWorkflowEvent)
|
||||||
assert isinstance(events[2], TeleportCompleteEvent)
|
assert isinstance(events[2], TeleportCompleteEvent)
|
||||||
assert (
|
assert events[2].url == TELEPORT_COMPLETE_URL
|
||||||
events[2].url == "https://chat.example.com/code/project-id/web-session-id"
|
assert seen_url == f"https://chat.example.com{TELEPORT_SESSIONS_PATH}"
|
||||||
)
|
|
||||||
assert seen_url == "https://chat.example.com/api/v1/code/sessions"
|
|
||||||
assert seen_body is not None
|
assert seen_body is not None
|
||||||
assert seen_body["message"] == {
|
assert seen_body["message"] == {
|
||||||
"role": "user",
|
"role": "user",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from vibe.core.utils.text import snippet_start_line
|
from vibe.core.utils.text import snippet_start_line, snippet_start_lines
|
||||||
|
|
||||||
|
|
||||||
class TestSnippetStartLine:
|
class TestSnippetStartLine:
|
||||||
|
|
@ -27,3 +27,26 @@ class TestSnippetStartLine:
|
||||||
|
|
||||||
def test_blank_snippet(self) -> None:
|
def test_blank_snippet(self) -> None:
|
||||||
assert snippet_start_line("hello", "\n") is None
|
assert snippet_start_line("hello", "\n") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSnippetStartLines:
|
||||||
|
def test_single_occurrence(self) -> None:
|
||||||
|
assert snippet_start_lines("a\nb\nc", "b") == [2]
|
||||||
|
|
||||||
|
def test_all_occurrences(self) -> None:
|
||||||
|
assert snippet_start_lines("x\ny\nx\nz\nx", "x") == [1, 3, 5]
|
||||||
|
|
||||||
|
def test_repeated_on_same_line(self) -> None:
|
||||||
|
assert snippet_start_lines("x x x", "x") == [1, 1, 1]
|
||||||
|
|
||||||
|
def test_non_overlapping(self) -> None:
|
||||||
|
assert snippet_start_lines("aaaa", "aa") == [1, 1]
|
||||||
|
|
||||||
|
def test_multiline_snippet_occurrences(self) -> None:
|
||||||
|
assert snippet_start_lines("a\nb\nc\na\nb", "a\nb") == [1, 4]
|
||||||
|
|
||||||
|
def test_not_found(self) -> None:
|
||||||
|
assert snippet_start_lines("hello\nworld", "missing") == []
|
||||||
|
|
||||||
|
def test_blank_snippet(self) -> None:
|
||||||
|
assert snippet_start_lines("hello", "\n") == []
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,31 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from vibe.core.config.layer import LayerImplementationError
|
from vibe.core.config.fingerprint import create_file_fingerprint
|
||||||
|
from vibe.core.config.layer import LayerImplementationError, LayerNotLoadedError
|
||||||
from vibe.core.config.layers.user import UserConfigLayer
|
from vibe.core.config.layers.user import UserConfigLayer
|
||||||
from vibe.core.config.patch import ConfigPatch
|
from vibe.core.config.patch import (
|
||||||
|
AddOperationPatch,
|
||||||
|
ConfigPatch,
|
||||||
|
RemoveOperationPatch,
|
||||||
|
ReplaceOperationPatch,
|
||||||
|
)
|
||||||
from vibe.core.config.types import MISSING_CONFIG_FILE_FINGERPRINT
|
from vibe.core.config.types import MISSING_CONFIG_FILE_FINGERPRINT
|
||||||
|
|
||||||
|
|
||||||
|
def random_config_file_name() -> str:
|
||||||
|
return f"config-{uuid4().hex}.toml"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reads_toml_file(tmp_working_directory: Path) -> None:
|
async def test_reads_toml_file(tmp_working_directory: Path) -> None:
|
||||||
path = tmp_working_directory / "config.toml"
|
path = tmp_working_directory / random_config_file_name()
|
||||||
path.write_text('active_model = "mistral-large"\ncount = 42\n')
|
path.write_text('active_model = "mistral-large"\ncount = 42\n')
|
||||||
|
|
||||||
layer = UserConfigLayer(path=path, name="user-toml")
|
layer = UserConfigLayer(path=path, name="user-toml")
|
||||||
|
|
@ -25,7 +38,7 @@ async def test_reads_toml_file(tmp_working_directory: Path) -> None:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_always_trusted(tmp_working_directory: Path) -> None:
|
async def test_always_trusted(tmp_working_directory: Path) -> None:
|
||||||
path = tmp_working_directory / "config.toml"
|
path = tmp_working_directory / random_config_file_name()
|
||||||
path.write_text('key = "value"\n')
|
path.write_text('key = "value"\n')
|
||||||
|
|
||||||
layer = UserConfigLayer(path=path, name="user-toml")
|
layer = UserConfigLayer(path=path, name="user-toml")
|
||||||
|
|
@ -37,7 +50,7 @@ async def test_always_trusted(tmp_working_directory: Path) -> None:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None:
|
async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None:
|
||||||
path = tmp_working_directory / "nonexistent.toml"
|
path = tmp_working_directory / random_config_file_name()
|
||||||
layer = UserConfigLayer(path=path, name="user-toml")
|
layer = UserConfigLayer(path=path, name="user-toml")
|
||||||
data = await layer.load()
|
data = await layer.load()
|
||||||
assert data.model_extra == {}
|
assert data.model_extra == {}
|
||||||
|
|
@ -45,16 +58,241 @@ async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_apply_raises_not_implemented(tmp_working_directory: Path) -> None:
|
async def test_apply_raises_when_file_does_not_exist(
|
||||||
path = tmp_working_directory / "config.toml"
|
tmp_working_directory: Path,
|
||||||
|
) -> None:
|
||||||
|
path = tmp_working_directory / random_config_file_name()
|
||||||
layer = UserConfigLayer(path=path, name="user-toml")
|
layer = UserConfigLayer(path=path, name="user-toml")
|
||||||
with pytest.raises(NotImplementedError, match="M2"):
|
|
||||||
await layer.apply(ConfigPatch(fingerprint="fp-1"))
|
await layer.load()
|
||||||
|
assert layer.fingerprint == MISSING_CONFIG_FILE_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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_sets_field_and_refreshes_cache(
|
||||||
|
tmp_working_directory: Path,
|
||||||
|
) -> None:
|
||||||
|
path = tmp_working_directory / random_config_file_name()
|
||||||
|
path.write_text("""\
|
||||||
|
active_model = "old"
|
||||||
|
|
||||||
|
[tools]
|
||||||
|
disabled_tools = ["bash", "python"]
|
||||||
|
deprecated_setting = true
|
||||||
|
""")
|
||||||
|
layer = UserConfigLayer(path=path, name="user-toml")
|
||||||
|
|
||||||
|
await layer.load()
|
||||||
|
fingerprint = layer.fingerprint
|
||||||
|
assert isinstance(fingerprint, str)
|
||||||
|
|
||||||
|
await layer.apply(
|
||||||
|
ConfigPatch(
|
||||||
|
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"),
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_data = {
|
||||||
|
"active_model": "new",
|
||||||
|
"tools": {"disabled_tools": ["python", "node"], "enabled_tools": ["read"]},
|
||||||
|
}
|
||||||
|
with path.open("rb") as file:
|
||||||
|
assert tomllib.load(file) == expected_data
|
||||||
|
|
||||||
|
cached_data = layer._state.data
|
||||||
|
assert cached_data is not None
|
||||||
|
assert cached_data.model_extra == expected_data
|
||||||
|
assert layer.fingerprint != fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_cache_fingerprint_matches_written_file(
|
||||||
|
tmp_working_directory: Path,
|
||||||
|
) -> None:
|
||||||
|
path = tmp_working_directory / random_config_file_name()
|
||||||
|
path.write_text("")
|
||||||
|
layer = UserConfigLayer(path=path, name="user-toml")
|
||||||
|
|
||||||
|
await layer.load()
|
||||||
|
fingerprint = layer.fingerprint
|
||||||
|
assert isinstance(fingerprint, str)
|
||||||
|
|
||||||
|
await layer.apply(
|
||||||
|
ConfigPatch(
|
||||||
|
AddOperationPatch(path="/active_model", value="mistral-large"),
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with path.open("rb") as file:
|
||||||
|
assert layer.fingerprint == create_file_fingerprint(file)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_uses_unique_temp_file(tmp_working_directory: Path) -> None:
|
||||||
|
path = tmp_working_directory / random_config_file_name()
|
||||||
|
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")
|
||||||
|
|
||||||
|
await layer.load()
|
||||||
|
fingerprint = layer.fingerprint
|
||||||
|
assert isinstance(fingerprint, str)
|
||||||
|
|
||||||
|
await layer.apply(
|
||||||
|
ConfigPatch(
|
||||||
|
AddOperationPatch(path="/active_model", value="mistral-large"),
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert fixed_tmp_path.read_text() == "stale"
|
||||||
|
assert list(tmp_working_directory.glob(f".{path.name}.*.tmp")) == []
|
||||||
|
with path.open("rb") as file:
|
||||||
|
assert tomllib.load(file) == {"active_model": "mistral-large"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_atomic_replace_preserves_replacement_fingerprint(
|
||||||
|
tmp_working_directory: Path,
|
||||||
|
) -> None:
|
||||||
|
path = tmp_working_directory / random_config_file_name()
|
||||||
|
replacement = tmp_working_directory / f".{path.name}.tmp"
|
||||||
|
path.write_text("key = 1")
|
||||||
|
replacement.write_text("key = 2")
|
||||||
|
|
||||||
|
with replacement.open("rb") as file:
|
||||||
|
replacement_fingerprint = create_file_fingerprint(file)
|
||||||
|
|
||||||
|
os.replace(replacement, path)
|
||||||
|
|
||||||
|
with path.open("rb") as file:
|
||||||
|
assert create_file_fingerprint(file) == replacement_fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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")
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_raises_when_cache_is_invalidated(
|
||||||
|
tmp_working_directory: Path,
|
||||||
|
) -> None:
|
||||||
|
path = tmp_working_directory / random_config_file_name()
|
||||||
|
path.write_text('active_model = "old"\n')
|
||||||
|
layer = UserConfigLayer(path=path, name="user-toml")
|
||||||
|
|
||||||
|
await layer.load()
|
||||||
|
fingerprint = layer.fingerprint
|
||||||
|
assert isinstance(fingerprint, str)
|
||||||
|
await layer.invalidate_cache()
|
||||||
|
|
||||||
|
with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"):
|
||||||
|
await layer.apply(
|
||||||
|
ConfigPatch(
|
||||||
|
ReplaceOperationPatch(path="/active_model", value="new"),
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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")
|
||||||
|
|
||||||
|
await layer.load()
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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")
|
||||||
|
|
||||||
|
await layer.load()
|
||||||
|
fingerprint = layer.fingerprint
|
||||||
|
assert isinstance(fingerprint, str)
|
||||||
|
|
||||||
|
await layer.apply(
|
||||||
|
ConfigPatch(
|
||||||
|
AddOperationPatch(path="/models/active_model", value="mistral-large"),
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with path.open("rb") as file:
|
||||||
|
assert tomllib.load(file) == {"models": {"active_model": "mistral-large"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_overwrites_external_file_changes(
|
||||||
|
tmp_working_directory: Path,
|
||||||
|
) -> None:
|
||||||
|
path = tmp_working_directory / random_config_file_name()
|
||||||
|
path.write_text('active_model = "old"\n')
|
||||||
|
layer = UserConfigLayer(path=path, name="user-toml")
|
||||||
|
|
||||||
|
await layer.load()
|
||||||
|
fingerprint = layer.fingerprint
|
||||||
|
assert isinstance(fingerprint, str)
|
||||||
|
path.write_text('active_model = "external"\n')
|
||||||
|
|
||||||
|
await layer.apply(
|
||||||
|
ConfigPatch(
|
||||||
|
ReplaceOperationPatch(path="/active_model", value="new"),
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with path.open("rb") as file:
|
||||||
|
assert tomllib.load(file) == {"active_model": "new"}
|
||||||
|
data = await layer.load()
|
||||||
|
assert data.model_extra == {"active_model": "new"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_nested_toml_structure(tmp_working_directory: Path) -> None:
|
async def test_nested_toml_structure(tmp_working_directory: Path) -> None:
|
||||||
path = tmp_working_directory / "config.toml"
|
path = tmp_working_directory / random_config_file_name()
|
||||||
path.write_text("""\
|
path.write_text("""\
|
||||||
[models]
|
[models]
|
||||||
active_model = "test"
|
active_model = "test"
|
||||||
|
|
@ -72,7 +310,7 @@ provider = "p"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_invalid_toml_raises(tmp_working_directory: Path) -> None:
|
async def test_invalid_toml_raises(tmp_working_directory: Path) -> None:
|
||||||
path = tmp_working_directory / "bad.toml"
|
path = tmp_working_directory / random_config_file_name()
|
||||||
path.write_text("this is not valid = = = toml [[[")
|
path.write_text("this is not valid = = = toml [[[")
|
||||||
layer = UserConfigLayer(path=path, name="user-toml")
|
layer = UserConfigLayer(path=path, name="user-toml")
|
||||||
with pytest.raises(LayerImplementationError, match="_build_config_snapshot"):
|
with pytest.raises(LayerImplementationError, match="_build_config_snapshot"):
|
||||||
|
|
@ -81,7 +319,7 @@ async def test_invalid_toml_raises(tmp_working_directory: Path) -> None:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_force_reload_reads_fresh_data(tmp_working_directory: Path) -> None:
|
async def test_force_reload_reads_fresh_data(tmp_working_directory: Path) -> None:
|
||||||
path = tmp_working_directory / "config.toml"
|
path = tmp_working_directory / random_config_file_name()
|
||||||
path.write_text('value = "first"\n')
|
path.write_text('value = "first"\n')
|
||||||
layer = UserConfigLayer(path=path, name="user-toml")
|
layer = UserConfigLayer(path=path, name="user-toml")
|
||||||
|
|
||||||
|
|
@ -107,7 +345,7 @@ async def test_force_reload_reads_fresh_data(tmp_working_directory: Path) -> Non
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_empty_toml_file(tmp_working_directory: Path) -> None:
|
async def test_empty_toml_file(tmp_working_directory: Path) -> None:
|
||||||
path = tmp_working_directory / "empty.toml"
|
path = tmp_working_directory / random_config_file_name()
|
||||||
path.write_text("")
|
path.write_text("")
|
||||||
layer = UserConfigLayer(path=path, name="user-toml")
|
layer = UserConfigLayer(path=path, name="user-toml")
|
||||||
data = await layer.load()
|
data = await layer.load()
|
||||||
|
|
|
||||||
131
tests/core/test_user_display_content.py
Normal file
131
tests/core/test_user_display_content.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from vibe.core.types import LLMMessage, Role, UserDisplayContentMetadata
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata() -> UserDisplayContentMetadata:
|
||||||
|
return UserDisplayContentMetadata(
|
||||||
|
version="1.0.0",
|
||||||
|
host="mistral-vscode",
|
||||||
|
content=[
|
||||||
|
{"type": "text", "text": "Look at "},
|
||||||
|
{
|
||||||
|
"type": "workspace_mention",
|
||||||
|
"kind": "file",
|
||||||
|
"uri": "file:///repo/src/app.ts",
|
||||||
|
"name": "app.ts",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_accepts_valid_metadata_and_preserves_host_owned_content() -> None:
|
||||||
|
metadata = UserDisplayContentMetadata.model_validate({
|
||||||
|
"version": "1.0.0",
|
||||||
|
"host": "mistral-vscode",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Look at "},
|
||||||
|
{
|
||||||
|
"type": "workspace_mention",
|
||||||
|
"kind": "file",
|
||||||
|
"uri": "file:///repo/src/app.ts",
|
||||||
|
"name": "app.ts",
|
||||||
|
"automatic": False,
|
||||||
|
"nested": {"line": 12, "tags": ["source", None]},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert metadata.version == "1.0.0"
|
||||||
|
assert metadata.host == "mistral-vscode"
|
||||||
|
assert metadata.content == [
|
||||||
|
{"type": "text", "text": "Look at "},
|
||||||
|
{
|
||||||
|
"type": "workspace_mention",
|
||||||
|
"kind": "file",
|
||||||
|
"uri": "file:///repo/src/app.ts",
|
||||||
|
"name": "app.ts",
|
||||||
|
"automatic": False,
|
||||||
|
"nested": {"line": 12, "tags": ["source", None]},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_strips_metadata_strings_without_stripping_host_owned_content() -> None:
|
||||||
|
metadata = UserDisplayContentMetadata.model_validate({
|
||||||
|
"version": " 1.0.0 ",
|
||||||
|
"host": " mistral-vscode ",
|
||||||
|
"content": [{"type": "text", "text": " keep spaces "}],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert metadata.version == "1.0.0"
|
||||||
|
assert metadata.host == "mistral-vscode"
|
||||||
|
assert metadata.content == [{"type": "text", "text": " keep spaces "}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"payload",
|
||||||
|
[
|
||||||
|
{"version": " ", "host": "mistral-vscode", "content": []},
|
||||||
|
{"version": 2, "host": "mistral-vscode", "content": []},
|
||||||
|
{"version": "1.0.0", "host": " ", "content": []},
|
||||||
|
{"version": "1.0.0", "host": "mistral-vscode", "content": ["plain text"]},
|
||||||
|
{"version": "1.0.0", "host": "mistral-vscode", "content": {"type": "text"}},
|
||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"host": "mistral-vscode",
|
||||||
|
"content": [{"type": "text"}],
|
||||||
|
"unexpected": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"host": "mistral-vscode",
|
||||||
|
"content": [{"type": "text", "value": object()}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"host": "mistral-vscode",
|
||||||
|
"content": [{"type": "text", "value": math.nan}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_rejects_invalid_metadata(payload: object) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
UserDisplayContentMetadata.model_validate(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_message_round_trips_user_display_content() -> None:
|
||||||
|
metadata = _metadata()
|
||||||
|
message = LLMMessage(
|
||||||
|
role=Role.user, content="Look at app.ts", user_display_content=metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
dumped = message.model_dump(exclude_none=True, mode="json")
|
||||||
|
loaded = LLMMessage.model_validate(dumped)
|
||||||
|
|
||||||
|
assert dumped["user_display_content"] == metadata.model_dump(mode="json")
|
||||||
|
assert loaded.user_display_content == metadata
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_message_keeps_old_sessions_without_user_display_content_valid() -> None:
|
||||||
|
message = LLMMessage.model_validate({"role": "user", "content": "hello"})
|
||||||
|
|
||||||
|
assert message.user_display_content is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_message_object_adapter_preserves_user_display_content() -> None:
|
||||||
|
metadata = _metadata()
|
||||||
|
|
||||||
|
message = LLMMessage.model_validate(
|
||||||
|
SimpleNamespace(
|
||||||
|
role=Role.user, content="Look at app.ts", user_display_content=metadata
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert message.user_display_content == metadata
|
||||||
|
|
@ -27,6 +27,7 @@ async def test_full_toml_to_vibe_config_schema(tmp_path: Path) -> None:
|
||||||
"""\
|
"""\
|
||||||
vim_keybindings = true
|
vim_keybindings = true
|
||||||
api_timeout = 300.0
|
api_timeout = 300.0
|
||||||
|
api_retry_max_elapsed_time = 120.0
|
||||||
active_model = "codestral"
|
active_model = "codestral"
|
||||||
disabled_tools = ["bash"]
|
disabled_tools = ["bash"]
|
||||||
default_agent = "plan"
|
default_agent = "plan"
|
||||||
|
|
@ -54,6 +55,7 @@ provider = "mistral"
|
||||||
|
|
||||||
assert config.vim_keybindings is True
|
assert config.vim_keybindings is True
|
||||||
assert config.api_timeout == 300.0
|
assert config.api_timeout == 300.0
|
||||||
|
assert config.api_retry_max_elapsed_time == 120.0
|
||||||
assert config.active_model == "codestral"
|
assert config.active_model == "codestral"
|
||||||
assert config.models[0].alias == "codestral"
|
assert config.models[0].alias == "codestral"
|
||||||
assert "bash" in config.disabled_tools
|
assert "bash" in config.disabled_tools
|
||||||
|
|
|
||||||
|
|
@ -230,21 +230,21 @@ def test_get_result_display() -> None:
|
||||||
assert "foo.py" in display.message
|
assert "foo.py" in display.message
|
||||||
|
|
||||||
|
|
||||||
def test_ui_start_line_not_part_of_model_contract() -> None:
|
def test_ui_start_lines_not_part_of_model_contract() -> None:
|
||||||
result = EditResult(file="/x", message="m", old_string="a", new_string="b")
|
result = EditResult(file="/x", message="m", old_string="a", new_string="b")
|
||||||
result._ui_start_line = 42
|
result._ui_start_lines = [42]
|
||||||
|
|
||||||
assert result.ui_start_line == 42
|
assert result.ui_start_lines == [42]
|
||||||
assert "ui_start_line" not in result.model_dump()
|
assert "ui_start_lines" not in result.model_dump()
|
||||||
assert "_ui_start_line" not in result.model_dump()
|
assert "_ui_start_lines" not in result.model_dump()
|
||||||
assert "ui_start_line" not in result.model_dump_json()
|
assert "ui_start_lines" not in result.model_dump_json()
|
||||||
assert "ui_start_line" not in EditResult.model_fields
|
assert "ui_start_lines" not in EditResult.model_fields
|
||||||
assert "ui_start_line" not in EditResult.model_json_schema().get("properties", {})
|
assert "ui_start_lines" not in EditResult.model_json_schema().get("properties", {})
|
||||||
assert "ui_start_line" not in dict(result)
|
assert "ui_start_lines" not in dict(result)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ui_start_line_computed_at_edit_site(
|
async def test_ui_start_lines_computed_at_edit_site(
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
@ -255,11 +255,11 @@ async def test_ui_start_line_computed_at_edit_site(
|
||||||
edit.run(EditArgs(file_path="f.txt", old_string="gamma", new_string="GAMMA"))
|
edit.run(EditArgs(file_path="f.txt", old_string="gamma", new_string="GAMMA"))
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.ui_start_line == 3
|
assert result.ui_start_lines == [3]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ui_start_line_set_for_pure_deletion(
|
async def test_ui_start_lines_set_for_pure_deletion(
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
@ -270,4 +270,38 @@ async def test_ui_start_line_set_for_pure_deletion(
|
||||||
edit.run(EditArgs(file_path="f.txt", old_string="remove\n", new_string=""))
|
edit.run(EditArgs(file_path="f.txt", old_string="remove\n", new_string=""))
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.ui_start_line == 3
|
assert result.ui_start_lines == [3]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ui_start_lines_lists_all_occurrences_for_replace_all(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_write(tmp_path, "f.txt", "x\ntgt\ny\ntgt\nz\ntgt\n")
|
||||||
|
edit = _make_edit()
|
||||||
|
|
||||||
|
result = await collect_result(
|
||||||
|
edit.run(
|
||||||
|
EditArgs(
|
||||||
|
file_path="f.txt", old_string="tgt", new_string="TGT", replace_all=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.ui_start_lines == [2, 4, 6]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ui_start_lines_single_entry_without_replace_all(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_write(tmp_path, "f.txt", "a\nuniq\nb\n")
|
||||||
|
edit = _make_edit()
|
||||||
|
|
||||||
|
result = await collect_result(
|
||||||
|
edit.run(EditArgs(file_path="f.txt", old_string="uniq", new_string="UNIQ"))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.ui_start_lines == [2]
|
||||||
|
|
|
||||||
178
tests/e2e/agent_loop_characterization/support.py
Normal file
178
tests/e2e/agent_loop_characterization/support.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import time
|
||||||
|
import tomllib
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pexpect
|
||||||
|
import tomli_w
|
||||||
|
|
||||||
|
from tests.e2e.common import strip_ansi, wait_for_rendered_text
|
||||||
|
from tests.e2e.mock_server import ChatCompletionsRequestPayload, StreamingMockServer
|
||||||
|
|
||||||
|
APPROVAL_INPUT_GRACE_PERIOD_S = 0.65
|
||||||
|
|
||||||
|
|
||||||
|
def assistant_text_chunks(text: str, *, created: int = 100) -> list[dict[str, object]]:
|
||||||
|
return [
|
||||||
|
StreamingMockServer.build_chunk(
|
||||||
|
created=created,
|
||||||
|
delta={"role": "assistant", "content": text},
|
||||||
|
finish_reason=None,
|
||||||
|
),
|
||||||
|
StreamingMockServer.build_chunk(
|
||||||
|
created=created + 1,
|
||||||
|
delta={},
|
||||||
|
finish_reason="stop",
|
||||||
|
usage={"prompt_tokens": 3, "completion_tokens": 4},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def single_tool_call_chunks(
|
||||||
|
*, call_id: str, tool_name: str, arguments: dict[str, Any], created: int = 10
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
return [
|
||||||
|
StreamingMockServer.build_chunk(
|
||||||
|
created=created,
|
||||||
|
delta=StreamingMockServer.build_tool_call_delta(
|
||||||
|
call_id=call_id,
|
||||||
|
tool_name=tool_name,
|
||||||
|
arguments=json.dumps(arguments, separators=(",", ":")),
|
||||||
|
),
|
||||||
|
finish_reason=None,
|
||||||
|
),
|
||||||
|
StreamingMockServer.build_chunk(
|
||||||
|
created=created + 1,
|
||||||
|
delta={},
|
||||||
|
finish_reason="tool_calls",
|
||||||
|
usage={"prompt_tokens": 3, "completion_tokens": 4},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def multi_tool_call_chunks(
|
||||||
|
tool_calls: list[tuple[str, str, dict[str, Any]]], *, created: int = 10
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
return [
|
||||||
|
StreamingMockServer.build_chunk(
|
||||||
|
created=created,
|
||||||
|
delta={
|
||||||
|
"role": "assistant",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"index": index,
|
||||||
|
"id": call_id,
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": tool_name,
|
||||||
|
"arguments": json.dumps(arguments, separators=(",", ":")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for index, (call_id, tool_name, arguments) in enumerate(tool_calls)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finish_reason=None,
|
||||||
|
),
|
||||||
|
StreamingMockServer.build_chunk(
|
||||||
|
created=created + 1,
|
||||||
|
delta={},
|
||||||
|
finish_reason="tool_calls",
|
||||||
|
usage={"prompt_tokens": 3, "completion_tokens": 4},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _messages(payload: ChatCompletionsRequestPayload) -> list[dict[str, Any]]:
|
||||||
|
raw_messages = payload.get("messages")
|
||||||
|
assert raw_messages is not None
|
||||||
|
return [dict(message) for message in raw_messages]
|
||||||
|
|
||||||
|
|
||||||
|
def assert_tool_result_contains(
|
||||||
|
payload: ChatCompletionsRequestPayload, *, call_id: str, expected: str
|
||||||
|
) -> None:
|
||||||
|
matching_messages = [
|
||||||
|
message
|
||||||
|
for message in _messages(payload)
|
||||||
|
if message.get("role") == "tool" and message.get("tool_call_id") == call_id
|
||||||
|
]
|
||||||
|
assert len(matching_messages) == 1
|
||||||
|
content = matching_messages[0].get("content")
|
||||||
|
assert isinstance(content, str)
|
||||||
|
assert expected in content, content
|
||||||
|
|
||||||
|
|
||||||
|
def assert_assistant_tool_call_present(
|
||||||
|
payload: ChatCompletionsRequestPayload, *, call_id: str, tool_name: str
|
||||||
|
) -> None:
|
||||||
|
for message in _messages(payload):
|
||||||
|
if message.get("role") != "assistant":
|
||||||
|
continue
|
||||||
|
tool_calls = message.get("tool_calls")
|
||||||
|
if not isinstance(tool_calls, list):
|
||||||
|
continue
|
||||||
|
for tool_call in tool_calls:
|
||||||
|
if not isinstance(tool_call, dict):
|
||||||
|
continue
|
||||||
|
function = tool_call.get("function")
|
||||||
|
if not isinstance(function, dict):
|
||||||
|
continue
|
||||||
|
if tool_call.get("id") == call_id and function.get("name") == tool_name:
|
||||||
|
return
|
||||||
|
raise AssertionError(f"Tool call {call_id!r} for {tool_name!r} was not present.")
|
||||||
|
|
||||||
|
|
||||||
|
def assert_message_content_present(
|
||||||
|
payload: ChatCompletionsRequestPayload, *, role: str, expected: str
|
||||||
|
) -> None:
|
||||||
|
assert any(
|
||||||
|
message.get("role") == role and expected in str(message.get("content", ""))
|
||||||
|
for message in _messages(payload)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_request_count_while_draining_child_output(
|
||||||
|
child: pexpect.spawn,
|
||||||
|
captured: io.StringIO,
|
||||||
|
request_count_getter: Callable[[], int],
|
||||||
|
*,
|
||||||
|
expected_count: int,
|
||||||
|
timeout: float,
|
||||||
|
) -> None:
|
||||||
|
start = time.monotonic()
|
||||||
|
while time.monotonic() - start < timeout:
|
||||||
|
if request_count_getter() >= expected_count:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
child.expect(r"\S", timeout=0.05)
|
||||||
|
except pexpect.TIMEOUT:
|
||||||
|
pass
|
||||||
|
rendered_tail = strip_ansi(captured.getvalue())[-1200:]
|
||||||
|
raise AssertionError(
|
||||||
|
f"Timed out waiting for {expected_count} backend request(s).\n\n"
|
||||||
|
f"Rendered tail:\n{rendered_tail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def answer_approval(
|
||||||
|
child: pexpect.spawn, captured: io.StringIO, *, tool_name: str, key: str
|
||||||
|
) -> None:
|
||||||
|
wait_for_rendered_text(
|
||||||
|
child, captured, needle=f"Permission for the {tool_name} tool", timeout=10
|
||||||
|
)
|
||||||
|
wait_for_rendered_text(child, captured, needle="Deny", timeout=10)
|
||||||
|
time.sleep(APPROVAL_INPUT_GRACE_PERIOD_S)
|
||||||
|
child.send(key)
|
||||||
|
child.send("\r")
|
||||||
|
|
||||||
|
|
||||||
|
def set_tool_denylist(vibe_home: Path, tool_name: str, patterns: list[str]) -> None:
|
||||||
|
config_path = vibe_home / "config.toml"
|
||||||
|
config = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
config.setdefault("tools", {}).setdefault(tool_name, {})["denylist"] = patterns
|
||||||
|
config_path.write_bytes(tomli_w.dumps(config).encode())
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue