refactor(agent-runner): use sandbox file model

This commit is contained in:
huanghuoguoguo
2026-06-19 09:30:12 +08:00
parent 2c09af406e
commit 79a5fba06b
49 changed files with 203 additions and 3401 deletions
-10
View File
@@ -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"}
],
)
-108
View File
@@ -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
+16 -27
View File
@@ -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