v2.17.0 (#822)
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Hdandria <henri.dandria@mistral.ai> Co-authored-by: Ivana Dunisijevic <ivana.dunisijevic@mistral.ai> Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Mert Unsal <mert.unsal@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
564a14365e
commit
6bedf271ce
223 changed files with 10533 additions and 6947 deletions
|
|
@ -553,6 +553,7 @@ class TestSessionUpdates:
|
|||
|
||||
assert tool_call.params.update.session_update == "tool_call"
|
||||
assert tool_call.params.update.kind == "search"
|
||||
assert tool_call.params.update.status == "pending"
|
||||
assert tool_call.params.update.title == "Grepping 'auth'"
|
||||
assert (
|
||||
tool_call.params.update.raw_input
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import os
|
|||
from pathlib import Path
|
||||
|
||||
from dotenv import dotenv_values
|
||||
import keyring
|
||||
from keyring.errors import KeyringError
|
||||
import pytest
|
||||
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.acp.exceptions import InvalidRequestError
|
||||
from vibe.acp.exceptions import InternalError, InvalidRequestError
|
||||
from vibe.core.config import (
|
||||
DEFAULT_MISTRAL_API_ENV_KEY,
|
||||
ProviderConfig,
|
||||
|
|
@ -18,6 +20,15 @@ from vibe.setup.auth import AuthStateKind
|
|||
from vibe.setup.onboarding.context import OnboardingContext
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_keyring(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
monkeypatch.setattr(
|
||||
keyring, "set_password", lambda service, username, password: None
|
||||
)
|
||||
monkeypatch.setattr(keyring, "delete_password", lambda service, username: None)
|
||||
|
||||
|
||||
def build_mistral_provider(
|
||||
*, api_key_env_var: str = DEFAULT_MISTRAL_API_ENV_KEY
|
||||
) -> ProviderConfig:
|
||||
|
|
@ -127,7 +138,7 @@ class TestACPAuthStatus:
|
|||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_combined_source_when_process_env_existed_before_dotenv(
|
||||
async def test_returns_process_env_when_process_env_existed_before_dotenv(
|
||||
self, config_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv(DEFAULT_MISTRAL_API_ENV_KEY, "process-key")
|
||||
|
|
@ -138,7 +149,25 @@ class TestACPAuthStatus:
|
|||
|
||||
assert response == {
|
||||
"authenticated": True,
|
||||
"authState": AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV.value,
|
||||
"authState": AuthStateKind.PROCESS_ENV.value,
|
||||
"signOutAvailable": False,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_keyring_when_key_only_exists_in_keyring(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv(DEFAULT_MISTRAL_API_ENV_KEY, raising=False)
|
||||
monkeypatch.setattr(
|
||||
keyring, "get_password", lambda service, username: "keyring-key"
|
||||
)
|
||||
acp_agent_loop = build_acp_agent_loop(build_mistral_provider())
|
||||
|
||||
response = await acp_agent_loop.ext_method("auth/status", {})
|
||||
|
||||
assert response == {
|
||||
"authenticated": True,
|
||||
"authState": AuthStateKind.OS_KEYRING.value,
|
||||
"signOutAvailable": True,
|
||||
}
|
||||
|
||||
|
|
@ -213,23 +242,58 @@ class TestACPAuthSignOut:
|
|||
assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removes_dotenv_key_and_restores_process_env_key(
|
||||
async def test_refuses_dotenv_key_when_process_env_key_exists(
|
||||
self, config_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv(DEFAULT_MISTRAL_API_ENV_KEY, "process-key")
|
||||
write_env_file(config_dir, f"{DEFAULT_MISTRAL_API_ENV_KEY}=file-key\n")
|
||||
acp_agent_loop = build_acp_agent_loop(build_mistral_provider())
|
||||
|
||||
with pytest.raises(InvalidRequestError, match=AuthStateKind.PROCESS_ENV.value):
|
||||
await acp_agent_loop.ext_method("auth/signOut", {})
|
||||
|
||||
assert (
|
||||
dotenv_values(config_dir / ".env")[DEFAULT_MISTRAL_API_ENV_KEY]
|
||||
== "file-key"
|
||||
)
|
||||
assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removes_keyring_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
deleted: list[str] = []
|
||||
monkeypatch.delenv(DEFAULT_MISTRAL_API_ENV_KEY, raising=False)
|
||||
monkeypatch.setattr(
|
||||
keyring, "get_password", lambda service, username: "keyring-key"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
keyring,
|
||||
"delete_password",
|
||||
lambda service, username: deleted.append(username),
|
||||
)
|
||||
acp_agent_loop = build_acp_agent_loop(build_mistral_provider())
|
||||
|
||||
response = await acp_agent_loop.ext_method("auth/signOut", {})
|
||||
|
||||
assert response == {}
|
||||
assert DEFAULT_MISTRAL_API_ENV_KEY not in dotenv_values(config_dir / ".env")
|
||||
assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
|
||||
assert await acp_agent_loop.ext_method("auth/status", {}) == {
|
||||
"authenticated": True,
|
||||
"authState": AuthStateKind.PROCESS_ENV.value,
|
||||
"signOutAvailable": False,
|
||||
}
|
||||
assert deleted == [DEFAULT_MISTRAL_API_ENV_KEY]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_surfaces_internal_error_when_keyring_delete_fails(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv(DEFAULT_MISTRAL_API_ENV_KEY, raising=False)
|
||||
monkeypatch.setattr(
|
||||
keyring, "get_password", lambda service, username: "keyring-key"
|
||||
)
|
||||
|
||||
def _failed(service: str, username: str) -> None:
|
||||
raise KeyringError("delete failed")
|
||||
|
||||
monkeypatch.setattr(keyring, "delete_password", _failed)
|
||||
acp_agent_loop = build_acp_agent_loop(build_mistral_provider())
|
||||
|
||||
with pytest.raises(InternalError, match="Failed to sign out"):
|
||||
await acp_agent_loop.ext_method("auth/signOut", {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refuses_unsupported_provider_key(
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ class TestCompactEventHandling:
|
|||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session = acp_agent_loop.sessions[session_response.session_id]
|
||||
await session.agent_loop.wait_until_ready()
|
||||
# Need >1 message so /compact does not early-return.
|
||||
session.agent_loop.messages.append(LLMMessage(role=Role.user, content="hello"))
|
||||
|
||||
|
|
|
|||
113
tests/acp/test_image_blocks.py
Normal file
113
tests/acp/test_image_blocks.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from acp.helpers import ImageContentBlock, TextContentBlock
|
||||
import pytest
|
||||
|
||||
from vibe.acp.exceptions import INVALID_IMAGE_ATTACHMENT, InvalidImageAttachmentError
|
||||
from vibe.acp.image_blocks import extract_image_attachments
|
||||
from vibe.core.types import (
|
||||
MAX_IMAGE_BYTES,
|
||||
MAX_IMAGES_PER_MESSAGE,
|
||||
FileImageSource,
|
||||
InlineImageSource,
|
||||
)
|
||||
|
||||
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
||||
|
||||
|
||||
def _image_block(uri: str | None = None) -> ImageContentBlock:
|
||||
return ImageContentBlock(
|
||||
type="image",
|
||||
data=base64.b64encode(PNG_BYTES).decode("ascii"),
|
||||
mime_type="image/png",
|
||||
uri=uri,
|
||||
)
|
||||
|
||||
|
||||
def test_inlines_bytes_when_session_dir_is_none() -> None:
|
||||
[att] = extract_image_attachments([_image_block()], session_dir=None)
|
||||
|
||||
assert isinstance(att.source, InlineImageSource)
|
||||
assert att.source.data == base64.b64encode(PNG_BYTES).decode("ascii")
|
||||
assert att.mime_type == "image/png"
|
||||
|
||||
|
||||
def test_writes_attachment_file_when_session_dir_is_set(tmp_path: Path) -> None:
|
||||
[att] = extract_image_attachments([_image_block()], session_dir=tmp_path)
|
||||
|
||||
digest = hashlib.sha1(PNG_BYTES, usedforsecurity=False).hexdigest()
|
||||
assert isinstance(att.source, FileImageSource)
|
||||
assert att.source.path == (tmp_path / "attachments" / f"{digest}.png").resolve()
|
||||
assert att.source.path.read_bytes() == PNG_BYTES
|
||||
|
||||
|
||||
def test_derives_alias_from_uri_basename() -> None:
|
||||
[att] = extract_image_attachments([_image_block(uri="cat.png")], session_dir=None)
|
||||
|
||||
assert att.alias == "cat.png"
|
||||
|
||||
|
||||
def test_falls_back_to_default_alias_without_uri() -> None:
|
||||
[att] = extract_image_attachments([_image_block()], session_dir=None)
|
||||
|
||||
assert att.alias == "pasted-image.png"
|
||||
|
||||
|
||||
def test_ignores_non_image_blocks() -> None:
|
||||
block = TextContentBlock(type="text", text="hello")
|
||||
|
||||
assert extract_image_attachments([block], session_dir=None) == []
|
||||
|
||||
|
||||
def test_rejects_unsupported_mime() -> None:
|
||||
block = ImageContentBlock(
|
||||
type="image",
|
||||
data=base64.b64encode(PNG_BYTES).decode("ascii"),
|
||||
mime_type="image/tiff",
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidImageAttachmentError):
|
||||
extract_image_attachments([block], session_dir=None)
|
||||
|
||||
|
||||
def test_image_block_error_is_structured_acp_error() -> None:
|
||||
block = ImageContentBlock(
|
||||
type="image",
|
||||
data=base64.b64encode(PNG_BYTES).decode("ascii"),
|
||||
mime_type="image/tiff",
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidImageAttachmentError) as exc_info:
|
||||
extract_image_attachments([block], session_dir=None)
|
||||
|
||||
assert exc_info.value.code == INVALID_IMAGE_ATTACHMENT
|
||||
assert exc_info.value.data == {"reason": "wrong_type"}
|
||||
|
||||
|
||||
def test_rejects_invalid_base64() -> None:
|
||||
block = ImageContentBlock(type="image", data="not base64!!!", mime_type="image/png")
|
||||
|
||||
with pytest.raises(InvalidImageAttachmentError):
|
||||
extract_image_attachments([block], session_dir=None)
|
||||
|
||||
|
||||
def test_rejects_too_many_images() -> None:
|
||||
blocks = [_image_block() for _ in range(MAX_IMAGES_PER_MESSAGE + 1)]
|
||||
|
||||
with pytest.raises(InvalidImageAttachmentError):
|
||||
extract_image_attachments(blocks, session_dir=None)
|
||||
|
||||
|
||||
def test_rejects_oversized_image() -> None:
|
||||
block = ImageContentBlock(
|
||||
type="image",
|
||||
data=base64.b64encode(b"x" * (MAX_IMAGE_BYTES + 1)).decode("ascii"),
|
||||
mime_type="image/png",
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidImageAttachmentError):
|
||||
extract_image_attachments([block], session_dir=None)
|
||||
|
|
@ -63,7 +63,7 @@ class TestACPInitialize:
|
|||
assert response.agent_capabilities == AgentCapabilities(
|
||||
load_session=True,
|
||||
prompt_capabilities=PromptCapabilities(
|
||||
audio=False, embedded_context=True, image=False
|
||||
audio=False, embedded_context=True, image=True
|
||||
),
|
||||
session_capabilities=SessionCapabilities(
|
||||
close=SessionCloseCapabilities(),
|
||||
|
|
@ -72,7 +72,7 @@ class TestACPInitialize:
|
|||
),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.16.1"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.0"
|
||||
)
|
||||
|
||||
assert response.auth_methods is not None
|
||||
|
|
@ -163,7 +163,7 @@ class TestACPInitialize:
|
|||
assert response.agent_capabilities == AgentCapabilities(
|
||||
load_session=True,
|
||||
prompt_capabilities=PromptCapabilities(
|
||||
audio=False, embedded_context=True, image=False
|
||||
audio=False, embedded_context=True, image=True
|
||||
),
|
||||
session_capabilities=SessionCapabilities(
|
||||
close=SessionCloseCapabilities(),
|
||||
|
|
@ -172,7 +172,7 @@ class TestACPInitialize:
|
|||
),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.16.1"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.0"
|
||||
)
|
||||
|
||||
assert response.auth_methods is not None
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from acp import RequestError
|
||||
from acp.schema import (
|
||||
AgentMessageChunk,
|
||||
AgentThoughtChunk,
|
||||
ClientCapabilities,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
UserMessageChunk,
|
||||
|
|
@ -17,7 +15,8 @@ import pytest
|
|||
from tests.conftest import build_test_vibe_config
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_client import FakeClient
|
||||
from vibe.acp.acp_agent_loop import WORKSPACE_TRUST_CAPABILITY, VibeAcpAgentLoop
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.acp.user_display_content import USER_DISPLAY_CONTENT_META_KEY
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import ModelConfig, SessionLoggingConfig
|
||||
|
|
@ -126,35 +125,42 @@ class TestLoadSession:
|
|||
assert response.config_options[2].current_value == "off"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_resolves_workspace_trust_before_loading_config(
|
||||
async def test_load_session_returns_trust_details_without_loading_project_docs(
|
||||
self,
|
||||
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||
temp_session_dir: Path,
|
||||
create_test_session,
|
||||
tmp_working_directory: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
acp_agent, client = acp_agent_with_session_config
|
||||
acp_agent.client_capabilities = ClientCapabilities(
|
||||
field_meta={WORKSPACE_TRUST_CAPABILITY: True}
|
||||
)
|
||||
request_trust = AsyncMock(return_value={"decision": "trust_cwd"})
|
||||
monkeypatch.setattr(client, "ext_method", request_trust)
|
||||
acp_agent, _client = acp_agent_with_session_config
|
||||
(tmp_working_directory / "AGENTS.md").write_text(
|
||||
"Loaded session project instructions", encoding="utf-8"
|
||||
)
|
||||
session_id = "test-sess-trust"
|
||||
create_test_session(temp_session_dir, session_id, str(tmp_working_directory))
|
||||
|
||||
await acp_agent.load_session(
|
||||
response = await acp_agent.load_session(
|
||||
cwd=str(tmp_working_directory), mcp_servers=[], session_id=session_id
|
||||
)
|
||||
|
||||
request_trust.assert_awaited_once()
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is True
|
||||
assert response is not None
|
||||
payload = response.model_dump(mode="json", by_alias=True)
|
||||
assert payload.get("_meta") == {
|
||||
"workspace_trust": {
|
||||
"status": "untrusted",
|
||||
"details": {
|
||||
"cwd": str(tmp_working_directory.resolve()),
|
||||
"repoRoot": None,
|
||||
"ignoredFiles": ["AGENTS.md"],
|
||||
"availableDecisions": ["trust_cwd", "trust_session", "decline"],
|
||||
},
|
||||
}
|
||||
}
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
||||
await acp_agent.sessions[session_id].agent_loop.wait_until_ready()
|
||||
system_prompt = acp_agent.sessions[session_id].agent_loop.messages[0].content
|
||||
assert system_prompt is not None
|
||||
assert "Loaded session project instructions" in system_prompt
|
||||
assert "Loaded session project instructions" not in system_prompt
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_registers_session_with_original_id(
|
||||
|
|
@ -223,6 +229,51 @@ class TestLoadSession:
|
|||
]
|
||||
assert len(user_updates) == 1
|
||||
assert user_updates[0].update.content.text == "Hello world"
|
||||
assert user_updates[0].update.field_meta is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_replays_user_display_content(
|
||||
self,
|
||||
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||
temp_session_dir: Path,
|
||||
create_test_session,
|
||||
) -> None:
|
||||
acp_agent, client = acp_agent_with_session_config
|
||||
|
||||
session_id = "replay-dsp-123456"
|
||||
cwd = str(Path.cwd())
|
||||
user_display_content = {
|
||||
"version": "1.0.0",
|
||||
"host": "mistral-vscode",
|
||||
"content": [
|
||||
{"type": "text", "text": "Look at "},
|
||||
{
|
||||
"type": "workspace_mention",
|
||||
"kind": "file",
|
||||
"uri": "file:///repo/src/app.ts",
|
||||
"name": "app.ts",
|
||||
},
|
||||
],
|
||||
}
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Look at app.ts",
|
||||
"user_display_content": user_display_content,
|
||||
}
|
||||
]
|
||||
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
|
||||
|
||||
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
|
||||
|
||||
user_updates = [
|
||||
u for u in client._session_updates if isinstance(u.update, UserMessageChunk)
|
||||
]
|
||||
assert len(user_updates) == 1
|
||||
assert user_updates[0].update.content.text == "Look at app.ts"
|
||||
assert user_updates[0].update.field_meta == {
|
||||
USER_DISPLAY_CONTENT_META_KEY: user_display_content
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_replays_assistant_messages(
|
||||
|
|
@ -278,7 +329,12 @@ class TestLoadSession:
|
|||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_123", "content": "file contents"},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_123",
|
||||
"name": "read",
|
||||
"content": "file contents",
|
||||
},
|
||||
]
|
||||
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
|
||||
|
||||
|
|
@ -288,15 +344,58 @@ class TestLoadSession:
|
|||
u for u in client._session_updates if isinstance(u.update, ToolCallStart)
|
||||
]
|
||||
assert len(tool_call_starts) == 1
|
||||
assert tool_call_starts[0].update.title == "read"
|
||||
assert tool_call_starts[0].update.tool_call_id == "call_123"
|
||||
start = tool_call_starts[0].update
|
||||
assert start.tool_call_id == "call_123"
|
||||
assert start.kind == "read"
|
||||
# The host drops created events without a status; replayed calls are
|
||||
# historical, so they must carry one.
|
||||
assert start.status == "completed"
|
||||
assert start.field_meta is not None
|
||||
assert start.field_meta["tool_name"] == "read"
|
||||
|
||||
tool_results = [
|
||||
u for u in client._session_updates if isinstance(u.update, ToolCallProgress)
|
||||
]
|
||||
assert len(tool_results) == 1
|
||||
assert tool_results[0].update.tool_call_id == "call_123"
|
||||
assert tool_results[0].update.status == "completed"
|
||||
result = tool_results[0].update
|
||||
assert result.tool_call_id == "call_123"
|
||||
assert result.status == "completed"
|
||||
# The host drops tool_call_update events without a kind.
|
||||
assert result.kind == "read"
|
||||
assert result.field_meta is not None
|
||||
assert result.field_meta["tool_name"] == "read"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_skips_result_whose_call_was_not_replayed(
|
||||
self,
|
||||
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||
temp_session_dir: Path,
|
||||
create_test_session,
|
||||
) -> None:
|
||||
acp_agent, client = acp_agent_with_session_config
|
||||
|
||||
session_id = "replay-orphan-12345"
|
||||
cwd = str(Path.cwd())
|
||||
# A tool result whose call was not replayed (here no matching tool call
|
||||
# at all, mirroring a hidden tool whose call replay returns None).
|
||||
# Emitting it would orphan a tool_call_update with no preceding call.
|
||||
messages = [
|
||||
{"role": "user", "content": "Do something"},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_orphan",
|
||||
"name": "read",
|
||||
"content": "file contents",
|
||||
},
|
||||
]
|
||||
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
|
||||
|
||||
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
|
||||
|
||||
tool_results = [
|
||||
u for u in client._session_updates if isinstance(u.update, ToolCallProgress)
|
||||
]
|
||||
assert tool_results == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_replays_reasoning_content(
|
||||
|
|
|
|||
|
|
@ -3,29 +3,32 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from acp import RequestError
|
||||
from acp.schema import ClientCapabilities
|
||||
from acp import NewSessionResponse
|
||||
import pytest
|
||||
|
||||
from tests.acp.conftest import _create_acp_agent
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.acp.acp_agent_loop import WORKSPACE_TRUST_CAPABILITY, VibeAcpAgentLoop
|
||||
from vibe.acp.exceptions import InvalidRequestError
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import ModelConfig
|
||||
from vibe.core.feedback import _CACHE_SECTION, _LAST_SHOWN_KEY, record_feedback_asked
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
|
||||
|
||||
def _system_prompt(acp_agent_loop: VibeAcpAgentLoop, session_id: str) -> str:
|
||||
async def _system_prompt(acp_agent_loop: VibeAcpAgentLoop, session_id: str) -> str:
|
||||
session = acp_agent_loop.sessions[session_id]
|
||||
await session.agent_loop.wait_until_ready()
|
||||
return session.agent_loop.messages[0].content or ""
|
||||
|
||||
|
||||
def _enable_workspace_trust(acp_agent_loop: VibeAcpAgentLoop) -> None:
|
||||
acp_agent_loop.client_capabilities = ClientCapabilities(
|
||||
field_meta={WORKSPACE_TRUST_CAPABILITY: True}
|
||||
)
|
||||
def _workspace_trust_meta(session_response: NewSessionResponse) -> dict:
|
||||
payload = session_response.model_dump(mode="json", by_alias=True)
|
||||
meta = payload.get("_meta")
|
||||
assert isinstance(meta, dict)
|
||||
workspace_trust = meta.get("workspace_trust")
|
||||
assert isinstance(workspace_trust, dict)
|
||||
return workspace_trust
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -153,7 +156,27 @@ class TestACPNewSession:
|
|||
assert len(thinking_config.options) == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_loads_root_agents_md_from_workspace_cwd(
|
||||
async def test_new_session_uses_file_backed_feedback_cache(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, config_dir: Path
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
acp_session = acp_agent_loop.sessions[session_response.session_id]
|
||||
|
||||
record_feedback_asked(acp_session.agent_loop.cache_store)
|
||||
|
||||
cache_path = config_dir / "cache.toml"
|
||||
assert cache_path.exists()
|
||||
assert (
|
||||
acp_session.agent_loop.cache_store.read_section(_CACHE_SECTION)[
|
||||
_LAST_SHOWN_KEY
|
||||
]
|
||||
> 0
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_returns_actionable_trust_details_without_prompting(
|
||||
self,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_working_directory: Path,
|
||||
|
|
@ -162,7 +185,6 @@ class TestACPNewSession:
|
|||
(tmp_working_directory / "AGENTS.md").write_text(
|
||||
"Root project instructions", encoding="utf-8"
|
||||
)
|
||||
_enable_workspace_trust(acp_agent_loop)
|
||||
request_trust = AsyncMock(return_value={"decision": "trust_cwd"})
|
||||
monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust)
|
||||
|
||||
|
|
@ -171,26 +193,24 @@ class TestACPNewSession:
|
|||
)
|
||||
assert session_response.session_id is not None
|
||||
|
||||
request_trust.assert_awaited_once()
|
||||
await_args = request_trust.await_args
|
||||
assert await_args is not None
|
||||
method, params = await_args.args
|
||||
assert method == "trust/request"
|
||||
assert params["cwd"] == str(tmp_working_directory.resolve())
|
||||
assert params["detectedFiles"] == ["AGENTS.md"]
|
||||
assert params["repoDetectedFiles"] == []
|
||||
assert params["availableDecisions"] == ["trust_cwd", "trust_session", "decline"]
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is True
|
||||
assert "Root project instructions" in _system_prompt(
|
||||
request_trust.assert_not_awaited()
|
||||
assert _workspace_trust_meta(session_response) == {
|
||||
"status": "untrusted",
|
||||
"details": {
|
||||
"cwd": str(tmp_working_directory.resolve()),
|
||||
"repoRoot": None,
|
||||
"ignoredFiles": ["AGENTS.md"],
|
||||
"availableDecisions": ["trust_cwd", "trust_session", "decline"],
|
||||
},
|
||||
}
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
||||
assert "Root project instructions" not in await _system_prompt(
|
||||
acp_agent_loop, session_response.session_id
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_can_trust_full_repo_from_subdirectory(
|
||||
self,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
async def test_new_session_returns_repo_trust_details_from_subdirectory(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, tmp_path: Path
|
||||
) -> None:
|
||||
repo = tmp_path / "repo"
|
||||
cwd = repo / "src" / "pkg"
|
||||
|
|
@ -198,79 +218,68 @@ class TestACPNewSession:
|
|||
(repo / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
|
||||
cwd.mkdir(parents=True)
|
||||
(repo / "AGENTS.md").write_text("Repo instructions", encoding="utf-8")
|
||||
_enable_workspace_trust(acp_agent_loop)
|
||||
request_trust = AsyncMock(return_value={"decision": "trust_repo"})
|
||||
monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust)
|
||||
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(cwd), mcp_servers=[]
|
||||
)
|
||||
assert session_response.session_id is not None
|
||||
|
||||
request_trust.assert_awaited_once()
|
||||
await_args = request_trust.await_args
|
||||
assert await_args is not None
|
||||
method, params = await_args.args
|
||||
assert method == "trust/request"
|
||||
assert params["cwd"] == str(cwd.resolve())
|
||||
assert params["repoRoot"] == str(repo.resolve())
|
||||
assert params["detectedFiles"] == []
|
||||
assert params["repoDetectedFiles"] == ["AGENTS.md"]
|
||||
assert params["availableDecisions"] == [
|
||||
"trust_repo",
|
||||
"trust_cwd",
|
||||
"trust_session",
|
||||
"decline",
|
||||
]
|
||||
assert trusted_folders_manager.is_trusted(repo) is True
|
||||
assert trusted_folders_manager.is_trusted(cwd) is True
|
||||
assert "Repo instructions" in _system_prompt(
|
||||
assert _workspace_trust_meta(session_response) == {
|
||||
"status": "untrusted",
|
||||
"details": {
|
||||
"cwd": str(cwd.resolve()),
|
||||
"repoRoot": str(repo.resolve()),
|
||||
"ignoredFiles": ["AGENTS.md"],
|
||||
"availableDecisions": [
|
||||
"trust_repo",
|
||||
"trust_cwd",
|
||||
"trust_session",
|
||||
"decline",
|
||||
],
|
||||
},
|
||||
}
|
||||
assert trusted_folders_manager.is_trusted(repo) is None
|
||||
assert trusted_folders_manager.is_trusted(cwd) is None
|
||||
assert "Repo instructions" not in await _system_prompt(
|
||||
acp_agent_loop, session_response.session_id
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_decline_skips_project_docs(
|
||||
self,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_working_directory: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
async def test_new_session_returns_details_after_explicit_decline(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||
) -> None:
|
||||
(tmp_working_directory / "AGENTS.md").write_text(
|
||||
"Do not load this", encoding="utf-8"
|
||||
)
|
||||
_enable_workspace_trust(acp_agent_loop)
|
||||
monkeypatch.setattr(
|
||||
acp_agent_loop.client,
|
||||
"ext_method",
|
||||
AsyncMock(return_value={"decision": "decline"}),
|
||||
)
|
||||
trusted_folders_manager.add_untrusted(tmp_working_directory)
|
||||
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||
)
|
||||
assert session_response.session_id is not None
|
||||
|
||||
assert _workspace_trust_meta(session_response) == {
|
||||
"status": "untrusted",
|
||||
"details": {
|
||||
"cwd": str(tmp_working_directory.resolve()),
|
||||
"repoRoot": None,
|
||||
"ignoredFiles": ["AGENTS.md"],
|
||||
"availableDecisions": ["trust_cwd", "trust_session", "decline"],
|
||||
},
|
||||
}
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is False
|
||||
assert "Do not load this" not in _system_prompt(
|
||||
assert "Do not load this" not in await _system_prompt(
|
||||
acp_agent_loop, session_response.session_id
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_session_trust_loads_docs_without_persisting(
|
||||
self,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_working_directory: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||
) -> None:
|
||||
(tmp_working_directory / "AGENTS.md").write_text(
|
||||
"Session-only instructions", encoding="utf-8"
|
||||
)
|
||||
_enable_workspace_trust(acp_agent_loop)
|
||||
monkeypatch.setattr(
|
||||
acp_agent_loop.client,
|
||||
"ext_method",
|
||||
AsyncMock(return_value={"decision": "trust_session"}),
|
||||
)
|
||||
trusted_folders_manager.trust_for_session(tmp_working_directory)
|
||||
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||
|
|
@ -278,10 +287,14 @@ class TestACPNewSession:
|
|||
assert session_response.session_id is not None
|
||||
|
||||
normalized = str(tmp_working_directory.resolve())
|
||||
assert _workspace_trust_meta(session_response) == {
|
||||
"status": "session",
|
||||
"details": None,
|
||||
}
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is True
|
||||
assert normalized in trusted_folders_manager._session_trusted
|
||||
assert normalized not in trusted_folders_manager._trusted
|
||||
assert "Session-only instructions" in _system_prompt(
|
||||
assert "Session-only instructions" in await _system_prompt(
|
||||
acp_agent_loop, session_response.session_id
|
||||
)
|
||||
|
||||
|
|
@ -292,90 +305,45 @@ class TestACPNewSession:
|
|||
tmp_working_directory: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_enable_workspace_trust(acp_agent_loop)
|
||||
request_trust = AsyncMock(return_value={"decision": "trust_cwd"})
|
||||
monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust)
|
||||
|
||||
await acp_agent_loop.new_session(cwd=str(tmp_working_directory), mcp_servers=[])
|
||||
|
||||
request_trust.assert_not_awaited()
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_direct_client_fallback_skips_project_docs(
|
||||
self,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_working_directory: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
(tmp_working_directory / "AGENTS.md").write_text(
|
||||
"Direct client should skip this", encoding="utf-8"
|
||||
)
|
||||
_enable_workspace_trust(acp_agent_loop)
|
||||
monkeypatch.setattr(
|
||||
acp_agent_loop.client,
|
||||
"ext_method",
|
||||
AsyncMock(side_effect=RequestError.method_not_found("trust/request")),
|
||||
)
|
||||
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||
)
|
||||
assert session_response.session_id is not None
|
||||
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
||||
assert "Direct client should skip this" not in _system_prompt(
|
||||
acp_agent_loop, session_response.session_id
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_without_workspace_trust_capability_skips_prompt(
|
||||
self,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_working_directory: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
(tmp_working_directory / "AGENTS.md").write_text(
|
||||
"Unsupported client should skip this", encoding="utf-8"
|
||||
)
|
||||
request_trust = AsyncMock(return_value={"decision": "trust_cwd"})
|
||||
monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust)
|
||||
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||
)
|
||||
assert session_response.session_id is not None
|
||||
|
||||
request_trust.assert_not_awaited()
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
||||
assert "Unsupported client should skip this" not in _system_prompt(
|
||||
acp_agent_loop, session_response.session_id
|
||||
assert _workspace_trust_meta(session_response) == {
|
||||
"status": "untrusted",
|
||||
"details": None,
|
||||
}
|
||||
assert (
|
||||
trusted_folders_manager.trust_status(tmp_working_directory) == "untrusted"
|
||||
)
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_cancelled_trust_prompt_cancels_session_creation(
|
||||
self,
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
tmp_working_directory: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
async def test_new_session_trusted_folder_loads_docs_with_no_details(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||
) -> None:
|
||||
(tmp_working_directory / "AGENTS.md").write_text(
|
||||
"Cancelled prompt", encoding="utf-8"
|
||||
"Trusted folder instructions", encoding="utf-8"
|
||||
)
|
||||
_enable_workspace_trust(acp_agent_loop)
|
||||
monkeypatch.setattr(
|
||||
acp_agent_loop.client,
|
||||
"ext_method",
|
||||
AsyncMock(return_value={"decision": "cancelled"}),
|
||||
trusted_folders_manager.add_trusted(tmp_working_directory)
|
||||
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||
)
|
||||
assert session_response.session_id is not None
|
||||
|
||||
with pytest.raises(InvalidRequestError):
|
||||
await acp_agent_loop.new_session(
|
||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||
)
|
||||
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
||||
assert acp_agent_loop.sessions == {}
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is True
|
||||
assert _workspace_trust_meta(session_response) == {
|
||||
"status": "trusted",
|
||||
"details": None,
|
||||
}
|
||||
assert "Trusted folder instructions" in await _system_prompt(
|
||||
acp_agent_loop, session_response.session_id
|
||||
)
|
||||
|
||||
@pytest.mark.skip(reason="TODO: Fix this test")
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -235,6 +235,7 @@ class TestACPSetModel:
|
|||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
await acp_session.agent_loop.wait_until_ready()
|
||||
|
||||
user_msg = LLMMessage(role=Role.user, content="Hello")
|
||||
assistant_msg = LLMMessage(role=Role.assistant, content="Hi there!")
|
||||
|
|
|
|||
|
|
@ -40,3 +40,32 @@ class TestToolCallSessionUpdate:
|
|||
assert update.tool_call_id == "test_call_123"
|
||||
assert update.kind == "read"
|
||||
assert update.raw_input is None
|
||||
|
||||
def test_whole_file_read_emits_plain_file_location(self) -> None:
|
||||
event = self._create_event()
|
||||
|
||||
update = tool_call_session_update(event)
|
||||
|
||||
assert isinstance(update, ToolCallStart)
|
||||
assert update.locations is not None
|
||||
location = update.locations[0]
|
||||
assert location.field_meta == {"type": "file"}
|
||||
assert location.line is None
|
||||
|
||||
def test_bounded_read_emits_file_range_location(self) -> None:
|
||||
event = ToolCallEvent(
|
||||
tool_name="read",
|
||||
tool_call_id="test_call_123",
|
||||
args=ReadArgs(file_path="/tmp/test.txt", offset=10, limit=20),
|
||||
tool_class=Read,
|
||||
)
|
||||
|
||||
update = tool_call_session_update(event)
|
||||
|
||||
assert isinstance(update, ToolCallStart)
|
||||
assert update.locations is not None
|
||||
location = update.locations[0]
|
||||
assert location.field_meta is not None
|
||||
assert location.field_meta["type"] == "file_range"
|
||||
assert location.field_meta["offset"] == 10
|
||||
assert location.field_meta["limit"] == 20
|
||||
|
|
|
|||
|
|
@ -112,14 +112,25 @@ class TestReadFieldMeta:
|
|||
assert loc.field_meta == {"type": "file_range", "offset": 10, "limit": 50}
|
||||
assert update.field_meta == {"tool_name": "read"}
|
||||
|
||||
def test_call_defaults_offset_none_limit_default(self) -> None:
|
||||
def test_whole_file_read_emits_plain_file_location(self) -> None:
|
||||
event = _call_event("read", Read, ReadArgs(file_path="/tmp/f.txt"))
|
||||
update = tool_call_session_update(event)
|
||||
|
||||
assert isinstance(update, ToolCallStart)
|
||||
assert update.locations is not None
|
||||
loc = update.locations[0]
|
||||
assert loc.field_meta == {"type": "file_range", "offset": None, "limit": 2000}
|
||||
assert loc.field_meta == {"type": "file"}
|
||||
assert loc.line is None
|
||||
|
||||
def test_read_from_offset_to_end_emits_file_location_with_line(self) -> None:
|
||||
event = _call_event("read", Read, ReadArgs(file_path="/tmp/f.txt", offset=42))
|
||||
update = tool_call_session_update(event)
|
||||
|
||||
assert isinstance(update, ToolCallStart)
|
||||
assert update.locations is not None
|
||||
loc = update.locations[0]
|
||||
assert loc.field_meta == {"type": "file"}
|
||||
assert loc.line == 42
|
||||
|
||||
def test_result_location_has_start_line_and_num_lines(self) -> None:
|
||||
result = ReadResult(
|
||||
|
|
@ -128,6 +139,7 @@ class TestReadFieldMeta:
|
|||
num_lines=3,
|
||||
start_line=10,
|
||||
total_lines=20,
|
||||
requested_limit=50,
|
||||
)
|
||||
event = _result_event("read", Read, result)
|
||||
update = tool_result_session_update(event)
|
||||
|
|
@ -137,6 +149,77 @@ class TestReadFieldMeta:
|
|||
loc = update.locations[0]
|
||||
assert loc.field_meta == {"type": "file_range", "offset": 10, "limit": 3}
|
||||
|
||||
def test_whole_file_result_emits_plain_file_location(self) -> None:
|
||||
result = ReadResult(
|
||||
file_path="/tmp/f.txt",
|
||||
content=" 1→line1\n 2→line2",
|
||||
num_lines=2,
|
||||
start_line=1,
|
||||
total_lines=2,
|
||||
)
|
||||
event = _result_event("read", Read, result)
|
||||
update = tool_result_session_update(event)
|
||||
|
||||
assert isinstance(update, ToolCallProgress)
|
||||
assert update.locations is not None
|
||||
loc = update.locations[0]
|
||||
assert loc.field_meta == {"type": "file"}
|
||||
assert loc.line is None
|
||||
|
||||
def test_truncated_default_limit_result_emits_range(self) -> None:
|
||||
# No limit given, but the file exceeded the default limit: the read was
|
||||
# partial, so the chip must show the range, not imply the whole file.
|
||||
result = ReadResult(
|
||||
file_path="/tmp/f.txt",
|
||||
content=" 1→line1",
|
||||
num_lines=2000,
|
||||
start_line=1,
|
||||
total_lines=None,
|
||||
was_truncated=True,
|
||||
)
|
||||
event = _result_event("read", Read, result)
|
||||
update = tool_result_session_update(event)
|
||||
|
||||
assert isinstance(update, ToolCallProgress)
|
||||
assert update.locations is not None
|
||||
loc = update.locations[0]
|
||||
assert loc.field_meta == {"type": "file_range", "offset": 1, "limit": 2000}
|
||||
|
||||
def test_offset_to_end_result_emits_file_location_with_line(self) -> None:
|
||||
result = ReadResult(
|
||||
file_path="/tmp/f.txt",
|
||||
content=" 42→line42",
|
||||
num_lines=1,
|
||||
start_line=42,
|
||||
total_lines=42,
|
||||
requested_offset=42,
|
||||
)
|
||||
event = _result_event("read", Read, result)
|
||||
update = tool_result_session_update(event)
|
||||
|
||||
assert isinstance(update, ToolCallProgress)
|
||||
assert update.locations is not None
|
||||
loc = update.locations[0]
|
||||
assert loc.field_meta == {"type": "file"}
|
||||
assert loc.line == 42
|
||||
|
||||
def test_bounded_result_clamps_limit_to_lines_read(self) -> None:
|
||||
result = ReadResult(
|
||||
file_path="/tmp/f.txt",
|
||||
content=" 1→line1",
|
||||
num_lines=1,
|
||||
start_line=1,
|
||||
total_lines=1,
|
||||
requested_limit=100,
|
||||
)
|
||||
event = _result_event("read", Read, result)
|
||||
update = tool_result_session_update(event)
|
||||
|
||||
assert isinstance(update, ToolCallProgress)
|
||||
assert update.locations is not None
|
||||
loc = update.locations[0]
|
||||
assert loc.field_meta == {"type": "file_range", "offset": 1, "limit": 1}
|
||||
|
||||
|
||||
class TestWebSearchFieldMeta:
|
||||
def test_call_meta_contains_query(self) -> None:
|
||||
|
|
|
|||
144
tests/acp/test_user_display_content.py
Normal file
144
tests/acp/test_user_display_content.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from acp.schema import TextContentBlock
|
||||
from pydantic import ValidationError
|
||||
import pytest
|
||||
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_client import FakeClient
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.acp.exceptions import InvalidRequestError
|
||||
from vibe.acp.user_display_content import (
|
||||
USER_DISPLAY_CONTENT_META_KEY,
|
||||
parse_user_display_content_metadata,
|
||||
)
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.types import Role, UserDisplayContentMetadata
|
||||
|
||||
|
||||
def _metadata_payload() -> dict[str, object]:
|
||||
return {
|
||||
"version": "1.0.0",
|
||||
"host": "mistral-vscode",
|
||||
"content": [
|
||||
{"type": "text", "text": "Look at "},
|
||||
{
|
||||
"type": "workspace_mention",
|
||||
"kind": "file",
|
||||
"uri": "file:///repo/src/app.ts",
|
||||
"name": "app.ts",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _metadata_kwargs(value: object) -> dict[str, Any]:
|
||||
return {USER_DISPLAY_CONTENT_META_KEY: value}
|
||||
|
||||
|
||||
def test_user_display_content_meta_key_is_snake_case() -> None:
|
||||
assert USER_DISPLAY_CONTENT_META_KEY == "user_display_content"
|
||||
|
||||
|
||||
def test_parse_returns_none_when_metadata_is_missing() -> None:
|
||||
assert parse_user_display_content_metadata(None) is None
|
||||
|
||||
|
||||
def test_parse_validates_present_metadata() -> None:
|
||||
metadata = parse_user_display_content_metadata({
|
||||
"version": "1.0.0",
|
||||
"host": "mistral-vscode",
|
||||
"content": [],
|
||||
})
|
||||
|
||||
assert metadata == UserDisplayContentMetadata(
|
||||
version="1.0.0", host="mistral-vscode", content=[]
|
||||
)
|
||||
|
||||
|
||||
def test_parse_rejects_invalid_metadata() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
parse_user_display_content_metadata({
|
||||
"version": 2,
|
||||
"host": "mistral-vscode",
|
||||
"content": [],
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_attaches_user_display_content_to_user_message(
|
||||
acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
payload = _metadata_payload()
|
||||
|
||||
response = await acp_agent_loop.prompt(
|
||||
prompt=[TextContentBlock(type="text", text="Look at app.ts")],
|
||||
session_id=session_response.session_id,
|
||||
**_metadata_kwargs(payload),
|
||||
)
|
||||
|
||||
assert response.stop_reason == "end_turn"
|
||||
user_message = next(
|
||||
(msg for msg in backend.requests_messages[0] if msg.role == Role.user), None
|
||||
)
|
||||
assert user_message is not None
|
||||
assert user_message.content == "Look at app.ts"
|
||||
assert (
|
||||
user_message.user_display_content
|
||||
== UserDisplayContentMetadata.model_validate(payload)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_rejects_invalid_user_display_content(
|
||||
acp_agent_loop: VibeAcpAgentLoop,
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidRequestError, match="Invalid user display content"):
|
||||
await acp_agent_loop.prompt(
|
||||
prompt=[TextContentBlock(type="text", text="Look at app.ts")],
|
||||
session_id=session_response.session_id,
|
||||
**_metadata_kwargs({"version": 2, "host": "mistral-vscode", "content": []}),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_persists_user_display_content(
|
||||
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
|
||||
temp_session_dir: Path,
|
||||
) -> None:
|
||||
acp_agent, _client = acp_agent_with_session_config
|
||||
session_response = await acp_agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
payload = _metadata_payload()
|
||||
|
||||
await acp_agent.prompt(
|
||||
prompt=[TextContentBlock(type="text", text="Look at app.ts")],
|
||||
session_id=session_response.session_id,
|
||||
**_metadata_kwargs(payload),
|
||||
)
|
||||
|
||||
session_dir = next(temp_session_dir.glob("session_*"))
|
||||
messages_file = session_dir / "messages.jsonl"
|
||||
messages = [
|
||||
json.loads(line)
|
||||
for line in messages_file.read_text(encoding="utf-8").splitlines()
|
||||
]
|
||||
user_message = next(msg for msg in messages if msg["role"] == "user")
|
||||
|
||||
assert user_message["user_display_content"] == payload
|
||||
|
||||
loaded_messages, _metadata = SessionLoader.load_session(session_dir)
|
||||
loaded_user_message = next(msg for msg in loaded_messages if msg.role == Role.user)
|
||||
assert loaded_user_message.user_display_content == (
|
||||
UserDisplayContentMetadata.model_validate(payload)
|
||||
)
|
||||
|
|
@ -1,14 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from acp.schema import ToolCallStart
|
||||
|
||||
from vibe.acp.tools.builtins.task import Task as AcpTask
|
||||
from vibe.acp.tools.builtins.todo import Todo as AcpTodo, TodoArgs
|
||||
from vibe.acp.user_display_content import USER_DISPLAY_CONTENT_META_KEY
|
||||
from vibe.acp.utils import (
|
||||
TOOL_OPTIONS,
|
||||
ToolOption,
|
||||
build_permission_options,
|
||||
create_tool_call_replay,
|
||||
create_tool_result_replay,
|
||||
create_user_message_replay,
|
||||
get_proxy_help_text,
|
||||
tool_call_replay_update,
|
||||
)
|
||||
from vibe.core.llm.format import ResolvedToolCall
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE
|
||||
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS
|
||||
from vibe.core.tools.builtins.task import TaskArgs
|
||||
from vibe.core.tools.permissions import PermissionScope, RequiredPermission
|
||||
from vibe.core.types import (
|
||||
FunctionCall,
|
||||
LLMMessage,
|
||||
Role,
|
||||
ToolCall,
|
||||
UserDisplayContentMetadata,
|
||||
)
|
||||
|
||||
|
||||
def _write_env_file(content: str) -> None:
|
||||
|
|
@ -124,3 +142,126 @@ class TestBuildPermissionOptions:
|
|||
assert reject_once.name == "Deny"
|
||||
assert allow_once.field_meta is None
|
||||
assert reject_once.field_meta is None
|
||||
|
||||
|
||||
class TestCreateUserMessageReplay:
|
||||
def test_replays_plain_text_without_field_meta(self) -> None:
|
||||
replay = create_user_message_replay(
|
||||
LLMMessage(role=Role.user, content="Hello", message_id="msg-1")
|
||||
)
|
||||
|
||||
assert replay.content.text == "Hello"
|
||||
assert replay.message_id == "msg-1"
|
||||
assert replay.field_meta is None
|
||||
|
||||
def test_replays_user_display_content_as_field_meta(self) -> None:
|
||||
user_display_content = UserDisplayContentMetadata(
|
||||
version="1.0.0",
|
||||
host="mistral-vscode",
|
||||
content=[
|
||||
{"type": "text", "text": "Look at "},
|
||||
{
|
||||
"type": "workspace_mention",
|
||||
"kind": "file",
|
||||
"uri": "file:///repo/src/app.ts",
|
||||
"name": "app.ts",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
replay = create_user_message_replay(
|
||||
LLMMessage(
|
||||
role=Role.user,
|
||||
content="Look at app.ts",
|
||||
message_id="msg-1",
|
||||
user_display_content=user_display_content,
|
||||
)
|
||||
)
|
||||
|
||||
assert replay.content.text == "Look at app.ts"
|
||||
assert replay.field_meta == {
|
||||
USER_DISPLAY_CONTENT_META_KEY: user_display_content.model_dump(mode="json")
|
||||
}
|
||||
|
||||
|
||||
class TestCreateToolCallReplay:
|
||||
def test_carries_status_and_resolved_kind(self) -> None:
|
||||
update = create_tool_call_replay("call_1", "grep", '{"pattern": "foo"}')
|
||||
|
||||
assert update.status == "completed"
|
||||
assert update.kind == "search"
|
||||
assert update.field_meta == {"tool_name": "grep"}
|
||||
assert update.raw_input == '{"pattern": "foo"}'
|
||||
|
||||
def test_unknown_tool_defaults_kind_to_other(self) -> None:
|
||||
update = create_tool_call_replay("call_2", "mystery_tool", None)
|
||||
|
||||
assert update.status == "completed"
|
||||
assert update.kind == "other"
|
||||
assert update.field_meta == {"tool_name": "mystery_tool"}
|
||||
|
||||
|
||||
class TestCreateToolResultReplay:
|
||||
def test_carries_kind_and_tool_name_meta(self) -> None:
|
||||
msg = LLMMessage(
|
||||
role=Role.tool, tool_call_id="call_1", name="bash", content="exit 0"
|
||||
)
|
||||
|
||||
update = create_tool_result_replay(msg)
|
||||
|
||||
assert update is not None
|
||||
assert update.kind == "execute"
|
||||
assert update.status == "completed"
|
||||
assert update.field_meta == {"tool_name": "bash"}
|
||||
|
||||
def test_returns_none_without_tool_call_id(self) -> None:
|
||||
msg = LLMMessage(role=Role.tool, name="bash", content="exit 0")
|
||||
|
||||
assert create_tool_result_replay(msg) is None
|
||||
|
||||
|
||||
def _tool_call(call_id: str, name: str, arguments: str | None) -> ToolCall:
|
||||
return ToolCall(id=call_id, function=FunctionCall(name=name, arguments=arguments))
|
||||
|
||||
|
||||
class TestToolCallReplayUpdate:
|
||||
def test_resolved_task_carries_agent_and_task_meta(self) -> None:
|
||||
tool_call = _tool_call(
|
||||
"call_1", "task", '{"agent": "explore", "task": "find the bug"}'
|
||||
)
|
||||
resolved = ResolvedToolCall(
|
||||
tool_name="task",
|
||||
tool_class=AcpTask,
|
||||
validated_args=TaskArgs(agent="explore", task="find the bug"),
|
||||
call_id="call_1",
|
||||
)
|
||||
|
||||
update = tool_call_replay_update(resolved, tool_call)
|
||||
|
||||
assert isinstance(update, ToolCallStart)
|
||||
assert update.status == "completed"
|
||||
assert update.field_meta is not None
|
||||
assert update.field_meta["tool_name"] == "task"
|
||||
assert update.field_meta["agent"] == "explore"
|
||||
assert update.field_meta["task"] == "find the bug"
|
||||
|
||||
def test_unresolved_call_falls_back_to_generic_replay(self) -> None:
|
||||
tool_call = _tool_call("call_2", "grep", '{"pattern": "foo"}')
|
||||
|
||||
update = tool_call_replay_update(None, tool_call)
|
||||
|
||||
assert isinstance(update, ToolCallStart)
|
||||
assert update.status == "completed"
|
||||
assert update.kind == "search"
|
||||
assert update.field_meta == {"tool_name": "grep"}
|
||||
|
||||
def test_hidden_tool_call_is_skipped(self) -> None:
|
||||
tool_call = _tool_call("call_3", "todo", "{}")
|
||||
resolved = ResolvedToolCall(
|
||||
tool_name="todo",
|
||||
tool_class=AcpTodo,
|
||||
validated_args=TodoArgs(action="read"),
|
||||
call_id="call_3",
|
||||
)
|
||||
|
||||
assert tool_call_replay_update(resolved, tool_call) is None
|
||||
|
|
|
|||
140
tests/acp/test_workspace_trust.py
Normal file
140
tests/acp/test_workspace_trust.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.acp.conftest import _create_acp_agent
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.acp.exceptions import InvalidRequestError, SessionNotFoundError
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import ModelConfig
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
|
||||
|
||||
async def _system_prompt(acp_agent_loop: VibeAcpAgentLoop, session_id: str) -> str:
|
||||
session = acp_agent_loop.sessions[session_id]
|
||||
await session.agent_loop.wait_until_ready()
|
||||
return session.agent_loop.messages[0].content or ""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent_loop(
|
||||
backend: FakeBackend, monkeypatch: pytest.MonkeyPatch
|
||||
) -> VibeAcpAgentLoop:
|
||||
config = build_test_vibe_config(
|
||||
active_model="devstral-latest",
|
||||
models=[
|
||||
ModelConfig(
|
||||
name="devstral-latest", provider="mistral", alias="devstral-latest"
|
||||
),
|
||||
ModelConfig(
|
||||
name="devstral-small", provider="mistral", alias="devstral-small"
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
class PatchedAgentLoop(AgentLoop):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **{**kwargs, "backend": backend})
|
||||
self._base_config = config
|
||||
self.agent_manager.invalidate_config()
|
||||
|
||||
monkeypatch.setattr("vibe.acp.acp_agent_loop.AgentLoop", PatchedAgentLoop)
|
||||
return _create_acp_agent()
|
||||
|
||||
|
||||
class TestWorkspaceTrustExtMethods:
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_trust_status_returns_details_even_after_decline(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||
) -> None:
|
||||
(tmp_working_directory / "AGENTS.md").write_text(
|
||||
"Trust me later", encoding="utf-8"
|
||||
)
|
||||
trusted_folders_manager.add_untrusted(tmp_working_directory)
|
||||
|
||||
response = await acp_agent_loop.ext_method(
|
||||
"trust/status", {"cwd": str(tmp_working_directory)}
|
||||
)
|
||||
|
||||
assert response == {
|
||||
"trust_status": "untrusted",
|
||||
"details": {
|
||||
"cwd": str(tmp_working_directory.resolve()),
|
||||
"repoRoot": None,
|
||||
"ignoredFiles": ["AGENTS.md"],
|
||||
"availableDecisions": ["trust_cwd", "trust_session", "decline"],
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_trust_decision_trusts_session_and_reloads_session(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||
) -> None:
|
||||
(tmp_working_directory / "AGENTS.md").write_text(
|
||||
"Reloaded session instructions", encoding="utf-8"
|
||||
)
|
||||
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(tmp_working_directory), mcp_servers=[]
|
||||
)
|
||||
assert session_response.session_id is not None
|
||||
assert "Reloaded session instructions" not in await _system_prompt(
|
||||
acp_agent_loop, session_response.session_id
|
||||
)
|
||||
|
||||
response = await acp_agent_loop.ext_method(
|
||||
"trust/decision",
|
||||
{
|
||||
"cwd": str(tmp_working_directory),
|
||||
"decision": "trust_session",
|
||||
"session_id": session_response.session_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert response == {"trust_status": "session", "details": None}
|
||||
normalized = str(tmp_working_directory.resolve())
|
||||
assert normalized in trusted_folders_manager._session_trusted
|
||||
assert normalized not in trusted_folders_manager._trusted
|
||||
assert "Reloaded session instructions" in await _system_prompt(
|
||||
acp_agent_loop, session_response.session_id
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_trust_decision_rejects_unavailable_decision(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||
) -> None:
|
||||
(tmp_working_directory / "AGENTS.md").write_text(
|
||||
"No repo decision here", encoding="utf-8"
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidRequestError):
|
||||
await acp_agent_loop.ext_method(
|
||||
"trust/decision",
|
||||
{"cwd": str(tmp_working_directory), "decision": "trust_repo"},
|
||||
)
|
||||
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_trust_decision_rejects_unknown_session_id(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
|
||||
) -> None:
|
||||
(tmp_working_directory / "AGENTS.md").write_text(
|
||||
"Unknown session", encoding="utf-8"
|
||||
)
|
||||
|
||||
with pytest.raises(SessionNotFoundError):
|
||||
await acp_agent_loop.ext_method(
|
||||
"trust/decision",
|
||||
{
|
||||
"cwd": str(tmp_working_directory),
|
||||
"decision": "trust_session",
|
||||
"session_id": "missing-session",
|
||||
},
|
||||
)
|
||||
|
||||
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
|
||||
Loading…
Add table
Add a link
Reference in a new issue