mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
feat(runner): unify plugin execution across agents and event processors
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Tests for current AgentRunner config resolution."""
|
||||
"""Tests for current Runner config resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for persisted AgentRunner config templates."""
|
||||
"""Tests for persisted Runner config templates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
"""Tests for ContextAccess.state determination in AgentRunContextBuilder.
|
||||
"""Tests for ContextAccess.state determination in RunnerContextBuilder.
|
||||
|
||||
Tests focus on:
|
||||
- Event-first mode: state=True when enable_state=True and state_scopes non-empty
|
||||
- Event-first mode: state=False when enable_state=False
|
||||
- Legacy Query mode: state=False (no persistent state API)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from langbot.pkg.agent.runner.context_builder import AgentRunContextBuilder
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.context_builder import RunnerContextBuilder
|
||||
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
|
||||
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope, AgentBinding, BindingScope, StatePolicy
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.runner.event import ActorContext
|
||||
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
||||
|
||||
|
||||
class MockApplication:
|
||||
"""Mock Application for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = MagicMock()
|
||||
self.persistence_mgr = MagicMock()
|
||||
@@ -28,8 +30,8 @@ class MockApplication:
|
||||
|
||||
def make_descriptor(
|
||||
permissions: dict | None = None,
|
||||
) -> AgentRunnerDescriptor:
|
||||
return AgentRunnerDescriptor(
|
||||
) -> RunnerDescriptor:
|
||||
return RunnerDescriptor(
|
||||
id='plugin:test/runner/default',
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
@@ -91,7 +93,7 @@ class TestContextAccessStateDetermination:
|
||||
),
|
||||
)
|
||||
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
|
||||
# Real call to _build_context_access
|
||||
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
|
||||
@@ -112,7 +114,7 @@ class TestContextAccessStateDetermination:
|
||||
),
|
||||
)
|
||||
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
|
||||
# Real call
|
||||
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
|
||||
@@ -133,7 +135,7 @@ class TestContextAccessStateDetermination:
|
||||
),
|
||||
)
|
||||
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
|
||||
# Real call
|
||||
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
|
||||
@@ -144,7 +146,7 @@ class TestContextAccessStateDetermination:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_binding_sets_state_false(self, mock_app, mock_event, mock_descriptor):
|
||||
"""ContextAccess.state=False when no binding is provided."""
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
|
||||
# Real call without binding
|
||||
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding=None)
|
||||
@@ -180,7 +182,7 @@ class TestContextAccessStateDetermination:
|
||||
),
|
||||
)
|
||||
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
|
||||
# Real call
|
||||
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
|
||||
@@ -201,7 +203,7 @@ class TestContextAccessStateDetermination:
|
||||
),
|
||||
)
|
||||
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
|
||||
# Real call
|
||||
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
|
||||
@@ -277,7 +279,7 @@ class TestContextAccessOtherAPIs:
|
||||
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
|
||||
)
|
||||
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
|
||||
# Real call
|
||||
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
|
||||
@@ -301,7 +303,7 @@ class TestContextAccessOtherAPIs:
|
||||
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
|
||||
)
|
||||
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
|
||||
# Real call
|
||||
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
|
||||
@@ -324,7 +326,7 @@ class TestContextAccessOtherAPIs:
|
||||
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
|
||||
)
|
||||
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
|
||||
# Real call
|
||||
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
|
||||
@@ -350,7 +352,7 @@ class TestContextAccessOtherAPIs:
|
||||
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
|
||||
)
|
||||
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
|
||||
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Test that LangBot context builder output validates against SDK AgentRunContext."""
|
||||
"""Test that LangBot context builder output validates against SDK RunnerContext."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,26 +7,26 @@ from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
# SDK imports for validation
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.event import AgentEventContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.context_access import ContextAccess
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
|
||||
from langbot_plugin.api.entities.builtin.runner.context import RunnerContext
|
||||
from langbot_plugin.api.entities.builtin.runner.event import AgentEventContext
|
||||
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.runner.context_access import ContextAccess
|
||||
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.runner.resources import AgentResources
|
||||
from langbot_plugin.api.entities.builtin.runner.runtime import AgentRuntimeContext
|
||||
|
||||
# LangBot imports
|
||||
from langbot.pkg.agent.runner.context_builder import (
|
||||
AgentRunContextBuilder,
|
||||
RunnerContextBuilder,
|
||||
AgentResources as BuilderResources,
|
||||
)
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
|
||||
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope, AgentBinding, BindingScope
|
||||
from langbot.pkg.core import app
|
||||
|
||||
|
||||
class TestContextValidation:
|
||||
"""Test that context builder output validates against SDK AgentRunContext."""
|
||||
"""Test that context builder output validates against SDK RunnerContext."""
|
||||
|
||||
def _make_mock_app(self):
|
||||
"""Create a mock application."""
|
||||
@@ -40,9 +40,9 @@ class TestContextValidation:
|
||||
|
||||
def _make_event_envelope(self) -> AgentEventEnvelope:
|
||||
"""Create a test event envelope."""
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput as EventInput
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.runner.event import ActorContext
|
||||
from langbot_plugin.api.entities.builtin.runner.input import AgentInput as EventInput
|
||||
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
||||
|
||||
return AgentEventEnvelope(
|
||||
event_id='evt_1',
|
||||
@@ -90,7 +90,7 @@ class TestContextValidation:
|
||||
|
||||
def _make_descriptor(self):
|
||||
"""Create a mock runner descriptor."""
|
||||
return AgentRunnerDescriptor(
|
||||
return RunnerDescriptor(
|
||||
id='plugin:test/plugin/runner',
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
@@ -106,9 +106,9 @@ class TestContextValidation:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_from_event_validates(self):
|
||||
"""Test that build_context_from_event output validates against SDK AgentRunContext."""
|
||||
"""Test that build_context_from_event output validates against SDK RunnerContext."""
|
||||
mock_app = self._make_mock_app()
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
|
||||
event = self._make_event_envelope()
|
||||
binding = self._make_binding()
|
||||
@@ -136,9 +136,9 @@ class TestContextValidation:
|
||||
resources=resources,
|
||||
)
|
||||
|
||||
# Validate it can be parsed by SDK AgentRunContext
|
||||
# Validate it can be parsed by SDK RunnerContext
|
||||
# This will raise ValidationError if invalid
|
||||
validated = AgentRunContext.model_validate(context_dict)
|
||||
validated = RunnerContext.model_validate(context_dict)
|
||||
|
||||
# Verify required fields
|
||||
assert validated.run_id is not None
|
||||
@@ -179,13 +179,13 @@ class TestContextValidation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_preserves_interaction_protocol_fields(self):
|
||||
"""Validated submissions and delivery capabilities survive the final context projection."""
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.interaction import (
|
||||
from langbot_plugin.api.entities.builtin.runner.interaction import (
|
||||
InteractionDeliveryCapabilities,
|
||||
InteractionSubmission,
|
||||
)
|
||||
|
||||
mock_app = self._make_mock_app()
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
event = self._make_event_envelope()
|
||||
event.event_type = 'interaction.submitted'
|
||||
event.input.interaction = InteractionSubmission(
|
||||
@@ -211,7 +211,7 @@ class TestContextValidation:
|
||||
resources=self._make_resources(),
|
||||
)
|
||||
|
||||
validated = AgentRunContext.model_validate(context_dict)
|
||||
validated = RunnerContext.model_validate(context_dict)
|
||||
assert validated.input.interaction is not None
|
||||
assert validated.input.interaction.interaction_id == 'form-1'
|
||||
assert validated.input.interaction.values == {'comment': 'looks good'}
|
||||
@@ -228,7 +228,7 @@ class TestContextValidation:
|
||||
model_entity=SimpleNamespace(context_length=128000),
|
||||
)
|
||||
)
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
|
||||
event = self._make_event_envelope()
|
||||
binding = self._make_binding()
|
||||
@@ -281,7 +281,7 @@ class TestContextValidation:
|
||||
model_entity=SimpleNamespace(context_length=None),
|
||||
)
|
||||
)
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
resources = self._make_resources()
|
||||
resources['models'] = [
|
||||
{
|
||||
@@ -304,12 +304,12 @@ class TestContextValidation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_preserves_subject_data_for_non_message_events(self):
|
||||
"""Non-message EBA events keep subject.data instead of relying on message text."""
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext, SubjectContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput as EventInput
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.runner.event import ActorContext, SubjectContext
|
||||
from langbot_plugin.api.entities.builtin.runner.input import AgentInput as EventInput
|
||||
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
||||
|
||||
mock_app = self._make_mock_app()
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
event = AgentEventEnvelope(
|
||||
event_id='evt_recall_1',
|
||||
event_type='message.recalled',
|
||||
@@ -353,7 +353,7 @@ class TestContextValidation:
|
||||
resources=resources,
|
||||
)
|
||||
|
||||
validated = AgentRunContext.model_validate(context_dict)
|
||||
validated = RunnerContext.model_validate(context_dict)
|
||||
|
||||
assert validated.event.event_type == 'message.recalled'
|
||||
assert validated.input.text is None
|
||||
@@ -366,7 +366,7 @@ class TestContextValidation:
|
||||
async def test_build_context_from_event_has_no_legacy_top_level_fields(self):
|
||||
"""Test that build_context_from_event does NOT have top-level messages/prompt/params."""
|
||||
mock_app = self._make_mock_app()
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
|
||||
event = self._make_event_envelope()
|
||||
binding = self._make_binding()
|
||||
@@ -409,7 +409,7 @@ class TestContextValidation:
|
||||
async def test_build_context_from_event_event_is_not_none(self):
|
||||
"""Test that event field is NOT None in Protocol v1."""
|
||||
mock_app = self._make_mock_app()
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
|
||||
event = self._make_event_envelope()
|
||||
binding = self._make_binding()
|
||||
@@ -440,14 +440,14 @@ class TestContextValidation:
|
||||
assert context_dict.get('event') is not None, 'event is REQUIRED for Protocol v1'
|
||||
|
||||
# Validate
|
||||
validated = AgentRunContext.model_validate(context_dict)
|
||||
validated = RunnerContext.model_validate(context_dict)
|
||||
assert validated.event is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_from_event_delivery_is_not_none(self):
|
||||
"""Test that delivery field is NOT None in Protocol v1."""
|
||||
mock_app = self._make_mock_app()
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
builder = RunnerContextBuilder(mock_app)
|
||||
|
||||
event = self._make_event_envelope()
|
||||
binding = self._make_binding()
|
||||
@@ -478,5 +478,5 @@ class TestContextValidation:
|
||||
assert context_dict.get('delivery') is not None, 'delivery is REQUIRED for Protocol v1'
|
||||
|
||||
# Validate
|
||||
validated = AgentRunContext.model_validate(context_dict)
|
||||
validated = RunnerContext.model_validate(context_dict)
|
||||
assert validated.delivery is not None
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Tests cover:
|
||||
1. Query -> AgentEventEnvelope conversion
|
||||
2. Current config -> AgentConfig projection and single-binding resolution
|
||||
3. AgentRunContext not inlining full history by default
|
||||
3. RunnerContext not inlining full history by default
|
||||
4. LangBot Host not defining context-window controls
|
||||
5. Event-first run() entry point
|
||||
"""
|
||||
@@ -14,14 +14,14 @@ import pytest
|
||||
from unittest.mock import Mock
|
||||
|
||||
# Import SDK entities
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.event import (
|
||||
from langbot_plugin.api.entities.builtin.runner.event import (
|
||||
AgentEventContext,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.result import (
|
||||
AgentRunResult,
|
||||
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.runner.trigger import AgentTrigger
|
||||
from langbot_plugin.api.entities.builtin.runner.context import RunnerContext
|
||||
from langbot_plugin.api.entities.builtin.runner.result import (
|
||||
RunnerResult,
|
||||
)
|
||||
|
||||
# Import LangBot host models
|
||||
@@ -263,8 +263,8 @@ class TestQueryConfigToAgentConfig:
|
||||
AgentBindingResolver().resolve_one(event, [first, second])
|
||||
|
||||
|
||||
class TestAgentRunContextProtocolV1:
|
||||
"""Test AgentRunContext Protocol v1 behavior."""
|
||||
class TestRunnerContextProtocolV1:
|
||||
"""Test RunnerContext Protocol v1 behavior."""
|
||||
|
||||
def test_sdk_context_event_required(self):
|
||||
"""Test that event is required in Protocol v1 context."""
|
||||
@@ -275,11 +275,11 @@ class TestAgentRunContextProtocolV1:
|
||||
source='platform',
|
||||
)
|
||||
input = AgentInput(text='Hello')
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.runner.resources import AgentResources
|
||||
from langbot_plugin.api.entities.builtin.runner.runtime import AgentRuntimeContext
|
||||
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
||||
|
||||
ctx = AgentRunContext(
|
||||
ctx = RunnerContext(
|
||||
run_id='run_1',
|
||||
trigger=trigger,
|
||||
event=event,
|
||||
@@ -293,7 +293,7 @@ class TestAgentRunContextProtocolV1:
|
||||
assert ctx.event.event_type == 'message.received'
|
||||
|
||||
def test_sdk_context_has_no_history_message_fields(self):
|
||||
"""AgentRunContext should not expose inline history message fields."""
|
||||
"""RunnerContext should not expose inline history message fields."""
|
||||
trigger = AgentTrigger(type='message.received')
|
||||
event = AgentEventContext(
|
||||
event_id='evt_1',
|
||||
@@ -301,11 +301,11 @@ class TestAgentRunContextProtocolV1:
|
||||
source='platform',
|
||||
)
|
||||
input = AgentInput(text='Hello')
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.runner.resources import AgentResources
|
||||
from langbot_plugin.api.entities.builtin.runner.runtime import AgentRuntimeContext
|
||||
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
||||
|
||||
ctx = AgentRunContext(
|
||||
ctx = RunnerContext(
|
||||
run_id='run_1',
|
||||
trigger=trigger,
|
||||
event=event,
|
||||
@@ -315,8 +315,8 @@ class TestAgentRunContextProtocolV1:
|
||||
runtime=AgentRuntimeContext(),
|
||||
)
|
||||
|
||||
assert 'messages' not in AgentRunContext.model_fields
|
||||
assert 'bootstrap' not in AgentRunContext.model_fields
|
||||
assert 'messages' not in RunnerContext.model_fields
|
||||
assert 'bootstrap' not in RunnerContext.model_fields
|
||||
assert not hasattr(ctx, 'bootstrap')
|
||||
|
||||
|
||||
@@ -324,20 +324,20 @@ class TestHostManagedHistoryNotInProtocol:
|
||||
"""Test that Host-managed history payloads are not in Protocol v1."""
|
||||
|
||||
def test_messages_not_in_sdk_context_top_level(self):
|
||||
"""AgentRunContext should not expose top-level history messages."""
|
||||
ctx_fields = AgentRunContext.model_fields.keys()
|
||||
"""RunnerContext should not expose top-level history messages."""
|
||||
ctx_fields = RunnerContext.model_fields.keys()
|
||||
|
||||
assert 'messages' not in ctx_fields
|
||||
|
||||
|
||||
class TestSDKResultProtocolV1:
|
||||
"""Test SDK AgentRunResult for Protocol v1."""
|
||||
"""Test SDK RunnerResult for Protocol v1."""
|
||||
|
||||
def test_result_requires_run_id(self):
|
||||
"""Test result requires run_id for Protocol v1."""
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
|
||||
result = AgentRunResult.message_completed(
|
||||
result = RunnerResult.message_completed(
|
||||
run_id='run_1',
|
||||
message=Message(role='assistant', content='Hello'),
|
||||
)
|
||||
|
||||
@@ -17,11 +17,11 @@ from langbot.pkg.agent.runner.host_models import (
|
||||
from langbot.pkg.agent.runner.event_log_store import EventLogStore
|
||||
from langbot.pkg.agent.runner.transcript_store import TranscriptStore
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.event import (
|
||||
from langbot_plugin.api.entities.builtin.runner.event import (
|
||||
ActorContext,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
||||
|
||||
|
||||
def make_event_envelope(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Tests for Host-only AgentRunner tool execution context."""
|
||||
"""Tests for Host-only Runner tool execution context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
||||
from langbot_plugin.api.entities.builtin.provider.message import ContentElement
|
||||
|
||||
@@ -129,7 +129,7 @@ def test_prepare_box_scope_overwrites_untrusted_existing_scope():
|
||||
assert json.loads(variables['_host_box_scope'])['target_id'] == 'user-1'
|
||||
|
||||
|
||||
def test_project_mcp_resource_config_uses_independent_agent_runner_settings():
|
||||
def test_project_mcp_resource_config_uses_independent_runner_settings():
|
||||
query = pipeline_query.Query.model_construct(variables={})
|
||||
attachments = [
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ Tests focus on:
|
||||
- RETRIEVE_KNOWLEDGE_BASE authorization
|
||||
|
||||
Authorization paths:
|
||||
1. AgentRunner calls: has run_id, validates against session_registry
|
||||
1. Runner calls: has run_id, validates against session_registry
|
||||
2. Regular plugin calls: no run_id, unscoped plugin action path
|
||||
"""
|
||||
|
||||
@@ -16,7 +16,7 @@ from __future__ import annotations
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
|
||||
from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
|
||||
from langbot.pkg.plugin.handler import _get_pipeline_knowledge_base_uuids
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
@@ -122,9 +122,9 @@ class MockApplication:
|
||||
self.persistence_mgr.execute_async = AsyncMock(return_value=MagicMock(first=lambda: None))
|
||||
|
||||
|
||||
class FakeAgentRunnerRegistry:
|
||||
class FakeRunnerRegistry:
|
||||
async def get(self, context, runner_id, bound_plugins=None):
|
||||
return AgentRunnerDescriptor(
|
||||
return RunnerDescriptor(
|
||||
id=runner_id,
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
@@ -161,7 +161,7 @@ class TestPipelineKnowledgeBaseScope:
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_runner_schema_when_query_scope_not_preprocessed(self):
|
||||
app = MockApplication()
|
||||
app.agent_runner_registry = FakeAgentRunnerRegistry()
|
||||
app.runner_registry = FakeRunnerRegistry()
|
||||
query = MockQuery()
|
||||
query.variables = {}
|
||||
|
||||
@@ -458,15 +458,15 @@ class TestRetrieveKnowledgeBaseAuthorization:
|
||||
|
||||
|
||||
class TestAuthorizationPathDifferentiation:
|
||||
"""Tests that verify AgentRunner vs regular plugin call differentiation."""
|
||||
"""Tests that verify Runner vs regular plugin call differentiation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_runner_path_with_run_id(self):
|
||||
"""AgentRunner calls provide run_id and use session_registry."""
|
||||
async def test_runner_path_with_run_id(self):
|
||||
"""Runner calls provide run_id and use session_registry."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
|
||||
# AgentRunner call has run_id
|
||||
run_id = 'run_agent_123'
|
||||
# Runner call has run_id
|
||||
run_id = 'run_runner_123'
|
||||
|
||||
# Register session with resources
|
||||
await registry.register(
|
||||
@@ -548,7 +548,7 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
|
||||
Fix: Now uses RunnerConfigResolver.resolve_runner_id first, then resolve_runner_config.
|
||||
"""
|
||||
|
||||
def test_retrieve_kb_fix_local_agent_runner(self):
|
||||
def test_retrieve_kb_fix_local_runner(self):
|
||||
"""Fix should work for local-agent runner."""
|
||||
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
|
||||
|
||||
@@ -830,8 +830,8 @@ class TestHandlerActionAuthorization:
|
||||
await registry.unregister(run_id)
|
||||
|
||||
|
||||
class TestSDKAgentRunAPIProxyFieldConsistency:
|
||||
"""Tests for SDK AgentRunAPIProxy field name consistency with Host handler.
|
||||
class TestSDKRunnerAPIProxyFieldConsistency:
|
||||
"""Tests for SDK RunnerAPIProxy field name consistency with Host handler.
|
||||
|
||||
These tests verify that SDK sends field names that match what Host handler reads.
|
||||
"""
|
||||
@@ -901,7 +901,7 @@ class TestSDKAgentRunAPIProxyFieldConsistency:
|
||||
class TestNoRunIdBackwardCompatPath:
|
||||
"""Tests for unscoped plugin action path when no run_id is provided.
|
||||
|
||||
Regular plugins (non-AgentRunner) don't have run_id and should
|
||||
Regular plugins (non-Runner) don't have run_id and should
|
||||
have unrestricted access to certain APIs.
|
||||
"""
|
||||
|
||||
@@ -1182,14 +1182,14 @@ class TestResourceTypeValidation:
|
||||
|
||||
|
||||
class TestBypassPrevention:
|
||||
"""Tests to ensure AgentRunAPIProxy cannot bypass authorization."""
|
||||
"""Tests to ensure RunnerAPIProxy cannot bypass authorization."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_bypass_via_unrestricted_retrieve_knowledge(self):
|
||||
"""Cannot bypass KB authorization via unrestricted RETRIEVE_KNOWLEDGE action."""
|
||||
# AgentRunAPIProxy uses RETRIEVE_KNOWLEDGE_BASE (with run_id)
|
||||
# RunnerAPIProxy uses RETRIEVE_KNOWLEDGE_BASE (with run_id)
|
||||
# RETRIEVE_KNOWLEDGE is unrestricted and separate
|
||||
# AgentRunner should NOT use RETRIEVE_KNOWLEDGE to bypass authorization
|
||||
# Runner should NOT use RETRIEVE_KNOWLEDGE to bypass authorization
|
||||
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}])
|
||||
@@ -1207,8 +1207,8 @@ class TestBypassPrevention:
|
||||
# kb_002 is not authorized
|
||||
assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_002') is False
|
||||
|
||||
# If AgentRunner tried to use RETRIEVE_KNOWLEDGE (unrestricted),
|
||||
# it would bypass authorization - but AgentRunAPIProxy correctly uses
|
||||
# If Runner tried to use RETRIEVE_KNOWLEDGE (unrestricted),
|
||||
# it would bypass authorization - but RunnerAPIProxy correctly uses
|
||||
# RETRIE_KNOWLEDGE_BASE which requires authorization
|
||||
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
|
||||
@@ -2006,7 +2006,7 @@ class TestCallerPluginIdentityValidation:
|
||||
class TestBackwardCompatStorageNoRunId:
|
||||
"""Tests for unscoped storage actions without run_id.
|
||||
|
||||
Regular plugins (non-AgentRunner) don't have run_id and should
|
||||
Regular plugins (non-Runner) don't have run_id and should
|
||||
have unrestricted access to storage APIs.
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for AgentRunner history/event pull API authorization."""
|
||||
"""Tests for Runner history/event pull API authorization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
@@ -11,7 +12,7 @@ from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
|
||||
from langbot.pkg.entity.persistence import event_log as event_log_model
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.page_results import (
|
||||
from langbot_plugin.api.entities.builtin.runner.page_results import (
|
||||
AgentEventRecord,
|
||||
EventPage,
|
||||
)
|
||||
@@ -92,10 +93,12 @@ async def test_history_page_requires_runtime_capability(session_registry, db_eng
|
||||
handler = _handler(db_engine, session_registry)
|
||||
history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value]
|
||||
|
||||
result = await history_page({
|
||||
'run_id': 'run_1',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
result = await history_page(
|
||||
{
|
||||
'run_id': 'run_1',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code != 0
|
||||
assert 'not authorized' in result.message.lower()
|
||||
@@ -107,11 +110,13 @@ async def test_history_page_rejects_cross_conversation(session_registry, db_engi
|
||||
handler = _handler(db_engine, session_registry)
|
||||
history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value]
|
||||
|
||||
result = await history_page({
|
||||
'run_id': 'run_1',
|
||||
'conversation_id': 'conv_other',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
result = await history_page(
|
||||
{
|
||||
'run_id': 'run_1',
|
||||
'conversation_id': 'conv_other',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code != 0
|
||||
assert 'not accessible' in result.message.lower()
|
||||
@@ -123,12 +128,14 @@ async def test_history_search_rejects_filter_conversation_override(session_regis
|
||||
handler = _handler(db_engine, session_registry)
|
||||
history_search = handler.actions[PluginToRuntimeAction.HISTORY_SEARCH.value]
|
||||
|
||||
result = await history_search({
|
||||
'run_id': 'run_1',
|
||||
'query': 'hello',
|
||||
'filters': {'conversation_id': 'conv_other'},
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
result = await history_search(
|
||||
{
|
||||
'run_id': 'run_1',
|
||||
'query': 'hello',
|
||||
'filters': {'conversation_id': 'conv_other'},
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code != 0
|
||||
assert 'not accessible' in result.message.lower()
|
||||
@@ -140,10 +147,12 @@ async def test_event_page_requires_runtime_capability(session_registry, db_engin
|
||||
handler = _handler(db_engine, session_registry)
|
||||
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
|
||||
|
||||
result = await event_page({
|
||||
'run_id': 'run_1',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
result = await event_page(
|
||||
{
|
||||
'run_id': 'run_1',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code != 0
|
||||
assert 'not authorized' in result.message.lower()
|
||||
@@ -155,11 +164,13 @@ async def test_event_page_rejects_cross_conversation(session_registry, db_engine
|
||||
handler = _handler(db_engine, session_registry)
|
||||
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
|
||||
|
||||
result = await event_page({
|
||||
'run_id': 'run_1',
|
||||
'conversation_id': 'conv_other',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
result = await event_page(
|
||||
{
|
||||
'run_id': 'run_1',
|
||||
'conversation_id': 'conv_other',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code != 0
|
||||
assert 'not accessible' in result.message.lower()
|
||||
@@ -184,11 +195,13 @@ async def test_event_get_returns_sdk_record_projection(session_registry, db_engi
|
||||
handler = _handler(db_engine, session_registry)
|
||||
event_get = handler.actions[PluginToRuntimeAction.EVENT_GET.value]
|
||||
|
||||
result = await event_get({
|
||||
'run_id': 'run_1',
|
||||
'event_id': event_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
result = await event_get(
|
||||
{
|
||||
'run_id': 'run_1',
|
||||
'event_id': event_id,
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code == 0
|
||||
AgentEventRecord.model_validate(result.data)
|
||||
@@ -216,10 +229,12 @@ async def test_event_page_returns_sdk_page_projection(session_registry, db_engin
|
||||
handler = _handler(db_engine, session_registry)
|
||||
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
|
||||
|
||||
result = await event_page({
|
||||
'run_id': 'run_1',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
result = await event_page(
|
||||
{
|
||||
'run_id': 'run_1',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code == 0
|
||||
page = EventPage.model_validate(result.data)
|
||||
@@ -272,10 +287,12 @@ async def test_history_page_filters_run_scope_thread_and_bot(session_registry, d
|
||||
handler = _handler(db_engine, session_registry)
|
||||
history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value]
|
||||
|
||||
result = await history_page({
|
||||
'run_id': 'run_1',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
result = await history_page(
|
||||
{
|
||||
'run_id': 'run_1',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code == 0
|
||||
assert [item['content'] for item in result.data['items']] == ['visible']
|
||||
@@ -317,10 +334,12 @@ async def test_event_page_filters_run_scope_thread_and_bot(session_registry, db_
|
||||
handler = _handler(db_engine, session_registry)
|
||||
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
|
||||
|
||||
result = await event_page({
|
||||
'run_id': 'run_1',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
result = await event_page(
|
||||
{
|
||||
'run_id': 'run_1',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code == 0
|
||||
assert [item['event_id'] for item in result.data['items']] == ['evt_visible']
|
||||
|
||||
@@ -8,10 +8,10 @@ import time
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.interaction import (
|
||||
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.runner.event import ActorContext
|
||||
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.runner.interaction import (
|
||||
InteractionDeliveryCapabilities,
|
||||
InteractionSubmission,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
|
||||
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
|
||||
from langbot.pkg.agent.runner.errors import RunnerExecutionError
|
||||
from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator
|
||||
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
@@ -84,7 +84,7 @@ class FakePluginConnector:
|
||||
self.contexts: list[dict] = []
|
||||
self.sessions_during_run: list[dict | None] = []
|
||||
|
||||
async def run_agent(self, plugin_author, plugin_name, runner_name, context):
|
||||
async def run_runner(self, plugin_author, plugin_name, runner_name, context):
|
||||
self.calls.append(
|
||||
{
|
||||
'plugin_author': plugin_author,
|
||||
@@ -105,7 +105,7 @@ class FakePluginConnector:
|
||||
|
||||
|
||||
class FakeRegistry:
|
||||
def __init__(self, descriptor: AgentRunnerDescriptor):
|
||||
def __init__(self, descriptor: RunnerDescriptor):
|
||||
self.descriptor = descriptor
|
||||
self.calls: list[dict] = []
|
||||
|
||||
@@ -162,8 +162,8 @@ class FakeConversation:
|
||||
create_time = datetime.datetime(2026, 5, 15, 12, 0, 0)
|
||||
|
||||
|
||||
def make_descriptor() -> AgentRunnerDescriptor:
|
||||
return AgentRunnerDescriptor(
|
||||
def make_descriptor() -> RunnerDescriptor:
|
||||
return RunnerDescriptor(
|
||||
id=RUNNER_ID,
|
||||
source='plugin',
|
||||
label={'en_US': 'Local Agent'},
|
||||
@@ -798,7 +798,7 @@ async def test_unconsumed_steering_audit_does_not_persist_pinned_context(clean_a
|
||||
self.started = asyncio.Event()
|
||||
self.release = asyncio.Event()
|
||||
|
||||
async def run_agent(self, plugin_author, plugin_name, runner_name, context):
|
||||
async def run_runner(self, plugin_author, plugin_name, runner_name, context):
|
||||
self.calls.append(
|
||||
{
|
||||
'plugin_author': plugin_author,
|
||||
@@ -998,8 +998,8 @@ class TestQueryEntrySessionQueryId:
|
||||
DeliveryPolicy,
|
||||
ResourcePolicy,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
||||
|
||||
db_engine = clean_agent_state
|
||||
descriptor = make_descriptor()
|
||||
|
||||
@@ -2,7 +2,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.api.entities.builtin.agent_runner import (
|
||||
from langbot_plugin.api.entities.builtin.runner import (
|
||||
ActorContext,
|
||||
AgentInput,
|
||||
DeliveryContext,
|
||||
|
||||
@@ -6,8 +6,8 @@ from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.agent.runner.registry import AgentRunnerRegistry
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.registry import RunnerRegistry
|
||||
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
|
||||
from langbot.pkg.agent.runner.errors import RunnerNotFoundError, RunnerNotAuthorizedError
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
|
||||
@@ -44,7 +44,7 @@ class FakeApplication:
|
||||
async def require_workspace_context(self, context):
|
||||
return context
|
||||
|
||||
async def list_agent_runners(self, bound_plugins=None):
|
||||
async def list_runners(self, bound_plugins=None):
|
||||
# Return sample runner data
|
||||
return [
|
||||
{
|
||||
@@ -90,7 +90,7 @@ class FakeApplication:
|
||||
'plugin_name': 'missing-name',
|
||||
'runner_name': 'default',
|
||||
'manifest': {
|
||||
'kind': 'AgentRunner',
|
||||
'kind': 'Runner',
|
||||
'metadata': {}, # No name
|
||||
'spec': {},
|
||||
},
|
||||
@@ -107,7 +107,7 @@ class TestRegistryDiscovery:
|
||||
async def test_discover_valid_runners(self):
|
||||
"""Discover valid runners from plugin runtime."""
|
||||
ap = FakeApplication()
|
||||
registry = AgentRunnerRegistry(ap)
|
||||
registry = RunnerRegistry(ap)
|
||||
|
||||
runners = await registry.list_runners(TEST_CONTEXT, use_cache=False)
|
||||
|
||||
@@ -122,7 +122,7 @@ class TestRegistryDiscovery:
|
||||
async def test_discover_caches_results(self):
|
||||
"""Discovery should cache results."""
|
||||
ap = FakeApplication()
|
||||
registry = AgentRunnerRegistry(ap)
|
||||
registry = RunnerRegistry(ap)
|
||||
|
||||
# First discovery
|
||||
runners1 = await registry.list_runners(TEST_CONTEXT, use_cache=True)
|
||||
@@ -138,7 +138,7 @@ class TestRegistryDiscovery:
|
||||
"""Discovery returns empty when plugin system disabled."""
|
||||
ap = FakeApplication()
|
||||
ap.plugin_connector.is_enable_plugin = False
|
||||
registry = AgentRunnerRegistry(ap)
|
||||
registry = RunnerRegistry(ap)
|
||||
|
||||
runners = await registry.list_runners(TEST_CONTEXT, use_cache=False)
|
||||
|
||||
@@ -152,7 +152,7 @@ class TestRegistryDiscovery:
|
||||
so subsequent list_runners(bound_plugins=None) should return all runners.
|
||||
"""
|
||||
ap = FakeApplication()
|
||||
registry = AgentRunnerRegistry(ap)
|
||||
registry = RunnerRegistry(ap)
|
||||
|
||||
# First: get with bound_plugins filter (should not pollute cache)
|
||||
descriptor = await registry.get(
|
||||
@@ -189,7 +189,7 @@ class TestRegistryGet:
|
||||
async def test_get_existing_runner(self):
|
||||
"""Get existing runner by ID."""
|
||||
ap = FakeApplication()
|
||||
registry = AgentRunnerRegistry(ap)
|
||||
registry = RunnerRegistry(ap)
|
||||
|
||||
descriptor = await registry.get(
|
||||
TEST_CONTEXT,
|
||||
@@ -205,7 +205,7 @@ class TestRegistryGet:
|
||||
async def test_get_nonexistent_runner(self):
|
||||
"""Get nonexistent runner raises RunnerNotFoundError."""
|
||||
ap = FakeApplication()
|
||||
registry = AgentRunnerRegistry(ap)
|
||||
registry = RunnerRegistry(ap)
|
||||
|
||||
with pytest.raises(RunnerNotFoundError) as exc_info:
|
||||
await registry.get(TEST_CONTEXT, 'plugin:notexist/unknown/default')
|
||||
@@ -216,10 +216,10 @@ class TestRegistryGet:
|
||||
async def test_get_refreshes_partial_startup_cache_on_miss(self):
|
||||
"""A runner initialized after early discovery should become available."""
|
||||
ap = FakeApplication()
|
||||
ap.plugin_connector.list_agent_runners = AsyncMock(
|
||||
side_effect=ap.plugin_connector.list_agent_runners,
|
||||
ap.plugin_connector.list_runners = AsyncMock(
|
||||
side_effect=ap.plugin_connector.list_runners,
|
||||
)
|
||||
registry = AgentRunnerRegistry(ap)
|
||||
registry = RunnerRegistry(ap)
|
||||
|
||||
await registry.list_runners(TEST_CONTEXT)
|
||||
cache = registry._cache[('instance-test', 'workspace-test', 1)]
|
||||
@@ -231,13 +231,13 @@ class TestRegistryGet:
|
||||
)
|
||||
|
||||
assert descriptor.id == 'plugin:alice/my-agent/custom'
|
||||
assert ap.plugin_connector.list_agent_runners.await_count == 2
|
||||
assert ap.plugin_connector.list_runners.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_runner_with_bound_plugins_filter(self):
|
||||
"""Get runner with bound plugins authorization."""
|
||||
ap = FakeApplication()
|
||||
registry = AgentRunnerRegistry(ap)
|
||||
registry = RunnerRegistry(ap)
|
||||
|
||||
# Authorized - langbot plugin in bound list
|
||||
descriptor = await registry.get(
|
||||
@@ -263,7 +263,7 @@ class TestRegistryMetadataForPipeline:
|
||||
async def test_get_metadata_options_and_stages(self):
|
||||
"""Get metadata options and stages for pipeline UI."""
|
||||
ap = FakeApplication()
|
||||
registry = AgentRunnerRegistry(ap)
|
||||
registry = RunnerRegistry(ap)
|
||||
|
||||
options, stages = await registry.get_runner_metadata_for_pipeline(TEST_CONTEXT)
|
||||
|
||||
@@ -284,10 +284,10 @@ class TestRegistryMetadataForPipeline:
|
||||
async def test_metadata_refreshes_partial_startup_cache(self):
|
||||
"""Pipeline metadata should not preserve an early partial discovery."""
|
||||
ap = FakeApplication()
|
||||
ap.plugin_connector.list_agent_runners = AsyncMock(
|
||||
side_effect=ap.plugin_connector.list_agent_runners,
|
||||
ap.plugin_connector.list_runners = AsyncMock(
|
||||
side_effect=ap.plugin_connector.list_runners,
|
||||
)
|
||||
registry = AgentRunnerRegistry(ap)
|
||||
registry = RunnerRegistry(ap)
|
||||
|
||||
await registry.list_runners(TEST_CONTEXT)
|
||||
cache = registry._cache[('instance-test', 'workspace-test', 1)]
|
||||
@@ -299,7 +299,7 @@ class TestRegistryMetadataForPipeline:
|
||||
'plugin:langbot-team/LocalAgent/default',
|
||||
'plugin:alice/my-agent/custom',
|
||||
}
|
||||
assert ap.plugin_connector.list_agent_runners.await_count == 2
|
||||
assert ap.plugin_connector.list_runners.await_count == 2
|
||||
|
||||
|
||||
class TestDescriptorValidation:
|
||||
@@ -307,7 +307,7 @@ class TestDescriptorValidation:
|
||||
|
||||
def test_validate_runner_descriptor(self):
|
||||
"""Validate correctly built descriptor."""
|
||||
descriptor = AgentRunnerDescriptor(
|
||||
descriptor = RunnerDescriptor(
|
||||
id='plugin:test/my-runner/default',
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
@@ -318,11 +318,11 @@ class TestDescriptorValidation:
|
||||
|
||||
assert descriptor.id == 'plugin:test/my-runner/default'
|
||||
assert descriptor.get_plugin_id() == 'test/my-runner'
|
||||
assert 'protocol_version' not in AgentRunnerDescriptor.model_fields
|
||||
assert 'protocol_version' not in RunnerDescriptor.model_fields
|
||||
|
||||
def test_descriptor_capabilities(self):
|
||||
"""Descriptor capability helper methods."""
|
||||
descriptor = AgentRunnerDescriptor(
|
||||
descriptor = RunnerDescriptor(
|
||||
id='plugin:test/my-runner/default',
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
@@ -338,28 +338,30 @@ class TestDescriptorValidation:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_separates_processor_kinds_with_same_plugin_component_name():
|
||||
async def test_registry_filters_usages_without_splitting_component_identity():
|
||||
ap = FakeApplication()
|
||||
entries = []
|
||||
for kind, prefix in [('AgentRunner', 'plugin'), ('EventProcessor', 'event_processor')]:
|
||||
for name, usages in [('agent', ['agent']), ('events', ['event']), ('both', ['agent', 'event'])]:
|
||||
entries.append(
|
||||
{
|
||||
'plugin_author': 'test',
|
||||
'plugin_name': 'both',
|
||||
'runner_name': 'default',
|
||||
'plugin_name': 'runners',
|
||||
'runner_name': name,
|
||||
'manifest': {
|
||||
'id': f'{prefix}:test/both/default',
|
||||
'name': 'default',
|
||||
'component_kind': kind,
|
||||
'label': {'en_US': kind},
|
||||
'id': f'plugin:test/runners/{name}',
|
||||
'name': name,
|
||||
'component_kind': 'Runner',
|
||||
'usages': usages,
|
||||
'label': {'en_US': name},
|
||||
'supported_event_patterns': ['group.member_joined'],
|
||||
},
|
||||
}
|
||||
)
|
||||
ap.plugin_connector.list_agent_runners = AsyncMock(return_value=entries)
|
||||
registry = AgentRunnerRegistry(ap)
|
||||
ap.plugin_connector.list_runners = AsyncMock(return_value=entries)
|
||||
registry = RunnerRegistry(ap)
|
||||
agents = await registry.list_runners(TEST_CONTEXT)
|
||||
processors = await registry.list_runners(TEST_CONTEXT, component_kind='EventProcessor')
|
||||
assert [item.id for item in agents] == ['plugin:test/both/default']
|
||||
assert [item.id for item in processors] == ['event_processor:test/both/default']
|
||||
assert (await registry.get(TEST_CONTEXT, processors[0].id)).component_kind == 'EventProcessor'
|
||||
processors = await registry.list_runners(TEST_CONTEXT, usage='event')
|
||||
assert [item.runner_name for item in agents] == ['agent', 'both']
|
||||
assert [item.runner_name for item in processors] == ['events', 'both']
|
||||
assert agents[-1].id == processors[-1].id
|
||||
assert (await registry.get(TEST_CONTEXT, processors[0].id)).usages == ['event']
|
||||
|
||||
@@ -7,9 +7,9 @@ from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.api.entities.builtin.platform import events, entities, message
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.context_access import ContextAPICapabilities
|
||||
from langbot_plugin.api.proxies.agent_run import AgentRunAPIProxy
|
||||
from langbot_plugin.api.proxies.agent_run.common import PermissionDeniedError
|
||||
from langbot_plugin.api.entities.builtin.runner.context_access import ContextAPICapabilities
|
||||
from langbot_plugin.api.proxies.runner import RunnerAPIProxy
|
||||
from langbot_plugin.api.proxies.runner.common import PermissionDeniedError
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
|
||||
|
||||
from langbot.pkg.agent.runner.reply_stream import ReplyStreamRequest, ReplyStreamSession
|
||||
@@ -50,7 +50,7 @@ def request(key, operation='update', text='hello'):
|
||||
|
||||
|
||||
def proxy_for(session, *, allowed=True, advertised=True):
|
||||
if not hasattr(AgentRunAPIProxy, 'reply_stream'):
|
||||
if not hasattr(RunnerAPIProxy, 'reply_stream'):
|
||||
pytest.skip('SDK does not provide the optional streaming reply API')
|
||||
context = SimpleNamespace(
|
||||
run_id='run-1',
|
||||
@@ -73,7 +73,7 @@ def proxy_for(session, *, allowed=True, advertised=True):
|
||||
}
|
||||
|
||||
transport = SimpleNamespace(call_action=AsyncMock(side_effect=action))
|
||||
return AgentRunAPIProxy(context, transport), transport
|
||||
return RunnerAPIProxy(context, transport), transport
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -211,11 +211,11 @@ async def test_streams_are_isolated_by_run_and_bounded():
|
||||
|
||||
async def test_event_processor_uses_shared_sdk_api_and_emits_one_trace_for_the_stream():
|
||||
from unittest.mock import Mock
|
||||
from langbot_plugin.api.definition.components.event_processor import EventProcessor
|
||||
from langbot_plugin.api.definition.components.runner import Runner, RunnerContext
|
||||
|
||||
session, adapter, incoming = make_session()
|
||||
api, _ = proxy_for(session)
|
||||
processor = EventProcessor()
|
||||
processor = Runner()
|
||||
processor.get_run_api = Mock(return_value=api)
|
||||
|
||||
@processor.handler(events.MessageReceivedEvent)
|
||||
@@ -224,14 +224,23 @@ async def test_event_processor_uses_shared_sdk_api_and_emits_one_trace_for_the_s
|
||||
await stream.update('one')
|
||||
await stream.update('one two')
|
||||
|
||||
context = SimpleNamespace(
|
||||
run_id='run-1',
|
||||
config={},
|
||||
event=SimpleNamespace(
|
||||
data=incoming.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'})
|
||||
),
|
||||
context = RunnerContext.model_validate(
|
||||
{
|
||||
'run_id': 'run-1',
|
||||
'trigger': {'type': incoming.type},
|
||||
'event': {
|
||||
'event_id': 'one',
|
||||
'event_type': incoming.type,
|
||||
'source': 'test',
|
||||
'data': incoming.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'}),
|
||||
},
|
||||
'input': {},
|
||||
'delivery': {'surface': 'test'},
|
||||
'resources': {},
|
||||
'runtime': {},
|
||||
}
|
||||
)
|
||||
results = [result async for result in processor.run(context)]
|
||||
results = [result async for result in processor.invoke(context)]
|
||||
assert [r.type for r in results] == ['tool.call.started', 'tool.call.completed', 'run.completed']
|
||||
assert results[1].data['result']['text'] == 'one two'
|
||||
assert adapter.reply_message_chunk.await_count == 3
|
||||
|
||||
@@ -6,9 +6,9 @@ from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.api.entities.builtin.agent_runner import AgentInput, DeliveryContext
|
||||
from langbot_plugin.api.entities.builtin.runner import AgentInput, DeliveryContext
|
||||
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
|
||||
from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver
|
||||
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
|
||||
@@ -37,8 +37,8 @@ def make_descriptor(
|
||||
config_schema: list[dict] | None = None,
|
||||
capabilities: dict | None = None,
|
||||
permissions: dict | None = None,
|
||||
) -> AgentRunnerDescriptor:
|
||||
return AgentRunnerDescriptor(
|
||||
) -> RunnerDescriptor:
|
||||
return RunnerDescriptor(
|
||||
id=RUNNER_ID,
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for generic AgentRunner resource-policy projection."""
|
||||
"""Tests for generic Runner resource-policy projection."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.agent.runner.result_normalizer import AgentResultNormalizer
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
|
||||
from langbot.pkg.agent.runner.errors import RunnerExecutionError, RunnerProtocolError
|
||||
|
||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||
@@ -36,7 +36,7 @@ class FakeApplication:
|
||||
|
||||
def make_descriptor():
|
||||
"""Create a test descriptor."""
|
||||
return AgentRunnerDescriptor(
|
||||
return RunnerDescriptor(
|
||||
id='plugin:langbot-team/LocalAgent/default',
|
||||
source='plugin',
|
||||
label={'en_US': 'Local Agent', 'zh_Hans': '内置 Agent'},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for AgentRunner run ledger pull API authorization."""
|
||||
"""Tests for Runner run ledger pull API authorization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,7 +17,7 @@ from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
|
||||
from langbot.pkg.entity.persistence import agent_run as agent_run_model
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.run_ledger import (
|
||||
from langbot_plugin.api.entities.builtin.runner.run_ledger import (
|
||||
AgentRun,
|
||||
AgentRunEvent,
|
||||
RunEventPage,
|
||||
@@ -37,10 +37,10 @@ class FakeApplication:
|
||||
self.logger = MagicMock()
|
||||
self.persistence_mgr = MagicMock()
|
||||
self.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
|
||||
self.agent_runner_registry = runner_registry
|
||||
self.runner_registry = runner_registry
|
||||
self.instance_config = SimpleNamespace(
|
||||
data={
|
||||
'agent_runner': {
|
||||
'runner': {
|
||||
'admin_plugins': admin_plugins or [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ Authorization rules:
|
||||
- enable_state must be True
|
||||
- scope must be in state_scopes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -34,11 +35,13 @@ from .conftest import bind_runtime_action_context, make_resources
|
||||
|
||||
class FakeConnection:
|
||||
"""Fake connection for testing."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class FakeApplication:
|
||||
"""Fake Application for testing."""
|
||||
|
||||
def __init__(self, db_engine=None):
|
||||
self.logger = MagicMock()
|
||||
self.logger.debug = MagicMock()
|
||||
@@ -77,10 +80,10 @@ async def persistent_store(db_engine):
|
||||
store = PersistentStateStore(db_engine)
|
||||
|
||||
# Create the table
|
||||
from langbot.pkg.entity.persistence.agent_runner_state import AgentRunnerState
|
||||
from langbot.pkg.entity.persistence.runner_state import RunnerState
|
||||
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.run_sync(AgentRunnerState.__table__.create, checkfirst=True)
|
||||
await conn.run_sync(RunnerState.__table__.create, checkfirst=True)
|
||||
|
||||
yield store
|
||||
reset_persistent_state_store()
|
||||
@@ -124,11 +127,13 @@ class TestStateAPIHandlerAuthorization:
|
||||
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
|
||||
|
||||
# Call with non-existent run_id
|
||||
result = await state_get_handler({
|
||||
'run_id': 'nonexistent_run',
|
||||
'scope': 'conversation',
|
||||
'key': 'test_key',
|
||||
})
|
||||
result = await state_get_handler(
|
||||
{
|
||||
'run_id': 'nonexistent_run',
|
||||
'scope': 'conversation',
|
||||
'key': 'test_key',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code != 0
|
||||
assert 'not found' in result.message.lower()
|
||||
@@ -164,11 +169,13 @@ class TestStateAPIHandlerAuthorization:
|
||||
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
|
||||
|
||||
# Call without caller_plugin_identity
|
||||
result = await state_get_handler({
|
||||
'run_id': 'run_test_missing_identity',
|
||||
'scope': 'conversation',
|
||||
'key': 'test_key',
|
||||
})
|
||||
result = await state_get_handler(
|
||||
{
|
||||
'run_id': 'run_test_missing_identity',
|
||||
'scope': 'conversation',
|
||||
'key': 'test_key',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code == 0
|
||||
assert result.data == {'value': None}
|
||||
@@ -176,7 +183,9 @@ class TestStateAPIHandlerAuthorization:
|
||||
await session_registry.unregister('run_test_missing_identity')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_get_caller_identity_mismatch_returns_error(self, session_registry, db_engine, persistent_store):
|
||||
async def test_state_get_caller_identity_mismatch_returns_error(
|
||||
self, session_registry, db_engine, persistent_store
|
||||
):
|
||||
"""STATE_GET: caller_plugin_identity mismatch returns error."""
|
||||
fake_app = FakeApplication(db_engine)
|
||||
fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
|
||||
@@ -200,12 +209,14 @@ class TestStateAPIHandlerAuthorization:
|
||||
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
|
||||
|
||||
# Call with wrong caller_plugin_identity
|
||||
result = await state_get_handler({
|
||||
'run_id': 'run_test_mismatch',
|
||||
'scope': 'conversation',
|
||||
'key': 'test_key',
|
||||
'caller_plugin_identity': 'other/plugin',
|
||||
})
|
||||
result = await state_get_handler(
|
||||
{
|
||||
'run_id': 'run_test_mismatch',
|
||||
'scope': 'conversation',
|
||||
'key': 'test_key',
|
||||
'caller_plugin_identity': 'other/plugin',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code != 0
|
||||
assert 'does not match' in result.message.lower()
|
||||
@@ -236,12 +247,14 @@ class TestStateAPIHandlerAuthorization:
|
||||
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
|
||||
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
|
||||
|
||||
result = await state_get_handler({
|
||||
'run_id': 'run_test_disabled',
|
||||
'scope': 'conversation',
|
||||
'key': 'test_key',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
result = await state_get_handler(
|
||||
{
|
||||
'run_id': 'run_test_disabled',
|
||||
'scope': 'conversation',
|
||||
'key': 'test_key',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code != 0
|
||||
assert 'disabled' in result.message.lower()
|
||||
@@ -262,7 +275,10 @@ class TestStateAPIHandlerAuthorization:
|
||||
resources=make_resources(),
|
||||
available_apis={'state': True},
|
||||
state_policy={'enable_state': True, 'state_scopes': ['conversation']},
|
||||
state_context={'scope_keys': {'conversation': 'conv_key', 'actor': 'actor_key'}, 'binding_identity': 'binding_1'},
|
||||
state_context={
|
||||
'scope_keys': {'conversation': 'conv_key', 'actor': 'actor_key'},
|
||||
'binding_identity': 'binding_1',
|
||||
},
|
||||
)
|
||||
|
||||
async def fake_disconnect():
|
||||
@@ -273,12 +289,14 @@ class TestStateAPIHandlerAuthorization:
|
||||
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
|
||||
|
||||
# Request 'actor' scope which is not in state_scopes
|
||||
result = await state_get_handler({
|
||||
'run_id': 'run_test_scope_disabled',
|
||||
'scope': 'actor',
|
||||
'key': 'test_key',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
result = await state_get_handler(
|
||||
{
|
||||
'run_id': 'run_test_scope_disabled',
|
||||
'scope': 'actor',
|
||||
'key': 'test_key',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code != 0
|
||||
assert 'not enabled' in result.message.lower() or 'scope' in result.message.lower()
|
||||
@@ -309,12 +327,14 @@ class TestStateAPIHandlerAuthorization:
|
||||
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
|
||||
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
|
||||
|
||||
result = await state_get_handler({
|
||||
'run_id': 'run_test_no_scope_key',
|
||||
'scope': 'conversation',
|
||||
'key': 'test_key',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
result = await state_get_handler(
|
||||
{
|
||||
'run_id': 'run_test_no_scope_key',
|
||||
'scope': 'conversation',
|
||||
'key': 'test_key',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code != 0
|
||||
assert 'not available' in result.message.lower()
|
||||
@@ -360,9 +380,11 @@ class TestStateAPIFullFlowWithRealDB:
|
||||
session = await session_registry.get('run_full_flow')
|
||||
assert session is not None
|
||||
state_ctx = session['authorization']['state_context']
|
||||
assert state_ctx is not None, f"state_context is None. Session keys: {list(session.keys())}"
|
||||
assert 'scope_keys' in state_ctx, f"scope_keys not in state_context: {state_ctx}"
|
||||
assert 'conversation' in state_ctx['scope_keys'], f"conversation not in scope_keys: {state_ctx['scope_keys']}"
|
||||
assert state_ctx is not None, f'state_context is None. Session keys: {list(session.keys())}'
|
||||
assert 'scope_keys' in state_ctx, f'scope_keys not in state_context: {state_ctx}'
|
||||
assert 'conversation' in state_ctx['scope_keys'], (
|
||||
f'conversation not in scope_keys: {state_ctx["scope_keys"]}'
|
||||
)
|
||||
|
||||
# Get handlers (actions dict is keyed by action value string)
|
||||
state_set_handler = handler.actions[PluginToRuntimeAction.STATE_SET.value]
|
||||
@@ -371,57 +393,67 @@ class TestStateAPIFullFlowWithRealDB:
|
||||
state_delete_handler = handler.actions[PluginToRuntimeAction.STATE_DELETE.value]
|
||||
|
||||
# 1. STATE_SET
|
||||
set_result = await state_set_handler({
|
||||
'run_id': 'run_full_flow',
|
||||
'scope': 'conversation',
|
||||
'key': 'external.test_key',
|
||||
'value': {'data': 'test_value'},
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
set_result = await state_set_handler(
|
||||
{
|
||||
'run_id': 'run_full_flow',
|
||||
'scope': 'conversation',
|
||||
'key': 'external.test_key',
|
||||
'value': {'data': 'test_value'},
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert set_result.code == 0
|
||||
assert set_result.data.get('success') is True
|
||||
|
||||
# 2. STATE_GET
|
||||
get_result = await state_get_handler({
|
||||
'run_id': 'run_full_flow',
|
||||
'scope': 'conversation',
|
||||
'key': 'external.test_key',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
get_result = await state_get_handler(
|
||||
{
|
||||
'run_id': 'run_full_flow',
|
||||
'scope': 'conversation',
|
||||
'key': 'external.test_key',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert get_result.code == 0
|
||||
assert get_result.data.get('value') == {'data': 'test_value'}
|
||||
|
||||
# 3. STATE_LIST
|
||||
list_result = await state_list_handler({
|
||||
'run_id': 'run_full_flow',
|
||||
'scope': 'conversation',
|
||||
'prefix': 'external.',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
list_result = await state_list_handler(
|
||||
{
|
||||
'run_id': 'run_full_flow',
|
||||
'scope': 'conversation',
|
||||
'prefix': 'external.',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert list_result.code == 0
|
||||
keys = list_result.data.get('keys', [])
|
||||
assert 'external.test_key' in keys
|
||||
|
||||
# 4. STATE_DELETE
|
||||
delete_result = await state_delete_handler({
|
||||
'run_id': 'run_full_flow',
|
||||
'scope': 'conversation',
|
||||
'key': 'external.test_key',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
delete_result = await state_delete_handler(
|
||||
{
|
||||
'run_id': 'run_full_flow',
|
||||
'scope': 'conversation',
|
||||
'key': 'external.test_key',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert delete_result.code == 0
|
||||
|
||||
# 5. Verify deleted
|
||||
get_after_delete = await state_get_handler({
|
||||
'run_id': 'run_full_flow',
|
||||
'scope': 'conversation',
|
||||
'key': 'external.test_key',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
get_after_delete = await state_get_handler(
|
||||
{
|
||||
'run_id': 'run_full_flow',
|
||||
'scope': 'conversation',
|
||||
'key': 'external.test_key',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert get_after_delete.code == 0
|
||||
assert get_after_delete.data.get('value') is None
|
||||
@@ -433,7 +465,9 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
|
||||
"""Tests verifying handlers read state_policy/state_context from authorization snapshot."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_handler_reads_state_policy_from_authorization(self, session_registry, db_engine, persistent_store):
|
||||
async def test_state_handler_reads_state_policy_from_authorization(
|
||||
self, session_registry, db_engine, persistent_store
|
||||
):
|
||||
"""Handler reads state_policy from session['authorization'], not resources."""
|
||||
fake_app = FakeApplication(db_engine)
|
||||
fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
|
||||
@@ -454,7 +488,7 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
|
||||
session = await session_registry.get('run_policy_top_level')
|
||||
assert session is not None
|
||||
resources = session['authorization']['resources']
|
||||
assert 'state_policy' not in resources, "resources should NOT contain state_policy"
|
||||
assert 'state_policy' not in resources, 'resources should NOT contain state_policy'
|
||||
|
||||
async def fake_disconnect():
|
||||
return True
|
||||
@@ -464,12 +498,14 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
|
||||
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
|
||||
|
||||
# Should fail because enable_state=False in authorization.state_policy
|
||||
result = await state_get_handler({
|
||||
'run_id': 'run_policy_top_level',
|
||||
'scope': 'conversation',
|
||||
'key': 'test_key',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
result = await state_get_handler(
|
||||
{
|
||||
'run_id': 'run_policy_top_level',
|
||||
'scope': 'conversation',
|
||||
'key': 'test_key',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
assert result.code != 0
|
||||
assert 'disabled' in result.message.lower()
|
||||
@@ -477,7 +513,9 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
|
||||
await session_registry.unregister('run_policy_top_level')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_handler_reads_state_context_from_authorization(self, session_registry, db_engine, persistent_store):
|
||||
async def test_state_handler_reads_state_context_from_authorization(
|
||||
self, session_registry, db_engine, persistent_store
|
||||
):
|
||||
"""Handler reads state_context from session['authorization'], not resources."""
|
||||
fake_app = FakeApplication(db_engine)
|
||||
fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
|
||||
@@ -498,7 +536,7 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
|
||||
session = await session_registry.get('run_context_top_level')
|
||||
assert session is not None
|
||||
resources = session['authorization']['resources']
|
||||
assert 'state_context' not in resources, "resources should NOT contain state_context"
|
||||
assert 'state_context' not in resources, 'resources should NOT contain state_context'
|
||||
|
||||
async def fake_disconnect():
|
||||
return True
|
||||
@@ -508,13 +546,15 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
|
||||
state_set_handler = handler.actions[PluginToRuntimeAction.STATE_SET.value]
|
||||
|
||||
# Should use scope_key from authorization.state_context.scope_keys.conversation
|
||||
result = await state_set_handler({
|
||||
'run_id': 'run_context_top_level',
|
||||
'scope': 'conversation',
|
||||
'key': 'test_key',
|
||||
'value': 'test_value',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
})
|
||||
result = await state_set_handler(
|
||||
{
|
||||
'run_id': 'run_context_top_level',
|
||||
'scope': 'conversation',
|
||||
'key': 'test_key',
|
||||
'value': 'test_value',
|
||||
'caller_plugin_identity': 'test/runner',
|
||||
}
|
||||
)
|
||||
|
||||
# Should succeed - scope_key was found in state_context
|
||||
assert result.code == 0
|
||||
@@ -546,10 +586,8 @@ class TestResourcesDoesNotContainStateMetadata:
|
||||
# Verify resources is nested under authorization and is clean.
|
||||
assert 'resources' not in session
|
||||
session_resources = session['authorization']['resources']
|
||||
assert 'state_policy' not in session_resources, \
|
||||
"authorization['resources'] should NOT contain state_policy"
|
||||
assert 'state_context' not in session_resources, \
|
||||
"authorization['resources'] should NOT contain state_context"
|
||||
assert 'state_policy' not in session_resources, "authorization['resources'] should NOT contain state_policy"
|
||||
assert 'state_context' not in session_resources, "authorization['resources'] should NOT contain state_context"
|
||||
|
||||
assert 'state_policy' in session['authorization']
|
||||
assert 'state_context' in session['authorization']
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for persistent AgentRunner state store."""
|
||||
"""Tests for persistent Runner state store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -8,7 +9,7 @@ import tempfile
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
|
||||
from langbot.pkg.agent.runner.host_models import BindingScope, StatePolicy
|
||||
from langbot.pkg.agent.runner.persistent_state_store import PersistentStateStore
|
||||
from langbot.pkg.agent.runner.state_scope import (
|
||||
@@ -21,9 +22,9 @@ from langbot.pkg.agent.runner.state_scope import (
|
||||
)
|
||||
|
||||
|
||||
def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> AgentRunnerDescriptor:
|
||||
def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> RunnerDescriptor:
|
||||
"""Create a test descriptor."""
|
||||
return AgentRunnerDescriptor(
|
||||
return RunnerDescriptor(
|
||||
id=runner_id,
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
@@ -36,6 +37,7 @@ def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> AgentRu
|
||||
|
||||
class FakeActorContext:
|
||||
"""Fake actor context for event testing."""
|
||||
|
||||
def __init__(self, actor_type: str = 'user', actor_id: str = 'user_123', actor_name: str = 'Test User'):
|
||||
self.actor_type = actor_type
|
||||
self.actor_id = actor_id
|
||||
@@ -44,6 +46,7 @@ class FakeActorContext:
|
||||
|
||||
class FakeSubjectContext:
|
||||
"""Fake subject context for event testing."""
|
||||
|
||||
def __init__(self, subject_type: str = 'message', subject_id: str = 'msg_001', data: dict | None = None):
|
||||
self.subject_type = subject_type
|
||||
self.subject_id = subject_id
|
||||
@@ -52,6 +55,7 @@ class FakeSubjectContext:
|
||||
|
||||
class FakeEventEnvelope:
|
||||
"""Fake event envelope for testing event-first state."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
event_id: str = 'evt_001',
|
||||
@@ -78,6 +82,7 @@ class FakeEventEnvelope:
|
||||
|
||||
class FakeBinding:
|
||||
"""Fake binding for testing state."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
binding_id: str = 'binding_001',
|
||||
@@ -119,10 +124,7 @@ class TestStateScopeHelpers:
|
||||
thread_id='thread_001',
|
||||
)
|
||||
|
||||
keys = {
|
||||
scope: build_state_scope_key(scope, event, binding, descriptor)
|
||||
for scope in VALID_STATE_SCOPES
|
||||
}
|
||||
keys = {scope: build_state_scope_key(scope, event, binding, descriptor) for scope in VALID_STATE_SCOPES}
|
||||
|
||||
assert keys['conversation'].startswith('conversation:v2:')
|
||||
assert keys['actor'].startswith('actor:v2:')
|
||||
@@ -168,6 +170,7 @@ class TestPersistentStateStore:
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{db_path}', echo=False)
|
||||
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
@@ -366,9 +369,7 @@ class TestPersistentStateStore:
|
||||
event = FakeEventEnvelope(conversation_id='conv_001')
|
||||
binding = FakeBinding()
|
||||
|
||||
await persistent_store.apply_update_from_event(
|
||||
event, binding, descriptor, 'conversation', 'key', 'value', None
|
||||
)
|
||||
await persistent_store.apply_update_from_event(event, binding, descriptor, 'conversation', 'key', 'value', None)
|
||||
snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor)
|
||||
assert snapshot['conversation']['key'] == 'value'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user