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:
Clément Drouin 2026-06-19 11:01:24 +02:00 committed by GitHub
parent 564a14365e
commit 6bedf271ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
223 changed files with 10533 additions and 6947 deletions

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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"

View file

@ -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

View file

@ -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(

View file

@ -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"))

View 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)

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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!")

View file

@ -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

View file

@ -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:

View 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)
)

View file

@ -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

View 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

View 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)

View 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"

View 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()

View 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)

View 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
)

View 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)

View 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")

View 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

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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))

View file

@ -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

View 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"},
)
]

View file

@ -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",

View file

@ -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",

View file

@ -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):

View file

@ -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.

View file

@ -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

View file

@ -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:

View file

@ -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(

View file

@ -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"),
[

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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()

View file

@ -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"

View file

@ -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

View file

@ -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."

View file

@ -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:

View file

@ -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,
),

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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:

View file

@ -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
View 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"

View file

@ -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()

View file

@ -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

View file

@ -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,
)

View 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}

View file

@ -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 {},

View file

@ -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",
)

View 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

View file

@ -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:

View file

@ -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()

View file

@ -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:

View file

@ -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

View file

@ -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"

View file

@ -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,

View file

@ -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'",
]

View file

@ -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({})

View 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"})

View file

@ -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

View file

@ -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

View 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"

View file

@ -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={})

View file

@ -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,

View file

@ -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"

View 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 == ""

View file

@ -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:

View file

@ -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) == {

View file

@ -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",

View file

@ -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") == []

View file

@ -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()

View 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

View file

@ -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

View file

@ -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]

View 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