mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-17 01:46:07 +00:00
feat(agent-runner): enforce 4.x host-owned execution
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Shared test fixtures for agent runner tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
@@ -45,6 +46,7 @@ def make_session(
|
||||
available_apis: dict[str, bool] | None = None,
|
||||
state_policy: dict[str, typing.Any] | None = None,
|
||||
state_context: dict[str, typing.Any] | None = None,
|
||||
execution_query: typing.Any | None = None,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Create a minimal AgentRunSession dict for testing.
|
||||
|
||||
@@ -59,13 +61,12 @@ def make_session(
|
||||
AgentRunSession dict with run-scoped authorization snapshot
|
||||
"""
|
||||
import time
|
||||
|
||||
now = int(time.time())
|
||||
res = resources if resources is not None else make_resources()
|
||||
apis = available_apis if available_apis is not None else {}
|
||||
policy = (
|
||||
state_policy
|
||||
if state_policy is not None
|
||||
else {'enable_state': True, 'state_scopes': ['conversation', 'actor']}
|
||||
state_policy if state_policy is not None else {'enable_state': True, 'state_scopes': ['conversation', 'actor']}
|
||||
)
|
||||
context = state_context if state_context is not None else {}
|
||||
|
||||
@@ -102,6 +103,7 @@ def make_session(
|
||||
'run_id': run_id,
|
||||
'runner_id': runner_id,
|
||||
'query_id': query_id,
|
||||
'execution_query': execution_query,
|
||||
'plugin_identity': plugin_identity,
|
||||
'authorization': {
|
||||
'resources': res,
|
||||
|
||||
@@ -8,6 +8,7 @@ Tests focus on:
|
||||
|
||||
Avoids circular imports by using proper import structure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
@@ -19,11 +20,12 @@ from langbot.pkg.agent.runner.errors import (
|
||||
RunnerExecutionError,
|
||||
RunnerNotAuthorizedError,
|
||||
)
|
||||
from langbot.pkg.agent.runner.config_migration import ConfigMigration
|
||||
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
|
||||
|
||||
|
||||
# Define mock classes in dependency order (no forward references needed)
|
||||
|
||||
|
||||
class MockLauncherType:
|
||||
value = 'person'
|
||||
|
||||
@@ -59,6 +61,7 @@ class MockSession:
|
||||
|
||||
class MockQuery:
|
||||
"""Mock Query for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.query_id = 1
|
||||
self.launcher_type = MockLauncherType()
|
||||
@@ -93,6 +96,7 @@ class MockQuery:
|
||||
|
||||
class MockMessageChunk:
|
||||
"""Mock MessageChunk for testing."""
|
||||
|
||||
def __init__(self, content, resp_message_id=None):
|
||||
self.role = 'assistant'
|
||||
self.content = content
|
||||
@@ -106,6 +110,7 @@ class MockMessageChunk:
|
||||
|
||||
class MockEventContext:
|
||||
"""Mock event context for testing."""
|
||||
|
||||
def __init__(self, prevented=False, reply_message_chain=None, user_message_alter=None):
|
||||
self._prevented = prevented
|
||||
self.event = MagicMock()
|
||||
@@ -118,6 +123,7 @@ class MockEventContext:
|
||||
|
||||
class MockAgentRunOrchestrator:
|
||||
"""Mock AgentRunOrchestrator for testing."""
|
||||
|
||||
def __init__(self, chunks=None, error=None):
|
||||
self._chunks = chunks or []
|
||||
self._error = error
|
||||
@@ -138,6 +144,7 @@ class MockAgentRunOrchestrator:
|
||||
|
||||
class MockApplication:
|
||||
"""Mock Application for testing."""
|
||||
|
||||
def __init__(self, orchestrator=None):
|
||||
self.agent_run_orchestrator = orchestrator or MockAgentRunOrchestrator()
|
||||
self.logger = MagicMock()
|
||||
@@ -226,11 +233,11 @@ class TestStreamingBehavior:
|
||||
assert len(resp_messages) == 2
|
||||
|
||||
|
||||
class TestConfigMigrationInChatHandler:
|
||||
"""Tests for ConfigMigration usage in chat handler context."""
|
||||
class TestRunnerConfigResolverInChatHandler:
|
||||
"""Tests for RunnerConfigResolver usage in chat handler context."""
|
||||
|
||||
def test_resolve_runner_id_from_pipeline_config(self):
|
||||
"""Chat handler should use ConfigMigration to resolve runner ID."""
|
||||
"""Chat handler should use RunnerConfigResolver to resolve runner ID."""
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
@@ -239,7 +246,7 @@ class TestConfigMigrationInChatHandler:
|
||||
},
|
||||
}
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
|
||||
assert runner_id == 'plugin:langbot-team/LocalAgent/default'
|
||||
|
||||
def test_old_runner_field_is_not_resolved(self):
|
||||
@@ -252,7 +259,7 @@ class TestConfigMigrationInChatHandler:
|
||||
},
|
||||
}
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
|
||||
assert runner_id is None
|
||||
|
||||
|
||||
@@ -302,19 +309,23 @@ class TestChatHandlerImports:
|
||||
"""Import chat handler module should work."""
|
||||
# This test verifies the import works without circular dependency
|
||||
from langbot.pkg.pipeline.process.handlers import chat
|
||||
|
||||
assert chat.ChatMessageHandler is not None
|
||||
|
||||
def test_chat_handler_class_exists(self):
|
||||
"""ChatMessageHandler class should be defined."""
|
||||
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
|
||||
|
||||
assert ChatMessageHandler.__name__ == 'ChatMessageHandler'
|
||||
|
||||
def test_chat_handler_has_handle_method(self):
|
||||
"""ChatMessageHandler should have async generator handle method."""
|
||||
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
|
||||
|
||||
assert hasattr(ChatMessageHandler, 'handle')
|
||||
# handle returns AsyncGenerator, so check for async generator function
|
||||
import inspect
|
||||
|
||||
assert inspect.isasyncgenfunction(ChatMessageHandler.handle)
|
||||
|
||||
|
||||
@@ -353,8 +364,10 @@ class TestChatHandlerAsyncBehavior:
|
||||
def make_result(*args, **kwargs):
|
||||
return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE))
|
||||
|
||||
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
|
||||
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
|
||||
with (
|
||||
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
|
||||
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
|
||||
):
|
||||
mock_events_module.PersonNormalMessageReceived = mock_event
|
||||
mock_events_module.GroupNormalMessageReceived = mock_event
|
||||
|
||||
@@ -396,8 +409,10 @@ class TestChatHandlerAsyncBehavior:
|
||||
def make_result(*args, **kwargs):
|
||||
return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE))
|
||||
|
||||
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
|
||||
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
|
||||
with (
|
||||
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
|
||||
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
|
||||
):
|
||||
mock_events_module.PersonNormalMessageReceived = mock_event
|
||||
mock_events_module.GroupNormalMessageReceived = mock_event
|
||||
|
||||
@@ -439,8 +454,10 @@ class TestChatHandlerAsyncBehavior:
|
||||
def make_result(*args, **kwargs):
|
||||
return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE))
|
||||
|
||||
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
|
||||
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
|
||||
with (
|
||||
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
|
||||
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
|
||||
):
|
||||
mock_events_module.PersonNormalMessageReceived = mock_event
|
||||
mock_events_module.GroupNormalMessageReceived = mock_event
|
||||
|
||||
@@ -460,9 +477,7 @@ class TestChatHandlerAsyncBehavior:
|
||||
from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
|
||||
from langbot.pkg.pipeline import entities
|
||||
|
||||
orchestrator = MockAgentRunOrchestrator(
|
||||
error=RunnerNotFoundError('plugin:notexist/unknown/default')
|
||||
)
|
||||
orchestrator = MockAgentRunOrchestrator(error=RunnerNotFoundError('plugin:notexist/unknown/default'))
|
||||
mock_ap = MockApplication(orchestrator=orchestrator)
|
||||
mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False))
|
||||
|
||||
@@ -479,8 +494,10 @@ class TestChatHandlerAsyncBehavior:
|
||||
user_notice=kwargs.get('user_notice'),
|
||||
)
|
||||
|
||||
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
|
||||
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
|
||||
with (
|
||||
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
|
||||
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
|
||||
):
|
||||
mock_events_module.PersonNormalMessageReceived = mock_event
|
||||
mock_events_module.GroupNormalMessageReceived = mock_event
|
||||
|
||||
@@ -518,8 +535,10 @@ class TestChatHandlerAsyncBehavior:
|
||||
user_notice=kwargs.get('user_notice'),
|
||||
)
|
||||
|
||||
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
|
||||
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
|
||||
with (
|
||||
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
|
||||
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
|
||||
):
|
||||
mock_events_module.PersonNormalMessageReceived = mock_event
|
||||
mock_events_module.GroupNormalMessageReceived = mock_event
|
||||
|
||||
@@ -556,8 +575,10 @@ class TestChatHandlerAsyncBehavior:
|
||||
user_notice=kwargs.get('user_notice'),
|
||||
)
|
||||
|
||||
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
|
||||
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
|
||||
with (
|
||||
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
|
||||
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
|
||||
):
|
||||
mock_events_module.PersonNormalMessageReceived = mock_event
|
||||
mock_events_module.GroupNormalMessageReceived = mock_event
|
||||
|
||||
@@ -593,8 +614,10 @@ class TestChatHandlerAsyncBehavior:
|
||||
def make_result(*args, **kwargs):
|
||||
return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE))
|
||||
|
||||
with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \
|
||||
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result):
|
||||
with (
|
||||
patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
|
||||
patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
|
||||
):
|
||||
mock_events_module.PersonNormalMessageReceived = mock_event
|
||||
mock_events_module.GroupNormalMessageReceived = mock_event
|
||||
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Tests for current AgentRunner config helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.agent.runner.config_migration import ConfigMigration
|
||||
|
||||
|
||||
class TestResolveRunnerId:
|
||||
"""Tests for ConfigMigration.resolve_runner_id."""
|
||||
|
||||
def test_resolve_current_runner_id(self):
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
'id': 'plugin:langbot-team/LocalAgent/default',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
assert runner_id == 'plugin:langbot-team/LocalAgent/default'
|
||||
|
||||
def test_does_not_resolve_legacy_runner_field(self):
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
'runner': 'local-agent',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
assert runner_id is None
|
||||
|
||||
def test_resolve_no_runner_config(self):
|
||||
runner_id = ConfigMigration.resolve_runner_id({})
|
||||
assert runner_id is None
|
||||
|
||||
|
||||
class TestResolveRunnerConfig:
|
||||
"""Tests for ConfigMigration.resolve_runner_config."""
|
||||
|
||||
def test_resolve_current_config(self):
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'runner_config': {
|
||||
'plugin:langbot-team/LocalAgent/default': {
|
||||
'model': 'uuid-123',
|
||||
'custom_option': 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config = ConfigMigration.resolve_runner_config(
|
||||
pipeline_config,
|
||||
'plugin:langbot-team/LocalAgent/default',
|
||||
)
|
||||
assert config == {'model': 'uuid-123', 'custom_option': 10}
|
||||
|
||||
def test_does_not_read_legacy_runner_block(self):
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'local-agent': {
|
||||
'model': 'uuid-123',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config = ConfigMigration.resolve_runner_config(
|
||||
pipeline_config,
|
||||
'plugin:langbot-team/LocalAgent/default',
|
||||
)
|
||||
assert config == {}
|
||||
|
||||
def test_resolve_no_config(self):
|
||||
config = ConfigMigration.resolve_runner_config(
|
||||
{},
|
||||
'plugin:langbot-team/LocalAgent/default',
|
||||
)
|
||||
assert config == {}
|
||||
|
||||
|
||||
class TestGetExpireTime:
|
||||
"""Tests for ConfigMigration.get_expire_time."""
|
||||
|
||||
def test_get_expire_time_zero(self):
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
'expire-time': 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expire_time = ConfigMigration.get_expire_time(pipeline_config)
|
||||
assert expire_time == 0
|
||||
|
||||
def test_get_expire_time_positive(self):
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
'expire-time': 3600,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expire_time = ConfigMigration.get_expire_time(pipeline_config)
|
||||
assert expire_time == 3600
|
||||
|
||||
def test_get_expire_time_default(self):
|
||||
expire_time = ConfigMigration.get_expire_time({})
|
||||
assert expire_time == 0
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Tests for current AgentRunner config resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
|
||||
|
||||
|
||||
class TestResolveRunnerId:
|
||||
"""Tests for RunnerConfigResolver.resolve_runner_id."""
|
||||
|
||||
def test_resolve_current_runner_id(self):
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
'id': 'plugin:langbot-team/LocalAgent/default',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
|
||||
assert runner_id == 'plugin:langbot-team/LocalAgent/default'
|
||||
|
||||
def test_does_not_resolve_legacy_runner_field(self):
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
'runner': 'local-agent',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
|
||||
assert runner_id is None
|
||||
|
||||
def test_resolve_no_runner_config(self):
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id({})
|
||||
assert runner_id is None
|
||||
|
||||
|
||||
class TestResolveRunnerConfig:
|
||||
"""Tests for RunnerConfigResolver.resolve_runner_config."""
|
||||
|
||||
def test_resolve_current_config(self):
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'runner_config': {
|
||||
'plugin:langbot-team/LocalAgent/default': {
|
||||
'model': 'uuid-123',
|
||||
'custom_option': 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config = RunnerConfigResolver.resolve_runner_config(
|
||||
pipeline_config,
|
||||
'plugin:langbot-team/LocalAgent/default',
|
||||
)
|
||||
assert config == {'model': 'uuid-123', 'custom_option': 10}
|
||||
|
||||
def test_does_not_read_legacy_runner_block(self):
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'local-agent': {
|
||||
'model': 'uuid-123',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config = RunnerConfigResolver.resolve_runner_config(
|
||||
pipeline_config,
|
||||
'plugin:langbot-team/LocalAgent/default',
|
||||
)
|
||||
assert config == {}
|
||||
|
||||
def test_resolve_no_config(self):
|
||||
config = RunnerConfigResolver.resolve_runner_config(
|
||||
{},
|
||||
'plugin:langbot-team/LocalAgent/default',
|
||||
)
|
||||
assert config == {}
|
||||
|
||||
|
||||
class TestGetExpireTime:
|
||||
"""Tests for RunnerConfigResolver.get_expire_time."""
|
||||
|
||||
def test_get_expire_time_zero(self):
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
'expire-time': 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expire_time = RunnerConfigResolver.get_expire_time(pipeline_config)
|
||||
assert expire_time == 0
|
||||
|
||||
def test_get_expire_time_positive(self):
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {
|
||||
'expire-time': 3600,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expire_time = RunnerConfigResolver.get_expire_time(pipeline_config)
|
||||
assert expire_time == 3600
|
||||
|
||||
|
||||
class TestValidateCurrentConfig:
|
||||
@pytest.mark.parametrize(
|
||||
('field_name', 'invalid_value'),
|
||||
[
|
||||
('enable-all-tools', 0),
|
||||
('enable-all-tools', None),
|
||||
('enable-all-tools', 'false'),
|
||||
('mcp-resource-agent-read-enabled', 0),
|
||||
('mcp-resource-agent-read-enabled', None),
|
||||
('mcp-resource-agent-read-enabled', 'false'),
|
||||
],
|
||||
)
|
||||
def test_agent_rejects_non_boolean_selected_runner_security_fields(self, field_name, invalid_value):
|
||||
config = {
|
||||
'runner': {'id': 'plugin:test/runner/default'},
|
||||
'runner_config': {
|
||||
'plugin:test/runner/default': {
|
||||
field_name: invalid_value,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match=f'{field_name}.*boolean'):
|
||||
RunnerConfigResolver.validate_agent_config(config)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('field_name', 'invalid_value'),
|
||||
[
|
||||
('enable-all-tools', 0),
|
||||
('enable-all-tools', None),
|
||||
('enable-all-tools', 'false'),
|
||||
('mcp-resource-agent-read-enabled', 0),
|
||||
('mcp-resource-agent-read-enabled', None),
|
||||
('mcp-resource-agent-read-enabled', 'false'),
|
||||
],
|
||||
)
|
||||
def test_pipeline_rejects_non_boolean_selected_runner_security_fields(self, field_name, invalid_value):
|
||||
config = {
|
||||
'ai': {
|
||||
'runner': {'id': 'plugin:test/runner/default'},
|
||||
'runner_config': {
|
||||
'plugin:test/runner/default': {
|
||||
field_name: invalid_value,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match=f'{field_name}.*boolean'):
|
||||
RunnerConfigResolver.validate_pipeline_config(config)
|
||||
|
||||
def test_only_selected_runner_security_fields_are_validated(self):
|
||||
config = {
|
||||
'runner': {'id': 'plugin:test/selected/default'},
|
||||
'runner_config': {
|
||||
'plugin:test/selected/default': {'enable-all-tools': False},
|
||||
'plugin:test/unselected/default': {'enable-all-tools': 'preserved'},
|
||||
},
|
||||
}
|
||||
|
||||
assert RunnerConfigResolver.validate_agent_config(config) is config
|
||||
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false'])
|
||||
@pytest.mark.parametrize('config_kind', ['agent', 'pipeline'])
|
||||
def test_rejects_non_boolean_mcp_resource_enabled(self, config_kind, invalid_value):
|
||||
runner_id = 'plugin:test/runner/default'
|
||||
runner_config = {'mcp-resources': [{'uri': 'file:///README.md', 'enabled': invalid_value}]}
|
||||
if config_kind == 'agent':
|
||||
config = {
|
||||
'runner': {'id': runner_id},
|
||||
'runner_config': {runner_id: runner_config},
|
||||
}
|
||||
validate = RunnerConfigResolver.validate_agent_config
|
||||
else:
|
||||
config = {
|
||||
'ai': {
|
||||
'runner': {'id': runner_id},
|
||||
'runner_config': {runner_id: runner_config},
|
||||
}
|
||||
}
|
||||
validate = RunnerConfigResolver.validate_pipeline_config
|
||||
|
||||
with pytest.raises(ValueError, match=r'mcp-resources\[0\]\.enabled.*boolean'):
|
||||
validate(config)
|
||||
|
||||
@pytest.mark.parametrize('enabled', [True, False])
|
||||
def test_accepts_boolean_mcp_resource_enabled(self, enabled):
|
||||
runner_config = {'mcp-resources': [{'enabled': enabled}]}
|
||||
|
||||
assert RunnerConfigResolver.validate_runner_security_fields(runner_config) is runner_config
|
||||
|
||||
def test_get_expire_time_default(self):
|
||||
expire_time = RunnerConfigResolver.get_expire_time({})
|
||||
assert expire_time == 0
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
"""Tests for persisted AgentRunner config shape."""
|
||||
"""Tests for persisted AgentRunner config templates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from langbot.pkg.agent.runner.config_migration import ConfigMigration
|
||||
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
|
||||
|
||||
|
||||
class TestDefaultPipelineConfig:
|
||||
@@ -35,7 +35,7 @@ class TestResolveRunnerId:
|
||||
'runner': {'id': 'plugin:test/my-runner/default'},
|
||||
},
|
||||
}
|
||||
runner_id = ConfigMigration.resolve_runner_id(config)
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(config)
|
||||
assert runner_id == 'plugin:test/my-runner/default'
|
||||
|
||||
def test_old_runner_field_is_not_supported(self):
|
||||
@@ -44,7 +44,7 @@ class TestResolveRunnerId:
|
||||
'runner': {'runner': 'local-agent'},
|
||||
},
|
||||
}
|
||||
runner_id = ConfigMigration.resolve_runner_id(config)
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(config)
|
||||
assert runner_id is None
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ class TestResolveRunnerConfig:
|
||||
},
|
||||
},
|
||||
}
|
||||
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default')
|
||||
runner_config = RunnerConfigResolver.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default')
|
||||
assert runner_config['custom-option'] == 20
|
||||
|
||||
def test_old_runner_block_is_not_supported(self):
|
||||
@@ -68,5 +68,5 @@ class TestResolveRunnerConfig:
|
||||
'local-agent': {'custom-option': 20},
|
||||
},
|
||||
}
|
||||
runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default')
|
||||
runner_config = RunnerConfigResolver.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default')
|
||||
assert runner_config == {}
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Tests for Host-only AgentRunner 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.pipeline import query as pipeline_query
|
||||
from langbot_plugin.api.entities.builtin.provider.message import ContentElement
|
||||
|
||||
from langbot.pkg.agent.runner.execution_context import (
|
||||
append_mcp_resource_context_to_event,
|
||||
build_execution_query,
|
||||
build_host_box_scope,
|
||||
prepare_box_scope,
|
||||
prepare_execution_query,
|
||||
project_mcp_resource_config,
|
||||
)
|
||||
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope
|
||||
from langbot.pkg.utils import constants
|
||||
|
||||
|
||||
class PlatformAdapter:
|
||||
pass
|
||||
|
||||
|
||||
def make_event(
|
||||
*,
|
||||
event_id: str = 'event-1',
|
||||
conversation_id: str | None = 'person_user-1',
|
||||
target_type: str | None = 'person',
|
||||
target_id: str | None = 'user-1',
|
||||
adapter: str = 'PlatformAdapter',
|
||||
) -> AgentEventEnvelope:
|
||||
reply_target = {}
|
||||
if target_type is not None:
|
||||
reply_target['target_type'] = target_type
|
||||
if target_id is not None:
|
||||
reply_target['target_id'] = target_id
|
||||
return AgentEventEnvelope(
|
||||
event_id=event_id,
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
bot_id='bot-1',
|
||||
workspace_id='workspace-1',
|
||||
conversation_id=conversation_id,
|
||||
input=AgentInput(text='hello'),
|
||||
delivery=DeliveryContext(
|
||||
surface='platform',
|
||||
reply_target=reply_target,
|
||||
platform_capabilities={'adapter': adapter},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_pipeline_and_event_execution_use_same_platform_session_scope(monkeypatch):
|
||||
monkeypatch.setattr(constants, 'instance_id', 'instance-1')
|
||||
event = make_event()
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id=1,
|
||||
launcher_type='person',
|
||||
launcher_id='user-1',
|
||||
sender_id='user-1',
|
||||
adapter=PlatformAdapter(),
|
||||
variables={},
|
||||
)
|
||||
|
||||
prepare_execution_query(query, event, ['pdf'])
|
||||
event_query = build_execution_query(event, ['pdf'])
|
||||
|
||||
assert query.variables['_host_box_scope'] == event_query.variables['_host_box_scope']
|
||||
assert query.variables['_pipeline_bound_skills'] == ['pdf']
|
||||
assert event_query.variables['_pipeline_bound_skills'] == ['pdf']
|
||||
scope = json.loads(query.variables['_host_box_scope'])
|
||||
assert scope == {
|
||||
'instance_id': 'instance-1',
|
||||
'workspace_id': 'workspace-1',
|
||||
'bot_id': 'bot-1',
|
||||
'platform_adapter': 'PlatformAdapter',
|
||||
'target_type': 'person',
|
||||
'target_id': 'user-1',
|
||||
'thread_id': None,
|
||||
}
|
||||
|
||||
|
||||
def test_prepare_box_scope_does_not_change_existing_skill_projection():
|
||||
event = make_event()
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id=1,
|
||||
launcher_type='person',
|
||||
launcher_id='user-1',
|
||||
variables={'_pipeline_bound_skills': ['existing']},
|
||||
)
|
||||
|
||||
variables = prepare_box_scope(query, event)
|
||||
|
||||
assert variables['_host_box_scope']
|
||||
assert variables['_pipeline_bound_skills'] == ['existing']
|
||||
|
||||
|
||||
def test_prepare_box_scope_preserves_event_first_channel_scope():
|
||||
channel_event = make_event(target_type='channel', target_id='same')
|
||||
channel_query = build_execution_query(channel_event, [])
|
||||
original_scope = channel_query.variables['_host_box_scope']
|
||||
|
||||
prepare_execution_query(channel_query, channel_event, [])
|
||||
|
||||
person_scope = build_execution_query(make_event(target_type='person', target_id='same'), []).variables[
|
||||
'_host_box_scope'
|
||||
]
|
||||
assert channel_query.variables['_host_box_scope'] == original_scope
|
||||
assert json.loads(original_scope)['target_type'] == 'channel'
|
||||
assert original_scope != person_scope
|
||||
|
||||
|
||||
def test_prepare_box_scope_overwrites_untrusted_existing_scope():
|
||||
event = make_event(target_type='person', target_id='user-1')
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id=1,
|
||||
launcher_type='person',
|
||||
launcher_id='user-1',
|
||||
variables={'_host_box_scope': 'forged-scope'},
|
||||
)
|
||||
|
||||
variables = prepare_box_scope(query, event)
|
||||
|
||||
assert variables['_host_box_scope'] != 'forged-scope'
|
||||
assert json.loads(variables['_host_box_scope'])['target_id'] == 'user-1'
|
||||
|
||||
|
||||
def test_project_mcp_resource_config_uses_independent_agent_runner_settings():
|
||||
query = pipeline_query.Query.model_construct(variables={})
|
||||
attachments = [
|
||||
{
|
||||
'server_uuid': 'srv-1',
|
||||
'server_name': 'docs',
|
||||
'uri': 'file:///README.md',
|
||||
'mode': 'pinned',
|
||||
}
|
||||
]
|
||||
|
||||
variables = project_mcp_resource_config(
|
||||
query,
|
||||
{
|
||||
'mcp-resources': attachments,
|
||||
'mcp-resource-agent-read-enabled': False,
|
||||
},
|
||||
)
|
||||
|
||||
assert variables['_pipeline_mcp_resource_attachments'] == attachments
|
||||
assert variables['_pipeline_mcp_resource_attachments'] is not attachments
|
||||
assert variables['_pipeline_mcp_resource_agent_read_enabled'] is False
|
||||
|
||||
|
||||
def test_project_mcp_resource_config_fails_closed_for_non_boolean_read_flag():
|
||||
query = pipeline_query.Query.model_construct(variables={})
|
||||
|
||||
variables = project_mcp_resource_config(
|
||||
query,
|
||||
{'mcp-resource-agent-read-enabled': 0},
|
||||
)
|
||||
|
||||
assert variables['_pipeline_mcp_resource_agent_read_enabled'] is False
|
||||
|
||||
|
||||
def test_pinned_context_updates_text_and_structured_input():
|
||||
event = make_event()
|
||||
event.input = AgentInput(
|
||||
text='hello',
|
||||
contents=[ContentElement.from_text('hello')],
|
||||
)
|
||||
|
||||
append_mcp_resource_context_to_event(event, '\n\npinned-context')
|
||||
|
||||
assert event.input.text == 'hello\n\npinned-context'
|
||||
assert event.input.contents[0].text == 'hello\n\npinned-context'
|
||||
|
||||
|
||||
def test_event_reply_target_populates_valid_session_identity():
|
||||
event = make_event(conversation_id='rotating-transcript-id', target_type='group', target_id='room-1')
|
||||
|
||||
query = build_execution_query(event, [])
|
||||
|
||||
assert query.launcher_type.value == 'group'
|
||||
assert query.launcher_id == 'room-1'
|
||||
assert query.sender_id == 'room-1'
|
||||
assert query.session.launcher_type.value == 'group'
|
||||
assert query.session.launcher_id == 'room-1'
|
||||
scope = json.loads(query.variables['_host_box_scope'])
|
||||
assert scope['target_type'] == 'group'
|
||||
assert scope['target_id'] == 'room-1'
|
||||
assert 'rotating-transcript-id' not in query.variables['_host_box_scope']
|
||||
|
||||
|
||||
def test_non_message_event_without_conversation_uses_event_scope():
|
||||
event = make_event(
|
||||
event_id='scheduled/event/中文',
|
||||
conversation_id=None,
|
||||
target_type=None,
|
||||
target_id=None,
|
||||
adapter='scheduler',
|
||||
)
|
||||
event.event_type = 'schedule.triggered'
|
||||
|
||||
query = build_execution_query(event, [])
|
||||
|
||||
scope = json.loads(query.variables['_host_box_scope'])
|
||||
assert scope['target_type'] == 'event'
|
||||
assert scope['target_id'] == event.event_id
|
||||
assert query.pipeline_config is None
|
||||
assert query.pipeline_uuid is None
|
||||
|
||||
|
||||
def test_scope_isolated_by_instance_and_platform_adapter(monkeypatch):
|
||||
event = make_event(adapter='AdapterA')
|
||||
monkeypatch.setattr(constants, 'instance_id', 'instance-a')
|
||||
first = build_host_box_scope(event)
|
||||
|
||||
monkeypatch.setattr(constants, 'instance_id', 'instance-b')
|
||||
second = build_host_box_scope(event)
|
||||
other_adapter = build_host_box_scope(make_event(adapter='AdapterB'))
|
||||
|
||||
assert first != second
|
||||
assert second != other_adapter
|
||||
@@ -421,7 +421,7 @@ class TestRetrieveKnowledgeBaseAuthorization:
|
||||
# When no run_id, the handler checks against pipeline's configured KBs
|
||||
# This is the unscoped path for regular plugin calls
|
||||
|
||||
from langbot.pkg.agent.runner.config_migration import ConfigMigration
|
||||
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
|
||||
|
||||
# Simulate pipeline config
|
||||
pipeline_config = {
|
||||
@@ -437,10 +437,10 @@ class TestRetrieveKnowledgeBaseAuthorization:
|
||||
},
|
||||
}
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
|
||||
assert runner_id == 'plugin:test/runner/default'
|
||||
|
||||
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
|
||||
runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
|
||||
allowed_kbs = runner_config.get('knowledge-bases', [])
|
||||
assert 'kb_001' in allowed_kbs
|
||||
assert 'kb_999' not in allowed_kbs
|
||||
@@ -534,12 +534,12 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
|
||||
without first resolving the runner_id, causing issues when non-local-agent runners
|
||||
were used.
|
||||
|
||||
Fix: Now uses ConfigMigration.resolve_runner_id first, then resolve_runner_config.
|
||||
Fix: Now uses RunnerConfigResolver.resolve_runner_id first, then resolve_runner_config.
|
||||
"""
|
||||
|
||||
def test_retrieve_kb_fix_local_agent_runner(self):
|
||||
"""Fix should work for local-agent runner."""
|
||||
from langbot.pkg.agent.runner.config_migration import ConfigMigration
|
||||
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
|
||||
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
@@ -554,15 +554,15 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
|
||||
},
|
||||
}
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
|
||||
runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
|
||||
allowed_kbs = runner_config.get('knowledge-bases', [])
|
||||
|
||||
assert 'kb_001' in allowed_kbs
|
||||
|
||||
def test_retrieve_kb_fix_other_runner(self):
|
||||
"""Fix should work for non-local-agent runners."""
|
||||
from langbot.pkg.agent.runner.config_migration import ConfigMigration
|
||||
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
|
||||
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
@@ -577,15 +577,15 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
|
||||
},
|
||||
}
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
|
||||
runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
|
||||
allowed_kbs = runner_config.get('knowledge-bases', [])
|
||||
|
||||
assert 'kb_custom' in allowed_kbs
|
||||
|
||||
def test_retrieve_kb_does_not_read_old_runner_format(self):
|
||||
"""The 4.x authorization path ignores legacy Pipeline runner fields."""
|
||||
from langbot.pkg.agent.runner.config_migration import ConfigMigration
|
||||
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
|
||||
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
@@ -598,8 +598,8 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
|
||||
},
|
||||
}
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
|
||||
runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
|
||||
assert runner_id is None
|
||||
assert runner_config == {}
|
||||
|
||||
@@ -922,7 +922,7 @@ class TestNoRunIdBackwardCompatPath:
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve_knowledge_base_no_run_id_pipeline_check(self):
|
||||
"""RETRIEVE_KNOWLEDGE_BASE: no run_id uses pipeline config check."""
|
||||
from langbot.pkg.agent.runner.config_migration import ConfigMigration
|
||||
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
|
||||
|
||||
# When no run_id, handler.py lines 897-914 check pipeline config
|
||||
pipeline_config = {
|
||||
@@ -938,8 +938,8 @@ class TestNoRunIdBackwardCompatPath:
|
||||
},
|
||||
}
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
|
||||
runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id)
|
||||
allowed_kb_uuids = runner_config.get('knowledge-bases', [])
|
||||
|
||||
# kb_001 should be allowed
|
||||
|
||||
@@ -234,6 +234,12 @@ def make_query():
|
||||
'_pipeline_bound_plugins': ['langbot-team/LocalAgent'],
|
||||
'_fallback_model_uuids': ['model_fallback'],
|
||||
'_pipeline_bound_skills': ['demo'],
|
||||
'_host_tool_source_refs': {
|
||||
'langbot/test-tool/search': {
|
||||
'source': 'plugin',
|
||||
'source_id': 'langbot/test-tool',
|
||||
},
|
||||
},
|
||||
'public_param': 'visible',
|
||||
},
|
||||
use_llm_model_uuid='model_primary',
|
||||
@@ -704,6 +710,98 @@ async def test_orchestrator_unregisters_session_after_event_log_failure(clean_ag
|
||||
assert await get_session_registry().list_active_runs() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unconsumed_steering_audit_does_not_persist_pinned_context(clean_agent_state):
|
||||
"""Dropped steering audits retain routing metadata but never execution-only context."""
|
||||
from langbot.pkg.agent.runner.event_log_store import EventLogStore
|
||||
|
||||
class BlockingPluginConnector(FakePluginConnector):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.started = asyncio.Event()
|
||||
self.release = asyncio.Event()
|
||||
|
||||
async def run_agent(self, plugin_author, plugin_name, runner_name, context):
|
||||
self.calls.append(
|
||||
{
|
||||
'plugin_author': plugin_author,
|
||||
'plugin_name': plugin_name,
|
||||
'runner_name': runner_name,
|
||||
}
|
||||
)
|
||||
self.contexts.append(context)
|
||||
self.sessions_during_run.append(await get_session_registry().get(context['run_id']))
|
||||
self.started.set()
|
||||
await self.release.wait()
|
||||
yield {
|
||||
'type': 'message.completed',
|
||||
'data': {'message': {'role': 'assistant', 'content': 'response'}},
|
||||
}
|
||||
|
||||
db_engine = clean_agent_state
|
||||
descriptor = make_descriptor()
|
||||
descriptor.capabilities.steering = True
|
||||
plugin_connector = BlockingPluginConnector()
|
||||
ap = FakeApplication(plugin_connector, db_engine)
|
||||
pinned_context = 'PINNED_CONTEXT_MUST_NOT_BE_PERSISTED'
|
||||
|
||||
async def build_resource_context(query):
|
||||
attachments = query.variables.get('_pipeline_mcp_resource_attachments', [])
|
||||
return pinned_context if attachments else ''
|
||||
|
||||
mcp_loader = types.SimpleNamespace(build_resource_context_for_query=AsyncMock(side_effect=build_resource_context))
|
||||
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
|
||||
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
||||
|
||||
active_query = make_query()
|
||||
|
||||
async def consume_active_run():
|
||||
return [message async for message in orchestrator.run_from_query(active_query)]
|
||||
|
||||
active_task = asyncio.create_task(consume_active_run())
|
||||
await asyncio.wait_for(plugin_connector.started.wait(), timeout=1)
|
||||
|
||||
steering_query = make_query()
|
||||
steering_query.query_id = 1002
|
||||
steering_query.message_chain[0].id = 'msg_002'
|
||||
steering_query.variables['_pipeline_mcp_resource_attachments'] = [
|
||||
{
|
||||
'server_uuid': 'srv-1',
|
||||
'server_name': 'docs',
|
||||
'uri': 'file:///README.md',
|
||||
'mode': 'pinned',
|
||||
}
|
||||
]
|
||||
steering_query.variables['_pipeline_mcp_resource_agent_read_enabled'] = True
|
||||
|
||||
try:
|
||||
claimed = await orchestrator.try_claim_steering_from_query(steering_query)
|
||||
finally:
|
||||
plugin_connector.release.set()
|
||||
|
||||
messages = await asyncio.wait_for(active_task, timeout=1)
|
||||
assert claimed is True
|
||||
assert len(messages) == 1
|
||||
|
||||
event_store = EventLogStore(db_engine)
|
||||
event_logs, _, _ = await event_store.page_events(
|
||||
conversation_id=steering_query.session.using_conversation.uuid,
|
||||
limit=10,
|
||||
)
|
||||
dropped = next(event for event in event_logs if event['event_type'] == 'steering.dropped')
|
||||
queued = next(
|
||||
event
|
||||
for event in event_logs
|
||||
if event['event_type'] == 'message.received'
|
||||
and event.get('metadata', {}).get('steering', {}).get('status') == 'queued'
|
||||
)
|
||||
|
||||
assert dropped['input_summary'] == 'Unconsumed steering input dropped'
|
||||
assert dropped['input_json'] is None
|
||||
assert dropped['metadata']['steering']['original_event_id'] == queued['event_id']
|
||||
assert pinned_context not in str(event_logs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state):
|
||||
"""Test that orchestrator enforces total runner timeout."""
|
||||
@@ -733,6 +831,48 @@ async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state):
|
||||
class TestQueryEntrySessionQueryId:
|
||||
"""Tests for internal query_id entering session registry."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_box_scope_exists_before_attachment_materialization(self, clean_agent_state):
|
||||
"""Inbound staging and later runner tools resolve to the same Box session."""
|
||||
from langbot.pkg.box.service import BoxService
|
||||
|
||||
class CapturingBoxService:
|
||||
available = True
|
||||
|
||||
def __init__(self):
|
||||
self.resolver = object.__new__(BoxService)
|
||||
self.materialize_session_id = None
|
||||
|
||||
async def materialize_inbound_attachments(self, query):
|
||||
self.materialize_session_id = self.resolver.resolve_box_session_id(query)
|
||||
return []
|
||||
|
||||
db_engine = clean_agent_state
|
||||
descriptor = make_descriptor()
|
||||
plugin_connector = FakePluginConnector(
|
||||
results=[
|
||||
{
|
||||
'type': 'message.completed',
|
||||
'data': {'message': {'role': 'assistant', 'content': 'response'}},
|
||||
}
|
||||
]
|
||||
)
|
||||
ap = FakeApplication(plugin_connector, db_engine)
|
||||
box_service = CapturingBoxService()
|
||||
ap.box_service = box_service
|
||||
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
||||
query = make_query()
|
||||
|
||||
messages = [message async for message in orchestrator.run_from_query(query)]
|
||||
|
||||
assert len(messages) == 1
|
||||
session = plugin_connector.sessions_during_run[0]
|
||||
assert session is not None
|
||||
assert session['execution_query'] is query
|
||||
runner_session_id = box_service.resolver.resolve_box_session_id(session['execution_query'])
|
||||
assert box_service.materialize_session_id == runner_session_id
|
||||
assert query.variables['_host_box_scope']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_id_registered_in_session_for_query_entry_flow(self, clean_agent_state):
|
||||
"""query_id from Query entry flow is registered internally in session."""
|
||||
@@ -765,6 +905,9 @@ class TestQueryEntrySessionQueryId:
|
||||
session_during_run = plugin_connector.sessions_during_run[0]
|
||||
assert session_during_run is not None
|
||||
assert session_during_run['query_id'] == query.query_id
|
||||
assert session_during_run['execution_query'] is query
|
||||
assert query.pipeline_uuid == 'pipeline_001'
|
||||
assert query.pipeline_config is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_query_id_for_pure_event_first_flow(self, clean_agent_state):
|
||||
@@ -791,6 +934,10 @@ class TestQueryEntrySessionQueryId:
|
||||
]
|
||||
)
|
||||
ap = FakeApplication(plugin_connector, db_engine)
|
||||
mcp_loader = types.SimpleNamespace(
|
||||
build_resource_context_for_query=AsyncMock(return_value='Pinned documentation')
|
||||
)
|
||||
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
|
||||
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
||||
|
||||
# Create event and binding directly (not from Query)
|
||||
@@ -805,7 +952,11 @@ class TestQueryEntrySessionQueryId:
|
||||
thread_id=None,
|
||||
actor=None,
|
||||
subject=None,
|
||||
input=AgentInput(text='hello', contents=[], attachments=[]),
|
||||
input=AgentInput(
|
||||
text='hello',
|
||||
contents=[provider_message.ContentElement.from_text('hello')],
|
||||
attachments=[],
|
||||
),
|
||||
delivery=DeliveryContext(surface='test', supports_streaming=True),
|
||||
)
|
||||
binding = AgentBinding(
|
||||
@@ -813,7 +964,17 @@ class TestQueryEntrySessionQueryId:
|
||||
scope=BindingScope(scope_type='agent', scope_id='pipeline_001'),
|
||||
event_types=['message.received'],
|
||||
runner_id=RUNNER_ID,
|
||||
runner_config={},
|
||||
runner_config={
|
||||
'mcp-resources': [
|
||||
{
|
||||
'server_uuid': 'srv-1',
|
||||
'server_name': 'docs',
|
||||
'uri': 'file:///README.md',
|
||||
'mode': 'pinned',
|
||||
}
|
||||
],
|
||||
'mcp-resource-agent-read-enabled': True,
|
||||
},
|
||||
resource_policy=ResourcePolicy(),
|
||||
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
|
||||
delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True),
|
||||
@@ -827,6 +988,26 @@ class TestQueryEntrySessionQueryId:
|
||||
session_during_run = plugin_connector.sessions_during_run[0]
|
||||
assert session_during_run is not None
|
||||
assert session_during_run['query_id'] is None
|
||||
execution_query = session_during_run['execution_query']
|
||||
assert execution_query is not None
|
||||
assert execution_query.pipeline_uuid is None
|
||||
assert execution_query.pipeline_config is None
|
||||
assert execution_query.bot_uuid == event.bot_id
|
||||
assert execution_query.launcher_id == event.conversation_id
|
||||
assert execution_query.sender_id == event.conversation_id
|
||||
assert execution_query.session.launcher_id == event.conversation_id
|
||||
assert execution_query.message_event.type == event.event_type
|
||||
assert execution_query.variables['_host_box_scope']
|
||||
assert execution_query.variables['_pipeline_bound_skills'] == ['demo', 'hidden']
|
||||
assert execution_query.variables['_pipeline_mcp_resource_attachments'][0]['server_uuid'] == 'srv-1'
|
||||
assert execution_query.variables['_pipeline_mcp_resource_agent_read_enabled'] is True
|
||||
assert 'MCP resource context selected by LangBot host:' in plugin_connector.contexts[0]['input']['text']
|
||||
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['text']
|
||||
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text']
|
||||
assert event.input.text == 'hello'
|
||||
assert event.input.contents[0].text == 'hello'
|
||||
assert 'Pinned documentation' not in str(execution_query.user_message.content)
|
||||
mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query)
|
||||
|
||||
|
||||
class TestQueryEntryAdapterParams:
|
||||
@@ -1106,8 +1287,21 @@ class TestQueryEntryAdapterHostCapabilities:
|
||||
]
|
||||
)
|
||||
ap = FakeApplication(plugin_connector, db_engine)
|
||||
mcp_loader = types.SimpleNamespace(
|
||||
build_resource_context_for_query=AsyncMock(return_value='Pinned documentation')
|
||||
)
|
||||
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
|
||||
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
||||
query = make_query()
|
||||
query.variables['_pipeline_mcp_resource_attachments'] = [
|
||||
{
|
||||
'server_uuid': 'srv-1',
|
||||
'server_name': 'docs',
|
||||
'uri': 'file:///README.md',
|
||||
'mode': 'pinned',
|
||||
}
|
||||
]
|
||||
query.variables['_pipeline_mcp_resource_agent_read_enabled'] = True
|
||||
query.user_message = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
@@ -1119,6 +1313,7 @@ class TestQueryEntryAdapterHostCapabilities:
|
||||
messages = [message async for message in orchestrator.run_from_query(query)]
|
||||
|
||||
assert len(messages) == 1
|
||||
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['text']
|
||||
|
||||
# Check EventLog has incoming event
|
||||
event_log_store = EventLogStore(db_engine)
|
||||
@@ -1132,6 +1327,7 @@ class TestQueryEntryAdapterHostCapabilities:
|
||||
assert event_logs[0]['input_json']['contents'][1]['image_base64'] is None
|
||||
assert event_logs[0]['input_json']['contents'][1]['content_redacted'] is True
|
||||
assert 'aGVsbG8=' not in str(event_logs[0]['input_json'])
|
||||
assert 'Pinned documentation' not in str(event_logs[0]['input_json'])
|
||||
|
||||
# Check Transcript has user and assistant messages
|
||||
transcript_store = TranscriptStore(db_engine)
|
||||
@@ -1149,3 +1345,4 @@ class TestQueryEntryAdapterHostCapabilities:
|
||||
assert user_item['content_json']['content'][1]['image_base64'] is None
|
||||
assert user_item['attachment_refs'][0]['content'] is None
|
||||
assert 'aGVsbG8=' not in str(user_item)
|
||||
assert 'Pinned documentation' not in str(user_item)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for AgentResourceBuilder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
@@ -10,6 +11,7 @@ from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
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
|
||||
from langbot.pkg.agent.runner.host_models import AgentBinding, BindingScope, ResourcePolicy
|
||||
|
||||
|
||||
RUNNER_ID = 'plugin:test/runner/default'
|
||||
@@ -100,6 +102,7 @@ def app():
|
||||
mock_app.skill_mgr = None
|
||||
mock_app.tool_mgr = Mock()
|
||||
mock_app.tool_mgr.get_tool_schema = AsyncMock(return_value=(None, None))
|
||||
mock_app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
|
||||
return mock_app
|
||||
|
||||
|
||||
@@ -130,11 +133,13 @@ async def test_build_models_authorizes_config_declared_llm_and_rerank_models(app
|
||||
{'name': 'rerank-model', 'type': 'rerank-model-selector'},
|
||||
],
|
||||
)
|
||||
query = make_query({
|
||||
'model': {'primary': 'primary', 'fallbacks': ['fallback', 'primary']},
|
||||
'aux-model': 'aux',
|
||||
'rerank-model': 'rerank',
|
||||
})
|
||||
query = make_query(
|
||||
{
|
||||
'model': {'primary': 'primary', 'fallbacks': ['fallback', 'primary']},
|
||||
'aux-model': 'aux',
|
||||
'rerank-model': 'rerank',
|
||||
}
|
||||
)
|
||||
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
@@ -173,10 +178,12 @@ async def test_build_models_from_config_without_manifest_acl(app):
|
||||
],
|
||||
permissions={},
|
||||
)
|
||||
query = make_query({
|
||||
'model': {'primary': 'primary', 'fallbacks': ['fallback']},
|
||||
'rerank-model': 'rerank',
|
||||
})
|
||||
query = make_query(
|
||||
{
|
||||
'model': {'primary': 'primary', 'fallbacks': ['fallback']},
|
||||
'rerank-model': 'rerank',
|
||||
}
|
||||
)
|
||||
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
@@ -196,10 +203,12 @@ async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app):
|
||||
{'name': 'rerank-model', 'type': 'rerank-model-selector'},
|
||||
],
|
||||
)
|
||||
query = make_query({
|
||||
'model': 'llm',
|
||||
'rerank-model': 'rerank',
|
||||
})
|
||||
query = make_query(
|
||||
{
|
||||
'model': 'llm',
|
||||
'rerank-model': 'rerank',
|
||||
}
|
||||
)
|
||||
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
@@ -234,10 +243,12 @@ async def test_build_resources_accepts_dynamic_form_type_aliases(app):
|
||||
{'name': 'knowledge-bases', 'type': 'select-knowledge-bases'},
|
||||
],
|
||||
)
|
||||
query = make_query({
|
||||
'model': 'llm_alias',
|
||||
'knowledge-bases': ['kb_alias'],
|
||||
})
|
||||
query = make_query(
|
||||
{
|
||||
'model': 'llm_alias',
|
||||
'knowledge-bases': ['kb_alias'],
|
||||
}
|
||||
)
|
||||
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
@@ -271,10 +282,12 @@ async def test_build_models_manifest_permission_narrows_binding(app):
|
||||
'models': ['rerank'],
|
||||
},
|
||||
)
|
||||
query = make_query({
|
||||
'model': 'llm',
|
||||
'rerank-model': 'rerank',
|
||||
})
|
||||
query = make_query(
|
||||
{
|
||||
'model': 'llm',
|
||||
'rerank-model': 'rerank',
|
||||
}
|
||||
)
|
||||
|
||||
resources = await build_resources(app, query, descriptor)
|
||||
|
||||
@@ -309,7 +322,7 @@ async def test_build_tools_authorizes_query_declared_tools(app):
|
||||
"""Tools discovered by Pipeline preprocessing become run-scoped authorized
|
||||
resources, with full parameters schema prefilled by the host."""
|
||||
app.tool_mgr.get_tool_schema = AsyncMock(
|
||||
side_effect=lambda name: {
|
||||
side_effect=lambda name, source_ref=None: {
|
||||
'qa_plugin_echo': (
|
||||
'Echo test tool',
|
||||
{'type': 'object', 'properties': {'text': {'type': 'string'}}},
|
||||
@@ -321,6 +334,12 @@ async def test_build_tools_authorizes_query_declared_tools(app):
|
||||
)
|
||||
query = make_query(
|
||||
{},
|
||||
variables={
|
||||
'_host_tool_source_refs': {
|
||||
'qa_plugin_echo': {'source': 'plugin', 'source_id': 'test/plugin'},
|
||||
'qa_mcp_echo': {'source': 'mcp', 'source_id': 'mcp-server'},
|
||||
},
|
||||
},
|
||||
use_funcs=[
|
||||
{'name': 'qa_plugin_echo', 'description': 'Echo test tool'},
|
||||
SimpleNamespace(name='qa_mcp_echo'),
|
||||
@@ -332,17 +351,21 @@ async def test_build_tools_authorizes_query_declared_tools(app):
|
||||
assert resources['tools'] == [
|
||||
{
|
||||
'tool_name': 'qa_plugin_echo',
|
||||
'tool_type': None,
|
||||
'tool_type': 'plugin',
|
||||
'description': 'Echo test tool',
|
||||
'operations': ['detail', 'call'],
|
||||
'parameters': {'type': 'object', 'properties': {'text': {'type': 'string'}}},
|
||||
'source': 'plugin',
|
||||
'source_id': 'test/plugin',
|
||||
},
|
||||
{
|
||||
'tool_name': 'qa_mcp_echo',
|
||||
'tool_type': None,
|
||||
'tool_type': 'mcp',
|
||||
'description': None,
|
||||
'operations': ['detail', 'call'],
|
||||
'parameters': None,
|
||||
'source': 'mcp',
|
||||
'source_id': 'mcp-server',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -369,6 +392,103 @@ async def test_build_tools_manifest_permission_denies_binding_tools(app):
|
||||
assert resources['tools'] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_tools_materializes_independent_agent_all_tools_policy(app):
|
||||
"""Independent Agents resolve an all-tools grant against the live Host catalog."""
|
||||
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(
|
||||
return_value=[
|
||||
{'name': 'exec', 'source': 'builtin'},
|
||||
{'name': 'plugin_tool', 'source': 'plugin', 'source_id': 'test/plugin'},
|
||||
]
|
||||
)
|
||||
descriptor = make_descriptor(capabilities={'tool_calling': True})
|
||||
binding = AgentBinding(
|
||||
binding_id='agent-binding',
|
||||
scope=BindingScope(scope_type='agent', scope_id='agent-1'),
|
||||
runner_id=RUNNER_ID,
|
||||
runner_config={},
|
||||
resource_policy=ResourcePolicy(allow_all_tools=True),
|
||||
)
|
||||
|
||||
resources = await AgentResourceBuilder(app).build_resources_from_binding(
|
||||
event=QueryEntryAdapter.query_to_event(make_query({})),
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
)
|
||||
|
||||
assert [tool['tool_name'] for tool in resources['tools']] == ['exec', 'plugin_tool']
|
||||
app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
|
||||
include_skill_authoring=True,
|
||||
include_mcp_resource_tools=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_tools_denies_mcp_resource_tools_when_agent_reads_disabled(app):
|
||||
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(
|
||||
return_value=[
|
||||
{'name': 'exec', 'source': 'builtin'},
|
||||
{'name': 'langbot_mcp_list_resources', 'source': 'mcp'},
|
||||
{'name': 'langbot_mcp_read_resource', 'source': 'mcp'},
|
||||
]
|
||||
)
|
||||
descriptor = make_descriptor(capabilities={'tool_calling': True})
|
||||
binding = AgentBinding(
|
||||
binding_id='agent-binding',
|
||||
scope=BindingScope(scope_type='agent', scope_id='agent-1'),
|
||||
runner_id=RUNNER_ID,
|
||||
runner_config={'mcp-resource-agent-read-enabled': False},
|
||||
resource_policy=ResourcePolicy(allow_all_tools=True),
|
||||
)
|
||||
|
||||
resources = await AgentResourceBuilder(app).build_resources_from_binding(
|
||||
event=QueryEntryAdapter.query_to_event(make_query({})),
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
)
|
||||
|
||||
assert [tool['tool_name'] for tool in resources['tools']] == ['exec']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_tools_keeps_plugin_using_synthetic_mcp_tool_name_when_reads_disabled(app):
|
||||
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
'name': 'langbot_mcp_read_resource',
|
||||
'source': 'plugin',
|
||||
'source_id': 'test/resource-reader',
|
||||
},
|
||||
]
|
||||
)
|
||||
descriptor = make_descriptor(capabilities={'tool_calling': True})
|
||||
binding = AgentBinding(
|
||||
binding_id='agent-binding',
|
||||
scope=BindingScope(scope_type='agent', scope_id='agent-1'),
|
||||
runner_id=RUNNER_ID,
|
||||
runner_config={'mcp-resource-agent-read-enabled': False},
|
||||
resource_policy=ResourcePolicy(allow_all_tools=True),
|
||||
)
|
||||
|
||||
resources = await AgentResourceBuilder(app).build_resources_from_binding(
|
||||
event=QueryEntryAdapter.query_to_event(make_query({})),
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
)
|
||||
|
||||
assert resources['tools'] == [
|
||||
{
|
||||
'tool_name': 'langbot_mcp_read_resource',
|
||||
'tool_type': 'plugin',
|
||||
'description': None,
|
||||
'operations': ['detail', 'call'],
|
||||
'parameters': None,
|
||||
'source': 'plugin',
|
||||
'source_id': 'test/resource-reader',
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_knowledge_bases_unions_config_and_policy_grants(app):
|
||||
descriptor = make_descriptor(
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Tests for generic AgentRunner resource-policy projection."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.agent.runner.resource_policy import ResourcePolicyProjector
|
||||
|
||||
|
||||
def test_pipeline_projection_intersects_selected_tools_with_scoped_tools():
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{
|
||||
'enable-all-tools': False,
|
||||
'tools': ['plugin_tool', 'missing_tool', 'plugin_tool'],
|
||||
'knowledge-bases': ['kb-config'],
|
||||
},
|
||||
resolved_model_uuids=['model-1', 'model-1'],
|
||||
resolved_tool_names=['exec', 'plugin_tool', 'mcp_tool'],
|
||||
resolved_kb_uuids=['kb-runtime'],
|
||||
resolved_skill_names=[],
|
||||
)
|
||||
|
||||
assert policy.allow_all_tools is False
|
||||
assert policy.allowed_tool_names == ['plugin_tool']
|
||||
assert policy.allowed_model_uuids == ['model-1']
|
||||
assert policy.allowed_kb_uuids == ['kb-runtime']
|
||||
assert policy.allowed_skill_names == []
|
||||
|
||||
|
||||
def test_pipeline_projection_materializes_enable_all_tools_from_scoped_tools():
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{'enable-all-tools': True, 'tools': ['ignored']},
|
||||
resolved_tool_names=['exec', 'mcp_tool'],
|
||||
)
|
||||
|
||||
assert policy.allow_all_tools is False
|
||||
assert policy.allowed_tool_names == ['exec', 'mcp_tool']
|
||||
|
||||
|
||||
def test_pipeline_projection_keeps_sources_only_for_authorized_tools():
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{'enable-all-tools': False, 'tools': ['mcp_tool']},
|
||||
resolved_tool_names=['plugin_tool', 'mcp_tool'],
|
||||
resolved_tool_sources={
|
||||
'plugin_tool': {'source': 'plugin', 'source_id': 'test/plugin'},
|
||||
'mcp_tool': {'source': 'mcp', 'source_id': 'mcp-server'},
|
||||
},
|
||||
)
|
||||
|
||||
assert policy.allowed_tool_sources == {
|
||||
'mcp_tool': {'source': 'mcp', 'source_id': 'mcp-server'},
|
||||
}
|
||||
|
||||
|
||||
def test_independent_agent_projection_preserves_all_tools_intent():
|
||||
policy = ResourcePolicyProjector.from_runner_config({})
|
||||
|
||||
assert policy.allow_all_tools is True
|
||||
assert policy.allowed_tool_names is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}])
|
||||
def test_enable_all_tools_fails_closed_for_non_boolean_values(invalid_value):
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{'enable-all-tools': invalid_value, 'tools': ['explicit-tool']},
|
||||
)
|
||||
|
||||
assert policy.allow_all_tools is False
|
||||
assert policy.allowed_tool_names == ['explicit-tool']
|
||||
|
||||
|
||||
def test_independent_agent_projection_preserves_selected_tools():
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{'enable-all-tools': False, 'tools': ['exec', None, 'exec', 123]},
|
||||
)
|
||||
|
||||
assert policy.allow_all_tools is False
|
||||
assert policy.allowed_tool_names == ['exec']
|
||||
|
||||
|
||||
def test_filter_tools_supports_sdk_objects_and_dictionary_tools():
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{'enable-all-tools': False, 'tools': ['dict-tool', 'object-tool']},
|
||||
)
|
||||
tools = [
|
||||
{'name': 'dict-tool'},
|
||||
SimpleNamespace(name='object-tool'),
|
||||
SimpleNamespace(name='other-tool'),
|
||||
]
|
||||
|
||||
assert ResourcePolicyProjector.filter_tools(tools, policy) == tools[:2]
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for AgentRunSessionRegistry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -49,6 +50,27 @@ class TestSessionRegistryBasic:
|
||||
assert 'permissions' not in result
|
||||
assert '_authorized_ids' not in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_keeps_host_execution_query_by_identity(self):
|
||||
"""The runtime registry keeps the Host Query view in memory only."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
execution_query = object()
|
||||
|
||||
await registry.register(
|
||||
run_id='run_execution_query',
|
||||
runner_id='plugin:test/my-runner/default',
|
||||
query_id=None,
|
||||
plugin_identity='test/my-runner',
|
||||
resources=make_resources(),
|
||||
execution_query=execution_query,
|
||||
)
|
||||
|
||||
session = await registry.get('run_execution_query')
|
||||
|
||||
assert session is not None
|
||||
assert session['query_id'] is None
|
||||
assert session['execution_query'] is execution_query
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_requires_plugin_identity(self):
|
||||
"""Agent run sessions must always have an owning plugin identity."""
|
||||
@@ -319,27 +341,36 @@ class TestSessionRegistryBasic:
|
||||
available_apis={'steering_pull': True},
|
||||
)
|
||||
|
||||
assert await registry.find_steering_target(
|
||||
conversation_id='conv_1',
|
||||
runner_id='plugin:test/my-runner/default',
|
||||
bot_id='bot_1',
|
||||
workspace_id='workspace_1',
|
||||
thread_id='thread_1',
|
||||
) == 'run_steering_scoped'
|
||||
assert await registry.find_steering_target(
|
||||
conversation_id='conv_1',
|
||||
runner_id='plugin:test/my-runner/default',
|
||||
bot_id='bot_2',
|
||||
workspace_id='workspace_1',
|
||||
thread_id='thread_1',
|
||||
) is None
|
||||
assert await registry.find_steering_target(
|
||||
conversation_id='conv_1',
|
||||
runner_id='plugin:test/my-runner/default',
|
||||
bot_id='bot_1',
|
||||
workspace_id='workspace_1',
|
||||
thread_id='thread_2',
|
||||
) is None
|
||||
assert (
|
||||
await registry.find_steering_target(
|
||||
conversation_id='conv_1',
|
||||
runner_id='plugin:test/my-runner/default',
|
||||
bot_id='bot_1',
|
||||
workspace_id='workspace_1',
|
||||
thread_id='thread_1',
|
||||
)
|
||||
== 'run_steering_scoped'
|
||||
)
|
||||
assert (
|
||||
await registry.find_steering_target(
|
||||
conversation_id='conv_1',
|
||||
runner_id='plugin:test/my-runner/default',
|
||||
bot_id='bot_2',
|
||||
workspace_id='workspace_1',
|
||||
thread_id='thread_1',
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
await registry.find_steering_target(
|
||||
conversation_id='conv_1',
|
||||
runner_id='plugin:test/my-runner/default',
|
||||
bot_id='bot_1',
|
||||
workspace_id='workspace_1',
|
||||
thread_id='thread_2',
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unregister_returns_pending_steering_queue(self):
|
||||
@@ -554,6 +585,7 @@ class TestGlobalRegistry:
|
||||
# in tests can cause UnboundLocalError due to Python scoping
|
||||
# Instead, just verify the function signature
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
assert callable(get_session_registry)
|
||||
|
||||
# Create a fresh instance directly to verify the class works
|
||||
|
||||
Reference in New Issue
Block a user