mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-21 13:04:20 +00:00
refactor(agent-runner): use sandbox file model
This commit is contained in:
@@ -10,7 +10,6 @@ def make_resources(
|
||||
knowledge_bases: list[dict] | None = None,
|
||||
skills: list[dict] | None = None,
|
||||
storage: dict | None = None,
|
||||
files: list[dict] | None = None,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Create a minimal AgentResources dict for testing.
|
||||
|
||||
@@ -20,8 +19,6 @@ def make_resources(
|
||||
knowledge_bases: List of KB dicts with 'kb_id' key
|
||||
skills: List of skill dicts with 'skill_name' key
|
||||
storage: Storage permissions dict
|
||||
files: List of file dicts with 'file_id' key
|
||||
|
||||
Returns:
|
||||
AgentResources dict with all required fields
|
||||
"""
|
||||
@@ -30,7 +27,6 @@ def make_resources(
|
||||
'tools': tools or [],
|
||||
'knowledge_bases': knowledge_bases or [],
|
||||
'skills': skills or [],
|
||||
'files': files or [],
|
||||
'storage': storage or {'plugin_storage': False, 'workspace_storage': False},
|
||||
'platform_capabilities': {},
|
||||
}
|
||||
@@ -78,7 +74,6 @@ def make_session(
|
||||
'tool': {t.get('tool_name') for t in res.get('tools', [])},
|
||||
'knowledge_base': {kb.get('kb_id') for kb in res.get('knowledge_bases', [])},
|
||||
'skill': {s.get('skill_name') for s in res.get('skills', [])},
|
||||
'file': {f.get('file_id') for f in res.get('files', [])},
|
||||
}
|
||||
authorized_operations: dict[str, dict[str, set[str]]] = {
|
||||
'model': {
|
||||
@@ -101,11 +96,6 @@ def make_session(
|
||||
for s in res.get('skills', [])
|
||||
if s.get('skill_name')
|
||||
},
|
||||
'file': {
|
||||
f.get('file_id'): set(f.get('operations') or ['config', 'knowledge'])
|
||||
for f in res.get('files', [])
|
||||
if f.get('file_id')
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,774 +0,0 @@
|
||||
"""Tests for ArtifactStore and artifact action handlers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
import base64
|
||||
import datetime
|
||||
|
||||
from langbot.pkg.agent.runner.artifact_store import ArtifactStore
|
||||
from langbot.pkg.agent.runner.session_registry import (
|
||||
get_session_registry,
|
||||
)
|
||||
from .conftest import make_session
|
||||
|
||||
|
||||
class TestArtifactStore:
|
||||
"""Test ArtifactStore operations."""
|
||||
|
||||
def _make_mock_engine(self):
|
||||
"""Create a mock database engine for AsyncSession-based store.
|
||||
|
||||
Note: The new store uses AsyncSession, so we need to mock
|
||||
the session factory behavior.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
engine = MagicMock(spec=AsyncEngine)
|
||||
return engine
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_artifact_generates_id(self):
|
||||
"""Test register_artifact generates ID if not provided."""
|
||||
engine = self._make_mock_engine()
|
||||
store = ArtifactStore(engine)
|
||||
|
||||
# Mock the session factory
|
||||
mock_session = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
artifact_id = await store.register_artifact(
|
||||
artifact_id=None,
|
||||
artifact_type="image",
|
||||
source="platform",
|
||||
)
|
||||
|
||||
assert artifact_id is not None
|
||||
assert len(artifact_id) == 36 # UUID format
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_artifact_with_content(self):
|
||||
"""Test register_artifact stores content in BinaryStorage."""
|
||||
engine = self._make_mock_engine()
|
||||
store = ArtifactStore(engine)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
content = b"test image content"
|
||||
artifact_id = await store.register_artifact(
|
||||
artifact_id="art_001",
|
||||
artifact_type="image",
|
||||
source="platform",
|
||||
content=content,
|
||||
)
|
||||
|
||||
assert artifact_id == "art_001"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_artifact_with_storage_key(self):
|
||||
"""Test register_artifact with pre-existing storage_key."""
|
||||
engine = self._make_mock_engine()
|
||||
store = ArtifactStore(engine)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
artifact_id = await store.register_artifact(
|
||||
artifact_id="art_002",
|
||||
artifact_type="file",
|
||||
source="runner",
|
||||
storage_key="existing_key",
|
||||
storage_type="binary_storage",
|
||||
size_bytes=1024,
|
||||
)
|
||||
|
||||
assert artifact_id == "art_002"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_metadata_not_found(self):
|
||||
"""Test get_metadata returns None if not found."""
|
||||
engine = self._make_mock_engine()
|
||||
store = ArtifactStore(engine)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = None
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
metadata = await store.get_metadata("nonexistent")
|
||||
|
||||
assert metadata is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_artifact_validates_offset(self):
|
||||
"""Test read_artifact rejects negative offset."""
|
||||
engine = self._make_mock_engine()
|
||||
store = ArtifactStore(engine)
|
||||
|
||||
with pytest.raises(ValueError, match="offset must be >= 0"):
|
||||
await store.read_artifact("art_001", offset=-1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_artifact_validates_limit(self):
|
||||
"""Test read_artifact rejects zero or negative limit."""
|
||||
engine = self._make_mock_engine()
|
||||
store = ArtifactStore(engine)
|
||||
|
||||
with pytest.raises(ValueError, match="limit must be > 0"):
|
||||
await store.read_artifact("art_001", limit=0)
|
||||
|
||||
with pytest.raises(ValueError, match="limit must be > 0"):
|
||||
await store.read_artifact("art_001", limit=-5)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_artifact_not_found(self):
|
||||
"""Test read_artifact returns None if not found."""
|
||||
engine = self._make_mock_engine()
|
||||
store = ArtifactStore(engine)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = None
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
result = await store.read_artifact("nonexistent")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestArtifactAuthorization:
|
||||
"""Test artifact action handler authorization."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_registry(self):
|
||||
"""Create a fresh session registry for testing."""
|
||||
# Reset global registry
|
||||
import langbot.pkg.agent.runner.session_registry as reg
|
||||
reg._global_registry = None
|
||||
return get_session_registry()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_handler(self):
|
||||
"""Create a mock handler for testing actions."""
|
||||
from langbot_plugin.runtime.io.handler import Handler
|
||||
|
||||
class MockHandler(Handler):
|
||||
def __init__(self):
|
||||
self._responses = {}
|
||||
|
||||
async def call_action(self, action, data, timeout=30):
|
||||
# Simulate error response for missing run_id
|
||||
if not data.get("run_id"):
|
||||
return {"ok": False, "message": "run_id is required"}
|
||||
return {"ok": True, "data": {}}
|
||||
|
||||
return MockHandler()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_artifact_metadata_requires_run_id(self, mock_handler):
|
||||
"""Test artifact_metadata requires run_id."""
|
||||
result = await mock_handler.call_action(
|
||||
"artifact_metadata",
|
||||
{"run_id": None, "artifact_id": "art_001"},
|
||||
)
|
||||
|
||||
assert result.get("ok") is False or "error" in str(result).lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_artifact_read_requires_run_id(self, mock_handler):
|
||||
"""Test artifact_read requires run_id."""
|
||||
result = await mock_handler.call_action(
|
||||
"artifact_read",
|
||||
{"run_id": None, "artifact_id": "art_001"},
|
||||
)
|
||||
|
||||
assert result.get("ok") is False or "error" in str(result).lower()
|
||||
|
||||
|
||||
class TestArtifactAccessValidation:
|
||||
"""Test _validate_artifact_access authorization rules."""
|
||||
|
||||
def _make_session(
|
||||
self,
|
||||
conversation_id: str | None,
|
||||
*,
|
||||
bot_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
):
|
||||
return make_session(
|
||||
run_id="run_001",
|
||||
conversation_id=conversation_id,
|
||||
bot_id=bot_id,
|
||||
workspace_id=workspace_id,
|
||||
thread_id=thread_id,
|
||||
available_apis={"artifact_metadata": True, "artifact_read": True},
|
||||
)
|
||||
|
||||
def _call_validate(self, session, metadata, operation="metadata"):
|
||||
"""Helper to call the validation function."""
|
||||
from langbot.pkg.plugin.handler import _validate_artifact_access
|
||||
return _validate_artifact_access(session, metadata, operation)
|
||||
|
||||
def test_global_artifact_denied_by_default(self):
|
||||
"""Artifacts without conversation_id are denied by default (no global access)."""
|
||||
session = self._make_session("conv_001")
|
||||
metadata = {
|
||||
"artifact_id": "art_global",
|
||||
"conversation_id": None, # No conversation scope
|
||||
"run_id": None, # Not created by any run
|
||||
}
|
||||
|
||||
is_allowed, error = self._call_validate(session, metadata)
|
||||
assert is_allowed is False
|
||||
assert "denied" in error.lower()
|
||||
|
||||
def test_own_run_artifact_allowed(self):
|
||||
"""Artifacts created by same run are allowed (even cross-conversation)."""
|
||||
session = self._make_session("conv_001")
|
||||
metadata = {
|
||||
"artifact_id": "art_001",
|
||||
"conversation_id": "conv_other", # Different conversation
|
||||
"run_id": "run_001", # Same run
|
||||
}
|
||||
|
||||
is_allowed, error = self._call_validate(session, metadata)
|
||||
assert is_allowed is True
|
||||
assert error is None
|
||||
|
||||
def test_same_conversation_allowed(self):
|
||||
"""Artifacts in same conversation are allowed."""
|
||||
session = self._make_session("conv_001")
|
||||
metadata = {
|
||||
"artifact_id": "art_001",
|
||||
"conversation_id": "conv_001", # Same as session
|
||||
"run_id": "run_other", # Different run
|
||||
}
|
||||
|
||||
is_allowed, error = self._call_validate(session, metadata)
|
||||
assert is_allowed is True
|
||||
assert error is None
|
||||
|
||||
def test_same_conversation_and_scope_allowed(self):
|
||||
"""Artifacts in the same run scope are allowed across runs."""
|
||||
session = self._make_session(
|
||||
"conv_001",
|
||||
bot_id="bot_001",
|
||||
workspace_id="workspace_001",
|
||||
thread_id="thread_001",
|
||||
)
|
||||
metadata = {
|
||||
"artifact_id": "art_001",
|
||||
"conversation_id": "conv_001",
|
||||
"run_id": "run_other",
|
||||
"bot_id": "bot_001",
|
||||
"workspace_id": "workspace_001",
|
||||
"thread_id": "thread_001",
|
||||
}
|
||||
|
||||
is_allowed, error = self._call_validate(session, metadata)
|
||||
assert is_allowed is True
|
||||
assert error is None
|
||||
|
||||
def test_same_conversation_different_scope_denied(self):
|
||||
"""Artifacts in another bot/thread scope are denied even in the same conversation."""
|
||||
session = self._make_session(
|
||||
"conv_001",
|
||||
bot_id="bot_001",
|
||||
workspace_id="workspace_001",
|
||||
thread_id="thread_001",
|
||||
)
|
||||
metadata = {
|
||||
"artifact_id": "art_001",
|
||||
"conversation_id": "conv_001",
|
||||
"run_id": "run_other",
|
||||
"bot_id": "bot_002",
|
||||
"workspace_id": "workspace_001",
|
||||
"thread_id": "thread_001",
|
||||
}
|
||||
|
||||
is_allowed, error = self._call_validate(session, metadata)
|
||||
assert is_allowed is False
|
||||
assert "denied" in error.lower()
|
||||
|
||||
def test_same_conversation_missing_scope_denied_for_scoped_session(self):
|
||||
"""Scoped runs should not read legacy-scope artifacts from other runs."""
|
||||
session = self._make_session("conv_001", bot_id="bot_001")
|
||||
metadata = {
|
||||
"artifact_id": "art_001",
|
||||
"conversation_id": "conv_001",
|
||||
"run_id": "run_other",
|
||||
"bot_id": None,
|
||||
"workspace_id": None,
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
is_allowed, error = self._call_validate(session, metadata)
|
||||
assert is_allowed is False
|
||||
assert "denied" in error.lower()
|
||||
|
||||
def test_different_conversation_and_run_denied(self):
|
||||
"""Artifacts in different conversation and different run are denied."""
|
||||
session = self._make_session("conv_001")
|
||||
metadata = {
|
||||
"artifact_id": "art_001",
|
||||
"conversation_id": "conv_other", # Different conversation
|
||||
"run_id": "run_other", # Different run
|
||||
}
|
||||
|
||||
is_allowed, error = self._call_validate(session, metadata)
|
||||
assert is_allowed is False
|
||||
assert "denied" in error.lower()
|
||||
|
||||
def test_session_without_conversation_denied_for_conversation_artifact(self):
|
||||
"""Session without conversation_id cannot access conversation-scoped artifacts."""
|
||||
session = self._make_session(None)
|
||||
metadata = {
|
||||
"artifact_id": "art_001",
|
||||
"conversation_id": "conv_001", # Has conversation
|
||||
"run_id": "run_other", # Different run
|
||||
}
|
||||
|
||||
is_allowed, error = self._call_validate(session, metadata)
|
||||
assert is_allowed is False
|
||||
|
||||
def test_session_without_conversation_allowed_for_own_artifact(self):
|
||||
"""Session without conversation can access artifacts it created."""
|
||||
session = self._make_session(None)
|
||||
metadata = {
|
||||
"artifact_id": "art_001",
|
||||
"conversation_id": "conv_001", # Has conversation
|
||||
"run_id": "run_001", # Same run (created by this run)
|
||||
}
|
||||
|
||||
is_allowed, error = self._call_validate(session, metadata)
|
||||
assert is_allowed is True
|
||||
|
||||
|
||||
class TestContextAccessArtifactAPIs:
|
||||
"""Test ContextAccess reflects runtime artifact API availability."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_access_has_artifact_apis_when_permitted(self):
|
||||
"""Artifact APIs are exposed through run-scoped available_apis."""
|
||||
available_apis = {"artifact_metadata": True, "artifact_read": True}
|
||||
|
||||
assert available_apis["artifact_metadata"] is True
|
||||
assert available_apis["artifact_read"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_access_no_artifact_apis_without_permission(self):
|
||||
"""Artifact APIs are absent when the run did not receive them."""
|
||||
available_apis = {}
|
||||
|
||||
assert available_apis.get("artifact_metadata", False) is False
|
||||
assert available_apis.get("artifact_read", False) is False
|
||||
|
||||
|
||||
class TestArtifactMetadataFieldAlignment:
|
||||
"""Test that Host returns metadata compatible with SDK ArtifactMetadata."""
|
||||
|
||||
def test_row_to_public_dict_excludes_host_only_fields(self):
|
||||
"""_row_to_public_dict should not return Host-only fields."""
|
||||
from langbot.pkg.agent.runner.artifact_store import ArtifactStore
|
||||
from langbot.pkg.entity.persistence.artifact import AgentArtifact
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Create a mock row
|
||||
mock_row = MagicMock(spec=AgentArtifact)
|
||||
mock_row.artifact_id = "art_001"
|
||||
mock_row.artifact_type = "image"
|
||||
mock_row.mime_type = "image/png"
|
||||
mock_row.name = "test.png"
|
||||
mock_row.size_bytes = 1024
|
||||
mock_row.sha256 = "abc123"
|
||||
mock_row.source = "platform"
|
||||
mock_row.conversation_id = "conv_001"
|
||||
mock_row.run_id = "run_001"
|
||||
mock_row.runner_id = "plugin:test/plugin/runner"
|
||||
mock_row.created_at = datetime.datetime(2024, 1, 1, 0, 0, 0)
|
||||
mock_row.expires_at = None
|
||||
mock_row.metadata_json = None
|
||||
|
||||
# These are Host-only fields that should NOT be in output
|
||||
# (they don't exist in SDK ArtifactMetadata)
|
||||
mock_row.bot_id = "bot_001"
|
||||
mock_row.workspace_id = "ws_001"
|
||||
mock_row.storage_key = "artifact:art_001"
|
||||
mock_row.storage_type = "binary_storage"
|
||||
|
||||
store = ArtifactStore(MagicMock())
|
||||
result = store._row_to_public_dict(mock_row)
|
||||
|
||||
# SDK-compatible fields should be present
|
||||
assert result["artifact_id"] == "art_001"
|
||||
assert result["artifact_type"] == "image"
|
||||
assert result["source"] == "platform"
|
||||
assert result["conversation_id"] == "conv_001"
|
||||
assert result["run_id"] == "run_001"
|
||||
|
||||
# Host-only fields should NOT be present
|
||||
assert "bot_id" not in result
|
||||
assert "workspace_id" not in result
|
||||
assert "storage_key" not in result
|
||||
assert "storage_type" not in result
|
||||
|
||||
|
||||
class TestSessionRegistryAvailableAPIs:
|
||||
"""Test that session registry stores and retrieves available APIs correctly."""
|
||||
|
||||
@pytest.fixture
|
||||
def session_registry(self):
|
||||
"""Create a fresh session registry for testing."""
|
||||
import langbot.pkg.agent.runner.session_registry as reg
|
||||
reg._global_registry = None
|
||||
return get_session_registry()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_stores_available_apis(self, session_registry):
|
||||
"""Test that register() stores runtime API availability."""
|
||||
await session_registry.register(
|
||||
run_id="run_001",
|
||||
runner_id="plugin:author/plugin/runner",
|
||||
query_id=None,
|
||||
plugin_identity="author/plugin",
|
||||
resources={
|
||||
"models": [],
|
||||
"tools": [],
|
||||
"knowledge_bases": [],
|
||||
"files": [],
|
||||
"storage": {"plugin_storage": True, "workspace_storage": False},
|
||||
"platform_capabilities": {},
|
||||
},
|
||||
available_apis={
|
||||
"artifact_metadata": True,
|
||||
"artifact_read": True,
|
||||
"history_page": True,
|
||||
"event_get": True,
|
||||
},
|
||||
conversation_id="conv_001",
|
||||
)
|
||||
|
||||
session = await session_registry.get("run_001")
|
||||
assert session is not None
|
||||
available_apis = session["authorization"]["available_apis"]
|
||||
assert available_apis["artifact_metadata"] is True
|
||||
assert available_apis["artifact_read"] is True
|
||||
assert available_apis["history_page"] is True
|
||||
assert available_apis["event_get"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_with_empty_available_apis(self, session_registry):
|
||||
"""Test that register() handles empty API availability."""
|
||||
await session_registry.register(
|
||||
run_id="run_002",
|
||||
runner_id="plugin:author/plugin/runner",
|
||||
query_id=None,
|
||||
plugin_identity="author/plugin",
|
||||
resources={
|
||||
"models": [],
|
||||
"tools": [],
|
||||
"knowledge_bases": [],
|
||||
"files": [],
|
||||
"storage": {"plugin_storage": True, "workspace_storage": False},
|
||||
"platform_capabilities": {},
|
||||
},
|
||||
available_apis={},
|
||||
conversation_id="conv_001",
|
||||
)
|
||||
|
||||
session = await session_registry.get("run_002")
|
||||
assert session is not None
|
||||
assert session["authorization"]["available_apis"] == {}
|
||||
|
||||
|
||||
class TestArtifactStoreRealSQLite:
|
||||
"""Test ArtifactStore with real SQLite database."""
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine(self):
|
||||
"""Create an in-memory SQLite database for testing."""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
|
||||
# Create tables
|
||||
async with engine.begin() as conn:
|
||||
# Create tables manually for in-memory DB
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_get_metadata_round_trip(self, db_engine):
|
||||
"""Test register_artifact -> get_metadata round trip with real DB."""
|
||||
store = ArtifactStore(db_engine)
|
||||
|
||||
# Register artifact with content
|
||||
content = b"test image content for round trip"
|
||||
artifact_id = await store.register_artifact(
|
||||
artifact_id="art_real_001",
|
||||
artifact_type="image",
|
||||
source="platform",
|
||||
mime_type="image/png",
|
||||
name="test.png",
|
||||
content=content,
|
||||
conversation_id="conv_001",
|
||||
run_id="run_001",
|
||||
bot_id="bot_001",
|
||||
workspace_id="workspace_001",
|
||||
thread_id="thread_001",
|
||||
)
|
||||
|
||||
assert artifact_id == "art_real_001"
|
||||
|
||||
# Get metadata
|
||||
metadata = await store.get_metadata(artifact_id)
|
||||
assert metadata is not None
|
||||
assert metadata["artifact_id"] == "art_real_001"
|
||||
assert metadata["artifact_type"] == "image"
|
||||
assert metadata["mime_type"] == "image/png"
|
||||
assert metadata["source"] == "platform"
|
||||
assert metadata["conversation_id"] == "conv_001"
|
||||
assert metadata["run_id"] == "run_001"
|
||||
|
||||
# Verify Host-only fields are NOT in public metadata
|
||||
assert "storage_key" not in metadata
|
||||
assert "storage_type" not in metadata
|
||||
assert "bot_id" not in metadata
|
||||
assert "workspace_id" not in metadata
|
||||
assert "thread_id" not in metadata
|
||||
assert "_langbot_thread_id" not in metadata.get("metadata", {})
|
||||
|
||||
auth_metadata = await store.get_authorization_metadata(artifact_id)
|
||||
assert auth_metadata is not None
|
||||
assert auth_metadata["bot_id"] == "bot_001"
|
||||
assert auth_metadata["workspace_id"] == "workspace_001"
|
||||
assert auth_metadata["thread_id"] == "thread_001"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_artifact_round_trip(self, db_engine):
|
||||
"""Test register_artifact -> read_artifact round trip with real DB."""
|
||||
store = ArtifactStore(db_engine)
|
||||
|
||||
# Register artifact with content
|
||||
content = b"test file content for read test"
|
||||
artifact_id = await store.register_artifact(
|
||||
artifact_id="art_real_002",
|
||||
artifact_type="file",
|
||||
source="runner",
|
||||
mime_type="text/plain",
|
||||
name="test.txt",
|
||||
content=content,
|
||||
conversation_id="conv_001",
|
||||
run_id="run_001",
|
||||
)
|
||||
|
||||
# Read artifact
|
||||
result = await store.read_artifact(artifact_id)
|
||||
assert result is not None
|
||||
assert result["artifact_id"] == "art_real_002"
|
||||
assert result["mime_type"] == "text/plain"
|
||||
assert result["offset"] == 0
|
||||
assert result["length"] == len(content)
|
||||
assert result["has_more"] is False
|
||||
|
||||
# Verify content
|
||||
decoded_content = base64.b64decode(result["content_base64"])
|
||||
assert decoded_content == content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_artifact_with_offset_limit(self, db_engine):
|
||||
"""Test read_artifact with offset and limit."""
|
||||
store = ArtifactStore(db_engine)
|
||||
|
||||
# Register artifact with content
|
||||
content = b"0123456789" * 100 # 1000 bytes
|
||||
artifact_id = await store.register_artifact(
|
||||
artifact_id="art_real_003",
|
||||
artifact_type="file",
|
||||
source="runner",
|
||||
mime_type="application/octet-stream",
|
||||
content=content,
|
||||
)
|
||||
|
||||
# Read with offset
|
||||
result = await store.read_artifact(artifact_id, offset=100, limit=100)
|
||||
assert result is not None
|
||||
assert result["offset"] == 100
|
||||
assert result["length"] == 100
|
||||
|
||||
# Verify content
|
||||
decoded_content = base64.b64decode(result["content_base64"])
|
||||
assert decoded_content == content[100:200]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_artifact_has_more(self, db_engine):
|
||||
"""Test read_artifact sets has_more correctly."""
|
||||
store = ArtifactStore(db_engine)
|
||||
|
||||
# Register artifact with content
|
||||
content = b"0123456789" * 100 # 1000 bytes
|
||||
artifact_id = await store.register_artifact(
|
||||
artifact_id="art_real_004",
|
||||
artifact_type="file",
|
||||
source="runner",
|
||||
content=content,
|
||||
)
|
||||
|
||||
# Read with limit smaller than content
|
||||
result = await store.read_artifact(artifact_id, offset=0, limit=100)
|
||||
assert result is not None
|
||||
assert result["has_more"] is True
|
||||
assert result["length"] == 100
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_artifact_is_not_readable_before_cleanup(self, db_engine):
|
||||
"""Expired artifacts are hidden even before a cleanup job deletes rows."""
|
||||
store = ArtifactStore(db_engine)
|
||||
await store.register_artifact(
|
||||
artifact_id="art_expired_hidden",
|
||||
artifact_type="file",
|
||||
source="runner",
|
||||
content=b"expired",
|
||||
expires_at=datetime.datetime.utcnow() - datetime.timedelta(seconds=1),
|
||||
)
|
||||
|
||||
assert await store.get_metadata("art_expired_hidden") is None
|
||||
assert await store.read_artifact("art_expired_hidden") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_expired_artifacts_deletes_binary_storage(self, db_engine):
|
||||
"""Expired artifacts and their Host-owned binary blobs are removed."""
|
||||
from sqlalchemy import select
|
||||
from langbot.pkg.entity.persistence.bstorage import BinaryStorage
|
||||
|
||||
store = ArtifactStore(db_engine)
|
||||
now = datetime.datetime.utcnow()
|
||||
await store.register_artifact(
|
||||
artifact_id="art_expired",
|
||||
artifact_type="file",
|
||||
source="runner",
|
||||
content=b"expired",
|
||||
expires_at=now - datetime.timedelta(seconds=1),
|
||||
)
|
||||
await store.register_artifact(
|
||||
artifact_id="art_fresh",
|
||||
artifact_type="file",
|
||||
source="runner",
|
||||
content=b"fresh",
|
||||
expires_at=now + datetime.timedelta(days=1),
|
||||
)
|
||||
|
||||
removed = await store.cleanup_expired_artifacts(now=now)
|
||||
|
||||
assert removed == 1
|
||||
assert await store.get_metadata("art_expired") is None
|
||||
assert await store.get_metadata("art_fresh") is not None
|
||||
async with store._session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(BinaryStorage).where(BinaryStorage.unique_key == "artifact:art_expired")
|
||||
)
|
||||
assert result.scalars().first() is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_artifact_range_read_and_public_metadata(self, db_engine, tmp_path):
|
||||
"""File-backed artifacts read ranges without exposing host paths."""
|
||||
store = ArtifactStore(db_engine)
|
||||
content = b"0123456789" * 20
|
||||
file_path = tmp_path / "large.txt"
|
||||
file_path.write_bytes(content)
|
||||
|
||||
artifact_id = await store.register_file_artifact(
|
||||
artifact_id="art_file_001",
|
||||
host_path=str(file_path),
|
||||
host_root=str(tmp_path),
|
||||
source="tool",
|
||||
mime_type="text/plain",
|
||||
name="large.txt",
|
||||
conversation_id="conv_001",
|
||||
run_id="run_001",
|
||||
metadata={"sandbox_path": "/workspace/large.txt"},
|
||||
)
|
||||
|
||||
metadata = await store.get_metadata(artifact_id)
|
||||
assert metadata is not None
|
||||
assert metadata["artifact_id"] == "art_file_001"
|
||||
assert metadata["metadata"] == {"sandbox_path": "/workspace/large.txt"}
|
||||
assert str(file_path) not in str(metadata)
|
||||
|
||||
result = await store.read_artifact(artifact_id, offset=10, limit=15)
|
||||
assert result is not None
|
||||
assert result["offset"] == 10
|
||||
assert result["length"] == 15
|
||||
assert result["size_bytes"] == len(content)
|
||||
assert result["has_more"] is True
|
||||
assert base64.b64decode(result["content_base64"]) == content[10:25]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_file_artifact_rejects_path_escape(self, db_engine, tmp_path):
|
||||
"""File-backed artifacts must stay inside their declared host root."""
|
||||
store = ArtifactStore(db_engine)
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("outside")
|
||||
|
||||
with pytest.raises(ValueError, match="escapes"):
|
||||
await store.register_file_artifact(
|
||||
artifact_id="art_file_escape",
|
||||
host_path=str(outside),
|
||||
host_root=str(root),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metadata_sdk_validation(self, db_engine):
|
||||
"""Test that metadata can be validated by SDK ArtifactMetadata."""
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.artifact import ArtifactMetadata
|
||||
|
||||
store = ArtifactStore(db_engine)
|
||||
|
||||
# Register artifact
|
||||
artifact_id = await store.register_artifact(
|
||||
artifact_id="art_real_005",
|
||||
artifact_type="file",
|
||||
source="runner",
|
||||
mime_type="application/pdf",
|
||||
name="document.pdf",
|
||||
size_bytes=1024,
|
||||
conversation_id="conv_001",
|
||||
run_id="run_001",
|
||||
runner_id="plugin:test/plugin/runner",
|
||||
)
|
||||
|
||||
# Get metadata
|
||||
metadata = await store.get_metadata(artifact_id)
|
||||
assert metadata is not None
|
||||
|
||||
# Should not raise ValidationError
|
||||
validated = ArtifactMetadata.model_validate(metadata)
|
||||
assert validated.artifact_id == "art_real_005"
|
||||
assert validated.artifact_type == "file"
|
||||
@@ -41,7 +41,6 @@ def make_descriptor(
|
||||
else {
|
||||
'history': ['page', 'search'],
|
||||
'events': ['get', 'page'],
|
||||
'artifacts': ['metadata', 'read'],
|
||||
'storage': ['plugin'],
|
||||
},
|
||||
)
|
||||
@@ -310,29 +309,6 @@ class TestContextAccessOtherAPIs:
|
||||
assert context_access['available_apis']['event_get'] is True
|
||||
assert context_access['available_apis']['event_page'] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_artifact_apis_enabled_by_default(self, mock_app):
|
||||
"""Artifact APIs are available based on current run scope."""
|
||||
mock_event = MagicMock()
|
||||
mock_event.conversation_id = 'conv_001'
|
||||
mock_event.thread_id = None
|
||||
mock_descriptor = make_descriptor()
|
||||
|
||||
binding = AgentBinding(
|
||||
binding_id='binding_001',
|
||||
runner_id='plugin:test/runner/default',
|
||||
scope=BindingScope(scope_type='agent', scope_id='conv_001'),
|
||||
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
|
||||
)
|
||||
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
|
||||
# Real call
|
||||
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
|
||||
|
||||
assert context_access['available_apis']['artifact_metadata'] is True
|
||||
assert context_access['available_apis']['artifact_read'] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_required_apis_disabled_without_conversation(self, mock_app):
|
||||
"""Conversation-scoped APIs are disabled when the run has no conversation."""
|
||||
@@ -357,8 +333,6 @@ class TestContextAccessOtherAPIs:
|
||||
assert context_access['available_apis']['history_search'] is False
|
||||
assert context_access['available_apis']['event_get'] is True
|
||||
assert context_access['available_apis']['event_page'] is False
|
||||
assert context_access['available_apis']['artifact_metadata'] is True
|
||||
assert context_access['available_apis']['artifact_read'] is True
|
||||
assert context_access['available_apis']['state'] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -384,6 +358,4 @@ class TestContextAccessOtherAPIs:
|
||||
assert context_access['available_apis']['history_search'] is False
|
||||
assert context_access['available_apis']['event_get'] is False
|
||||
assert context_access['available_apis']['event_page'] is False
|
||||
assert context_access['available_apis']['artifact_metadata'] is False
|
||||
assert context_access['available_apis']['artifact_read'] is False
|
||||
assert context_access['available_apis']['storage'] is False
|
||||
|
||||
@@ -100,7 +100,6 @@ class TestContextValidation:
|
||||
permissions={
|
||||
"history": ["page", "search"],
|
||||
"events": ["get", "page"],
|
||||
"artifacts": ["metadata", "read"],
|
||||
"storage": ["plugin", "workspace"],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -286,18 +286,6 @@ class TestSDKResultProtocolV1:
|
||||
|
||||
assert result.run_id == "run_1"
|
||||
|
||||
def test_artifact_created_result_type(self):
|
||||
"""Test artifact.created result type."""
|
||||
result = AgentRunResult.artifact_created(
|
||||
run_id="run_1",
|
||||
artifact_id="artifact_1",
|
||||
artifact_type="image",
|
||||
)
|
||||
|
||||
assert result.type == AgentRunResultType.ARTIFACT_CREATED
|
||||
assert result.data["artifact_id"] == "artifact_1"
|
||||
|
||||
|
||||
# Fixtures
|
||||
@pytest.fixture
|
||||
def mock_query():
|
||||
|
||||
@@ -211,8 +211,8 @@ class TestTranscriptStore:
|
||||
assert transcript_id is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_transcript_with_artifacts(self, mock_db_engine):
|
||||
"""Test appending transcript with artifact refs."""
|
||||
async def test_append_transcript_with_attachments(self, mock_db_engine):
|
||||
"""Test appending transcript with attachment refs."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
store = TranscriptStore(mock_db_engine)
|
||||
@@ -231,8 +231,8 @@ class TestTranscriptStore:
|
||||
conversation_id="conv_1",
|
||||
role="assistant",
|
||||
content="Here's an image",
|
||||
artifact_refs=[
|
||||
{"artifact_id": "art_1", "artifact_type": "image", "url": "http://example.com/img.png"}
|
||||
attachment_refs=[
|
||||
{"id": "att_1", "type": "image", "url": "http://example.com/img.png"}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -1510,36 +1510,6 @@ class TestStorageResourcePermissionHelper:
|
||||
assert registry.is_resource_allowed(session, 'storage', 'workspace') is False
|
||||
|
||||
|
||||
class TestFilesResourcePermission:
|
||||
"""Tests for session_registry.is_resource_allowed for files resource type.
|
||||
|
||||
Phase 6: 'files' resource type is now implemented in is_resource_allowed.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_files_resource_type_now_implemented(self):
|
||||
"""'files' resource type is now implemented in is_resource_allowed."""
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(files=[{'file_id': 'file_001'}])
|
||||
|
||||
await registry.register(
|
||||
run_id='run_files_implemented',
|
||||
runner_id='plugin:test/runner/default',
|
||||
query_id=1,
|
||||
plugin_identity='test/runner',
|
||||
resources=resources,
|
||||
)
|
||||
|
||||
session = await registry.get('run_files_implemented')
|
||||
|
||||
# 'files' resource type is now implemented
|
||||
assert registry.is_resource_allowed(session, 'file', 'file_001') is True
|
||||
assert registry.is_resource_allowed(session, 'file', 'file_999') is False
|
||||
|
||||
await registry.unregister('run_files_implemented')
|
||||
|
||||
|
||||
class TestRealActionHandlerSimulation:
|
||||
"""Tests that simulate real RuntimeConnectionHandler action registration and execution.
|
||||
@@ -1797,84 +1767,6 @@ class TestStoragePermissionValidation:
|
||||
await registry.unregister('run_workspace_storage_denied')
|
||||
|
||||
|
||||
class TestFilePermissionValidation:
|
||||
"""Tests for Host-side file permission validation via _validate_run_authorization.
|
||||
|
||||
Phase 6: GET_CONFIG_FILE action now validates file permissions
|
||||
via _validate_run_authorization when run_id is present.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_allowed_when_in_resources(self):
|
||||
"""_validate_run_authorization allows file when in resources."""
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(files=[{'file_id': 'file_001'}])
|
||||
|
||||
await registry.register(
|
||||
run_id='run_file_auth',
|
||||
runner_id='plugin:test/runner/default',
|
||||
query_id=1,
|
||||
plugin_identity='test/runner',
|
||||
resources=resources,
|
||||
)
|
||||
|
||||
from langbot.pkg.plugin.handler import _validate_run_authorization
|
||||
|
||||
mock_ap = MagicMock()
|
||||
mock_ap.logger = MagicMock()
|
||||
|
||||
session, error = await _validate_run_authorization(
|
||||
'run_file_auth',
|
||||
'file',
|
||||
'file_001',
|
||||
mock_ap,
|
||||
caller_plugin_identity='test/runner',
|
||||
)
|
||||
|
||||
assert session is not None
|
||||
assert error is None
|
||||
|
||||
await registry.unregister('run_file_auth')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_denied_when_not_in_resources(self):
|
||||
"""_validate_run_authorization denies file when not in resources."""
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(files=[{'file_id': 'file_001'}])
|
||||
|
||||
await registry.register(
|
||||
run_id='run_file_denied',
|
||||
runner_id='plugin:test/runner/default',
|
||||
query_id=1,
|
||||
plugin_identity='test/runner',
|
||||
resources=resources,
|
||||
)
|
||||
|
||||
from langbot.pkg.plugin.handler import _validate_run_authorization
|
||||
|
||||
mock_ap = MagicMock()
|
||||
mock_ap.logger = MagicMock()
|
||||
mock_ap.logger.warning = MagicMock()
|
||||
|
||||
session, error = await _validate_run_authorization(
|
||||
'run_file_denied',
|
||||
'file',
|
||||
'file_999', # Not in resources
|
||||
mock_ap,
|
||||
caller_plugin_identity='test/runner',
|
||||
)
|
||||
|
||||
assert session is None
|
||||
assert error is not None
|
||||
assert 'not authorized' in error.message.lower()
|
||||
|
||||
await registry.unregister('run_file_denied')
|
||||
|
||||
|
||||
class TestOperationPermissionValidation:
|
||||
"""Tests operation-level Host-side run authorization."""
|
||||
|
||||
|
||||
@@ -1,657 +0,0 @@
|
||||
"""Tests for artifact.created handling in orchestrator."""
|
||||
import pytest
|
||||
import base64
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import uuid
|
||||
|
||||
from langbot.pkg.agent.runner.orchestrator import (
|
||||
AgentRunOrchestrator,
|
||||
MAX_ARTIFACT_INLINE_BYTES,
|
||||
)
|
||||
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope
|
||||
from langbot.pkg.agent.runner.errors import RunnerProtocolError
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext
|
||||
from langbot.pkg.core import app
|
||||
|
||||
|
||||
class TestArtifactCreatedValidation:
|
||||
"""Test artifact.created validation and protocol errors."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
"""Create mock application."""
|
||||
ap = MagicMock(spec=app.Application)
|
||||
ap.logger = MagicMock()
|
||||
ap.plugin_connector = MagicMock()
|
||||
ap.plugin_connector.is_enable_plugin = True
|
||||
ap.persistence_mgr = MagicMock()
|
||||
ap.persistence_mgr.get_db_engine = MagicMock()
|
||||
return ap
|
||||
|
||||
@pytest.fixture
|
||||
def mock_registry(self):
|
||||
"""Create mock registry."""
|
||||
registry = MagicMock()
|
||||
registry.get = AsyncMock()
|
||||
return registry
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event(self):
|
||||
"""Create mock event envelope."""
|
||||
event = MagicMock(spec=AgentEventEnvelope)
|
||||
event.event_id = str(uuid.uuid4())
|
||||
event.event_type = 'message.received'
|
||||
event.source = 'test'
|
||||
event.bot_id = str(uuid.uuid4())
|
||||
event.workspace_id = str(uuid.uuid4())
|
||||
event.conversation_id = str(uuid.uuid4())
|
||||
event.thread_id = None
|
||||
event.event_time = 1700000000
|
||||
event.actor = MagicMock(spec=ActorContext)
|
||||
event.actor.actor_type = 'user'
|
||||
event.actor.actor_id = 'user-123'
|
||||
event.actor.actor_name = 'Test User'
|
||||
event.subject = None
|
||||
event.input = MagicMock(spec=AgentInput)
|
||||
event.input.text = 'Hello'
|
||||
event.input.contents = []
|
||||
event.input.attachments = []
|
||||
return event
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_id_mismatch_raises_protocol_error(
|
||||
self, mock_app, mock_registry, mock_event
|
||||
):
|
||||
"""Test that run_id mismatch raises RunnerProtocolError."""
|
||||
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
|
||||
run_id = str(uuid.uuid4())
|
||||
wrong_run_id = str(uuid.uuid4())
|
||||
|
||||
result_dict = {
|
||||
'type': 'artifact.created',
|
||||
'run_id': wrong_run_id,
|
||||
'data': {
|
||||
'artifact_type': 'image',
|
||||
},
|
||||
}
|
||||
|
||||
with pytest.raises(RunnerProtocolError) as exc_info:
|
||||
await orchestrator._handle_artifact_created(
|
||||
result_dict=result_dict,
|
||||
event=mock_event,
|
||||
run_id=run_id,
|
||||
runner_id='test-runner',
|
||||
)
|
||||
|
||||
assert 'run_id mismatch' in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_artifact_type_raises_protocol_error(
|
||||
self, mock_app, mock_registry, mock_event
|
||||
):
|
||||
"""Test that missing artifact_type raises RunnerProtocolError."""
|
||||
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
|
||||
run_id = str(uuid.uuid4())
|
||||
|
||||
result_dict = {
|
||||
'type': 'artifact.created',
|
||||
'run_id': run_id,
|
||||
'data': {
|
||||
'artifact_id': str(uuid.uuid4()),
|
||||
# missing artifact_type
|
||||
},
|
||||
}
|
||||
|
||||
with pytest.raises(RunnerProtocolError) as exc_info:
|
||||
await orchestrator._handle_artifact_created(
|
||||
result_dict=result_dict,
|
||||
event=mock_event,
|
||||
run_id=run_id,
|
||||
runner_id='test-runner',
|
||||
)
|
||||
|
||||
assert 'missing required field' in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_base64_raises_protocol_error(
|
||||
self, mock_app, mock_registry, mock_event
|
||||
):
|
||||
"""Test that invalid base64 raises RunnerProtocolError."""
|
||||
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
|
||||
run_id = str(uuid.uuid4())
|
||||
|
||||
result_dict = {
|
||||
'type': 'artifact.created',
|
||||
'run_id': run_id,
|
||||
'data': {
|
||||
'artifact_type': 'image',
|
||||
'content_base64': '!!!invalid-base64!!!',
|
||||
},
|
||||
}
|
||||
|
||||
with pytest.raises(RunnerProtocolError) as exc_info:
|
||||
await orchestrator._handle_artifact_created(
|
||||
result_dict=result_dict,
|
||||
event=mock_event,
|
||||
run_id=run_id,
|
||||
runner_id='test-runner',
|
||||
)
|
||||
|
||||
assert 'invalid base64' in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oversized_content_raises_protocol_error(
|
||||
self, mock_app, mock_registry, mock_event
|
||||
):
|
||||
"""Test that content exceeding limit raises RunnerProtocolError."""
|
||||
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
|
||||
run_id = str(uuid.uuid4())
|
||||
|
||||
# Create content larger than limit
|
||||
oversized_content = b'x' * (MAX_ARTIFACT_INLINE_BYTES + 1)
|
||||
content_base64 = base64.b64encode(oversized_content).decode('utf-8')
|
||||
|
||||
result_dict = {
|
||||
'type': 'artifact.created',
|
||||
'run_id': run_id,
|
||||
'data': {
|
||||
'artifact_type': 'image',
|
||||
'content_base64': content_base64,
|
||||
},
|
||||
}
|
||||
|
||||
with pytest.raises(RunnerProtocolError) as exc_info:
|
||||
await orchestrator._handle_artifact_created(
|
||||
result_dict=result_dict,
|
||||
event=mock_event,
|
||||
run_id=run_id,
|
||||
runner_id='test-runner',
|
||||
)
|
||||
|
||||
assert 'exceeds limit' in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_artifact_store_failure_raises_protocol_error(
|
||||
self, mock_app, mock_registry, mock_event
|
||||
):
|
||||
"""Test that ArtifactStore failure raises RunnerProtocolError."""
|
||||
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
|
||||
run_id = str(uuid.uuid4())
|
||||
|
||||
result_dict = {
|
||||
'type': 'artifact.created',
|
||||
'run_id': run_id,
|
||||
'data': {
|
||||
'artifact_type': 'image',
|
||||
},
|
||||
}
|
||||
|
||||
with patch('langbot.pkg.agent.runner.artifact_store.ArtifactStore') as MockArtifactStore:
|
||||
mock_artifact_store = MagicMock()
|
||||
mock_artifact_store.register_artifact = AsyncMock(
|
||||
side_effect=Exception('DB connection failed')
|
||||
)
|
||||
MockArtifactStore.return_value = mock_artifact_store
|
||||
|
||||
with pytest.raises(RunnerProtocolError) as exc_info:
|
||||
await orchestrator._handle_artifact_created(
|
||||
result_dict=result_dict,
|
||||
event=mock_event,
|
||||
run_id=run_id,
|
||||
runner_id='test-runner',
|
||||
)
|
||||
|
||||
assert 'failed to register artifact' in str(exc_info.value)
|
||||
|
||||
|
||||
class TestArtifactCreatedSuccess:
|
||||
"""Test successful artifact.created handling."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
"""Create mock application."""
|
||||
ap = MagicMock(spec=app.Application)
|
||||
ap.logger = MagicMock()
|
||||
ap.plugin_connector = MagicMock()
|
||||
ap.plugin_connector.is_enable_plugin = True
|
||||
ap.persistence_mgr = MagicMock()
|
||||
ap.persistence_mgr.get_db_engine = MagicMock()
|
||||
return ap
|
||||
|
||||
@pytest.fixture
|
||||
def mock_registry(self):
|
||||
"""Create mock registry."""
|
||||
registry = MagicMock()
|
||||
registry.get = AsyncMock()
|
||||
return registry
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event(self):
|
||||
"""Create mock event envelope."""
|
||||
event = MagicMock(spec=AgentEventEnvelope)
|
||||
event.event_id = str(uuid.uuid4())
|
||||
event.event_type = 'message.received'
|
||||
event.source = 'test'
|
||||
event.bot_id = str(uuid.uuid4())
|
||||
event.workspace_id = str(uuid.uuid4())
|
||||
event.conversation_id = str(uuid.uuid4())
|
||||
event.thread_id = None
|
||||
event.event_time = 1700000000
|
||||
event.actor = MagicMock(spec=ActorContext)
|
||||
event.actor.actor_type = 'user'
|
||||
event.actor.actor_id = 'user-123'
|
||||
event.actor.actor_name = 'Test User'
|
||||
event.subject = None
|
||||
return event
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_artifact_created_registers_artifact(
|
||||
self, mock_app, mock_registry, mock_event
|
||||
):
|
||||
"""Test that artifact.created registers artifact via ArtifactStore."""
|
||||
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
|
||||
run_id = str(uuid.uuid4())
|
||||
runner_id = 'test-runner'
|
||||
|
||||
# Create artifact.created result
|
||||
content = b'test artifact content'
|
||||
content_base64 = base64.b64encode(content).decode('utf-8')
|
||||
artifact_id = str(uuid.uuid4())
|
||||
|
||||
result_dict = {
|
||||
'type': 'artifact.created',
|
||||
'run_id': run_id,
|
||||
'data': {
|
||||
'artifact_id': artifact_id,
|
||||
'artifact_type': 'image',
|
||||
'mime_type': 'image/png',
|
||||
'name': 'test.png',
|
||||
'size_bytes': len(content),
|
||||
'content_base64': content_base64,
|
||||
},
|
||||
}
|
||||
|
||||
with patch('langbot.pkg.agent.runner.artifact_store.ArtifactStore') as MockArtifactStore:
|
||||
with patch('langbot.pkg.agent.runner.event_log_store.EventLogStore') as MockEventLogStore:
|
||||
mock_artifact_store = MagicMock()
|
||||
mock_artifact_store.register_artifact = AsyncMock(return_value=artifact_id)
|
||||
MockArtifactStore.return_value = mock_artifact_store
|
||||
|
||||
mock_event_log_store = MagicMock()
|
||||
mock_event_log_store.append_event = AsyncMock()
|
||||
MockEventLogStore.return_value = mock_event_log_store
|
||||
|
||||
# Call _handle_artifact_created
|
||||
result = await orchestrator._handle_artifact_created(
|
||||
result_dict=result_dict,
|
||||
event=mock_event,
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
)
|
||||
|
||||
# Verify artifact was registered
|
||||
mock_artifact_store.register_artifact.assert_called_once()
|
||||
call_kwargs = mock_artifact_store.register_artifact.call_args.kwargs
|
||||
assert call_kwargs['artifact_id'] == artifact_id
|
||||
assert call_kwargs['artifact_type'] == 'image'
|
||||
assert call_kwargs['mime_type'] == 'image/png'
|
||||
assert call_kwargs['name'] == 'test.png'
|
||||
assert call_kwargs['content'] == content
|
||||
assert call_kwargs['conversation_id'] == mock_event.conversation_id
|
||||
assert call_kwargs['run_id'] == run_id
|
||||
assert call_kwargs['runner_id'] == runner_id
|
||||
|
||||
# Verify EventLog was written
|
||||
mock_event_log_store.append_event.assert_called_once()
|
||||
event_kwargs = mock_event_log_store.append_event.call_args.kwargs
|
||||
assert event_kwargs['event_type'] == 'artifact.created'
|
||||
assert event_kwargs['run_id'] == run_id
|
||||
|
||||
# Verify artifact ref returned
|
||||
assert result is not None
|
||||
assert result['artifact_id'] == artifact_id
|
||||
assert result['artifact_type'] == 'image'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_artifact_created_metadata_only(
|
||||
self, mock_app, mock_registry, mock_event
|
||||
):
|
||||
"""Test artifact.created without content (metadata-only)."""
|
||||
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
|
||||
run_id = str(uuid.uuid4())
|
||||
artifact_id = str(uuid.uuid4())
|
||||
|
||||
result_dict = {
|
||||
'type': 'artifact.created',
|
||||
'run_id': run_id,
|
||||
'data': {
|
||||
'artifact_id': artifact_id,
|
||||
'artifact_type': 'file',
|
||||
'mime_type': 'application/pdf',
|
||||
'name': 'document.pdf',
|
||||
'size_bytes': 1024,
|
||||
'sha256': 'abc123',
|
||||
'metadata': {'source': 'external'},
|
||||
},
|
||||
}
|
||||
|
||||
with patch('langbot.pkg.agent.runner.artifact_store.ArtifactStore') as MockArtifactStore:
|
||||
with patch('langbot.pkg.agent.runner.event_log_store.EventLogStore') as MockEventLogStore:
|
||||
mock_artifact_store = MagicMock()
|
||||
mock_artifact_store.register_artifact = AsyncMock(return_value=artifact_id)
|
||||
MockArtifactStore.return_value = mock_artifact_store
|
||||
|
||||
mock_event_log_store = MagicMock()
|
||||
mock_event_log_store.append_event = AsyncMock()
|
||||
MockEventLogStore.return_value = mock_event_log_store
|
||||
|
||||
result = await orchestrator._handle_artifact_created(
|
||||
result_dict=result_dict,
|
||||
event=mock_event,
|
||||
run_id=run_id,
|
||||
runner_id='test-runner',
|
||||
)
|
||||
|
||||
# Verify artifact was registered without content
|
||||
call_kwargs = mock_artifact_store.register_artifact.call_args.kwargs
|
||||
assert call_kwargs['content'] is None
|
||||
assert call_kwargs['sha256'] == 'abc123'
|
||||
assert call_kwargs['metadata'] == {'source': 'external'}
|
||||
|
||||
assert result is not None
|
||||
assert result['artifact_id'] == artifact_id
|
||||
|
||||
|
||||
class TestArtifactRefsLifecycle:
|
||||
"""Test artifact refs lifecycle in event-first flow."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
"""Create mock application."""
|
||||
ap = MagicMock(spec=app.Application)
|
||||
ap.logger = MagicMock()
|
||||
ap.plugin_connector = MagicMock()
|
||||
ap.plugin_connector.is_enable_plugin = True
|
||||
ap.persistence_mgr = MagicMock()
|
||||
ap.persistence_mgr.get_db_engine = MagicMock()
|
||||
return ap
|
||||
|
||||
@pytest.fixture
|
||||
def mock_registry(self):
|
||||
"""Create mock registry."""
|
||||
registry = MagicMock()
|
||||
registry.get = AsyncMock()
|
||||
return registry
|
||||
|
||||
def test_merge_artifact_refs_deduplicates(
|
||||
self, mock_app, mock_registry
|
||||
):
|
||||
"""Test that _merge_artifact_refs deduplicates by artifact_id."""
|
||||
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
|
||||
|
||||
pending_refs = [
|
||||
{'artifact_id': 'artifact-1', 'artifact_type': 'image'},
|
||||
{'artifact_id': 'artifact-2', 'artifact_type': 'file'},
|
||||
]
|
||||
|
||||
result_dict = {
|
||||
'type': 'message.completed',
|
||||
'data': {
|
||||
'message': {
|
||||
'content': 'Hello',
|
||||
'artifact_refs': [
|
||||
{'artifact_id': 'artifact-2', 'artifact_type': 'file'}, # duplicate
|
||||
{'artifact_id': 'artifact-3', 'artifact_type': 'voice'},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
merged = orchestrator._merge_artifact_refs(pending_refs, result_dict)
|
||||
|
||||
# Should have 3 unique artifacts
|
||||
assert len(merged) == 3
|
||||
artifact_ids = {ref['artifact_id'] for ref in merged}
|
||||
assert artifact_ids == {'artifact-1', 'artifact-2', 'artifact-3'}
|
||||
|
||||
def test_merge_artifact_refs_empty_pending(
|
||||
self, mock_app, mock_registry
|
||||
):
|
||||
"""Test merge with empty pending refs."""
|
||||
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
|
||||
|
||||
pending_refs = []
|
||||
|
||||
result_dict = {
|
||||
'type': 'message.completed',
|
||||
'data': {
|
||||
'message': {
|
||||
'content': 'Hello',
|
||||
'artifact_refs': [
|
||||
{'artifact_id': 'artifact-1', 'artifact_type': 'image'},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
merged = orchestrator._merge_artifact_refs(pending_refs, result_dict)
|
||||
|
||||
assert len(merged) == 1
|
||||
assert merged[0]['artifact_id'] == 'artifact-1'
|
||||
|
||||
def test_merge_artifact_refs_empty_message_refs(
|
||||
self, mock_app, mock_registry
|
||||
):
|
||||
"""Test merge with no message artifact_refs."""
|
||||
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
|
||||
|
||||
pending_refs = [
|
||||
{'artifact_id': 'artifact-1', 'artifact_type': 'image'},
|
||||
]
|
||||
|
||||
result_dict = {
|
||||
'type': 'message.completed',
|
||||
'data': {
|
||||
'message': {
|
||||
'content': 'Hello',
|
||||
# no artifact_refs
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
merged = orchestrator._merge_artifact_refs(pending_refs, result_dict)
|
||||
|
||||
assert len(merged) == 1
|
||||
assert merged[0]['artifact_id'] == 'artifact-1'
|
||||
|
||||
|
||||
class TestResultNormalizerArtifactCreated:
|
||||
"""Test ResultNormalizer handling of artifact.created."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
"""Create mock application."""
|
||||
ap = MagicMock(spec=app.Application)
|
||||
ap.logger = MagicMock()
|
||||
return ap
|
||||
|
||||
@pytest.fixture
|
||||
def mock_descriptor(self):
|
||||
"""Create mock descriptor."""
|
||||
descriptor = MagicMock()
|
||||
descriptor.id = 'test-runner'
|
||||
return descriptor
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalize_artifact_created_returns_none(
|
||||
self, mock_app, mock_descriptor
|
||||
):
|
||||
"""Test that artifact.created is consumed (returns None)."""
|
||||
from langbot.pkg.agent.runner.result_normalizer import AgentResultNormalizer
|
||||
|
||||
normalizer = AgentResultNormalizer(mock_app)
|
||||
|
||||
result_dict = {
|
||||
'type': 'artifact.created',
|
||||
'run_id': 'test-run-id',
|
||||
'data': {
|
||||
'artifact_id': 'artifact-123',
|
||||
'artifact_type': 'image',
|
||||
},
|
||||
}
|
||||
|
||||
result = await normalizer.normalize(result_dict, mock_descriptor)
|
||||
|
||||
# Should return None (consumed)
|
||||
assert result is None
|
||||
|
||||
# Debug log should be written
|
||||
mock_app.logger.debug.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalize_unknown_type_warning(
|
||||
self, mock_app, mock_descriptor
|
||||
):
|
||||
"""Test that unknown result types still produce warnings."""
|
||||
from langbot.pkg.agent.runner.result_normalizer import AgentResultNormalizer
|
||||
|
||||
normalizer = AgentResultNormalizer(mock_app)
|
||||
|
||||
result_dict = {
|
||||
'type': 'unknown.type',
|
||||
'data': {},
|
||||
}
|
||||
|
||||
result = await normalizer.normalize(result_dict, mock_descriptor)
|
||||
|
||||
# Should return None
|
||||
assert result is None
|
||||
|
||||
# Warning should be logged
|
||||
mock_app.logger.warning.assert_called()
|
||||
|
||||
|
||||
class TestEventLogTranscriptIntegration:
|
||||
"""Test EventLog and Transcript integration with artifact.created."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
"""Create mock application."""
|
||||
ap = MagicMock(spec=app.Application)
|
||||
ap.logger = MagicMock()
|
||||
ap.plugin_connector = MagicMock()
|
||||
ap.plugin_connector.is_enable_plugin = True
|
||||
ap.persistence_mgr = MagicMock()
|
||||
ap.persistence_mgr.get_db_engine = MagicMock()
|
||||
return ap
|
||||
|
||||
@pytest.fixture
|
||||
def mock_registry(self):
|
||||
"""Create mock registry."""
|
||||
registry = MagicMock()
|
||||
registry.get = AsyncMock()
|
||||
return registry
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event(self):
|
||||
"""Create mock event envelope."""
|
||||
event = MagicMock(spec=AgentEventEnvelope)
|
||||
event.event_id = str(uuid.uuid4())
|
||||
event.event_type = 'message.received'
|
||||
event.source = 'test'
|
||||
event.bot_id = str(uuid.uuid4())
|
||||
event.workspace_id = str(uuid.uuid4())
|
||||
event.conversation_id = str(uuid.uuid4())
|
||||
event.thread_id = None
|
||||
event.event_time = 1700000000
|
||||
event.actor = MagicMock(spec=ActorContext)
|
||||
event.actor.actor_type = 'user'
|
||||
event.actor.actor_id = 'user-123'
|
||||
event.actor.actor_name = 'Test User'
|
||||
event.subject = None
|
||||
return event
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_log_written_with_correct_event_type(
|
||||
self, mock_app, mock_registry, mock_event
|
||||
):
|
||||
"""Test that EventLog is written with event_type='artifact.created'."""
|
||||
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
|
||||
run_id = str(uuid.uuid4())
|
||||
artifact_id = str(uuid.uuid4())
|
||||
|
||||
result_dict = {
|
||||
'type': 'artifact.created',
|
||||
'run_id': run_id,
|
||||
'data': {
|
||||
'artifact_id': artifact_id,
|
||||
'artifact_type': 'image',
|
||||
},
|
||||
}
|
||||
|
||||
with patch('langbot.pkg.agent.runner.artifact_store.ArtifactStore') as MockArtifactStore:
|
||||
with patch('langbot.pkg.agent.runner.event_log_store.EventLogStore') as MockEventLogStore:
|
||||
mock_artifact_store = MagicMock()
|
||||
mock_artifact_store.register_artifact = AsyncMock(return_value=artifact_id)
|
||||
MockArtifactStore.return_value = mock_artifact_store
|
||||
|
||||
mock_event_log_store = MagicMock()
|
||||
mock_event_log_store.append_event = AsyncMock()
|
||||
MockEventLogStore.return_value = mock_event_log_store
|
||||
|
||||
await orchestrator._handle_artifact_created(
|
||||
result_dict=result_dict,
|
||||
event=mock_event,
|
||||
run_id=run_id,
|
||||
runner_id='test-runner',
|
||||
)
|
||||
|
||||
# Verify EventLog.append_event was called with correct event_type
|
||||
mock_event_log_store.append_event.assert_called_once()
|
||||
call_kwargs = mock_event_log_store.append_event.call_args.kwargs
|
||||
assert call_kwargs['event_type'] == 'artifact.created'
|
||||
assert call_kwargs['source'] == 'runner'
|
||||
assert call_kwargs['conversation_id'] == mock_event.conversation_id
|
||||
assert call_kwargs['run_id'] == run_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assistant_transcript_receives_artifact_refs(
|
||||
self, mock_app, mock_registry, mock_event
|
||||
):
|
||||
"""Test that assistant transcript receives artifact refs from artifact.created."""
|
||||
orchestrator = AgentRunOrchestrator(mock_app, mock_registry)
|
||||
run_id = str(uuid.uuid4())
|
||||
artifact_id = str(uuid.uuid4())
|
||||
|
||||
# Create pending artifact refs
|
||||
pending_refs = [
|
||||
{'artifact_id': artifact_id, 'artifact_type': 'image', 'mime_type': 'image/png'},
|
||||
]
|
||||
|
||||
result_dict = {
|
||||
'type': 'message.completed',
|
||||
'data': {
|
||||
'message': {
|
||||
'content': 'Here is your image',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
with patch('langbot.pkg.agent.runner.transcript_store.TranscriptStore') as MockTranscriptStore:
|
||||
mock_transcript_store = MagicMock()
|
||||
mock_transcript_store.append_transcript = AsyncMock()
|
||||
MockTranscriptStore.return_value = mock_transcript_store
|
||||
|
||||
await orchestrator._write_assistant_transcript(
|
||||
result_dict=result_dict,
|
||||
event=mock_event,
|
||||
run_id=run_id,
|
||||
runner_id='test-runner',
|
||||
artifact_refs=pending_refs,
|
||||
)
|
||||
|
||||
# Verify transcript was written with artifact_refs
|
||||
mock_transcript_store.append_transcript.assert_called_once()
|
||||
call_kwargs = mock_transcript_store.append_transcript.call_args.kwargs
|
||||
assert call_kwargs['artifact_refs'] == pending_refs
|
||||
@@ -168,7 +168,6 @@ def make_descriptor() -> AgentRunnerDescriptor:
|
||||
'knowledge_bases': ['list', 'retrieve'],
|
||||
'history': ['page', 'search'],
|
||||
'events': ['get', 'page'],
|
||||
'artifacts': ['metadata', 'read'],
|
||||
'storage': ['plugin'],
|
||||
},
|
||||
config_schema=[
|
||||
@@ -270,8 +269,8 @@ def test_context_builder_includes_consumable_base64_attachments():
|
||||
assert input_data.contents[1].image_base64 == 'data:image/png;base64,aGVsbG8='
|
||||
assert input_data.contents[2].file_base64 == 'data:text/plain;base64,aGVsbG8='
|
||||
|
||||
artifact_types = [attachment.artifact_type for attachment in input_data.attachments]
|
||||
assert artifact_types == ['image', 'file', 'image']
|
||||
attachment_types = [attachment.type for attachment in input_data.attachments]
|
||||
assert attachment_types == ['image', 'file', 'image']
|
||||
assert input_data.attachments[1].name == 'hello.txt'
|
||||
|
||||
|
||||
@@ -286,7 +285,7 @@ def test_context_builder_deduplicates_message_chain_attachments():
|
||||
|
||||
assert [content.type for content in input_data.contents] == ['image_base64']
|
||||
assert len(input_data.attachments) == 1
|
||||
assert input_data.attachments[0].artifact_type == 'image'
|
||||
assert input_data.attachments[0].type == 'image'
|
||||
assert input_data.attachments[0].content == 'data:image/jpeg;base64,aGVsbG8='
|
||||
|
||||
|
||||
@@ -303,7 +302,7 @@ def test_context_builder_preserves_same_source_duplicate_attachments():
|
||||
|
||||
input_data = QueryEntryAdapter._build_input(query)
|
||||
|
||||
assert [attachment.artifact_type for attachment in input_data.attachments] == ['image', 'image']
|
||||
assert [attachment.type for attachment in input_data.attachments] == ['image', 'image']
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -1139,7 +1138,7 @@ class TestQueryEntryAdapterHostCapabilities:
|
||||
transcripts, _, _, _ = await transcript_store.page_transcript(
|
||||
conversation_id=query.session.using_conversation.uuid,
|
||||
limit=10,
|
||||
include_artifacts=True,
|
||||
include_attachments=True,
|
||||
)
|
||||
assert len(transcripts) >= 2
|
||||
# Find user and assistant messages
|
||||
@@ -1148,60 +1147,5 @@ class TestQueryEntryAdapterHostCapabilities:
|
||||
assert 'assistant' in roles
|
||||
user_item = next(t for t in transcripts if t['role'] == 'user')
|
||||
assert user_item['content_json']['content'][1]['image_base64'] is None
|
||||
assert user_item['artifact_refs'][0]['content'] is None
|
||||
assert user_item['attachment_refs'][0]['content'] is None
|
||||
assert 'aGVsbG8=' not in str(user_item)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_artifact_created_via_event_first_path(self, clean_agent_state):
|
||||
"""artifact.created via Pipeline path uses event-first ArtifactStore and EventLog."""
|
||||
import base64
|
||||
from langbot.pkg.agent.runner.artifact_store import ArtifactStore
|
||||
from langbot.pkg.agent.runner.event_log_store import EventLogStore
|
||||
|
||||
db_engine = clean_agent_state
|
||||
descriptor = make_descriptor()
|
||||
artifact_id = 'artifact_001'
|
||||
content = b'test artifact content'
|
||||
content_base64 = base64.b64encode(content).decode('utf-8')
|
||||
plugin_connector = FakePluginConnector(
|
||||
results=[
|
||||
{
|
||||
'type': 'artifact.created',
|
||||
'data': {
|
||||
'artifact_id': artifact_id,
|
||||
'artifact_type': 'file',
|
||||
'mime_type': 'text/plain',
|
||||
'name': 'test.txt',
|
||||
'content_base64': content_base64,
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'message.completed',
|
||||
'data': {'message': {'role': 'assistant', 'content': 'artifact created'}},
|
||||
},
|
||||
]
|
||||
)
|
||||
ap = FakeApplication(plugin_connector, db_engine)
|
||||
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
||||
query = make_query()
|
||||
|
||||
messages = [message async for message in orchestrator.run_from_query(query)]
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0].content == 'artifact created'
|
||||
|
||||
# Verify artifact was registered in ArtifactStore
|
||||
artifact_store = ArtifactStore(db_engine)
|
||||
artifact = await artifact_store.get_metadata(artifact_id)
|
||||
assert artifact is not None
|
||||
assert artifact['artifact_type'] == 'file'
|
||||
assert artifact['name'] == 'test.txt'
|
||||
|
||||
# Verify artifact.created event was written to EventLog
|
||||
event_log_store = EventLogStore(db_engine)
|
||||
event_logs, _, _ = await event_log_store.page_events(
|
||||
conversation_id=query.session.using_conversation.uuid,
|
||||
limit=10,
|
||||
)
|
||||
artifact_events = [e for e in event_logs if e['event_type'] == 'artifact.created']
|
||||
assert len(artifact_events) >= 1
|
||||
|
||||
@@ -39,16 +39,12 @@ class FakeApplication:
|
||||
'plugin_name': 'local-agent',
|
||||
'runner_name': 'default',
|
||||
'manifest': {
|
||||
'kind': 'AgentRunner',
|
||||
'metadata': {
|
||||
'name': 'default',
|
||||
'label': {'en_US': 'Local Agent'},
|
||||
},
|
||||
'spec': {
|
||||
'config': [],
|
||||
'capabilities': {'streaming': True},
|
||||
'permissions': {},
|
||||
},
|
||||
'id': 'plugin:langbot/local-agent/default',
|
||||
'name': 'default',
|
||||
'label': {'en_US': 'Local Agent'},
|
||||
'capabilities': {'streaming': True},
|
||||
'permissions': {},
|
||||
'config_schema': [],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -56,16 +52,12 @@ class FakeApplication:
|
||||
'plugin_name': 'my-agent',
|
||||
'runner_name': 'custom',
|
||||
'manifest': {
|
||||
'kind': 'AgentRunner',
|
||||
'metadata': {
|
||||
'name': 'custom',
|
||||
'label': {'en_US': 'Custom Agent'},
|
||||
},
|
||||
'spec': {
|
||||
'config': [{'name': 'param1', 'type': 'string'}],
|
||||
'capabilities': {},
|
||||
'permissions': {},
|
||||
},
|
||||
'id': 'plugin:alice/my-agent/custom',
|
||||
'name': 'custom',
|
||||
'label': {'en_US': 'Custom Agent'},
|
||||
'capabilities': {},
|
||||
'permissions': {},
|
||||
'config_schema': [{'name': 'param1', 'type': 'string'}],
|
||||
},
|
||||
},
|
||||
# Invalid runner - wrong kind
|
||||
@@ -237,15 +229,12 @@ class TestRegistryMetadataForPipeline:
|
||||
assert 'plugin:langbot/local-agent/default' in option_ids
|
||||
assert 'plugin:alice/my-agent/custom' in option_ids
|
||||
|
||||
# Should fall back to manifest.spec.config when runtime does not return
|
||||
# extracted config at top level.
|
||||
# Config comes from the typed manifest.
|
||||
assert len(stages) == 1
|
||||
assert stages[0]['name'] == 'plugin:alice/my-agent/custom'
|
||||
assert stages[0]['config'] == [{
|
||||
'name': 'param1',
|
||||
'type': 'string',
|
||||
'id': 'plugin:alice/my-agent/custom.param1',
|
||||
}]
|
||||
assert stages[0]['config'][0]['name'] == 'param1'
|
||||
assert stages[0]['config'][0]['type'] == 'string'
|
||||
assert stages[0]['config'][0]['id'] == 'plugin:alice/my-agent/custom.param1'
|
||||
|
||||
|
||||
class TestDescriptorValidation:
|
||||
|
||||
@@ -19,9 +19,7 @@ FULL_PERMISSIONS = {
|
||||
'knowledge_bases': ['list', 'retrieve'],
|
||||
'history': ['page', 'search'],
|
||||
'events': ['get', 'page'],
|
||||
'artifacts': ['metadata', 'read'],
|
||||
'storage': ['plugin', 'workspace'],
|
||||
'files': ['config', 'knowledge'],
|
||||
}
|
||||
|
||||
|
||||
@@ -384,42 +382,6 @@ async def test_build_knowledge_bases_manifest_permission_denies_binding_kbs(app)
|
||||
assert resources['knowledge_bases'] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_files_authorizes_config_declared_file_fields(app):
|
||||
descriptor = make_descriptor(
|
||||
config_schema=[
|
||||
{'name': 'avatar', 'type': 'file'},
|
||||
{'name': 'references', 'type': 'array[file]'},
|
||||
],
|
||||
)
|
||||
query = make_query({
|
||||
'avatar': {'file_key': 'plugin_config_avatar.png', 'mimetype': 'image/png'},
|
||||
'references': [
|
||||
{'file_key': 'plugin_config_doc.txt', 'mimetype': 'text/plain', 'file_name': 'doc.txt'},
|
||||
{'file_key': 'plugin_config_doc.txt', 'mimetype': 'text/plain', 'file_name': 'doc.txt'},
|
||||
],
|
||||
})
|
||||
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
assert resources['files'] == [
|
||||
{
|
||||
'file_id': 'plugin_config_avatar.png',
|
||||
'file_name': None,
|
||||
'mime_type': 'image/png',
|
||||
'source': 'config',
|
||||
'operations': ['config'],
|
||||
},
|
||||
{
|
||||
'file_id': 'plugin_config_doc.txt',
|
||||
'file_name': 'doc.txt',
|
||||
'mime_type': 'text/plain',
|
||||
'source': 'config',
|
||||
'operations': ['config'],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_storage_intersects_manifest_and_binding_policy(app):
|
||||
descriptor = make_descriptor(
|
||||
|
||||
@@ -290,29 +290,6 @@ class TestNormalizeNonMessageResults:
|
||||
assert result is None
|
||||
assert app.logger.warnings
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_artifact_created_payload_is_dropped(self):
|
||||
"""Invalid artifact.created payload returns None with a warning."""
|
||||
app = FakeApplication()
|
||||
normalizer = AgentResultNormalizer(app)
|
||||
descriptor = make_descriptor()
|
||||
|
||||
result = await normalizer.normalize(
|
||||
{
|
||||
'type': 'artifact.created',
|
||||
'data': {
|
||||
'artifact_id': 'artifact-1',
|
||||
'artifact_type': 'file',
|
||||
'content_base64': 'not base64',
|
||||
},
|
||||
},
|
||||
descriptor,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert app.logger.warnings
|
||||
|
||||
|
||||
class TestNormalizeInvalidResults:
|
||||
"""Tests for handling invalid results."""
|
||||
|
||||
|
||||
@@ -287,24 +287,6 @@ async def test_persistent_run_get_requires_capability(db_engine):
|
||||
assert 'not authorized' in result.message.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persistent_authorization_does_not_reopen_artifact_api(db_engine):
|
||||
await _create_run(db_engine, available_apis={'artifact_metadata': True})
|
||||
handler = _handler(db_engine)
|
||||
artifact_metadata = handler.actions[PluginToRuntimeAction.ARTIFACT_METADATA.value]
|
||||
|
||||
result = await artifact_metadata(
|
||||
{
|
||||
'run_id': 'run_1',
|
||||
'artifact_id': 'artifact_1',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code != 0
|
||||
assert 'not found or expired' in result.message.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_run_admin_can_list_all_runs_with_own_run_session(session_registry, db_engine):
|
||||
await _register_session(session_registry, available_apis={})
|
||||
|
||||
@@ -534,36 +534,6 @@ class TestIsResourceAllowed:
|
||||
|
||||
assert registry.is_resource_allowed(session, 'unknown_type', 'something') is False
|
||||
|
||||
def test_file_allowed(self):
|
||||
"""File in resources should be allowed."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(
|
||||
files=[
|
||||
{'file_id': 'file_001'},
|
||||
{'file_id': 'file_002'},
|
||||
]
|
||||
)
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'file', 'file_001') is True
|
||||
assert registry.is_resource_allowed(session, 'file', 'file_002') is True
|
||||
|
||||
def test_file_not_allowed(self):
|
||||
"""File not in resources should be denied."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(files=[{'file_id': 'file_001'}])
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'file', 'file_999') is False
|
||||
|
||||
def test_file_empty_resources(self):
|
||||
"""Empty files list should deny all."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(files=[])
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'file', 'file_001') is False
|
||||
|
||||
def test_missing_resources_field(self):
|
||||
"""Missing resources field should not raise."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
|
||||
@@ -430,7 +430,7 @@ async def test_runtime_provider_invoke_llm_stream_stashes_usage(runtime_provider
|
||||
}
|
||||
|
||||
async def fake_stream(**kwargs):
|
||||
kwargs['query'].variables[requester.STREAM_USAGE_QUERY_VARIABLE] = usage
|
||||
kwargs['query'].variables[requester.LLM_USAGE_QUERY_VARIABLE] = usage
|
||||
yield provider_message.MessageChunk(role='assistant', content='ok')
|
||||
|
||||
provider.requester.invoke_llm_stream = fake_stream
|
||||
@@ -446,7 +446,6 @@ async def test_runtime_provider_invoke_llm_stream_stashes_usage(runtime_provider
|
||||
|
||||
assert len(chunks) == 1
|
||||
assert query.variables[requester.LLM_USAGE_QUERY_VARIABLE] == usage
|
||||
assert requester.STREAM_USAGE_QUERY_VARIABLE not in query.variables
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user