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/),
|
||||
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
|
||||
|
||||
### 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.
|
||||
|
||||
> Note: `default_agent` applies in both interactive and programmatic
|
||||
> (`-p` / `--prompt`) sessions. Pass `--auto-approve` when a run should
|
||||
> approve all tool calls without prompting.
|
||||
> (`-p` / `--prompt`) sessions. Pass `--auto-approve` or `--yolo` when
|
||||
> a run should approve all tool calls without prompting.
|
||||
|
||||
### 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).
|
||||
To approve all tool calls without prompting, pass `--auto-approve` (also
|
||||
available for interactive sessions):
|
||||
To approve all tool calls without prompting, pass `--auto-approve` or `--yolo`
|
||||
(also available for interactive sessions):
|
||||
|
||||
```bash
|
||||
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-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.
|
||||
- **`--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_.*$`).
|
||||
- **`--output FORMAT`**: Set the output format. Options:
|
||||
- `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.
|
||||
|
||||
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`:
|
||||
|
||||
```toml
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
id = "mistral-vibe"
|
||||
name = "Mistral Vibe"
|
||||
description = "Mistral's open-source coding assistant"
|
||||
version = "2.16.1"
|
||||
version = "2.17.0"
|
||||
schema_version = 1
|
||||
authors = ["Mistral AI"]
|
||||
repository = "https://github.com/mistralai/mistral-vibe"
|
||||
|
|
@ -11,21 +11,21 @@ name = "Mistral Vibe"
|
|||
icon = "./icons/mistral_vibe.svg"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.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"
|
||||
|
||||
[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"
|
||||
|
||||
[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"
|
||||
|
||||
[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"
|
||||
|
||||
[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"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "mistral-vibe"
|
||||
version = "2.16.1"
|
||||
version = "2.17.0"
|
||||
description = "Minimal CLI coding agent by Mistral"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
|
@ -38,7 +38,7 @@ dependencies = [
|
|||
"charset-normalizer==3.4.7",
|
||||
"click==8.3.3 ; sys_platform != 'emscripten'",
|
||||
"colorama==0.4.6 ; sys_platform == 'win32'",
|
||||
"cryptography==47.0.0",
|
||||
"cryptography==48.0.1",
|
||||
"eval-type-backport==0.3.1",
|
||||
"gitdb==4.0.12",
|
||||
"gitpython==3.1.47",
|
||||
|
|
@ -65,10 +65,10 @@ dependencies = [
|
|||
"linkify-it-py==2.1.0",
|
||||
"markdown-it-py==4.0.0",
|
||||
"markdownify==1.2.2",
|
||||
"mcp==1.27.1",
|
||||
"mcp==1.27.2",
|
||||
"mdit-py-plugins==0.5.0",
|
||||
"mdurl==0.1.2",
|
||||
"mistralai==2.4.4",
|
||||
"mistralai==2.4.9",
|
||||
"more-itertools==11.0.2",
|
||||
"opentelemetry-api==1.39.1",
|
||||
"opentelemetry-exporter-otlp-proto-common==1.39.1",
|
||||
|
|
@ -88,11 +88,11 @@ dependencies = [
|
|||
"pydantic-core==2.46.3",
|
||||
"pydantic-settings==2.14.0",
|
||||
"pygments==2.20.0",
|
||||
"pyjwt==2.12.1",
|
||||
"pyjwt==2.13.0",
|
||||
"pyperclip==1.11.0",
|
||||
"python-dateutil==2.9.0.post0",
|
||||
"python-dotenv==1.2.2",
|
||||
"python-multipart==0.0.27",
|
||||
"python-multipart==0.0.32",
|
||||
"pywin32==311 ; sys_platform == 'win32'",
|
||||
"pywin32-ctypes==0.2.3 ; sys_platform == 'win32'",
|
||||
"pyyaml==6.0.3",
|
||||
|
|
@ -116,7 +116,7 @@ dependencies = [
|
|||
"typing-extensions==4.15.0",
|
||||
"typing-inspection==0.4.2",
|
||||
"uc-micro-py==2.0.0",
|
||||
"urllib3==2.6.3",
|
||||
"urllib3==2.7.0",
|
||||
"uvicorn==0.46.0 ; sys_platform != 'emscripten'",
|
||||
"watchfiles==1.1.1",
|
||||
"websockets==16.0",
|
||||
|
|
@ -147,7 +147,7 @@ vibe-acp = "vibe.acp.entrypoint:main"
|
|||
|
||||
|
||||
[tool.uv]
|
||||
exclude-newer = "7 days"
|
||||
exclude-newer = "2 days"
|
||||
|
||||
package = true
|
||||
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.kind == "search"
|
||||
assert tool_call.params.update.status == "pending"
|
||||
assert tool_call.params.update.title == "Grepping 'auth'"
|
||||
assert (
|
||||
tool_call.params.update.raw_input
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import os
|
|||
from pathlib import Path
|
||||
|
||||
from dotenv import dotenv_values
|
||||
import keyring
|
||||
from keyring.errors import KeyringError
|
||||
import pytest
|
||||
|
||||
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 (
|
||||
DEFAULT_MISTRAL_API_ENV_KEY,
|
||||
ProviderConfig,
|
||||
|
|
@ -18,6 +20,15 @@ from vibe.setup.auth import AuthStateKind
|
|||
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(
|
||||
*, api_key_env_var: str = DEFAULT_MISTRAL_API_ENV_KEY
|
||||
) -> ProviderConfig:
|
||||
|
|
@ -127,7 +138,7 @@ class TestACPAuthStatus:
|
|||
}
|
||||
|
||||
@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
|
||||
) -> None:
|
||||
monkeypatch.setenv(DEFAULT_MISTRAL_API_ENV_KEY, "process-key")
|
||||
|
|
@ -138,7 +149,25 @@ class TestACPAuthStatus:
|
|||
|
||||
assert response == {
|
||||
"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,
|
||||
}
|
||||
|
||||
|
|
@ -213,23 +242,58 @@ class TestACPAuthSignOut:
|
|||
assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
|
||||
|
||||
@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
|
||||
) -> None:
|
||||
monkeypatch.setenv(DEFAULT_MISTRAL_API_ENV_KEY, "process-key")
|
||||
write_env_file(config_dir, f"{DEFAULT_MISTRAL_API_ENV_KEY}=file-key\n")
|
||||
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", {})
|
||||
|
||||
assert response == {}
|
||||
assert DEFAULT_MISTRAL_API_ENV_KEY not in dotenv_values(config_dir / ".env")
|
||||
assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
|
||||
assert await acp_agent_loop.ext_method("auth/status", {}) == {
|
||||
"authenticated": True,
|
||||
"authState": AuthStateKind.PROCESS_ENV.value,
|
||||
"signOutAvailable": False,
|
||||
}
|
||||
assert deleted == [DEFAULT_MISTRAL_API_ENV_KEY]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_surfaces_internal_error_when_keyring_delete_fails(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> 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
|
||||
async def test_refuses_unsupported_provider_key(
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ class TestCompactEventHandling:
|
|||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
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.
|
||||
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(
|
||||
load_session=True,
|
||||
prompt_capabilities=PromptCapabilities(
|
||||
audio=False, embedded_context=True, image=False
|
||||
audio=False, embedded_context=True, image=True
|
||||
),
|
||||
session_capabilities=SessionCapabilities(
|
||||
close=SessionCloseCapabilities(),
|
||||
|
|
@ -72,7 +72,7 @@ class TestACPInitialize:
|
|||
),
|
||||
)
|
||||
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
|
||||
|
|
@ -163,7 +163,7 @@ class TestACPInitialize:
|
|||
assert response.agent_capabilities == AgentCapabilities(
|
||||
load_session=True,
|
||||
prompt_capabilities=PromptCapabilities(
|
||||
audio=False, embedded_context=True, image=False
|
||||
audio=False, embedded_context=True, image=True
|
||||
),
|
||||
session_capabilities=SessionCapabilities(
|
||||
close=SessionCloseCapabilities(),
|
||||
|
|
@ -172,7 +172,7 @@ class TestACPInitialize:
|
|||
),
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from acp import RequestError
|
||||
from acp.schema import (
|
||||
AgentMessageChunk,
|
||||
AgentThoughtChunk,
|
||||
ClientCapabilities,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
UserMessageChunk,
|
||||
|
|
@ -17,7 +15,8 @@ import pytest
|
|||
from tests.conftest import build_test_vibe_config
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_client import FakeClient
|
||||
from vibe.acp.acp_agent_loop import 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.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import ModelConfig, SessionLoggingConfig
|
||||
|
|
@ -126,35 +125,42 @@ class TestLoadSession:
|
|||
assert response.config_options[2].current_value == "off"
|
||||
|
||||
@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,
|
||||
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||
temp_session_dir: Path,
|
||||
create_test_session,
|
||||
tmp_working_directory: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
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)
|
||||
acp_agent, _client = acp_agent_with_session_config
|
||||
(tmp_working_directory / "AGENTS.md").write_text(
|
||||
"Loaded session project instructions", encoding="utf-8"
|
||||
)
|
||||
session_id = "test-sess-trust"
|
||||
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
|
||||
)
|
||||
|
||||
request_trust.assert_awaited_once()
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is True
|
||||
assert response is not None
|
||||
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
|
||||
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
|
||||
async def test_load_session_registers_session_with_original_id(
|
||||
|
|
@ -223,6 +229,51 @@ class TestLoadSession:
|
|||
]
|
||||
assert len(user_updates) == 1
|
||||
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
|
||||
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)
|
||||
|
||||
|
|
@ -288,15 +344,58 @@ class TestLoadSession:
|
|||
u for u in client._session_updates if isinstance(u.update, ToolCallStart)
|
||||
]
|
||||
assert len(tool_call_starts) == 1
|
||||
assert tool_call_starts[0].update.title == "read"
|
||||
assert tool_call_starts[0].update.tool_call_id == "call_123"
|
||||
start = tool_call_starts[0].update
|
||||
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 = [
|
||||
u for u in client._session_updates if isinstance(u.update, ToolCallProgress)
|
||||
]
|
||||
assert len(tool_results) == 1
|
||||
assert tool_results[0].update.tool_call_id == "call_123"
|
||||
assert tool_results[0].update.status == "completed"
|
||||
result = tool_results[0].update
|
||||
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
|
||||
async def test_load_session_replays_reasoning_content(
|
||||
|
|
|
|||
|
|
@ -3,29 +3,32 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from acp import RequestError
|
||||
from acp.schema import ClientCapabilities
|
||||
from acp import NewSessionResponse
|
||||
import pytest
|
||||
|
||||
from tests.acp.conftest import _create_acp_agent
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.acp.acp_agent_loop import WORKSPACE_TRUST_CAPABILITY, VibeAcpAgentLoop
|
||||
from vibe.acp.exceptions import InvalidRequestError
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import ModelConfig
|
||||
from vibe.core.feedback import _CACHE_SECTION, _LAST_SHOWN_KEY, record_feedback_asked
|
||||
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]
|
||||
await session.agent_loop.wait_until_ready()
|
||||
return session.agent_loop.messages[0].content or ""
|
||||
|
||||
|
||||
def _enable_workspace_trust(acp_agent_loop: VibeAcpAgentLoop) -> None:
|
||||
acp_agent_loop.client_capabilities = ClientCapabilities(
|
||||
field_meta={WORKSPACE_TRUST_CAPABILITY: True}
|
||||
)
|
||||
def _workspace_trust_meta(session_response: NewSessionResponse) -> dict:
|
||||
payload = session_response.model_dump(mode="json", by_alias=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
|
||||
|
|
@ -153,7 +156,27 @@ class TestACPNewSession:
|
|||
assert len(thinking_config.options) == 5
|
||||
|
||||
@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,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_working_directory: Path,
|
||||
|
|
@ -162,7 +185,6 @@ class TestACPNewSession:
|
|||
(tmp_working_directory / "AGENTS.md").write_text(
|
||||
"Root project instructions", encoding="utf-8"
|
||||
)
|
||||
_enable_workspace_trust(acp_agent_loop)
|
||||
request_trust = AsyncMock(return_value={"decision": "trust_cwd"})
|
||||
monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust)
|
||||
|
||||
|
|
@ -171,26 +193,24 @@ class TestACPNewSession:
|
|||
)
|
||||
assert session_response.session_id is not None
|
||||
|
||||
request_trust.assert_awaited_once()
|
||||
await_args = request_trust.await_args
|
||||
assert await_args is not None
|
||||
method, params = await_args.args
|
||||
assert method == "trust/request"
|
||||
assert params["cwd"] == str(tmp_working_directory.resolve())
|
||||
assert params["detectedFiles"] == ["AGENTS.md"]
|
||||
assert params["repoDetectedFiles"] == []
|
||||
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(
|
||||
request_trust.assert_not_awaited()
|
||||
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 None
|
||||
assert "Root project instructions" not in await _system_prompt(
|
||||
acp_agent_loop, session_response.session_id
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_can_trust_full_repo_from_subdirectory(
|
||||
self,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
async def test_new_session_returns_repo_trust_details_from_subdirectory(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, tmp_path: Path
|
||||
) -> None:
|
||||
repo = tmp_path / "repo"
|
||||
cwd = repo / "src" / "pkg"
|
||||
|
|
@ -198,79 +218,68 @@ class TestACPNewSession:
|
|||
(repo / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
|
||||
cwd.mkdir(parents=True)
|
||||
(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(
|
||||
cwd=str(cwd), mcp_servers=[]
|
||||
)
|
||||
assert session_response.session_id is not None
|
||||
|
||||
request_trust.assert_awaited_once()
|
||||
await_args = request_trust.await_args
|
||||
assert await_args is not None
|
||||
method, params = await_args.args
|
||||
assert method == "trust/request"
|
||||
assert params["cwd"] == str(cwd.resolve())
|
||||
assert params["repoRoot"] == str(repo.resolve())
|
||||
assert params["detectedFiles"] == []
|
||||
assert params["repoDetectedFiles"] == ["AGENTS.md"]
|
||||
assert params["availableDecisions"] == [
|
||||
"trust_repo",
|
||||
"trust_cwd",
|
||||
"trust_session",
|
||||
"decline",
|
||||
]
|
||||
assert trusted_folders_manager.is_trusted(repo) is True
|
||||
assert trusted_folders_manager.is_trusted(cwd) is True
|
||||
assert "Repo instructions" in _system_prompt(
|
||||
assert _workspace_trust_meta(session_response) == {
|
||||
"status": "untrusted",
|
||||
"details": {
|
||||
"cwd": str(cwd.resolve()),
|
||||
"repoRoot": str(repo.resolve()),
|
||||
"ignoredFiles": ["AGENTS.md"],
|
||||
"availableDecisions": [
|
||||
"trust_repo",
|
||||
"trust_cwd",
|
||||
"trust_session",
|
||||
"decline",
|
||||
],
|
||||
},
|
||||
}
|
||||
assert trusted_folders_manager.is_trusted(repo) is None
|
||||
assert trusted_folders_manager.is_trusted(cwd) is None
|
||||
assert "Repo instructions" not in await _system_prompt(
|
||||
acp_agent_loop, session_response.session_id
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_decline_skips_project_docs(
|
||||
self,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_working_directory: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
async def test_new_session_returns_details_after_explicit_decline(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||
) -> None:
|
||||
(tmp_working_directory / "AGENTS.md").write_text(
|
||||
"Do not load this", encoding="utf-8"
|
||||
)
|
||||
_enable_workspace_trust(acp_agent_loop)
|
||||
monkeypatch.setattr(
|
||||
acp_agent_loop.client,
|
||||
"ext_method",
|
||||
AsyncMock(return_value={"decision": "decline"}),
|
||||
)
|
||||
trusted_folders_manager.add_untrusted(tmp_working_directory)
|
||||
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||
)
|
||||
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 "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
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_session_trust_loads_docs_without_persisting(
|
||||
self,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_working_directory: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||
) -> None:
|
||||
(tmp_working_directory / "AGENTS.md").write_text(
|
||||
"Session-only instructions", encoding="utf-8"
|
||||
)
|
||||
_enable_workspace_trust(acp_agent_loop)
|
||||
monkeypatch.setattr(
|
||||
acp_agent_loop.client,
|
||||
"ext_method",
|
||||
AsyncMock(return_value={"decision": "trust_session"}),
|
||||
)
|
||||
trusted_folders_manager.trust_for_session(tmp_working_directory)
|
||||
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||
|
|
@ -278,10 +287,14 @@ class TestACPNewSession:
|
|||
assert session_response.session_id is not None
|
||||
|
||||
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 normalized in trusted_folders_manager._session_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
|
||||
)
|
||||
|
||||
|
|
@ -292,90 +305,45 @@ class TestACPNewSession:
|
|||
tmp_working_directory: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> 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"})
|
||||
monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust)
|
||||
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||
)
|
||||
assert session_response.session_id is not None
|
||||
|
||||
request_trust.assert_not_awaited()
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
||||
assert "Unsupported client should skip this" not in _system_prompt(
|
||||
acp_agent_loop, session_response.session_id
|
||||
assert _workspace_trust_meta(session_response) == {
|
||||
"status": "untrusted",
|
||||
"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
|
||||
async def test_new_session_cancelled_trust_prompt_cancels_session_creation(
|
||||
self,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_working_directory: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
async def test_new_session_trusted_folder_loads_docs_with_no_details(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||
) -> None:
|
||||
(tmp_working_directory / "AGENTS.md").write_text(
|
||||
"Cancelled prompt", encoding="utf-8"
|
||||
"Trusted folder instructions", encoding="utf-8"
|
||||
)
|
||||
_enable_workspace_trust(acp_agent_loop)
|
||||
monkeypatch.setattr(
|
||||
acp_agent_loop.client,
|
||||
"ext_method",
|
||||
AsyncMock(return_value={"decision": "cancelled"}),
|
||||
trusted_folders_manager.add_trusted(tmp_working_directory)
|
||||
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||
)
|
||||
assert session_response.session_id is not None
|
||||
|
||||
with pytest.raises(InvalidRequestError):
|
||||
await acp_agent_loop.new_session(
|
||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||
)
|
||||
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
||||
assert acp_agent_loop.sessions == {}
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is True
|
||||
assert _workspace_trust_meta(session_response) == {
|
||||
"status": "trusted",
|
||||
"details": None,
|
||||
}
|
||||
assert "Trusted folder instructions" in await _system_prompt(
|
||||
acp_agent_loop, session_response.session_id
|
||||
)
|
||||
|
||||
@pytest.mark.skip(reason="TODO: Fix this test")
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -235,6 +235,7 @@ class TestACPSetModel:
|
|||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
await acp_session.agent_loop.wait_until_ready()
|
||||
|
||||
user_msg = LLMMessage(role=Role.user, content="Hello")
|
||||
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.kind == "read"
|
||||
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 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"))
|
||||
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_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:
|
||||
result = ReadResult(
|
||||
|
|
@ -128,6 +139,7 @@ class TestReadFieldMeta:
|
|||
num_lines=3,
|
||||
start_line=10,
|
||||
total_lines=20,
|
||||
requested_limit=50,
|
||||
)
|
||||
event = _result_event("read", Read, result)
|
||||
update = tool_result_session_update(event)
|
||||
|
|
@ -137,6 +149,77 @@ class TestReadFieldMeta:
|
|||
loc = update.locations[0]
|
||||
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:
|
||||
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 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 (
|
||||
TOOL_OPTIONS,
|
||||
ToolOption,
|
||||
build_permission_options,
|
||||
create_tool_call_replay,
|
||||
create_tool_result_replay,
|
||||
create_user_message_replay,
|
||||
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.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.types import (
|
||||
FunctionCall,
|
||||
LLMMessage,
|
||||
Role,
|
||||
ToolCall,
|
||||
UserDisplayContentMetadata,
|
||||
)
|
||||
|
||||
|
||||
def _write_env_file(content: str) -> None:
|
||||
|
|
@ -124,3 +142,126 @@ class TestBuildPermissionOptions:
|
|||
assert reject_once.name == "Deny"
|
||||
assert allow_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 vibe.core.agent_loop import ImagesNotSupportedError
|
||||
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
|
||||
|
||||
|
|
@ -68,7 +68,9 @@ def _config_with_both_models() -> VibeConfig:
|
|||
def png_attachment(tmp_path: Path) -> ImageAttachment:
|
||||
p = tmp_path / "x.png"
|
||||
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
|
||||
|
|
@ -18,6 +18,7 @@ from vibe.core.agent_loop import AgentLoop
|
|||
from vibe.core.config import MCPStdio
|
||||
from vibe.core.telemetry.types import TerminalEmulator
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
from vibe.core.tools.mcp import AuthStatus
|
||||
from vibe.core.tools.mcp.tools import RemoteTool
|
||||
|
||||
|
||||
|
|
@ -157,6 +158,23 @@ class TestIntegrateMcpIdempotency:
|
|||
# so a future call with servers would still run discovery.
|
||||
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:
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -24,7 +24,9 @@ class StubView(CompletionView):
|
|||
def clear_completion_suggestions(self) -> None:
|
||||
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))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,9 @@ class StubView(CompletionView):
|
|||
def clear_completion_suggestions(self) -> None:
|
||||
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))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,14 +3,13 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from textual.content import Content
|
||||
from textual.style import Style
|
||||
from textual.widgets import Markdown
|
||||
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import (
|
||||
CompletionPopup,
|
||||
_CompletionItem,
|
||||
_CompletionRow,
|
||||
)
|
||||
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
|
||||
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,
|
||||
) -> None:
|
||||
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")
|
||||
|
||||
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:
|
||||
selected_aliases: list[str] = []
|
||||
for item in popup.query(_CompletionItem):
|
||||
renderable = item.render()
|
||||
assert isinstance(renderable, Content)
|
||||
content = str(renderable)
|
||||
for span in renderable.spans:
|
||||
style = span.style
|
||||
if isinstance(style, Style) and style.reverse:
|
||||
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
|
||||
selected_rows = [
|
||||
row
|
||||
for row in popup.query(_CompletionRow)
|
||||
if row.has_class("completion-selected")
|
||||
]
|
||||
assert len(selected_rows) == 1
|
||||
command = selected_rows[0].query_one(".completion-command", _CompletionItem)
|
||||
assert str(command.render()).strip() == expected_alias
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -297,20 +290,6 @@ async def test_finds_files_recursively_with_partial_path(
|
|||
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
|
||||
async def test_does_not_trigger_completion_when_navigating_history(
|
||||
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 typing import Any
|
||||
|
||||
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]] = [
|
||||
(
|
||||
"https://api.mistral.ai",
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import json
|
|||
from typing import Any
|
||||
|
||||
from tests.backend.data import Chunk, JsonResponse, ResultData, Url
|
||||
|
||||
OPENAI_RESPONSES_TEST_BASE_URL = "https://api.openai.com"
|
||||
from tests.constants import OPENAI_BASE_URL
|
||||
|
||||
|
||||
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]] = [
|
||||
(
|
||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
||||
OPENAI_BASE_URL,
|
||||
{
|
||||
"id": "resp_fake_id_1234",
|
||||
"object": "response",
|
||||
|
|
@ -113,7 +237,7 @@ SIMPLE_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",
|
||||
"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]]] = [
|
||||
(
|
||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
||||
OPENAI_BASE_URL,
|
||||
[
|
||||
_sse_event({
|
||||
"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]] = [
|
||||
(
|
||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
||||
OPENAI_BASE_URL,
|
||||
{
|
||||
"id": "resp_thinking_1234",
|
||||
"object": "response",
|
||||
|
|
@ -268,7 +392,7 @@ STREAMED_COMMENTARY_CONVERSATION_PARAMS: list[
|
|||
tuple[Url, list[Chunk], list[ResultData]]
|
||||
] = [
|
||||
(
|
||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
||||
OPENAI_BASE_URL,
|
||||
[
|
||||
_sse_event({
|
||||
"type": "response.created",
|
||||
|
|
@ -419,7 +543,7 @@ STREAMED_COMMENTARY_CONVERSATION_PARAMS: list[
|
|||
|
||||
STREAMED_TOOL_CONVERSATION_PARAMS: list[tuple[Url, list[Chunk], list[ResultData]]] = [
|
||||
(
|
||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
||||
OPENAI_BASE_URL,
|
||||
[
|
||||
_sse_event({
|
||||
"type": "response.created",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import json
|
|||
|
||||
import pytest
|
||||
|
||||
from tests.constants import ANTHROPIC_BASE_URL, ANTHROPIC_MESSAGES_PATH
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.llm.backend.anthropic import AnthropicAdapter, AnthropicMapper
|
||||
from vibe.core.types import (
|
||||
|
|
@ -30,7 +31,7 @@ def adapter():
|
|||
def provider():
|
||||
return ProviderConfig(
|
||||
name="anthropic",
|
||||
api_base="https://api.anthropic.com",
|
||||
api_base=ANTHROPIC_BASE_URL,
|
||||
api_key_env_var="ANTHROPIC_API_KEY",
|
||||
api_style="anthropic",
|
||||
)
|
||||
|
|
@ -229,75 +230,6 @@ class TestMapperParseResponse:
|
|||
assert chunk.usage.completion_tokens == 7
|
||||
|
||||
|
||||
class TestMapperStreamingEvents:
|
||||
def test_text_delta(self, mapper):
|
||||
chunk, idx = mapper.parse_streaming_event(
|
||||
"content_block_delta",
|
||||
{"delta": {"type": "text_delta", "text": "hi"}, "index": 0},
|
||||
0,
|
||||
)
|
||||
assert chunk.message.content == "hi"
|
||||
|
||||
def test_thinking_delta(self, mapper):
|
||||
chunk, _ = mapper.parse_streaming_event(
|
||||
"content_block_delta",
|
||||
{"delta": {"type": "thinking_delta", "thinking": "hmm"}, "index": 0},
|
||||
0,
|
||||
)
|
||||
assert chunk.message.reasoning_content == "hmm"
|
||||
|
||||
def test_tool_use_start(self, mapper):
|
||||
chunk, idx = mapper.parse_streaming_event(
|
||||
"content_block_start",
|
||||
{
|
||||
"content_block": {"type": "tool_use", "id": "t1", "name": "search"},
|
||||
"index": 2,
|
||||
},
|
||||
0,
|
||||
)
|
||||
assert chunk.message.tool_calls[0].id == "t1"
|
||||
assert idx == 2
|
||||
|
||||
def test_input_json_delta(self, mapper):
|
||||
chunk, _ = mapper.parse_streaming_event(
|
||||
"content_block_delta",
|
||||
{
|
||||
"delta": {"type": "input_json_delta", "partial_json": '{"q":'},
|
||||
"index": 1,
|
||||
},
|
||||
0,
|
||||
)
|
||||
assert chunk.message.tool_calls[0].function.arguments == '{"q":'
|
||||
|
||||
def test_message_start_usage(self, mapper):
|
||||
chunk, _ = mapper.parse_streaming_event(
|
||||
"message_start",
|
||||
{"message": {"usage": {"input_tokens": 50, "cache_read_input_tokens": 10}}},
|
||||
0,
|
||||
)
|
||||
assert chunk.usage.prompt_tokens == 60
|
||||
|
||||
def test_message_delta_usage(self, mapper):
|
||||
chunk, _ = mapper.parse_streaming_event(
|
||||
"message_delta", {"usage": {"output_tokens": 42}}, 0
|
||||
)
|
||||
assert chunk.usage.completion_tokens == 42
|
||||
|
||||
def test_unknown_event(self, mapper):
|
||||
chunk, idx = mapper.parse_streaming_event("ping", {}, 5)
|
||||
assert chunk is None
|
||||
assert idx == 5
|
||||
|
||||
def test_signature_delta(self, mapper):
|
||||
chunk, _ = mapper.parse_streaming_event(
|
||||
"content_block_delta",
|
||||
{"delta": {"type": "signature_delta", "signature": "sig"}, "index": 0},
|
||||
0,
|
||||
)
|
||||
assert chunk is not None
|
||||
assert chunk.message.reasoning_signature == "sig"
|
||||
|
||||
|
||||
class TestAdapterPrepareRequest:
|
||||
def test_basic(self, adapter, provider):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
|
|
@ -316,7 +248,7 @@ class TestAdapterPrepareRequest:
|
|||
assert payload["model"] == "claude-sonnet-4-20250514"
|
||||
assert payload["max_tokens"] == 1024
|
||||
assert "temperature" not in payload
|
||||
assert req.endpoint == "/v1/messages"
|
||||
assert req.endpoint == ANTHROPIC_MESSAGES_PATH
|
||||
assert req.headers["anthropic-version"] == "2023-06-01"
|
||||
|
||||
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,
|
||||
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.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.mistral import MistralBackend, MistralMapper
|
||||
from vibe.core.llm.exceptions import BackendError, BackendErrorBuilder
|
||||
|
|
@ -71,7 +72,7 @@ class TestBackend:
|
|||
self, base_url: Url, json_response: JsonResponse, result_data: ResultData
|
||||
):
|
||||
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)
|
||||
)
|
||||
provider = ProviderConfig(
|
||||
|
|
@ -139,7 +140,7 @@ class TestBackend:
|
|||
self, base_url: Url, chunks: list[Chunk], result_data: list[ResultData]
|
||||
):
|
||||
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,
|
||||
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
|
||||
|
|
@ -291,7 +292,7 @@ class TestBackend:
|
|||
response: httpx.Response,
|
||||
):
|
||||
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(
|
||||
name="provider_name",
|
||||
api_base=f"{base_url}/v1",
|
||||
|
|
@ -335,7 +336,7 @@ class TestBackend:
|
|||
self, base_url: Url, provider_name: str, expected_stream_options: dict
|
||||
):
|
||||
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(
|
||||
status_code=200,
|
||||
stream=httpx.ByteStream(
|
||||
|
|
@ -399,7 +400,7 @@ class TestBackend:
|
|||
],
|
||||
}
|
||||
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)
|
||||
)
|
||||
|
||||
|
|
@ -441,7 +442,7 @@ class TestBackend:
|
|||
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
|
||||
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(
|
||||
name="provider_name",
|
||||
|
|
@ -470,13 +471,29 @@ class TestBackend:
|
|||
|
||||
class TestMistralRetry:
|
||||
@staticmethod
|
||||
def _create_test_backend() -> MistralBackend:
|
||||
def _create_test_backend(
|
||||
timeout: float = 720.0, retry_max_elapsed_time: float = 300.0
|
||||
) -> MistralBackend:
|
||||
provider = ProviderConfig(
|
||||
name="test_provider",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
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
|
||||
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 "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:
|
||||
"""Tests for MistralMapper.prepare_message thinking-block handling.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from vibe.core.llm.backend._image import (
|
|||
to_base64,
|
||||
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
|
||||
|
||||
|
|
@ -24,7 +24,9 @@ def _clear_cache() -> None:
|
|||
def _att(tmp_path: Path, name: str = "shot.png") -> ImageAttachment:
|
||||
p = tmp_path / name
|
||||
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:
|
||||
|
|
@ -46,13 +48,14 @@ def test_cache_invalidates_when_file_mtime_changes(tmp_path: Path) -> None:
|
|||
to_base64(att)
|
||||
assert _encode_cached.cache_info().misses == 1
|
||||
|
||||
assert isinstance(att.source, FileImageSource)
|
||||
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.
|
||||
import os
|
||||
|
||||
stat = att.path.stat()
|
||||
os.utime(att.path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000))
|
||||
stat = att.source.path.stat()
|
||||
os.utime(att.source.path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000))
|
||||
|
||||
refreshed = to_base64(att)
|
||||
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:
|
||||
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):
|
||||
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.openai_responses import OpenAIResponsesAdapter
|
||||
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
|
||||
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:
|
||||
path = tmp_path / "shot.png"
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ import respx
|
|||
from tests.backend.data import Chunk, JsonResponse, ResultData, Url
|
||||
from tests.backend.data.openai_responses import (
|
||||
COMMENTARY_CONVERSATION_PARAMS,
|
||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
||||
SIMPLE_CONVERSATION_PARAMS,
|
||||
STREAMED_COMMENTARY_CONVERSATION_PARAMS,
|
||||
STREAMED_SIMPLE_CONVERSATION_PARAMS,
|
||||
STREAMED_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.llm.backend.generic import GenericBackend
|
||||
from vibe.core.llm.backend.openai_responses import OpenAIResponsesAdapter
|
||||
|
|
@ -55,7 +55,7 @@ def 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(
|
||||
name="openai",
|
||||
api_base=f"{base_url}/v1",
|
||||
|
|
@ -68,7 +68,7 @@ def _make_model() -> ModelConfig:
|
|||
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))
|
||||
|
||||
|
||||
|
|
@ -1229,7 +1229,7 @@ class TestGenericBackendIntegration:
|
|||
self, base_url: Url, json_response: JsonResponse, result_data: ResultData
|
||||
):
|
||||
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)
|
||||
)
|
||||
backend = _make_backend(base_url)
|
||||
|
|
@ -1261,7 +1261,7 @@ class TestGenericBackendIntegration:
|
|||
self, base_url: Url, chunks: list[Chunk], result_data: list[ResultData]
|
||||
):
|
||||
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,
|
||||
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
|
||||
|
|
@ -1289,9 +1289,9 @@ class TestGenericBackendIntegration:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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:
|
||||
route = mock_api.post("/v1/responses").mock(
|
||||
route = mock_api.post(OPENAI_RESPONSES_PATH).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
("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")
|
||||
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:
|
||||
registry = CommandRegistry()
|
||||
result = registry.parse_command("/data-retention")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import tomllib
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from vibe.cli.textual_ui.widgets.feedback_bar_manager import FeedbackBarManager
|
||||
from vibe.core.cache_store import FileSystemVibeCodeCacheStore
|
||||
from vibe.core.feedback import (
|
||||
_CACHE_SECTION,
|
||||
_LAST_SHOWN_KEY,
|
||||
|
|
@ -15,24 +16,18 @@ from vibe.core.feedback import (
|
|||
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):
|
||||
return patch("vibe.core.feedback.FEEDBACK_PROBABILITY", value)
|
||||
|
||||
|
||||
def _make_agent_loop(
|
||||
cache_path: Path,
|
||||
user_message_count: int = MIN_USER_MESSAGES_FOR_FEEDBACK,
|
||||
telemetry_active: bool = True,
|
||||
) -> MagicMock:
|
||||
loop = MagicMock()
|
||||
loop.telemetry_client.is_active.return_value = telemetry_active
|
||||
loop.cache_store = FileSystemVibeCodeCacheStore(cache_path)
|
||||
messages = [
|
||||
LLMMessage(role=Role.user, content=f"msg {i}")
|
||||
for i in range(user_message_count)
|
||||
|
|
@ -45,20 +40,22 @@ class TestShouldShow:
|
|||
def test_shows_when_conditions_met(self, tmp_path: Path) -> None:
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
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:
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
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:
|
||||
(tmp_path / "cache.toml").write_text(
|
||||
|
|
@ -66,11 +63,12 @@ class TestShouldShow:
|
|||
)
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
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:
|
||||
(tmp_path / "cache.toml").write_text(
|
||||
|
|
@ -78,30 +76,38 @@ class TestShouldShow:
|
|||
)
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
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:
|
||||
manager = FeedbackBarManager()
|
||||
with _patch_cache_file(tmp_path), _patch_probability(0.2):
|
||||
with _patch_probability(0.2):
|
||||
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:
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
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:
|
||||
loop = _make_agent_loop(user_message_count=0)
|
||||
loop = _make_agent_loop(tmp_path / "cache.toml", user_message_count=0)
|
||||
loop.messages = [
|
||||
LLMMessage(role=Role.user, content="real"),
|
||||
LLMMessage(role=Role.user, content="injected", injected=True),
|
||||
|
|
@ -109,7 +115,6 @@ class TestShouldShow:
|
|||
]
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
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:
|
||||
manager = FeedbackBarManager()
|
||||
before = int(time.time())
|
||||
with _patch_cache_file(tmp_path):
|
||||
manager.record_feedback_asked()
|
||||
with (tmp_path / "cache.toml").open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
manager.record_feedback_asked(_make_agent_loop(tmp_path / "cache.toml"))
|
||||
with (tmp_path / "cache.toml").open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
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
|
||||
output = capsys.readouterr().out
|
||||
assert "--auto-approve" in output
|
||||
assert "--yolo" 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]
|
||||
) -> 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:
|
||||
parse_arguments()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -21,6 +22,7 @@ def _make_args(**overrides: object) -> argparse.Namespace:
|
|||
"enabled_tools": None,
|
||||
"output": "text",
|
||||
"agent": "default",
|
||||
"check_upgrade": False,
|
||||
"setup": False,
|
||||
"workdir": None,
|
||||
"add_dir": [],
|
||||
|
|
@ -134,16 +136,15 @@ def test_trust_flag_trusts_cwd_for_session_only(
|
|||
|
||||
args = _make_args(trust=True, prompt=None)
|
||||
monkeypatch.setattr(entrypoint_mod, "parse_arguments", lambda: args)
|
||||
monkeypatch.setattr(
|
||||
entrypoint_mod, "check_and_resolve_trusted_folder", lambda _cwd: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None
|
||||
)
|
||||
|
||||
# Stop main() before it runs the actual CLI.
|
||||
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) as exc_info:
|
||||
entrypoint_mod.main()
|
||||
|
|
@ -172,9 +173,11 @@ def test_trust_flag_works_in_programmatic_mode(
|
|||
monkeypatch.setattr(
|
||||
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):
|
||||
entrypoint_mod.main()
|
||||
|
|
@ -183,6 +186,78 @@ def test_trust_flag_works_in_programmatic_mode(
|
|||
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(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
|
@ -224,3 +299,60 @@ def test_run_cli_passes_max_tokens_to_run_programmatic(
|
|||
|
||||
assert exc_info.value.code == 0
|
||||
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
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -114,7 +113,7 @@ async def test_resume_picker_shows_renamed_session_title(
|
|||
|
||||
assert handled is True
|
||||
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
|
||||
|
|
@ -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 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)
|
||||
|
||||
await app.on_session_picker_app_session_delete_requested(
|
||||
SessionPickerApp.SessionDeleteRequested(
|
||||
"local:deleted-session", "local", "deleted-session"
|
||||
)
|
||||
SessionPickerApp.SessionDeleteRequested("deleted-session", "deleted-session")
|
||||
)
|
||||
|
||||
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(
|
||||
isinstance(widget, UserCommandMessage)
|
||||
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)
|
||||
|
||||
await app.on_session_picker_app_session_delete_requested(
|
||||
SessionPickerApp.SessionDeleteRequested(
|
||||
"local:deleted-session", "local", "deleted-session"
|
||||
)
|
||||
SessionPickerApp.SessionDeleteRequested("deleted-session", "deleted-session")
|
||||
)
|
||||
|
||||
assert picker.removed_option_ids == []
|
||||
assert picker.cleared_pending_option_ids == ["local:deleted-session"]
|
||||
assert picker.cleared_pending_option_ids == ["deleted-session"]
|
||||
assert any(
|
||||
isinstance(widget, ErrorMessage)
|
||||
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)
|
||||
|
||||
await app.on_session_picker_app_session_delete_requested(
|
||||
SessionPickerApp.SessionDeleteRequested(
|
||||
"local:deleted-session", "local", "deleted-session"
|
||||
)
|
||||
SessionPickerApp.SessionDeleteRequested("deleted-session", "deleted-session")
|
||||
)
|
||||
|
||||
assert switched_to_input is True
|
||||
assert picker.removed_option_ids == ["local:deleted-session"]
|
||||
assert picker.removed_option_ids == ["deleted-session"]
|
||||
assert [
|
||||
widget._content
|
||||
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
|
||||
async def test_session_delete_request_rejects_current_session(
|
||||
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(
|
||||
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 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(
|
||||
isinstance(widget, ErrorMessage)
|
||||
and widget._error == "Deleting the current session is not supported."
|
||||
|
|
|
|||
|
|
@ -8,9 +8,20 @@ from unittest.mock import patch
|
|||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.cli.cli import _maybe_run_startup_update_prompt
|
||||
from vibe.cli.update_notifier import FileSystemUpdateCacheRepository, UpdateCache
|
||||
from vibe.setup.update_prompt.update_prompt_dialog import UpdatePromptResult
|
||||
from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
|
||||
from vibe import __version__
|
||||
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:
|
||||
|
|
@ -129,6 +140,76 @@ def test_no_op_when_cache_read_raises_oserror() -> None:
|
|||
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(
|
||||
repository: FileSystemUpdateCacheRepository,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -8,10 +8,11 @@ import pytest
|
|||
|
||||
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.constants import OPENAI_BASE_URL
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
||||
from vibe.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:
|
||||
|
|
@ -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
|
||||
async def test_teleport_command_hidden_when_current_key_is_not_eligible() -> None:
|
||||
app = build_test_vibe_app(
|
||||
|
|
@ -215,7 +176,7 @@ async def test_teleport_command_hides_after_switching_to_non_mistral_model(
|
|||
),
|
||||
ProviderConfig(
|
||||
name="openai",
|
||||
api_base="https://api.openai.com/v1",
|
||||
api_base=f"{OPENAI_BASE_URL}/v1",
|
||||
api_key_env_var="OPENAI_API_KEY",
|
||||
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):
|
||||
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)
|
||||
|
||||
|
||||
|
|
@ -156,6 +161,32 @@ class TestRenderEditDiff:
|
|||
assert any(_plain(w).rstrip().endswith("d") for w in removed)
|
||||
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:
|
||||
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 with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
chat_input.value = "!sleep 0.3"
|
||||
chat_input.value = "!sleep 1"
|
||||
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"
|
||||
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]
|
||||
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
|
||||
async def test_slash_command_rejected_with_warning_when_busy(vibe_app: VibeApp) -> None:
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -182,3 +183,69 @@ class TestActionDeleteRightOrQuit:
|
|||
mock_confirm.assert_called_once_with(
|
||||
"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 [
|
||||
ResumeSessionInfo(
|
||||
session_id="session-a",
|
||||
source="local",
|
||||
cwd="/test",
|
||||
title="Session A",
|
||||
end_time=(datetime.now(UTC) - timedelta(minutes=5)).isoformat(),
|
||||
),
|
||||
ResumeSessionInfo(
|
||||
session_id="session-b",
|
||||
source="local",
|
||||
cwd="/test",
|
||||
title="Session B",
|
||||
end_time=(datetime.now(UTC) - timedelta(hours=1)).isoformat(),
|
||||
),
|
||||
ResumeSessionInfo(
|
||||
session_id="session-c",
|
||||
source="remote",
|
||||
cwd="/test",
|
||||
title="Session C",
|
||||
end_time=(datetime.now(UTC) - timedelta(days=1)).isoformat(),
|
||||
status="RUNNING",
|
||||
),
|
||||
]
|
||||
|
||||
|
|
@ -45,9 +41,9 @@ def sample_sessions() -> list[ResumeSessionInfo]:
|
|||
@pytest.fixture
|
||||
def sample_latest_messages() -> dict[str, str]:
|
||||
return {
|
||||
"local:session-a": "Help me fix this bug",
|
||||
"local:session-b": "Refactor the authentication module",
|
||||
"remote:session-c": "Add unit tests for the API",
|
||||
"session-a": "Help me fix this bug",
|
||||
"session-b": "Refactor the authentication module",
|
||||
"session-c": "Add unit tests for the API",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -143,31 +139,19 @@ class TestSessionPickerAppInit:
|
|||
|
||||
class TestSessionPickerMessages:
|
||||
def test_session_selected_stores_option_id(self) -> None:
|
||||
msg = SessionPickerApp.SessionSelected(
|
||||
"local:test-session-id", "local", "test-session-id"
|
||||
)
|
||||
assert msg.option_id == "local:test-session-id"
|
||||
assert msg.source == "local"
|
||||
msg = SessionPickerApp.SessionSelected("test-session-id", "test-session-id")
|
||||
assert msg.option_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:
|
||||
msg = SessionPickerApp.Cancelled()
|
||||
assert isinstance(msg, SessionPickerApp.Cancelled)
|
||||
|
||||
def test_session_delete_requested_stores_session_info(self) -> None:
|
||||
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.source == "local"
|
||||
assert msg.option_id == "test-session-id"
|
||||
assert msg.session_id == "test-session-id"
|
||||
|
||||
|
||||
|
|
@ -199,15 +183,15 @@ class TestSessionPickerSessionRemoval:
|
|||
picker = SessionPickerApp(
|
||||
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] = []
|
||||
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="confirmation", option_id="local:session-a")
|
||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
||||
assert_delete_state(picker, kind="confirmation", option_id="session-a")
|
||||
assert option_list.replaced_prompts[-1].option_id == "session-a"
|
||||
assert (
|
||||
"Press D again to delete" in option_list.replaced_prompts[-1].prompt.plain
|
||||
)
|
||||
|
|
@ -222,7 +206,7 @@ class TestSessionPickerSessionRemoval:
|
|||
picker = SessionPickerApp(
|
||||
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] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
|
@ -230,14 +214,13 @@ class TestSessionPickerSessionRemoval:
|
|||
picker.action_request_delete()
|
||||
picker.action_request_delete()
|
||||
|
||||
assert_delete_state(picker, kind="pending", option_id="local:session-a")
|
||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
||||
assert_delete_state(picker, kind="pending", option_id="session-a")
|
||||
assert option_list.replaced_prompts[-1].option_id == "session-a"
|
||||
assert "Deleting..." in option_list.replaced_prompts[-1].prompt.plain
|
||||
assert len(posted_messages) == 1
|
||||
message = posted_messages[0]
|
||||
assert isinstance(message, SessionPickerApp.SessionDeleteRequested)
|
||||
assert message.option_id == "local:session-a"
|
||||
assert message.source == "local"
|
||||
assert message.option_id == "session-a"
|
||||
assert message.session_id == "session-a"
|
||||
|
||||
def test_delete_confirmation_is_consumed_after_request(
|
||||
|
|
@ -249,7 +232,7 @@ class TestSessionPickerSessionRemoval:
|
|||
picker = SessionPickerApp(
|
||||
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] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
|
@ -259,34 +242,10 @@ class TestSessionPickerSessionRemoval:
|
|||
picker.action_request_delete()
|
||||
|
||||
assert len(posted_messages) == 1
|
||||
assert_delete_state(picker, kind="pending", option_id="local:session-a")
|
||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
||||
assert_delete_state(picker, kind="pending", option_id="session-a")
|
||||
assert option_list.replaced_prompts[-1].option_id == "session-a"
|
||||
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(
|
||||
self,
|
||||
sample_sessions: list[ResumeSessionInfo],
|
||||
|
|
@ -298,15 +257,15 @@ class TestSessionPickerSessionRemoval:
|
|||
latest_messages=sample_latest_messages,
|
||||
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] = []
|
||||
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="local:session-a")
|
||||
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
|
||||
assert_delete_state(picker, kind="feedback", option_id="session-a")
|
||||
assert option_list.replaced_prompts[-1].option_id == "session-a"
|
||||
assert (
|
||||
"Can't delete current session"
|
||||
in option_list.replaced_prompts[-1].prompt.plain
|
||||
|
|
@ -315,7 +274,7 @@ class TestSessionPickerSessionRemoval:
|
|||
|
||||
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 == []
|
||||
|
||||
def test_pending_delete_blocks_resume_selection(
|
||||
|
|
@ -327,7 +286,7 @@ class TestSessionPickerSessionRemoval:
|
|||
picker = SessionPickerApp(
|
||||
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] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
|
@ -335,15 +294,15 @@ class TestSessionPickerSessionRemoval:
|
|||
picker.action_request_delete()
|
||||
picker.action_request_delete()
|
||||
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(
|
||||
cast(OptionList.OptionSelected, FakeOptionEvent("local:session-b"))
|
||||
cast(OptionList.OptionSelected, FakeOptionEvent("session-b"))
|
||||
)
|
||||
|
||||
assert len(posted_messages) == 1
|
||||
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(
|
||||
self,
|
||||
|
|
@ -354,7 +313,7 @@ class TestSessionPickerSessionRemoval:
|
|||
picker = SessionPickerApp(
|
||||
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] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
|
@ -362,9 +321,9 @@ class TestSessionPickerSessionRemoval:
|
|||
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 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
|
||||
|
||||
def test_highlighting_another_session_clears_confirmation(
|
||||
|
|
@ -376,16 +335,16 @@ class TestSessionPickerSessionRemoval:
|
|||
picker = SessionPickerApp(
|
||||
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)
|
||||
picker.action_request_delete()
|
||||
|
||||
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 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
|
||||
|
||||
def test_highlighting_another_session_clears_delete_feedback(
|
||||
|
|
@ -395,18 +354,20 @@ class TestSessionPickerSessionRemoval:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
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)
|
||||
picker.action_request_delete()
|
||||
|
||||
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 option_list.replaced_prompts[-1].option_id == "remote:session-c"
|
||||
assert option_list.replaced_prompts[-1].option_id == "session-c"
|
||||
assert (
|
||||
"Add unit tests for the API"
|
||||
in option_list.replaced_prompts[-1].prompt.plain
|
||||
|
|
@ -421,7 +382,7 @@ class TestSessionPickerSessionRemoval:
|
|||
picker = SessionPickerApp(
|
||||
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] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
|
@ -444,9 +405,11 @@ class TestSessionPickerSessionRemoval:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
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] = []
|
||||
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
|
||||
monkeypatch.setattr(picker, "post_message", posted_messages.append)
|
||||
|
|
@ -471,20 +434,20 @@ class TestSessionPickerSessionRemoval:
|
|||
picker = SessionPickerApp(
|
||||
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)
|
||||
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] == [
|
||||
"local:session-b",
|
||||
"remote:session-c",
|
||||
"session-b",
|
||||
"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 option_list.removed_option_ids == ["local:session-a"]
|
||||
assert option_list.removed_option_ids == ["session-a"]
|
||||
|
||||
def test_remove_missing_session_returns_false(
|
||||
self,
|
||||
|
|
@ -498,7 +461,7 @@ class TestSessionPickerSessionRemoval:
|
|||
option_list = FakeOptionList()
|
||||
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._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.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:
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ from pathlib import Path
|
|||
import sys
|
||||
from typing import Any
|
||||
|
||||
import keyring
|
||||
from keyring.backend import KeyringBackend
|
||||
import keyring.errors
|
||||
import pytest
|
||||
import tomli_w
|
||||
|
||||
|
|
@ -34,6 +37,39 @@ from vibe.core.config.harness_files import (
|
|||
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]:
|
||||
return {
|
||||
"active_model": "devstral-latest",
|
||||
|
|
@ -143,6 +179,8 @@ def _scratchpad_dir(
|
|||
@pytest.fixture(autouse=True)
|
||||
def _mock_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "mock")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "mock")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "mock")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -262,6 +300,9 @@ def build_test_vibe_config(**kwargs) -> VibeConfig:
|
|||
)
|
||||
if kwargs.get("models"):
|
||||
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(
|
||||
session_logging=resolved_session_logging,
|
||||
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
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
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.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:
|
||||
|
|
@ -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_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
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
assert att.path == src.resolve()
|
||||
assert isinstance(att.source, FileImageSource)
|
||||
assert att.source.path == src.resolve()
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
from tests.constants import CHAT_COMPLETIONS_PATH
|
||||
from vibe.core.llm.exceptions import BackendError, PayloadSummary
|
||||
|
||||
|
||||
|
|
@ -24,7 +25,7 @@ def _make_error(
|
|||
) -> BackendError:
|
||||
return BackendError(
|
||||
provider="test-provider",
|
||||
endpoint="/v1/chat/completions",
|
||||
endpoint=CHAT_COMPLETIONS_PATH,
|
||||
status=status,
|
||||
reason="some reason",
|
||||
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.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:
|
||||
|
|
@ -13,7 +13,9 @@ def _msg(images: list[ImageAttachment] | None) -> LLMMessage:
|
|||
|
||||
def _image() -> 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:
|
||||
return LayerConfigSnapshot(data=dict(self._data), fingerprint="fp")
|
||||
|
||||
async def _save_to_store(self, _next_config: RawConfig) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class UntrustedFakeLayer(FakeLayer):
|
||||
async def _check_trust(self) -> bool:
|
||||
|
|
|
|||
|
|
@ -3,18 +3,24 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from jsonpointer import JsonPointerException
|
||||
from pydantic import BaseModel, ValidationError
|
||||
import pytest
|
||||
|
||||
from vibe.core.config.layer import (
|
||||
ConfigLayer,
|
||||
ConfigPatchApplicationError,
|
||||
LayerImplementationError,
|
||||
RawConfig,
|
||||
TrustNotResolvedError,
|
||||
UntrustedLayerError,
|
||||
)
|
||||
from vibe.core.config.patch import ConfigPatch
|
||||
from vibe.core.config.types import ConcurrencyConflictError, LayerConfigSnapshot
|
||||
from vibe.core.config.patch import AddOperationPatch, ConfigPatch, ReplaceOperationPatch
|
||||
from vibe.core.config.types import (
|
||||
ConcurrencyConflictError,
|
||||
ConflictStrategy,
|
||||
LayerConfigSnapshot,
|
||||
)
|
||||
|
||||
|
||||
class StubLayer(ConfigLayer[BaseModel]):
|
||||
|
|
@ -45,6 +51,9 @@ class StubLayer(ConfigLayer[BaseModel]):
|
|||
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):
|
||||
"""Stub that records _on_trust_changed calls."""
|
||||
|
|
@ -57,6 +66,18 @@ class ObservableStubLayer(StubLayer):
|
|||
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):
|
||||
name: str
|
||||
count: int = 0
|
||||
|
|
@ -91,6 +112,9 @@ async def test_default_check_trust_returns_false() -> None:
|
|||
async def _build_config_snapshot(self) -> LayerConfigSnapshot:
|
||||
return LayerConfigSnapshot(data={}, fingerprint="fp")
|
||||
|
||||
async def _save_to_store(self, _next_config: BaseModel) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
layer = DefaultTrustLayer(name="default")
|
||||
result = await layer.resolve_trust()
|
||||
assert result is False
|
||||
|
|
@ -236,7 +260,7 @@ async def test_load_returns_data() -> None:
|
|||
layer = StubLayer(data={"key": "value"})
|
||||
result = await layer.load()
|
||||
assert isinstance(result, RawConfig)
|
||||
assert result.model_extra == {"key": "value"}
|
||||
assert result.model_dump() == {"key": "value"}
|
||||
assert layer.fingerprint == "fp-1"
|
||||
|
||||
|
||||
|
|
@ -246,7 +270,7 @@ async def test_load_auto_resolves_trust() -> None:
|
|||
assert layer.is_trusted is None
|
||||
result = await layer.load()
|
||||
assert layer.is_trusted is True
|
||||
assert result.model_extra == {"a": 1}
|
||||
assert result.model_dump() == {"a": 1}
|
||||
assert layer.fingerprint == "fp-1"
|
||||
|
||||
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:
|
||||
layer = StubLayer(data={"v": 1})
|
||||
result1 = await layer.load()
|
||||
assert result1.model_extra == {"v": 1}
|
||||
assert result1.model_dump() == {"v": 1}
|
||||
|
||||
await layer.revoke_trust()
|
||||
layer._data = {"v": 2}
|
||||
await layer.grant_trust()
|
||||
|
||||
result2 = await layer.load()
|
||||
assert result2.model_extra == {"v": 2}
|
||||
assert result2.model_dump() == {"v": 2}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_trust_clears_data_on_revocation() -> None:
|
||||
layer = StubLayer(data={"v": 1})
|
||||
result1 = await layer.load()
|
||||
assert result1.model_extra == {"v": 1}
|
||||
assert result1.model_dump() == {"v": 1}
|
||||
|
||||
# External revocation via resolve_trust (not revoke_trust)
|
||||
layer._stub_trusted = False
|
||||
|
|
@ -339,7 +363,7 @@ async def test_resolve_trust_clears_data_on_revocation() -> None:
|
|||
await layer.resolve_trust()
|
||||
|
||||
result2 = await layer.load()
|
||||
assert result2.model_extra == {"v": 2}
|
||||
assert result2.model_dump() == {"v": 2}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -350,7 +374,7 @@ async def test_load_returns_deep_copy() -> None:
|
|||
result1.model_extra["items"].append("mutated")
|
||||
|
||||
result2 = await layer.load()
|
||||
assert result2.model_extra == {"items": ["a", "b"]}
|
||||
assert result2.model_dump() == {"items": ["a", "b"]}
|
||||
assert layer.read_count == 1
|
||||
|
||||
|
||||
|
|
@ -384,7 +408,7 @@ async def test_default_schema_preserves_extras() -> None:
|
|||
layer = StubLayer(data={"anything": "goes"})
|
||||
result = await layer.load()
|
||||
assert isinstance(result, RawConfig)
|
||||
assert result.model_extra == {"anything": "goes"}
|
||||
assert result.model_dump() == {"anything": "goes"}
|
||||
|
||||
|
||||
@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}"
|
||||
)
|
||||
|
||||
async def _save_to_store(self, _next_config: BaseModel) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
layer = SlowLayer()
|
||||
results = await asyncio.gather(layer.load(), layer.load(), layer.load())
|
||||
assert layer.read_count == 1
|
||||
|
|
@ -438,8 +465,155 @@ async def test_fingerprint_returns_none_before_load() -> None:
|
|||
@pytest.mark.asyncio
|
||||
async def test_apply_not_implemented() -> None:
|
||||
layer = StubLayer()
|
||||
await layer.load()
|
||||
fingerprint = layer.fingerprint
|
||||
assert isinstance(fingerprint, str)
|
||||
|
||||
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
|
||||
|
|
@ -499,6 +673,9 @@ class FakeLocalUserLayer(ConfigLayer[UserConfigSchema]):
|
|||
async def _build_config_snapshot(self) -> LayerConfigSnapshot:
|
||||
return LayerConfigSnapshot(data=dict(self._data), fingerprint="user-fp")
|
||||
|
||||
async def _save_to_store(self, _next_config: UserConfigSchema) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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:
|
||||
return LayerConfigSnapshot(data=dict(self._data), fingerprint="project-fp")
|
||||
|
||||
async def _save_to_store(self, _next_config: BaseModel) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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()
|
||||
assert trust_store == {"/tmp/my-project": True}
|
||||
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
|
||||
layer2 = FakeLocalProjectLayer(
|
||||
|
|
@ -570,7 +750,7 @@ async def test_scenario_local_project_layer_trust_lifecycle() -> None:
|
|||
assert layer2.is_trusted is None
|
||||
result2 = await layer2.load()
|
||||
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
|
||||
await layer2.revoke_trust()
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ def test_skips_missing_file(tmp_path: Path) -> None:
|
|||
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"
|
||||
_write_env_file(
|
||||
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)
|
||||
|
||||
assert environ["MISTRAL_API_KEY"] == "new-key"
|
||||
assert environ["HTTPS_PROXY"] == "https://local-proxy:8080"
|
||||
assert environ["OTHER"] == "from-env"
|
||||
# An explicit process/shell value wins over the .env file.
|
||||
assert environ["MISTRAL_API_KEY"] == "old-key"
|
||||
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["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:
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from pydantic import ValidationError
|
||||
import pytest
|
||||
|
||||
from vibe.core.config import MCPHttp, MCPOAuth, MCPStaticAuth, MCPStreamableHttp
|
||||
from vibe.core.tools.mcp import AuthStatus
|
||||
from vibe.core.tools.mcp.registry import MCPRegistry
|
||||
|
||||
HTTP_TRANSPORTS = [
|
||||
|
|
@ -183,10 +184,8 @@ def test_oauth_scopes_empty_list_allowed() -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("cls", "transport"), HTTP_TRANSPORTS)
|
||||
async def test_registry_skips_oauth_servers_with_gated_warning(
|
||||
cls: type[MCPHttp | MCPStreamableHttp],
|
||||
transport: str,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
async def test_registry_marks_oauth_servers_without_tokens_as_needing_auth(
|
||||
cls: type[MCPHttp | MCPStreamableHttp], transport: str
|
||||
) -> None:
|
||||
srv = cls.model_validate({
|
||||
"name": "linear",
|
||||
|
|
@ -195,19 +194,23 @@ async def test_registry_skips_oauth_servers_with_gated_warning(
|
|||
"auth": {"type": "oauth", "scopes": ["read"]},
|
||||
})
|
||||
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])
|
||||
|
||||
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])
|
||||
|
||||
assert first == {}
|
||||
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:
|
||||
return LayerConfigSnapshot(data=dict(self._data), fingerprint="fp")
|
||||
|
||||
async def _save_to_store(self, _next_config: RawConfig) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class SimpleSchema(ConfigSchema):
|
||||
value: Annotated[str, WithReplaceMerge()] = "default"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import pytest
|
||||
|
||||
from tests.constants import ANTHROPIC_BASE_URL
|
||||
from vibe.core.config import OtelSpanExporterConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
|
|
@ -62,7 +63,7 @@ class TestOtelSpanExporterConfig:
|
|||
update={
|
||||
"providers": [
|
||||
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.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(
|
||||
self,
|
||||
vibe_config: VibeConfig,
|
||||
|
|
|
|||
|
|
@ -1,135 +1,84 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import FrozenInstanceError
|
||||
from typing import Any, get_args
|
||||
|
||||
from pydantic import ValidationError
|
||||
import pytest
|
||||
|
||||
from vibe.core.config import (
|
||||
AppendToList,
|
||||
DeleteField,
|
||||
PatchOp,
|
||||
RemoveFromList,
|
||||
SetField,
|
||||
AddOperationPatch,
|
||||
RemoveOperationPatch,
|
||||
ReplaceOperationPatch,
|
||||
)
|
||||
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:
|
||||
assert get_args(PatchOp) == (SetField, AppendToList, RemoveFromList, DeleteField)
|
||||
|
||||
|
||||
def test_set_field_accepts_top_level_key() -> None:
|
||||
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"
|
||||
assert set(get_args(PatchOp.__value__)) == {
|
||||
AddOperationPatch,
|
||||
ReplaceOperationPatch,
|
||||
RemoveOperationPatch,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"factory",
|
||||
[
|
||||
lambda key: SetField(key, "value"),
|
||||
lambda key: AppendToList(key, ("value",)),
|
||||
lambda key: RemoveFromList(key, ("value",)),
|
||||
lambda key: DeleteField(key),
|
||||
lambda path: AddOperationPatch(path=path, value="value"),
|
||||
lambda path: ReplaceOperationPatch(path=path, value="value"),
|
||||
lambda path: RemoveOperationPatch(path=path),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_key", ["", ".active_model", "active_model.", "tools..bash"]
|
||||
)
|
||||
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)
|
||||
def test_json_patch_operations_reject_non_pointer_paths(factory: Any) -> None:
|
||||
with pytest.raises(ValidationError, match="valid JSON Pointer"):
|
||||
factory("tools.disabled_tools")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"factory",
|
||||
[
|
||||
lambda key: SetField(key, "value"),
|
||||
lambda key: AppendToList(key, ("value",)),
|
||||
lambda key: RemoveFromList(key, ("value",)),
|
||||
lambda key: DeleteField(key),
|
||||
lambda path: AddOperationPatch(path=path, value="value"),
|
||||
lambda path: ReplaceOperationPatch(path=path, value="value"),
|
||||
lambda path: RemoveOperationPatch(path=path),
|
||||
],
|
||||
)
|
||||
def test_patch_operations_reject_non_string_keys(
|
||||
factory: Callable[[Any], object],
|
||||
) -> None:
|
||||
with pytest.raises(TypeError, match="Patch operation key must be a string"):
|
||||
factory(1)
|
||||
def test_json_patch_operations_reject_invalid_escapes(factory: Any) -> None:
|
||||
with pytest.raises(ValidationError, match="valid JSON Pointer"):
|
||||
factory("/tools/~2")
|
||||
|
||||
|
||||
def test_append_to_list_rejects_non_tuple_items() -> None:
|
||||
bad_items: Any = ["bash"]
|
||||
def test_json_patch_operations_accept_slash_prefixed_paths() -> None:
|
||||
op = ReplaceOperationPatch(path="/", value={"active_model": "devstral-small"})
|
||||
|
||||
with pytest.raises(TypeError, match="AppendToList.items must be a tuple"):
|
||||
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 ---
|
||||
assert op.path == "/"
|
||||
|
||||
|
||||
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")
|
||||
|
||||
assert patch.operations == [op]
|
||||
|
|
@ -145,9 +94,9 @@ def test_config_patch_defaults() -> None:
|
|||
|
||||
|
||||
def test_config_patch_accepts_multiple_operations() -> None:
|
||||
ops: list[PatchOp] = [
|
||||
SetField("active_model", "devstral-small"),
|
||||
AppendToList("tools.disabled_tools", ("bash",)),
|
||||
ops = [
|
||||
ReplaceOperationPatch(path="/active_model", value="devstral-small"),
|
||||
AddOperationPatch(path="/tools/disabled_tools/-", value="bash"),
|
||||
]
|
||||
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:
|
||||
patch = ConfigPatch(SetField("active_model", "devstral-small"), fingerprint="fp-1")
|
||||
patch.add(DeleteField("tools.deprecated_setting"))
|
||||
patch = ConfigPatch(
|
||||
ReplaceOperationPatch(path="/active_model", value="devstral-small"),
|
||||
fingerprint="fp-1",
|
||||
)
|
||||
patch.add(RemoveOperationPatch(path="/tools/deprecated_setting"))
|
||||
|
||||
assert patch.operations == [
|
||||
SetField("active_model", "devstral-small"),
|
||||
DeleteField("tools.deprecated_setting"),
|
||||
ReplaceOperationPatch(path="/active_model", value="devstral-small"),
|
||||
RemoveOperationPatch(path="/tools/deprecated_setting"),
|
||||
]
|
||||
|
||||
|
||||
def test_config_patch_add_returns_self() -> None:
|
||||
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
|
||||
|
||||
|
|
@ -174,8 +128,8 @@ def test_config_patch_add_returns_self() -> None:
|
|||
def test_config_patch_add_accepts_multiple_operations() -> None:
|
||||
patch = ConfigPatch(fingerprint="fp-1")
|
||||
patch.add(
|
||||
SetField("active_model", "devstral-small"),
|
||||
AppendToList("tools.disabled_tools", ("bash",)),
|
||||
ReplaceOperationPatch(path="/active_model", value="devstral-small"),
|
||||
AddOperationPatch(path="/tools/disabled_tools/-", value="bash"),
|
||||
)
|
||||
|
||||
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:
|
||||
patch = (
|
||||
ConfigPatch(fingerprint="fp-1")
|
||||
.add(SetField("active_model", "devstral-small"))
|
||||
.add(DeleteField("tools.deprecated_setting"))
|
||||
.add(ReplaceOperationPatch(path="/active_model", value="devstral-small"))
|
||||
.add(RemoveOperationPatch(path="/tools/deprecated_setting"))
|
||||
)
|
||||
|
||||
assert len(patch.operations) == 2
|
||||
|
||||
|
||||
def test_config_patch_describe_set_field() -> 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:
|
||||
def test_config_patch_to_json_patch_from_wrappers() -> None:
|
||||
patch = ConfigPatch(
|
||||
AppendToList("tools.disabled_tools", ("bash", "python")), fingerprint="fp-1"
|
||||
)
|
||||
|
||||
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",)),
|
||||
ReplaceOperationPatch(path="/active_model", value="devstral-small"),
|
||||
AddOperationPatch(path="/tools/disabled_tools/-", value="bash"),
|
||||
RemoveOperationPatch(path="/tools/deprecated_setting"),
|
||||
fingerprint="fp-1",
|
||||
)
|
||||
|
||||
assert patch.describe() == [
|
||||
"remove from 'models.available_models': ['codestral-latest']"
|
||||
assert patch.to_json_patch() == [
|
||||
{"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:
|
||||
patch = ConfigPatch(DeleteField("tools.deprecated_setting"), fingerprint="fp-1")
|
||||
def test_config_patch_describe_add_operation() -> None:
|
||||
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:
|
||||
|
|
@ -230,28 +194,28 @@ def test_config_patch_describe_empty_returns_empty_list() -> None:
|
|||
|
||||
def test_config_patch_describe_multiple_operations() -> None:
|
||||
patch = ConfigPatch(
|
||||
SetField("active_model", "devstral-small"),
|
||||
AppendToList("tools.disabled_tools", ("bash",)),
|
||||
DeleteField("tools.deprecated_setting"),
|
||||
ReplaceOperationPatch(path="/active_model", value="devstral-small"),
|
||||
AddOperationPatch(path="/tools/disabled_tools/-", value="bash"),
|
||||
RemoveOperationPatch(path="/tools/deprecated_setting"),
|
||||
fingerprint="fp-1",
|
||||
)
|
||||
|
||||
assert patch.describe() == [
|
||||
"set 'active_model' = 'devstral-small'",
|
||||
"append to 'tools.disabled_tools': ['bash']",
|
||||
"delete 'tools.deprecated_setting'",
|
||||
"replace '/active_model' = 'devstral-small'",
|
||||
"add '/tools/disabled_tools/-' = 'bash'",
|
||||
"remove '/tools/deprecated_setting'",
|
||||
]
|
||||
|
||||
|
||||
def test_scenario_build_patch_incrementally() -> None:
|
||||
patch = ConfigPatch(fingerprint="fp-abc", reason="/model command")
|
||||
patch.add(SetField("active_model", "devstral-small"))
|
||||
patch.add(AppendToList("tools.disabled_tools", ("bash",)))
|
||||
patch.add(ReplaceOperationPatch(path="/active_model", value="devstral-small"))
|
||||
patch.add(AddOperationPatch(path="/tools/disabled_tools/-", value="bash"))
|
||||
|
||||
assert patch.fingerprint == "fp-abc"
|
||||
assert patch.reason == "/model command"
|
||||
assert len(patch.operations) == 2
|
||||
assert patch.describe() == [
|
||||
"set 'active_model' = 'devstral-small'",
|
||||
"append to 'tools.disabled_tools': ['bash']",
|
||||
"replace '/active_model' = 'devstral-small'",
|
||||
"add '/tools/disabled_tools/-' = 'bash'",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ from pathlib import Path
|
|||
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -67,6 +71,32 @@ class TestCaptureStableFile:
|
|||
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:
|
||||
def test_empty_dict_returns_stable_non_empty_token(self) -> None:
|
||||
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
|
||||
|
||||
from vibe.core.types import ImageAttachment, LLMMessage, Role
|
||||
from vibe.core.types import (
|
||||
FileImageSource,
|
||||
ImageAttachment,
|
||||
LLMMessage,
|
||||
Role,
|
||||
UserDisplayContentMetadata,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def image_a(tmp_path: Path) -> ImageAttachment:
|
||||
p = tmp_path / "a.png"
|
||||
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()
|
||||
def image_b(tmp_path: Path) -> ImageAttachment:
|
||||
p = tmp_path / "b.png"
|
||||
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:
|
||||
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(
|
||||
image_a: ImageAttachment, image_b: ImageAttachment
|
||||
) -> 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:
|
||||
merged = _msg("hi") + _msg(" there")
|
||||
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 contextlib import suppress
|
||||
import socket
|
||||
from types import TracebackType
|
||||
from unittest.mock import patch
|
||||
import urllib.parse
|
||||
|
||||
import httpx
|
||||
|
|
@ -11,6 +13,7 @@ import keyring
|
|||
from keyring.backend import KeyringBackend
|
||||
import keyring.backends.fail
|
||||
import keyring.errors
|
||||
from mcp.client.auth import OAuthFlowError
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
import pytest
|
||||
import respx
|
||||
|
|
@ -21,6 +24,7 @@ from vibe.core.auth.mcp_oauth import (
|
|||
LoopbackCallbackHandler,
|
||||
MCPOAuthError,
|
||||
MCPOAuthHeadlessError,
|
||||
MCPOAuthLoginFailed,
|
||||
MCPOAuthPortInUse,
|
||||
build_oauth_provider,
|
||||
perform_oauth_login,
|
||||
|
|
@ -366,6 +370,26 @@ class TestBuildOAuthProvider:
|
|||
)
|
||||
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
|
||||
async def test_rejects_static_auth(self, memory_keyring: _MemoryKeyring) -> None:
|
||||
from vibe.core.config import MCPStaticAuth
|
||||
|
|
@ -388,6 +412,39 @@ class TestBuildOAuthProvider:
|
|||
|
||||
|
||||
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
|
||||
async def test_full_flow_persists_tokens_and_fingerprint(
|
||||
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
|
||||
|
||||
from vibe.core.config.layers.overrides import OverridesLayer
|
||||
from vibe.core.config.patch import ConfigPatch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -20,13 +19,6 @@ async def test_always_trusted() -> None:
|
|||
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
|
||||
async def test_default_name() -> None:
|
||||
layer = OverridesLayer(data={})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import pytest
|
|||
|
||||
from vibe.core.config.layer import UntrustedLayerError
|
||||
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.paths._vibe_home import GlobalPath
|
||||
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"
|
||||
|
||||
|
||||
@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
|
||||
async def test_trust_uses_path_parent_for_resolution(
|
||||
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,
|
||||
}
|
||||
|
||||
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(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import json
|
|||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.constants import TELEPORT_SESSIONS_PATH
|
||||
from vibe.core.teleport.errors import ServiceTeleportError
|
||||
from vibe.core.teleport.nuage import (
|
||||
NuageClient,
|
||||
|
|
@ -52,7 +53,7 @@ async def test_start_posts_nuage_request() -> None:
|
|||
response = await nuage.start(_request())
|
||||
|
||||
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["content-type"] == "application/json"
|
||||
assert json.loads(seen_request.content) == {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import pytest
|
|||
import zstandard
|
||||
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from tests.constants import TELEPORT_COMPLETE_URL, TELEPORT_SESSIONS_PATH
|
||||
from vibe.core.teleport.errors import (
|
||||
ServiceTeleportError,
|
||||
ServiceTeleportNotSupportedError,
|
||||
|
|
@ -59,7 +60,7 @@ def _mock_handler() -> Any:
|
|||
"webSessionId": "web-session-id",
|
||||
"projectId": "project-id",
|
||||
"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",
|
||||
"projectId": "project-id",
|
||||
"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[1], TeleportStartingWorkflowEvent)
|
||||
assert isinstance(events[2], TeleportCompleteEvent)
|
||||
assert (
|
||||
events[2].url == "https://chat.example.com/code/project-id/web-session-id"
|
||||
)
|
||||
assert seen_url == "https://chat.example.com/api/v1/code/sessions"
|
||||
assert events[2].url == TELEPORT_COMPLETE_URL
|
||||
assert seen_url == f"https://chat.example.com{TELEPORT_SESSIONS_PATH}"
|
||||
assert seen_body is not None
|
||||
assert seen_body["message"] == {
|
||||
"role": "user",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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:
|
||||
|
|
@ -27,3 +27,26 @@ class TestSnippetStartLine:
|
|||
|
||||
def test_blank_snippet(self) -> 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
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from uuid import uuid4
|
||||
|
||||
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.patch import ConfigPatch
|
||||
from vibe.core.config.patch import (
|
||||
AddOperationPatch,
|
||||
ConfigPatch,
|
||||
RemoveOperationPatch,
|
||||
ReplaceOperationPatch,
|
||||
)
|
||||
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
|
||||
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')
|
||||
|
||||
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
|
||||
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')
|
||||
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
|
|
@ -37,7 +50,7 @@ async def test_always_trusted(tmp_working_directory: Path) -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None:
|
||||
path = tmp_working_directory / "nonexistent.toml"
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
data = await layer.load()
|
||||
assert data.model_extra == {}
|
||||
|
|
@ -45,16 +58,241 @@ async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_raises_not_implemented(tmp_working_directory: Path) -> None:
|
||||
path = tmp_working_directory / "config.toml"
|
||||
async def test_apply_raises_when_file_does_not_exist(
|
||||
tmp_working_directory: Path,
|
||||
) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
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
|
||||
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("""\
|
||||
[models]
|
||||
active_model = "test"
|
||||
|
|
@ -72,7 +310,7 @@ provider = "p"
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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 [[[")
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
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
|
||||
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')
|
||||
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
|
||||
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("")
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
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
|
||||
api_timeout = 300.0
|
||||
api_retry_max_elapsed_time = 120.0
|
||||
active_model = "codestral"
|
||||
disabled_tools = ["bash"]
|
||||
default_agent = "plan"
|
||||
|
|
@ -54,6 +55,7 @@ provider = "mistral"
|
|||
|
||||
assert config.vim_keybindings is True
|
||||
assert config.api_timeout == 300.0
|
||||
assert config.api_retry_max_elapsed_time == 120.0
|
||||
assert config.active_model == "codestral"
|
||||
assert config.models[0].alias == "codestral"
|
||||
assert "bash" in config.disabled_tools
|
||||
|
|
|
|||
|
|
@ -230,21 +230,21 @@ def test_get_result_display() -> None:
|
|||
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._ui_start_line = 42
|
||||
result._ui_start_lines = [42]
|
||||
|
||||
assert result.ui_start_line == 42
|
||||
assert "ui_start_line" not in result.model_dump()
|
||||
assert "_ui_start_line" not in result.model_dump()
|
||||
assert "ui_start_line" not in result.model_dump_json()
|
||||
assert "ui_start_line" not in EditResult.model_fields
|
||||
assert "ui_start_line" not in EditResult.model_json_schema().get("properties", {})
|
||||
assert "ui_start_line" not in dict(result)
|
||||
assert result.ui_start_lines == [42]
|
||||
assert "ui_start_lines" not in result.model_dump()
|
||||
assert "_ui_start_lines" not in result.model_dump()
|
||||
assert "ui_start_lines" not in result.model_dump_json()
|
||||
assert "ui_start_lines" not in EditResult.model_fields
|
||||
assert "ui_start_lines" not in EditResult.model_json_schema().get("properties", {})
|
||||
assert "ui_start_lines" not in dict(result)
|
||||
|
||||
|
||||
@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
|
||||
) -> None:
|
||||
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"))
|
||||
)
|
||||
|
||||
assert result.ui_start_line == 3
|
||||
assert result.ui_start_lines == [3]
|
||||
|
||||
|
||||
@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
|
||||
) -> None:
|
||||
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=""))
|
||||
)
|
||||
|
||||
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